Files
familysync/.planning/milestones/v1.0-phases/04-shared-lists-live-sync/04-07-PLAN.md
T
Lucas Berger 982438dc10 style(13-03): apply Prettier formatting across repo
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.
2026-06-11 20:35:18 -04:00

214 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
phase: 04-shared-lists-live-sync
plan: 07
type: tdd
wave: 6
depends_on: ['04-03', '04-05']
files_modified:
- apps/api/src/db/schema.ts
- apps/api/src/db/migrations
- apps/api/src/routes/lists.ts
- apps/api/tests/routes/lists.test.ts
autonomous: true
gap_closure: true
requirements: [LIST-03]
must_haves:
truths:
- 'A member can drag an active item to a new position and the order persists (reorder via drag-to-top) — closes LIST-03 gap'
- 'T-04-08 closed: a non-owner sharee sending { isShared } to PATCH /api/lists/:id receives 403; list_shares is never mutated by a sharee'
- 'T-04-05 closed: the isShared reconciliation block runs only for the list owner (access.isOwner === true)'
artifacts:
- path: 'apps/api/src/db/schema.ts'
provides: 'listItems.rank column with explicit COLLATE utf8mb4_bin'
contains: 'utf8mb4_bin'
- path: 'apps/api/src/routes/lists.ts'
provides: 'owner-only guard before isShared reconciliation in PATCH /:id'
contains: 'access.isOwner'
- path: 'apps/api/tests/routes/lists.test.ts'
provides: 'rank-collation regression test + sharee-403 negative test'
key_links:
- from: 'apps/api/src/routes/lists.ts PATCH /:id'
to: 'list_shares reconciliation block'
via: 'owner-only guard returning 403 for non-owner isShared writes'
pattern: "access\\.isOwner"
- from: 'apps/api/src/db/schema.ts listItems.rank'
to: 'MariaDB list_items.rank column'
via: 'generate+migrate ALTER TABLE ... MODIFY rank ... COLLATE utf8mb4_bin'
pattern: 'utf8mb4_bin'
---
<objective>
Close the two open gaps blocking Phase 4 sign-off:
1. **LIST-03 drag-to-top (rank collation)**`list_items.rank` inherited the case-insensitive DB default collation (`utf8mb4_uca1400_ai_ci`). `fractional-indexing` emits uppercase-prefixed keys (e.g. `Zz`) on drag-to-top, which MariaDB sorts AFTER lowercase `a…` ranks even though JS sorts it BEFORE. The dragged item snaps to the bottom on refetch. Fix: migrate the column to `COLLATE utf8mb4_bin` so DB `ORDER BY rank` matches JS string order.
2. **T-04-08 / T-04-05 (security BLOCKER)** — The PATCH `/:id` `isShared` reconciliation block runs for ANY allowed user, including sharees. A non-owner sharee can delete every share row (`isShared:false`) or inject shares for all users (`isShared:true`). Fix: add an owner-only guard returning 403 when a non-owner sends `isShared`.
Both gaps are TDD: known-failing behavior with a defined assertion. Each feature follows RED → GREEN.
Purpose: Achieve `threats_open: 0` in 04-SECURITY.md and full LIST-03 satisfaction in 04-VERIFICATION.md.
Output: One additive migration SQL file, one schema collation edit, one owner-only guard, two new test cases.
DO NOT modify or replan 04-01 through 04-06 — they are VERIFIED. This plan adds NEW behavior and tests only.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@.planning/ROADMAP.md
@.planning/REQUIREMENTS.md
@.planning/phases/04-shared-lists-live-sync/04-VERIFICATION.md
@.planning/phases/04-shared-lists-live-sync/04-SECURITY.md
@.planning/phases/04-shared-lists-live-sync/04-CONTEXT.md
@apps/api/src/db/schema.ts
@apps/api/src/db/migrations/0001_lists_schema.sql
@apps/api/src/routes/lists.ts
@apps/api/tests/routes/lists.test.ts
@apps/api/drizzle.config.ts
@apps/api/package.json
</context>
<hard_constraints>
- **MariaDB only. NEVER `drizzle-kit push` (`pnpm db:push`).** `push` emits a false destructive diff that truncates populated tables. Use `pnpm --filter @familysync/api db:generate` to emit the migration SQL, then `pnpm --filter @familysync/api db:migrate` to apply it. The schema-push gate's default push task is OVERRIDDEN for this phase.
- The new migration MUST be a non-destructive `ALTER TABLE ... MODIFY` — NO DROP, NO TRUNCATE. Preserve `varchar(255)`, `NOT NULL`, and existing default/index semantics exactly.
- API integration tests live in `apps/api/tests/` (NEVER `src/`) and run against the real dev MariaDB. The regression test MUST exercise the real DB so it observes the column's actual collation, not JS comparison.
- Test run prelude (matches the file header at `lists.test.ts:7-10`): `set -a; . ./apps/api/.env 2>/dev/null; set +a; export DB_HOST=127.0.0.1 DB_PORT=3306`. Drizzle-kit reads the same `DB_*` env vars (see `drizzle.config.ts`).
- `<action>` blocks below name identifiers and behavior only — no fenced code blocks / full implementations.
</hard_constraints>
<tasks>
<task type="tdd" tdd="true">
<name>Task 1: RED → GREEN — rank-collation drag-to-top regression (LIST-03)</name>
<files>apps/api/tests/routes/lists.test.ts, apps/api/src/db/schema.ts, apps/api/src/db/migrations/</files>
<read_first>
- apps/api/tests/routes/lists.test.ts:919-982 — existing reorder describe block + seed helpers (`seedUser`, `seedList`, `seedItem`, `getApp`, `jsonRequest`, `currentDevUserId`). The test at line 930-932 explicitly sidesteps this bug with the comment "avoids collation issues with uppercase ranks".
- apps/api/src/db/schema.ts:222-241 — `listItems` table; `rank` is `varchar('rank', { length: 255 }).notNull()` at line 231 with no `.$type`/collation.
- apps/api/src/db/migrations/0001_lists_schema.sql:26-35 — existing additive CREATE TABLE style; the new migration must follow the same `--> statement-breakpoint` format drizzle-kit emits.
- apps/api/drizzle.config.ts — `out: './src/db/migrations'`, `dialect: 'mysql'`; confirms generate writes here and reads `DB_*` env.
- apps/api/package.json:14-15 — `db:generate` and `db:migrate` scripts.
- 04-VERIFICATION.md gap (frontmatter `gaps:` + "Measured divergence"): `SELECT ('Zz' < 'a0')` returns `0` under the current collation but `('Zz' < 'a0' COLLATE utf8mb4_bin)` returns `1`.
</read_first>
<action>
RED — Add a regression test inside the existing `describe('PATCH /api/list-items/:id { position } — reorder ordering (LIST-03, D-13)')` block in `lists.test.ts`. Title it to name the bug (e.g. "drag-to-top: uppercase-prefixed rank sorts above lowercase ranks (LIST-03 collation regression)"). The test must:
- seed an owner, set `currentDevUserId`, seed a private list;
- seed two active items where the FIRST has a lowercase rank (e.g. `a0`) and a SECOND item;
- simulate drag-to-top of the second item by PATCHing `/api/list-items/:id` with `{ position: 'Zz' }` (the uppercase-prefixed key `fractional-indexing`'s `generateKeyBetween(null, 'a0')` produces when prepending before the first item — assert `'Zz' < 'a0'` is `true` in JS first to document intent);
- GET `/api/lists/:listId/items` and assert the dragged item (`rank: 'Zz'`) is returned FIRST (index 0), matching JS string order.
Run the test BEFORE the schema change and confirm it FAILS (the item lands last) — this is the RED proof. Do not weaken the assertion to make it pass in JS; it must hit the real DB `ORDER BY rank`.
GREEN (schema) — In `schema.ts`, change the `listItems.rank` column so it carries an explicit binary collation. Preserve `varchar` length `255` and `.notNull()` exactly; add the `utf8mb4_bin` collation via drizzle's column collation option for the mysql varchar type. Do NOT touch any other column, index, or table.
GREEN (migrate — [BLOCKING], must run before the test passes) — From repo root, with the env prelude loaded, run `pnpm --filter @familysync/api db:generate`. Inspect the newly emitted SQL file under `apps/api/src/db/migrations/` (next sequential number, e.g. `0002_*.sql`): it MUST be a single non-destructive `ALTER TABLE list_items MODIFY ... rank varchar(255) ... COLLATE utf8mb4_bin NOT NULL` (or drizzle's equivalent MODIFY/CHANGE form) with NO DROP/TRUNCATE and NO change to length or nullability. If generate emits anything destructive, STOP and report — do not edit the SQL by hand to hide it. Then apply with `pnpm --filter @familysync/api db:migrate`. NEVER run `db:push`.
After migrate, re-run the regression test — it now passes because DB `ORDER BY rank` under `utf8mb4_bin` matches JS order.
</action>
<verify>
<automated>set -a; . ./apps/api/.env 2>/dev/null; set +a; export DB_HOST=127.0.0.1 DB_PORT=3306; pnpm --filter @familysync/api exec vitest run tests/routes/lists.test.ts -t "collation regression"</automated>
</verify>
<acceptance_criteria>
- The new test exists in the LIST-03 reorder describe block and asserts the `'Zz'`-ranked item is returned at index 0 from GET items.
- A new migration file exists under `apps/api/src/db/migrations/` whose body is an `ALTER TABLE list_items` MODIFY/CHANGE statement containing `utf8mb4_bin`, with zero occurrences of `DROP` or `TRUNCATE` (verify: `grep -ciE 'drop|truncate' apps/api/src/db/migrations/0002_*.sql` returns `0`).
- `apps/api/src/db/schema.ts` line for `rank` contains `utf8mb4_bin` (verify: `grep -c 'utf8mb4_bin' apps/api/src/db/schema.ts` returns `>= 1`).
- Live DB confirms the fix: a query of `information_schema.columns` for `list_items.rank` reports collation `utf8mb4_bin`.
- The full reorder describe block (including the pre-existing a0a5 tests) still passes — no regression.
</acceptance_criteria>
<done>Drag-to-top persists: an uppercase-prefixed rank now sorts above lowercase ranks in the DB, matching JS order. LIST-03 gap closed; migration is additive (generate+migrate, no push).</done>
</task>
<task type="tdd" tdd="true">
<name>Task 2: RED → GREEN — owner-only guard on PATCH isShared (T-04-08 / T-04-05)</name>
<files>apps/api/tests/routes/lists.test.ts, apps/api/src/routes/lists.ts</files>
<read_first>
- apps/api/src/routes/lists.ts:319-393 — PATCH `/:id` handler. `checkListAccess` (line 327) returns `{ allowed: true, isOwner: boolean, listRow }` for owner OR sharee. The `isShared` reconciliation block (lines 344-369) runs unconditionally for any allowed user. The DELETE handler at line 418 already uses `if (!access.isOwner)` as the exact guard idiom to mirror.
- apps/api/src/routes/lists.ts:121-152 — `checkListAccess` return shape; `isOwner` is the authoritative owner flag (true only when `listRow.ownerId === currentUserId`).
- apps/api/tests/routes/lists.test.ts:364-406, 438-450 — existing isShared toggle tests (all run as OWNER) and the "sharee can rename" test. There is NO test where a sharee toggles `isShared` — that path (WR-04) is uncovered; the existing 403-patch test (397-406) uses a non-sharee, caught earlier by `checkListAccess`.
- 04-SECURITY.md "Open Threat Detail" — the exact required guard and its placement (after the access check at lines 327-332, before the reconciliation).
</read_first>
<action>
RED — Add a negative test in the PATCH describe block of `lists.test.ts`. Title it for the threat (e.g. "T-04-08: sharee sending { isShared } gets 403 and list_shares is unchanged"). It must:
- seed an owner and a sharee, seed a SHARED list (`isShared: true`), `shareList(listId, shareeId)`;
- set `currentDevUserId = shareeId`;
- PATCH `/api/lists/:id` with `{ isShared: false }` and assert status `403`;
- assert the response body error mentions owner/sharing (the guard's message);
- assert `list_shares` for the list is UNCHANGED — the sharee row still exists (query `listShares` where `listId` and `userId = shareeId`, expect length `1`). This proves the destructive delete did not run.
Add a second assertion path (same or sibling test): a sharee sending `{ isShared: true }` on a private-but-shared scenario likewise gets `403` and inserts no new shares. Run before the fix and confirm it FAILS (currently 200 + shares wiped) — RED proof.
Preserve the existing owner-path tests at lines 364-395: they must still pass (owner toggling isShared continues to work).
GREEN — In `lists.ts`, immediately after the access check (the `if (!access.allowed)` block ending ~line 332) and BEFORE any update/reconciliation, add an owner-only guard: when `patch.isShared !== undefined && !access.isOwner`, return `c.json({ error: 'Only the list owner can change sharing settings' }, 403)`. This blocks both the `updateValues.isShared` write and the reconciliation block for non-owners. A sharee may still PATCH `{ name }` (the rename test at 438-450 must stay green). Update the stale inline comment at line 344 ("owner only affects shares") so it reflects the now-real guard rather than asserting a guard that didn't exist.
</action>
<verify>
<automated>set -a; . ./apps/api/.env 2>/dev/null; set +a; export DB_HOST=127.0.0.1 DB_PORT=3306; pnpm --filter @familysync/api exec vitest run tests/routes/lists.test.ts -t "isShared"</automated>
</verify>
<acceptance_criteria>
- New test asserts a sharee PATCHing `{ isShared: false }` receives HTTP `403` AND the sharee's `list_shares` row still exists afterward (length `1`).
- New test asserts a sharee PATCHing `{ isShared: true }` receives `403` and no new shares are inserted.
- `apps/api/src/routes/lists.ts` PATCH handler contains a guard referencing `access.isOwner` and `patch.isShared` that returns 403 (verify: `grep -n "patch.isShared !== undefined && !access.isOwner" apps/api/src/routes/lists.ts` returns a match before line 342).
- Existing owner-path isShared toggle tests (false→true, true→false) and the sharee-rename test still pass.
- Full API suite green: `pnpm --filter @familysync/api exec vitest run` reports 0 failures.
</acceptance_criteria>
<done>A non-owner sharee can no longer mutate list_shares via PATCH isShared; T-04-08 and T-04-05 are closed. The owner-only sharing-mutation invariant is enforced and regression-tested (WR-04 now covered).</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description | Data Crossing |
| -------------------------------------- | ---------------------------------------------------------------------------------- | ------------------------------- |
| Browser → API (`PATCH /api/lists/:id`) | OIDC session cookie (Authelia) or dev-bypass; caller may be owner OR sharee | `{ name, isShared }` patch body |
| API → MariaDB | Drizzle parameterized queries (mysql2); `list_shares` mutated on visibility change | list_shares delete/insert rows |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
| --------- | ---------------------- | ------------------------------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| T-04-08 | Elevation of Privilege | `PATCH /api/lists/:id` isShared reconciliation (`lists.ts:344-369`) | mitigate | Owner-only guard after access check: `if (patch.isShared !== undefined && !access.isOwner) return 403`. A sharee can no longer delete/insert `list_shares`. Verified by negative test asserting 403 + unchanged shares. |
| T-04-05 | Elevation of Privilege | sharee performing owner-only sharing mutation via direct id | mitigate | Same owner-only guard closes the shared root cause; sharee retains read + name-edit + item-edit access (already gated/tested), but is blocked from the owner-only sharing mutation. |
| T-04-SC | Tampering | npm/pnpm installs during this plan | accept | This plan installs NO new packages (schema collation + route guard + tests only). No supply-chain surface added. |
</threat_model>
<verification>
Phase-level checks after both tasks:
1. **Full API suite (real DB):** `set -a; . ./apps/api/.env 2>/dev/null; set +a; export DB_HOST=127.0.0.1 DB_PORT=3306; pnpm --filter @familysync/api exec vitest run` → 0 failures (was 181 passing; now 183+ with two new cases).
2. **Migration is additive:** `grep -ciE 'drop|truncate' apps/api/src/db/migrations/0002_*.sql``0`.
3. **Collation applied in DB:** query `information_schema.columns` for `list_items.rank` → collation `utf8mb4_bin`.
4. **No push used:** confirm the change was applied via `db:migrate` (a new numbered SQL file exists in `apps/api/src/db/migrations/`), not `db:push`.
5. **Typecheck/build clean:** `pnpm --filter @familysync/api typecheck`.
</verification>
<success_criteria>
- LIST-03 drag-to-top persists across refetch (uppercase-prefixed rank sorts correctly) — verified by the collation regression test against the real DB.
- T-04-08 and T-04-05 closed: a non-owner sharee receives 403 on PATCH `{ isShared }` and `list_shares` is untouched — verified by the negative test.
- The rank column carries `COLLATE utf8mb4_bin` in both `schema.ts` and the live DB, applied via a non-destructive generate+migrate (no push, no DROP/TRUNCATE).
- All pre-existing Phase 4 tests still pass (181 prior API tests + new cases; no regression).
- 04-SECURITY.md can move to `threats_open: 0`; 04-VERIFICATION.md LIST-03 gap resolved.
</success_criteria>
## Artifacts this phase produces
| Artifact | Type | Detail |
| ---------------------------------------------------------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `apps/api/src/db/migrations/0002_*.sql` (next sequential number) | NEW migration | `ALTER TABLE list_items` MODIFY `rank` to `COLLATE utf8mb4_bin`; additive, no DROP/TRUNCATE |
| `apps/api/src/db/schema.ts``listItems.rank` collation | EDIT | `varchar('rank', { length: 255 })` gains explicit `utf8mb4_bin` collation; length/notNull preserved |
| `apps/api/src/routes/lists.ts` — owner-only isShared guard | NEW guard | `if (patch.isShared !== undefined && !access.isOwner) return c.json({ error: 'Only the list owner can change sharing settings' }, 403)` after access check, before reconciliation |
| `lists.test.ts` — "collation regression" test (LIST-03) | NEW test | seeds `Zz` rank via drag-to-top PATCH; asserts GET returns it at index 0 |
| `lists.test.ts` — "T-04-08 sharee 403" test | NEW test | sharee PATCH `{ isShared }` → 403; `list_shares` unchanged (false→ and true→ paths) |
<output>
Create `.planning/phases/04-shared-lists-live-sync/04-07-SUMMARY.md` when done.
</output>