--- phase: 04-shared-lists-live-sync plan: 03 type: execute wave: 2 depends_on: ['04-01'] files_modified: - apps/api/src/routes/lists.ts - apps/api/tests/routes/lists.test.ts - apps/api/src/index.ts - apps/pwa/src/api/listsClient.ts - apps/pwa/src/routes/ListsIndex.tsx - apps/pwa/src/components/ListCard.tsx - apps/pwa/src/components/CreateListSheet.tsx - apps/pwa/src/components/ListDeleteDialog.tsx - apps/pwa/src/components/ListsEmptyState.tsx autonomous: true requirements: [LIST-01] user_setup: [] must_haves: truths: - 'A member can create a named list and it appears in their lists' - 'A new shared list auto-populates list_shares rows for the other household members (D-01/D-02)' - 'GET /api/lists returns only lists the member owns or that are shared with them (D-04)' - 'A member can delete a list (with confirmation) and its items/shares cascade-delete (D-06)' artifacts: - path: 'apps/api/src/routes/lists.ts' provides: 'POST/GET/PATCH/DELETE /api/lists with scoped access + zod validation' exports: ['listsRouter'] - path: 'apps/pwa/src/components/CreateListSheet.tsx' provides: 'new-list form with shared/private toggle (default shared)' min_lines: 30 - path: 'apps/pwa/src/components/ListCard.tsx' provides: 'list summary card navigating to /lists/:id' min_lines: 25 - path: 'apps/pwa/src/components/ListDeleteDialog.tsx' provides: 'list-delete confirmation (D-06)' min_lines: 25 key_links: - from: 'apps/pwa/src/routes/ListsIndex.tsx' to: '/api/lists' via: 'useQuery + useMutation in listsClient' pattern: 'fetchLists|createList' - from: 'apps/api/src/routes/lists.ts' to: 'list_shares' via: 'auto-insert shares on create + scoped GET' pattern: 'listShares' - from: 'apps/api/src/index.ts' to: 'listsRouter' via: "app.route('/api/lists', listsRouter)" pattern: 'api/lists' --- Deliver the list-CRUD vertical slice end to end (LIST-01): a member can create a named list (defaulting to Shared), see it in their list index, and delete it with confirmation. The slice spans UI (ListsIndex/ListCard/CreateListSheet/ListDeleteDialog) → API (POST/GET/PATCH/DELETE /api/lists) → DB (lists + list_shares), with server-enforced scoped access (D-04) so a member only ever sees their own and shared lists. MVP slice: after this plan a real user can create and delete lists — a capability they did not have after Plan 01's empty shell. Purpose: Establish the lists router (the analog every later list/item endpoint extends) with correct access control and the auto-share-on-create behavior, plus the lists-index UI. Output: listsRouter mounted at /api/lists; ListsIndex wired to real data; CreateListSheet + ListCard + ListDeleteDialog; listsClient typed functions. @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/phases/04-shared-lists-live-sync/04-CONTEXT.md @.planning/phases/04-shared-lists-live-sync/04-RESEARCH.md @.planning/phases/04-shared-lists-live-sync/04-PATTERNS.md @.planning/phases/04-shared-lists-live-sync/04-UI-SPEC.md @.planning/phases/04-shared-lists-live-sync/04-VALIDATION.md Task 1: Lists router — POST/GET/PATCH/DELETE /api/lists with scoped access (LIST-01, D-01/D-02/D-04/D-06) apps/api/src/routes/lists.ts, apps/api/tests/routes/lists.test.ts, apps/api/src/index.ts - apps/api/src/routes/events.ts (full — resolveUserId, zod schemas, handler/try-catch/401 conventions) - apps/api/tests/routes/lists.test.ts (RED stub from Plan 01) - apps/api/src/index.ts (route mount order) - apps/api/src/auth/user.ts (upsertUser, deriveDisplayName signatures) - apps/api/src/db/schema.ts (lists, listShares, listItems, users) - .planning/phases/04-shared-lists-live-sync/04-PATTERNS.md §"apps/api/src/routes/lists.ts" + §"Shared Patterns" - .planning/phases/04-shared-lists-live-sync/04-RESEARCH.md §"Open Questions" item 3 (auto-populate list_shares) - Test: POST /api/lists { name, isShared:true } inserts a lists row owned by the caller AND inserts list_shares rows for every other user (not the creator). (LIST-01, D-01, Open Question 3) - Test: POST /api/lists { name, isShared:false } inserts the list with NO list_shares rows. - Test: GET /api/lists returns lists where owner_id = caller OR caller is in list_shares; does NOT return another member's private list (D-04 security-critical). - Test: GET /api/lists includes an item-count summary per list (active/done) for the card badge; assert the field is present. - Test: DELETE /api/lists/:id by the owner removes the list and cascades items + shares; a non-owner/non-sharee gets 403; unknown id gets 404. - Test: PATCH /api/lists/:id updates name and/or isShared by an authorized member; toggling isShared false→true (re)populates shares, true→false removes non-owner shares. - Test: zod rejects name > 255 or empty. Create apps/api/src/routes/lists.ts exporting `listsRouter` (Hono). Copy the `resolveUserId` helper verbatim from events.ts (per project convention it is duplicated per router, not extracted). Apply the 401 guard + try/catch-503 conventions on every handler. Define zod schemas: createListSchema (name 1..255, isShared default true), patchListSchema (name?/isShared?, at least one). Implement handlers: POST / (create list; if isShared, query users for all member ids except creator and insert list_shares rows — YAGNI auto-share per Open Question 3); GET / (scoped select: owner_id = caller OR id IN list_shares.userId = caller, returning id/name/isShared/ownerId + per-list item counts); PATCH /:id (authorized update of name/isShared, reconciling list_shares on visibility change); DELETE /:id (owner-only delete is the safe default; cascade handles items/shares). Verify list access with the ownership/share-check pattern from 04-PATTERNS before any mutation. Mount in index.ts: `import { listsRouter }` and `app.route('/api/lists', listsRouter)` after the sseRouter mount (so it sits behind the OIDC/dev-bypass guard). Do NOT add fan-out emit calls here yet — Plan 06 adds publishListEvent triggers once the SSE endpoint exists (leave a commented seam, note it in SUMMARY). NOTE: per-field item PATCH and item endpoints are Plan 04; this plan is lists only. pnpm --filter @familysync/api exec vitest run tests/routes/lists.test.ts && grep -q "app.route('/api/lists'" apps/api/src/index.ts && pnpm --filter @familysync/api typecheck - lists.test.ts: all create/get/delete/patch/scope tests green, including the D-04 "private list of another member is NOT returned by GET /api/lists" assertion. - Shared-create auto-inserts list_shares for other members; private-create inserts none. - listsRouter mounted at /api/lists in index.ts; typecheck passes. POST/GET/PATCH/DELETE /api/lists work with server-enforced scoped access and auto-share-on-create; tests green. Task 2: ListsIndex wired to real data + ListCard + CreateListSheet + ListDeleteDialog (LIST-01, D-01/D-06) apps/pwa/src/api/listsClient.ts, apps/pwa/src/routes/ListsIndex.tsx, apps/pwa/src/components/ListCard.tsx, apps/pwa/src/components/CreateListSheet.tsx, apps/pwa/src/components/ListDeleteDialog.tsx, apps/pwa/src/components/ListsEmptyState.tsx - apps/pwa/src/routes/ListsIndex.tsx (placeholder shell from Plan 01) - apps/pwa/src/api/client.ts (credentials:'include' fetch convention) - apps/pwa/src/api/listsClient.ts (fetchLists/List from Plan 01, if present) - apps/pwa/src/components/DeleteConfirmationDialog.tsx (modal/focus-trap/CSS-token pattern to mirror) - apps/pwa/src/store/listsStore.ts (createListSheetOpen) - .planning/phases/04-shared-lists-live-sync/04-UI-SPEC.md §"ListsIndex", §"ListCard", §"CreateListSheet", §"ListsEmptyState", §"Sharing Toggle", §"Copywriting Contract", §"List Delete" - .planning/phases/04-shared-lists-live-sync/04-PATTERNS.md §"ListsIndex.tsx", §"listsClient.ts", §"DeleteConfirmationDialog reuse" Expand apps/pwa/src/api/listsClient.ts with credentials:'include' typed functions: fetchLists, createList({name,isShared}), patchList(id, {...}), deleteList(id), plus List/ListItem types (ListItem used by Plan 04). Follow the client.ts apiFetch wrapper convention. Build CreateListSheet.tsx per UI-SPEC: bottom sheet (mobile) / centered modal (desktop), heading "New list", auto-focused name input (placeholder "e.g. Groceries"), Shared/Private toggle defaulting to Shared (D-01), "Create" button (accent var(--color-member-0), disabled while name empty, destructive border on blank-submit attempt), "Cancel". On create: useMutation(createList) with optimistic insert into ['lists'] + onError rollback + onSettled invalidate; close sheet on success. Open/close driven by listsStore.createListSheetOpen. Build ListCard.tsx per UI-SPEC: rounded card, list name (heading), "N items / N active · M done" badge, "Shared" pill for shared lists (nothing for private), ChevronRight; whole card taps through to /lists/:id via react-router navigate/Link; swipe/long-press (phone) or hover X (desktop) reveals Delete which opens ListDeleteDialog. All user text as plain-text JSX (XSS guard). Build ListDeleteDialog.tsx by mirroring DeleteConfirmationDialog structure (do NOT modify the existing one — it is wired to calendarStore): same modal layout, backdrop, role="dialog"/aria-modal, Escape-to-close, focus-on-open, CSS tokens; heading "Delete list?", body '"{name}" and all its items will be permanently removed.', Cancel + destructive Delete (D-06). On confirm: useMutation(deleteList) optimistic removal from ['lists'] + navigate back to /lists; failure toast "Couldn't delete. Try again." Replace the ListsIndex placeholder card stack with real ListCard rendering from useQuery(['lists']); ListsEmptyState when zero lists; FAB ("+ New List") opens CreateListSheet. pnpm --filter @familysync/pwa exec tsc --noEmit && pnpm --filter @familysync/pwa exec vitest run src/components/DeleteConfirmationDialog.test.tsx 2>&1 | grep -Eiq 'passed' && grep -q "createList" apps/pwa/src/api/listsClient.ts - listsClient exports fetchLists/createList/patchList/deleteList + List/ListItem types. - CreateListSheet defaults to Shared, disables Create on empty name, creates via optimistic mutation. - ListCard shows name + count badge + "Shared" pill (shared only) and navigates to /lists/:id. - ListDeleteDialog confirms before delete and does not modify DeleteConfirmationDialog.tsx. - PWA typecheck passes; existing DeleteConfirmationDialog test still green. - Browser check (`playwright-cli`): create a list named "Groceries" → it appears as a card with a "Shared" pill; open delete dialog → confirm → card disappears. Record in SUMMARY. User can create (shared by default) and delete named lists through the UI, backed by scoped API; counts and sharing badge render. ## Trust Boundaries | Boundary | Description | | -------------------- | ----------------------------------------------------- | | browser → /api/lists | client supplies name/isShared/list id — all untrusted | | API → MariaDB | scoped queries enforce who can see/mutate a list | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation Plan | | --------- | ---------------------- | --------------------------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------- | | T-04-05 | Elevation of Privilege | accessing another member's private list via direct id (GET/DELETE/PATCH /api/lists/:id) | mitigate | Every handler resolves caller via resolveUserId and verifies owner_id OR list_shares before returning/mutating; 403 otherwise; tested | | T-04-02 | Information Disclosure | GET /api/lists leaking non-shared lists | mitigate | Scoped WHERE owner_id = caller OR id IN list_shares; negative test asserts another member's private list is absent (D-04) | | T-04-06 | Tampering | XSS via list name | mitigate | List names rendered as plain-text JSX children only; no dangerouslySetInnerHTML (T-03-15 pattern) | | T-04-07 | Tampering | overposting on PATCH (fields beyond name/isShared) | mitigate | zod patchListSchema whitelists name/isShared only | | T-04-08 | Elevation of Privilege | self-adding to list_shares | mitigate | Shares are server-managed only (auto-populated on create/visibility change); no client-writable shares endpoint exposed in Phase 4 | pnpm --filter @familysync/api exec vitest run tests/routes/lists.test.ts && pnpm --filter @familysync/pwa exec tsc --noEmit - `playwright-cli`: create + delete a list end to end. - D-04 negative test green. - LIST-01 satisfied: create + delete named lists end to end. - Shared-by-default with server-managed list_shares; scoped GET enforced. - listsRouter is the analog later item/SSE plans extend. **Symbols/files this plan creates (exclude from drift verification):** - `apps/api/src/routes/lists.ts` exporting `listsRouter` (POST/GET/PATCH/DELETE /api/lists); local `resolveUserId` copy - `app.route('/api/lists', listsRouter)` mount in apps/api/src/index.ts - `apps/pwa/src/api/listsClient.ts`: `fetchLists`, `createList`, `patchList`, `deleteList`, types `List`, `ListItem` - Components: `CreateListSheet`, `ListCard`, `ListDeleteDialog`, `ListsEmptyState` - Real-data `ListsIndex` (replaces Plan 01 placeholder) Create `.planning/phases/04-shared-lists-live-sync/04-03-SUMMARY.md` when done.