feat(push): suppress notifications to devices where the app is visible (presence-based send-time filter) #162

Merged
dominik.polakovics merged 2 commits from afk/160 into main 2026-07-13 22:19:00 +02:00

Implements the presence-based push-suppression design from the 2026-07-13 grill session. Broadcast Web Push is now dropped at send time for any device whose app is currently visible/focused; every other enrolled device is delivered to exactly as before. Suppression is 100% server-side — web/public/sw.js is untouched and still shows every push it receives (ADR-0039's rule stands).

What changed

  • internal/presence (new): in-memory conn → {deviceHash, visible} registry. The SSE stream owns entry lifetime (Connect on open, deferred Disconnect on handler return), the presence beacon only refines live entries (an unknown conn can never resurrect one), and every staleness path errs toward notifying — a restart clears everything.
  • internal/push: PresenceView seam injected into NewSender (nil ⇒ never suppress); Broadcast skips subscriptions whose SHA-256 endpoint hash reads visible, with a Debug log; Send deliberately bypasses the filter so the Settings Send test always delivers. Also fixes a pre-existing data race: each dispatch now gets its own payload copy (webpush-go pads the slice it is handed in place).
  • internal/httpapi: GET /api/v1/events?conn=<uuid> registers presence happens-before the 200; new authenticated POST /api/v1/presence {conn, device, visible} (204 always, strict conn/device validation); X-Lab-Csrf is waived for exactly this one endpoint — navigator.sendBeacon cannot set headers — while the Origin equality check stays fully enforced (verified live: beacon-shape 204, cross-origin 403).
  • web/: sse.ts mints a fresh conn UUID per connection and exposes a connID signal; new lib/deviceHash.ts (crypto.subtle SHA-256 of the push subscription endpoint — the capability URL never travels) and lib/presence.ts (reports on stream open, visibilitychange, and via sendBeacon on pagehide); wired to the singleton connection in events.tsx. A browser without a push subscription sends no presence at all.
  • docs/adr/0044-presence-based-push-suppression.md: the full decision record, referencing ADR-0038/0039.

Verification

  • go test ./... green (incl. -race on push/presence/httpapi). The only failures are internal/tmuxx integration tests, reproduced identically on clean main in this sandbox (no tmux pane spawning) — unrelated, CI has real tmux.
  • golangci-lint run clean; gofmt/go vet clean.
  • web: 795 vitest tests green, tsc --noEmit, eslint, prettier all clean.
  • Live boot smoke against the compiled binary: presence beacon without the CSRF header → 204 with matching Origin, 403 cross-origin, 400 malformed device, 401 unauthenticated.

Acceptance mapping

  • Visible on A / delivered to B → TestBroadcastSuppressesVisibleDevice
  • Send test always delivers → TestSendBypassesPresence
  • Presence dies with the SSE stream → TestSSERegistersPresenceForLifetimeOfStream
  • Restart suppresses nothing → registry is in-memory by design
  • web/public/sw.js unchanged → not in the diff

Closes #160

🤖 Generated with Claude Code

https://claude.ai/code/session_01HRzJpVActrGRhH5MXPZ3s9

Implements the presence-based push-suppression design from the 2026-07-13 grill session. Broadcast Web Push is now dropped at send time for any device whose app is currently visible/focused; every other enrolled device is delivered to exactly as before. Suppression is 100% server-side — `web/public/sw.js` is untouched and still shows every push it receives (ADR-0039's rule stands). ## What changed - **`internal/presence`** (new): in-memory `conn → {deviceHash, visible}` registry. The SSE stream owns entry lifetime (Connect on open, deferred Disconnect on handler return), the presence beacon only refines live entries (an unknown conn can never resurrect one), and every staleness path errs toward notifying — a restart clears everything. - **`internal/push`**: `PresenceView` seam injected into `NewSender` (nil ⇒ never suppress); `Broadcast` skips subscriptions whose SHA-256 endpoint hash reads visible, with a Debug log; `Send` deliberately bypasses the filter so the Settings **Send test always delivers**. Also fixes a pre-existing data race: each dispatch now gets its own payload copy (webpush-go pads the slice it is handed in place). - **`internal/httpapi`**: `GET /api/v1/events?conn=<uuid>` registers presence happens-before the 200; new authenticated `POST /api/v1/presence` `{conn, device, visible}` (204 always, strict conn/device validation); `X-Lab-Csrf` is waived for exactly this one endpoint — `navigator.sendBeacon` cannot set headers — while the Origin equality check stays fully enforced (verified live: beacon-shape 204, cross-origin 403). - **`web/`**: `sse.ts` mints a fresh conn UUID per connection and exposes a `connID` signal; new `lib/deviceHash.ts` (crypto.subtle SHA-256 of the push subscription endpoint — the capability URL never travels) and `lib/presence.ts` (reports on stream open, `visibilitychange`, and via `sendBeacon` on `pagehide`); wired to the singleton connection in `events.tsx`. A browser without a push subscription sends no presence at all. - **`docs/adr/0044-presence-based-push-suppression.md`**: the full decision record, referencing ADR-0038/0039. ## Verification - `go test ./...` green (incl. `-race` on push/presence/httpapi). The only failures are `internal/tmuxx` integration tests, reproduced identically on clean `main` in this sandbox (no tmux pane spawning) — unrelated, CI has real tmux. - `golangci-lint run` clean; `gofmt`/`go vet` clean. - web: 795 vitest tests green, `tsc --noEmit`, eslint, prettier all clean. - Live boot smoke against the compiled binary: presence beacon without the CSRF header → 204 with matching Origin, 403 cross-origin, 400 malformed device, 401 unauthenticated. ## Acceptance mapping - Visible on A / delivered to B → `TestBroadcastSuppressesVisibleDevice` - Send test always delivers → `TestSendBypassesPresence` - Presence dies with the SSE stream → `TestSSERegistersPresenceForLifetimeOfStream` - Restart suppresses nothing → registry is in-memory by design - `web/public/sw.js` unchanged → not in the diff Closes #160 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01HRzJpVActrGRhH5MXPZ3s9
feat(push): suppress notifications to devices where the app is visible
All checks were successful
ci / native (pull_request) Successful in 5m48s
8d9fa27986
Presence-based send-time filter (issue #160): broadcast Web Push is
dropped for any device whose app is currently visible/focused — the live
SSE-driven UI is the notification there — while every other enrolled
device is delivered to exactly as before.

- internal/presence: in-memory conn → {deviceHash, visible} registry;
  SSE stream owns entry lifetime, beacons only refine live entries, and
  every staleness path errs toward notifying (restart clears all).
- internal/push: PresenceView seam injected into NewSender; Broadcast
  skips subscriptions whose SHA-256 endpoint hash is visible; Send (the
  Settings "Send test") deliberately bypasses the filter. Also gives
  each dispatch its own payload copy — webpush-go pads the slice in
  place, so shared bodies raced under -race.
- internal/httpapi: GET /api/v1/events?conn=<uuid> registers presence
  for the stream's lifetime; new POST /api/v1/presence beacon endpoint;
  X-Lab-Csrf waived for exactly that endpoint (sendBeacon cannot set
  headers) with Origin checks fully enforced.
- web: per-connection conn UUID + connID signal in sse.ts; presence
  reporter (visibilitychange, post-open, pagehide sendBeacon) keyed by
  the crypto.subtle SHA-256 of the push subscription endpoint; sw.js
  untouched (ADR-0039's always-show rule stands).
- docs/adr/0044-presence-based-push-suppression.md records the design.

Closes #160

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HRzJpVActrGRhH5MXPZ3s9
Merge branch 'main' into afk/160
All checks were successful
ci / native (pull_request) Successful in 7m2s
7a3434edb4
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/coding-lab!162
No description provided.