Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion CLAUDE.md

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,14 @@ _Avoid_: mute list, blocklist, filter.
A per-device, per-source suppression of notification *interruptions* (push, foreground toast, badge bump) without removing entries from the Inbox or bell list — muted entries still appear, dimmed. Stored in server ui-state under `notifMutes_<deviceId>` (a set of mute keys, survives the iPad PWA's localStorage wipe). The **mute key** (`muteKey`, `core/notif-mutes.js` / `src/lib/notif-mutes.ts`) unifies both axes: a Slack entry keys by its merged `groupKey` (`slack:{groupId}`, per workspace), every other adapter by `adapter` name (per service). Complementary to but distinct from **Channel Exclude** (which removes Slack channels from capture globally, not per-device, and hides them everywhere). See ADR-0013.
_Avoid_: global mute, notification filter, blocklist.

**Web Push Subscription**:
A per-device push registration (endpoint URL + `auth`/`p256dh` keys) the browser issues via `pushManager.subscribe` and the server fans out to in `sendPushToAll`. Persisted in `web-push-subs.json`; each record carries a `deviceId` (t093) for per-device gating. The **endpoint** is the only identity that survives an iOS script-storage wipe (which clears localStorage, IndexedDB, cookies, and Cache *together*), so the server treats it as the stable key and reconciles a wiped device's `deviceId` by endpoint on (re)subscribe (t095). iOS 16.4+ standalone-PWA only.
_Avoid_: push token, device token, registration.

**userVisibleOnly revocation**:
The WebKit rule that every `push` event on an iOS PWA *must* end in a visible notification (`showNotification`); a handler that finishes without one violates the `userVisibleOnly` promise and WebKit revokes the **Web Push Subscription** — with no documented grace count and, on iOS, no recovery (`pushsubscriptionchange` never fires there). The service worker therefore always shows something (the real notification or a generic fallback) and the app re-validates the subscription on foreground (t095).
_Avoid_: silent push (iOS forbids it for PWAs).

**Phone Shell**:
A distinct layout mode for narrow viewports (reactive `matchMedia` width gate — not pointer-coarseness, not a `caps` flag) where the **Inbox** is the root view and the screencast canvas is a destination reached from a notification or the tab list, not home. The wide layout (sidebar + toolbar + canvas) is untouched above the breakpoint.
_Avoid_: mobile mode, responsive layout, compact view.
Expand Down
9 changes: 9 additions & 0 deletions core/push-send-options.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Pure push notification send options: high urgency for timely delivery on battery-conscious devices,
// 1800s TTL so undeliverable notifications don't linger and resurface stale.

export function pushSendOptions() {
return {
urgency: "high",
TTL: 1800,
}
}
21 changes: 21 additions & 0 deletions core/push-send-options.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest"
import { pushSendOptions } from "./push-send-options.js"

describe("pushSendOptions", () => {
it("returns urgency high and TTL 1800", () => {
const options = pushSendOptions()
expect(options.urgency).toBe("high")
expect(options.TTL).toBe(1800)
})

it("only includes urgency and TTL (no contentEncoding)", () => {
const options = pushSendOptions()
expect(Object.keys(options).sort()).toEqual(["TTL", "urgency"])
})

it("returns the same values on multiple calls (deterministic)", () => {
const opt1 = pushSendOptions()
const opt2 = pushSendOptions()
expect(opt1).toEqual(opt2)
})
})
15 changes: 15 additions & 0 deletions core/push-subscriptions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Pure subscription reconciliation: server-authoritative deviceId keyed by endpoint.
// After a storage wipe, the same endpoint recovers its prior deviceId and per-device prefs.

import { randomUUID } from "crypto"

export function reconcileDeviceId(existingSubs, incoming) {
const existing = existingSubs.find((sub) => sub.endpoint === incoming.endpoint)

if (existing && existing.deviceId) {
// Endpoint match wins; ignore any cached deviceId from the client
return { deviceId: existing.deviceId, isNew: false }
}

return { deviceId: randomUUID(), isNew: true }
}
119 changes: 119 additions & 0 deletions core/push-subscriptions.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { describe, expect, it } from "vitest"
import { reconcileDeviceId } from "./push-subscriptions.js"

describe("reconcileDeviceId", () => {
it("returns a new deviceId when the subscription is new (endpoint not in store)", () => {
const existingSubs = []
const incoming = { endpoint: "https://push.example.com/api/v1/sub1" }

const result = reconcileDeviceId(existingSubs, incoming)

expect(result.deviceId).toBeTruthy()
expect(result.isNew).toBe(true)
expect(result.deviceId.length).toBeGreaterThan(0)
})

it("reuses existing deviceId when endpoint matches", () => {
const existing = {
endpoint: "https://push.example.com/api/v1/sub1",
deviceId: "device-uuid-123",
}
const existingSubs = [existing]
const incoming = { endpoint: "https://push.example.com/api/v1/sub1" }

const result = reconcileDeviceId(existingSubs, incoming)

expect(result.deviceId).toBe("device-uuid-123")
expect(result.isNew).toBe(false)
})

it("generates a new id if incoming has a cached id that conflicts with endpoint binding", () => {
const existing = {
endpoint: "https://push.example.com/api/v1/sub1",
deviceId: "stored-uuid-123",
}
const existingSubs = [existing]
const incoming = {
endpoint: "https://push.example.com/api/v1/sub1",
deviceId: "different-cached-id",
}

const result = reconcileDeviceId(existingSubs, incoming)

// Endpoint match wins; incoming cached id is ignored
expect(result.deviceId).toBe("stored-uuid-123")
expect(result.isNew).toBe(false)
})

it("adds a new sub record for a new endpoint without duplicates", () => {
const sub1 = {
endpoint: "https://push.example.com/api/v1/sub1",
deviceId: "device-1",
}
const existingSubs = [sub1]
const incoming = { endpoint: "https://push.example.com/api/v1/sub2" }

const result = reconcileDeviceId(existingSubs, incoming)

expect(result.deviceId).not.toBe("device-1")
expect(result.isNew).toBe(true)
})

it("handles multiple existing subscriptions and picks the matching one", () => {
const existing1 = {
endpoint: "https://push.example.com/api/v1/sub1",
deviceId: "device-1",
}
const existing2 = {
endpoint: "https://push.example.com/api/v1/sub2",
deviceId: "device-2",
}
const existing3 = {
endpoint: "https://push.example.com/api/v1/sub3",
deviceId: "device-3",
}
const existingSubs = [existing1, existing2, existing3]
const incoming = { endpoint: "https://push.example.com/api/v1/sub2" }

const result = reconcileDeviceId(existingSubs, incoming)

expect(result.deviceId).toBe("device-2")
expect(result.isNew).toBe(false)
})

it("mints a valid UUID v4 for new subscriptions", () => {
const existingSubs = []
const incoming = { endpoint: "https://push.example.com/api/v1/new" }

const result = reconcileDeviceId(existingSubs, incoming)

// Basic UUID v4 validation
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
expect(result.deviceId).toMatch(uuidRegex)
})

it("is idempotent for the same endpoint", () => {
const existingSubs = []
const incoming = { endpoint: "https://push.example.com/api/v1/sub1" }

const result1 = reconcileDeviceId(existingSubs, incoming)
// Simulate storing the result
const stored = [{ endpoint: incoming.endpoint, deviceId: result1.deviceId }]
const result2 = reconcileDeviceId(stored, incoming)

expect(result2.deviceId).toBe(result1.deviceId)
expect(result2.isNew).toBe(false)
})

it("does not modify the input arrays", () => {
const existingSubs = [
{ endpoint: "https://push.example.com/api/v1/sub1", deviceId: "device-1" },
]
const existingSubsClone = JSON.parse(JSON.stringify(existingSubs))
const incoming = { endpoint: "https://push.example.com/api/v1/sub2" }

reconcileDeviceId(existingSubs, incoming)

expect(existingSubs).toEqual(existingSubsClone)
})
})
57 changes: 57 additions & 0 deletions docs/adr/0014-endpoint-reconciled-per-device-push-identity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# 0014 — Endpoint-reconciled per-device push identity

**Date:** 2026-06-20
**Status:** Accepted
**Deciders:** t095 (E0)

## Context

Web Push on iOS PWA (16.4+) requires stable per-device identity to survive storage eviction:

- **The problem:** localStorage, IndexedDB, cookies, Cache API, and SW registration are evicted together when iOS needs storage (or on install/uninstall). A locally-generated `deviceId` cached in localStorage orphans the push subscription binding and forgets per-device prefs (mute keys, master on/off) on the next app launch after a wipe.

- **The storage-layer contract:** The push endpoint URL (from `pushManager.subscribe()`) is the **only** identity that outlives storage eviction, because:
1. The browser's push manager remembers which endpoints are subscribed (the Push Notification API state is OS-level, not script-storage).
2. A re-subscribe to the same endpoint (same keys) is indistinguishable from the original.
3. The endpoint is public (sent to the server on every subscribe POST).

- **The per-device state problem (t093):** Per-device settings (master on/off, mute keys) are keyed by `deviceId` in ui-state (`notificationsEnabled_<deviceId>`, `notifMutes_<deviceId>`, `webPush_<deviceId>`). A lost or regenerated `deviceId` severs the link to these prefs, forcing the user to re-configure after a wipe.

## Decision

**The server becomes the authoritative source of `deviceId`, reconciled by push subscription endpoint.**

Flow:
1. Client calls `POST /api/notifications/subscribe` with only the `endpoint` (not `deviceId`).
2. Server checks: does this endpoint already exist in `pushSubs`?
- **Yes:** return its stored `deviceId`; endpoint match wins (ignore any cached client-sent id).
- **No:** generate a new UUID v4, store it with the endpoint, return it.
3. Client receives the reconciled `deviceId` and adopts it as the single source for device-keyed ui-state keys.
4. Client stores the returned `deviceId` in localStorage as a cosmetic cache (for immediate reads before server round-trip), but trusts the server's reconciled id on every subscribe.

### Why endpoint-keyed, not hash-keyed

Alternative considered: hash the endpoint (e.g., sha256 base64 first 16 chars) and use that as the deviceId.

- **Tradeoff:** A hash is deterministic (survives reload) but loses information (can't inspect logs) and requires pre-hashing on client to match the server's key (another point of skew). A UUID is opaque and paired with the endpoint in the record, so logs are greppable and the binding is explicit.
- **Decision:** UUIDs + explicit endpoint records are clearer for debugging and testing; the server is already reconciling anyway.

## Consequences

### Positive

- After a storage wipe + re-subscribe to the same endpoint, the device recovers its prior `deviceId` (and thus prior mutes/master).
- No device can accidentally double-subscribe on the same endpoint (server dedupes by endpoint, not by `deviceId`).
- The subscription record is the source of truth; client-side caches can drift without breaking anything (only used for immediate UI reads).
- Per-device prefs (t093) survive the lifecycle naturally without special migration logic.

### Negative

- If the push endpoint changes (e.g., a browser re-generates it), a new `deviceId` is minted and the device gets a fresh (opt-out) prefs set. This is correct but invisible to the user — they'll see all notifications until they reconfigure (rare in practice; browsers are sticky with endpoints).
- The endpoint URL is logged server-side and persisted to disk (the `web-push-subs.json` file). If that file is exposed, the endpoints (but not the credentials) leak. Mitigation: endpoints are public by definition (sent on subscribe); the real secret is the `keys.auth` field, which is kept in the file too but never logged.

## Links

- **ADR-0013:** Per-device delivery gates (t093); this ADR extends it by making `deviceId` server-authoritative.
- **Task t095:** Harden Web Push delivery reliability (E0 implementation).
- **Test:** `test/e2e/server.e2e.test.ts` — push subscription reconcile e2e keystone tests the endpoint-matching logic end-to-end.
4 changes: 2 additions & 2 deletions docs/conventions/docs-discipline.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ Any non-trivial design decision gets an ADR. Examples of "non-trivial":

ADRs are **append-only**. When a decision changes, write a new ADR that supersedes the old one. Update the old ADR's *Status* line to reference the superseder, but never edit its body. The history is the documentation.

Current ADRs: `docs/adr/0001-single-remote-page.md`, `0002-adaptive-viewport.md`, `0003-notifications-side-channel.md`, `0004-pin-live-tab-model.md`, `0005-local-tabs-base-window.md`, `0006-web-proxy-sse-transport.md`, `0007-web-websocket-transport.md`, `0008-defer-monorepo-shared-cjs-core.md`, `0009-touch-first-co-primary-input-surface.md`, `0010-multiple-workspaces-deferred-design.md`, `0011-slack-content-sweep-guaranteed-delivery.md`, `0012-phone-triage-surface-inbox-rooted-shell-conversati.md`, `0013-per-device-notification-delivery.md`.
Current ADRs: `docs/adr/0001-single-remote-page.md`, `0002-adaptive-viewport.md`, `0003-notifications-side-channel.md`, `0004-pin-live-tab-model.md`, `0005-local-tabs-base-window.md`, `0006-web-proxy-sse-transport.md`, `0007-web-websocket-transport.md`, `0008-defer-monorepo-shared-cjs-core.md`, `0009-touch-first-co-primary-input-surface.md`, `0010-multiple-workspaces-deferred-design.md`, `0011-slack-content-sweep-guaranteed-delivery.md`, `0012-phone-triage-surface-inbox-rooted-shell-conversati.md`, `0013-per-device-notification-delivery.md`, `0014-endpoint-reconciled-per-device-push-identity.md`.

---

Expand Down Expand Up @@ -151,4 +151,4 @@ If a doc has rotted beyond repair, delete it and write a fresh one. A wrong doc

_Docs you don't maintain are a liability. Docs you maintain are a force multiplier._

_Last revisited: 2026-06-03_
_Last revisited: 2026-06-20_
2 changes: 2 additions & 0 deletions docs/memories/learnings.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ Things discovered in practice that weren't obvious from the spec. Surprises, got

---

**2026-06-20** — On an **iOS PWA (incl. iOS 26)** Web Push notifications can show **only the installed app's icon** (manifest / apple-touch-icon). The `icon` and `image` fields of `showNotification()` are **ignored** by iOS/macOS Safari (confirmed on Apple's dev forums and across vendors), so there is no per-notification or per-source icon — a Slack, Teams, and Outlook ping all render the same app icon, indistinguishable in the banner. The "big avatar + small app-icon in the corner" look that native messaging apps have is **Communication Notifications** (`INSendMessageIntent` donation + a Notification Service Extension + the Communication Notifications capability) — **native-app only, explicitly not available to web push**. PWAs also can't swap their home-screen icon at runtime (no web equivalent of `setAlternateIconName`), so you can't fake it that way either. The **only** dynamic banner signals an iOS PWA gets are the **title**, the **body**, and the **app-badge number** (`navigator.setAppBadge`, already shipped t080/t093). Practical consequence: to convey *which source* sent a push on iOS, it must go in the **title text** — the per-adapter favicons (t088/t089, `adapter-icon.tsx`) only render **in-app** (Inbox/bell/toast), never in the OS banner. Don't spend effort trying to get a custom/dynamic push icon on iOS; it isn't possible.

**2026-05-29** — A clicked Electron **OS notification** silently did nothing (banner showed, click → no tab focus, no deep-open) because the `Notification` was a local `const` with no retained reference: V8 garbage-collects the JS `Notification` object, and a collected Notification never delivers its `click` event. Fix: hold every shown Notification in a module-level `Set` (`liveNotifications` in `main.js`) until it is clicked or closed. The diagnosis method that nailed it after several wrong guesses: fire a **synthetic `cdp:notification-activate` from main straight into the renderer** (a temporary env-gated hook), bypassing the OS click — the remote Teams conversation switched, proving the renderer / `chromeSend` / `openTeamsThread` path was fine and isolating the failure to OS-click delivery. Lesson: when an Electron notification click "does nothing", suspect Notification GC first; and when a multi-stage handler fails, trigger the downstream stage directly to bisect renderer-vs-transport-vs-OS rather than reasoning about the whole chain.

**2026-05-29** — Changing the notification entry shape (t028 added `activate`/`groupKey`/`adapter`) silently broke Teams/Outlook **Web Push** deep-open while the in-app path kept working. Root cause: three *hand-picked field lists* along the push chain each re-projected the entry to the pre-t028 schema and were never extended — the server push payload (`web/server.mjs` `sendPushToAll`), the service worker's `options.data` (`public/sw.js`), and the renderer's serviceWorker-`message` entry reconstruction (`cdp-web-transport.ts`). TypeScript can't catch this: the chain crosses process boundaries (server → SW → page) as untyped JSON, and `CdpNotification` even allowed the new fields as optional, so dropping them type-checked clean. Lesson: when a cross-boundary payload shape changes, grep for every manual `{ id, targetId, … }` re-projection of it — the SSE/in-app path that forwards the *whole* entry will mask the regression on the path that doesn't. Guarded now by a regression test asserting the SW-message path preserves `activate`.
Expand Down
Loading