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.
19 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, gap_closure, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | gap_closure | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 04-shared-lists-live-sync | 07 | tdd | 6 |
|
|
true | true |
|
|
-
LIST-03 drag-to-top (rank collation) —
list_items.rankinherited the case-insensitive DB default collation (utf8mb4_uca1400_ai_ci).fractional-indexingemits uppercase-prefixed keys (e.g.Zz) on drag-to-top, which MariaDB sorts AFTER lowercasea…ranks even though JS sorts it BEFORE. The dragged item snaps to the bottom on refetch. Fix: migrate the column toCOLLATE utf8mb4_binso DBORDER BY rankmatches JS string order. -
T-04-08 / T-04-05 (security BLOCKER) — The PATCH
/:idisSharedreconciliation 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 sendsisShared.
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.
<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_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<hard_constraints>
- MariaDB only. NEVER
drizzle-kit push(pnpm db:push).pushemits a false destructive diff that truncates populated tables. Usepnpm --filter @familysync/api db:generateto emit the migration SQL, thenpnpm --filter @familysync/api db:migrateto 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. Preservevarchar(255),NOT NULL, and existing default/index semantics exactly. - API integration tests live in
apps/api/tests/(NEVERsrc/) 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 sameDB_*env vars (seedrizzle.config.ts). <action>blocks below name identifiers and behavior only — no fenced code blocks / full implementations. </hard_constraints>
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.
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.
<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>
Phase-level checks after both tasks:- 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). - Migration is additive:
grep -ciE 'drop|truncate' apps/api/src/db/migrations/0002_*.sql→0. - Collation applied in DB: query
information_schema.columnsforlist_items.rank→ collationutf8mb4_bin. - No push used: confirm the change was applied via
db:migrate(a new numbered SQL file exists inapps/api/src/db/migrations/), notdb:push. - Typecheck/build clean:
pnpm --filter @familysync/api typecheck.
<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 }andlist_sharesis untouched — verified by the negative test. - The rank column carries
COLLATE utf8mb4_binin bothschema.tsand 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) |