Mechanical reformat — no logic changes. 398 files changed, 19125 insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc (singleQuote:true, semi:true, tabWidth:2, trailingComma:all, printWidth:100). Isolated per D-13-08 for reviewability.
9.1 KiB
9.1 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 05-web-push-notifications | 05 | execute | 4 |
|
|
true |
|
|
Purpose: List edits are the chattiest source; D-01 coalescing + D-02 generic copy + D-03 self-suppression turn a grocery burst into a single clean ping. The dispatch hooks the SAME mutation points as the existing publishListEvent SSE fan-out, scoped to list access (never a broadcast).
Output: listChangeDispatcher.ts (notifyListChange) called from the list/item mutation handlers; reorder excluded; tests prove burst→one push, reorder-silent, self-suppression, and access scoping.
<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @apps/api/src/routes/lists.ts @apps/api/src/lib/pushCoalescer.ts @apps/api/src/lib/pushDispatcher.ts @apps/api/src/db/schema.ts @apps/api/tests/routes/lists.test.ts @.planning/phases/05-web-push-notifications/05-RESEARCH.md @.planning/phases/05-web-push-notifications/05-UI-SPEC.md Task 1: listChangeDispatcher — access-scoped, self-suppressed fan-out - apps/api/src/routes/lists.ts (checkListAccess lines 123-153; GET access scoping lines 168-183 — owner + list_shares union; resolveUserId) - apps/api/src/lib/pushCoalescer.ts (coalesceListPush signature) - apps/api/src/lib/pushDispatcher.ts (dispatchPush) - apps/api/src/db/schema.ts (lists, listShares, users, pushSubscriptions) - .planning/phases/05-web-push-notifications/05-UI-SPEC.md (list-change copy template) - notifyListChange(listId, actorId): resolves actorName (users.displayName via deriveDisplayName fallback) and listName (lists.name); calls coalesceListPush(listId, actorId, actorName, listName, dispatch). The injected dispatch(payload, excludeUserId) computes the accessible audience = {list owner} ∪ {list_shares.userId} MINUS excludeUserId, loads their push_subscriptions, and calls dispatchPush per subscription. - Self-suppression: excludeUserId === actorId → actor's own subscriptions are never sent to. - Access scoping: a member with no owner/share relationship to the list is never in the audience. - Empty audience (no other accessible members or no subscriptions) → no dispatch, no crash. Tests (listChangeDispatcher.test.ts, real DB per lists.test.ts harness): seed two users, a shared list, push_subscriptions for both; call notifyListChange(listId, actorA) thrice within window, advance fake timers → exactly one dispatchPush to userB (mock dispatchPush), body "3 changes", actorA never dispatched to. Seed a third unrelated user with no access → never dispatched. Create apps/api/src/lib/listChangeDispatcher.ts exporting notifyListChange(listId, actorId). Reuse the owner + list_shares union access query idiom from lists.ts GET (lines 168-183) to build the audience. Mock dispatchPush in tests (vi.mock) to assert recipients without network. Use vi.useFakeTimers to drive the coalescer window. Log errors with '[listChangeDispatcher]' prefix; one failed send must not abort the loop. cd apps/api && pnpm exec vitest run tests/lib/listChangeDispatcher.test.ts Test green: burst→one push count=N to the non-actor accessible member; actor suppressed; unrelated member excluded; empty audience no-op. Access-scoped, self-suppressed, coalesced list-change dispatch implemented + tested. Task 2: Hook notifyListChange into list/item mutations (reorder excluded) - apps/api/src/routes/lists.ts (every publishListEvent call site: POST /:id/items line 497, PATCH /list-items/:itemId line 640, DELETE /list-items/:itemId line 691, POST / line 287, PATCH /:id line 385, DELETE /:id line 431) - apps/api/tests/routes/lists.test.ts (existing harness for the reorder-silent assertion) - .planning/phases/05-web-push-notifications/05-CONTEXT.md (D-01 reorder does NOT push) In apps/api/src/routes/lists.ts, after each MEANINGFUL mutation's publishListEvent call, add notifyListChange(listId, currentUserId): item added (POST /:id/items), item checked/unchecked or text edited (PATCH /list-items/:itemId — but NOT when the patch was a position change), item deleted (DELETE /list-items/:itemId), list renamed (PATCH /:id), list deleted (DELETE /:id). For POST / (list created) — a fresh empty list is not a "change to a shared list" worth pinging; do NOT notify on list create (matches D-01 spirit; the create already auto-shares silently). CRITICAL (D-01): in PATCH /list-items/:itemId, when patch.position !== undefined (reorder), do NOT call notifyListChange — only checked/text changes notify. notifyListChange is fire-and-forget (do not await in a way that blocks the response; call it and catch). Extend apps/api/tests/routes/lists.test.ts: assert that a position-only PATCH does NOT enqueue a list-change push (spy notifyListChange or the coalescer), and that a checked PATCH does. cd apps/api && grep -q "notifyListChange" src/routes/lists.ts && pnpm exec vitest run tests/routes/lists.test.ts notifyListChange called on add/check/text-edit/delete/rename/list-delete; NOT called on reorder (position) or list-create; lists.test.ts proves reorder-silent vs check-notifies; existing list tests still green. List mutations push (coalesced) for the other member; reorder stays silent.<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| list mutation → push audience | the audience must be derived from list access, not the request |
STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|
| T-05-14 | Information Disclosure | list-change push to a non-member | mitigate | audience = owner ∪ list_shares only (same scope as SSE / GET /api/lists); never all users |
| T-05-15 | Information Disclosure | item text in payload | mitigate | D-02 generic copy — coalescer payload carries no item text, only actor + list name + count |
| T-05-16 | Spoofing | actor notified of own change | mitigate | excludeUserId = actorId; fan-out filters userId != actorId (D-03) |
</threat_model>
- listChangeDispatcher.test.ts + lists.test.ts green. - `pnpm --filter @familysync/api typecheck` passes.<success_criteria>
- Meaningful list/item mutations push a coalesced, generic, actor-attributed notification to accessible non-actor members.
- Reorder and list-create push nothing.
- Audience strictly scoped to list access; actor suppressed. </success_criteria>