---
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'
---

<objective>
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.
</objective>

<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>

<context>
@.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
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Lists router — POST/GET/PATCH/DELETE /api/lists with scoped access (LIST-01, D-01/D-02/D-04/D-06)</name>
  <files>apps/api/src/routes/lists.ts, apps/api/tests/routes/lists.test.ts, apps/api/src/index.ts</files>
  <read_first>
    - 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)
  </read_first>
  <behavior>
    - 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.
  </behavior>
  <action>
    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.

  </action>
  <verify>
    <automated>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</automated>
  </verify>
  <acceptance_criteria>
    - 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.
  </acceptance_criteria>
  <done>POST/GET/PATCH/DELETE /api/lists work with server-enforced scoped access and auto-share-on-create; tests green.</done>
</task>

<task type="auto">
  <name>Task 2: ListsIndex wired to real data + ListCard + CreateListSheet + ListDeleteDialog (LIST-01, D-01/D-06)</name>
  <files>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</files>
  <read_first>
    - 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"
  </read_first>
  <action>
    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.

  </action>
  <verify>
    <automated>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</automated>
  </verify>
  <acceptance_criteria>
    - 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.
  </acceptance_criteria>
  <done>User can create (shared by default) and delete named lists through the UI, backed by scoped API; counts and sharing badge render.</done>
</task>

</tasks>

<threat_model>

## 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    |

</threat_model>

<verification>
<automated>pnpm --filter @familysync/api exec vitest run tests/routes/lists.test.ts && pnpm --filter @familysync/pwa exec tsc --noEmit</automated>
- `playwright-cli`: create + delete a list end to end.
- D-04 negative test green.
</verification>

<success_criteria>

- 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.
  </success_criteria>

<artifacts_produced>
**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)
  </artifacts_produced>

<output>
Create `.planning/phases/04-shared-lists-live-sync/04-03-SUMMARY.md` when done.
</output>
