docs(03): create phase plan (8 plans, 5 waves)
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
---
|
||||
phase: 03-event-write-back-pwa-install
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["03-01"]
|
||||
files_modified:
|
||||
- apps/api/src/routes/events.ts
|
||||
- apps/api/tests/routes/events.test.ts
|
||||
autonomous: true
|
||||
requirements: [CAL-04, CAL-05, CAL-06, CAL-07]
|
||||
user_setup: []
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "POST /api/events/create validates input, resolves the writable target calendar, enqueues a pending outbox row, and returns 202"
|
||||
- "PATCH /api/events/:uid/edit and DELETE /api/events/:uid enqueue update/delete outbox rows with the cached etag"
|
||||
- "A member cannot enqueue a write to a calendar they do not own (403) — D-03 / V4 access control"
|
||||
- "GET /api/events/sync-status?uid= returns the outbox status for that member's UID"
|
||||
- "Edit that changes the target calendar enqueues a linked delete+create pair in one transaction (D-04)"
|
||||
artifacts:
|
||||
- path: "apps/api/src/routes/events.ts"
|
||||
provides: "create/edit/delete write endpoints + sync-status, all enqueue-only (broker boundary)"
|
||||
contains: "/sync-status"
|
||||
key_links:
|
||||
- from: "apps/api/src/routes/events.ts"
|
||||
to: "calendarOutbox"
|
||||
via: "db.insert(calendarOutbox)"
|
||||
pattern: "calendarOutbox"
|
||||
- from: "apps/api/src/routes/events.ts"
|
||||
to: "calendars (ownership check)"
|
||||
via: "WHERE userId = currentUser.id"
|
||||
pattern: "calendars\\.userId"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Add the write API surface to the events router: `POST /create`, `PATCH /:uid/edit`,
|
||||
`DELETE /:uid`, and `GET /sync-status`. Every write endpoint validates with zod,
|
||||
asserts the target calendar belongs to the current member (D-03), and ENQUEUES an
|
||||
outbox row — it never calls Fastmail (broker boundary, D-12). The endpoints return 202
|
||||
immediately so the UI can optimistically accept (D-05). sync-status exposes the outbox
|
||||
state for the polled toast (D-09).
|
||||
|
||||
Purpose: this is the backend half of the create/edit/delete vertical slices. It depends
|
||||
only on the outbox schema (Plan 01); it does not import the worker or write.ts (those
|
||||
drain the queue the endpoints fill).
|
||||
|
||||
Output: extended events.ts, GREEN against the create/edit/delete/sync-status tests from Plan 01.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/03-event-write-back-pwa-install/03-RESEARCH.md
|
||||
@.planning/phases/03-event-write-back-pwa-install/03-PATTERNS.md
|
||||
@apps/api/src/routes/events.ts
|
||||
@apps/api/src/routes/me.ts
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: GREEN — write endpoints (create/edit/delete) with ownership enforcement</name>
|
||||
<files>apps/api/src/routes/events.ts, apps/api/tests/routes/events.test.ts</files>
|
||||
<read_first>
|
||||
- apps/api/tests/routes/events.test.ts (RED stubs from Plan 01 for create/edit/delete + 403 ownership)
|
||||
- apps/api/src/routes/events.ts (existing — header invariant comment, Hono+zValidator pattern, GET handler shape to mirror)
|
||||
- apps/api/src/routes/me.ts (lines ~29-49 — dev-bypass + getAuth current-user pattern; side-effect import of devBypass.js)
|
||||
- .planning/phases/03-event-write-back-pwa-install/03-RESEARCH.md (§Security Domain V4/V5 — ownership check + zod bounds; §Pitfall 5 — edit-as-move pair in one transaction)
|
||||
- .planning/phases/03-event-write-back-pwa-install/03-PATTERNS.md (§events.ts, §Auth guard in write route handlers, §Drizzle DB mock in tests)
|
||||
</read_first>
|
||||
<action>
|
||||
Extend `eventsRouter` (keep the existing GET / and the broker-boundary header comment — append a note that write endpoints enqueue only). Import `calendarOutbox` from `../db/schema.js`, `and`/`eq` from drizzle-orm, and the auth helpers per me.ts (`getAuth`, side-effect `import '../auth/devBypass.js'`). Resolve the current member id via the dev-bypass `c.get('user')` path then `getAuth(c)` fallback (401 if neither).
|
||||
|
||||
Define zod schemas with bounded lengths (V5): `title` 1..255, `location`/`description` optional max 2000, `allDay` boolean, `start`/`end` ISO strings, optional `recurrence` enum (`none|daily|weekly|monthly|yearly`), optional `calendarUrl`. Use `@hono/zod-validator` `zValidator('json', schema)`.
|
||||
|
||||
POST `/create`: resolve the writable target calendar — if `calendarUrl` given, assert a row in `calendars WHERE url=calendarUrl AND (userId=currentUser.id OR isShared=1)`; else default to the member's personal calendar (`calendars WHERE userId=currentUser.id` first row; D-01 last-used is a frontend concern). Reject a non-owned, non-shared calendar with 403 (D-03 / V4). Insert a `calendarOutbox` row `{ userId, operation:'create', status:'pending', uid: <generated or client-omitted; the worker builds VEVENT>, calendarUrl, payload: JSON of the validated event fields }`. Return `c.json({ uid }, 202)`.
|
||||
|
||||
PATCH `/:uid/edit`: look up the cached event by uid joined to a calendar owned by the member; 404 if not found, 403 if not owned. Read `etag` and `objectUrl` from calendarEvents. If the request's target `calendarUrl` differs from the event's current calendar (calendar move, D-04): insert TWO outbox rows in a SINGLE `db.transaction` sharing a `groupId` — a `create` row (new calendarUrl) and a `delete` row (old calendarObjectUrl + etag). Otherwise insert one `update` row with `calendarObjectUrl`, `etag`, `payload`. Return 202.
|
||||
|
||||
DELETE `/:uid`: ownership check as above; insert a `delete` outbox row with `calendarObjectUrl` + `etag`. Return 202.
|
||||
|
||||
Do NOT build the VEVENT here and do NOT call Fastmail — the worker (Plan 04 wiring) does both. Wrap DB work in try/catch returning 503 per the existing pattern.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/luc/Projects/familysync && pnpm --filter @familysync/api test -- routes/events && pnpm --filter @familysync/api exec tsc --noEmit</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- create/edit/delete tests GREEN, each asserting a 202 and a `db.insert(calendarOutbox)` call.
|
||||
- The 403 ownership test GREEN: writing to a non-owned/non-shared calendar is rejected.
|
||||
- `grep -q "db.transaction" apps/api/src/routes/events.ts` (edit-as-move pair).
|
||||
- The existing GET /api/events tests remain GREEN.
|
||||
</acceptance_criteria>
|
||||
<done>create/edit/delete endpoints enqueue outbox rows, enforce D-03 ownership, return 202, and handle the edit-as-move pair transactionally; no Fastmail call in the route.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: GREEN — GET /api/events/sync-status polled endpoint (D-09)</name>
|
||||
<files>apps/api/src/routes/events.ts, apps/api/tests/routes/events.test.ts</files>
|
||||
<read_first>
|
||||
- apps/api/tests/routes/events.test.ts (RED sync-status stub from Plan 01)
|
||||
- .planning/phases/03-event-write-back-pwa-install/03-RESEARCH.md (§Pattern 8 — sync-status request/response shape)
|
||||
</read_first>
|
||||
<action>
|
||||
Add `eventsRouter.get('/sync-status', zValidator('query', z.object({ uid: z.string().min(1).max(512) })), ...)`. Resolve current member (same auth pattern). Select the most recent `calendarOutbox` row `WHERE userId=currentUser.id AND uid=:uid` ordered by `createdAt` desc, limit 1. Return `c.json({ uid, status, error: lastError ?? undefined })` where status ∈ pending|done|failed|dead. If no row, return `{ uid, status: 'done' }` (nothing pending → treat as settled). Scope strictly to the member's own rows (V4 — never leak another member's outbox).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/luc/Projects/familysync && pnpm --filter @familysync/api test -- routes/events && grep -q "/sync-status" apps/api/src/routes/events.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- sync-status test GREEN: returns the outbox status for a given uid scoped to the member.
|
||||
- `grep -c "/sync-status" apps/api/src/routes/events.ts` ≥1.
|
||||
</acceptance_criteria>
|
||||
<done>GET /api/events/sync-status returns the member-scoped outbox status; tests GREEN.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| client → write API | Untrusted member input (event fields, target calendar, uid) crosses here |
|
||||
| member A → member B data | A member must never write to or read another member's outbox/calendar |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-03-06 | Elevation of Privilege | write to another member's personal calendar | mitigate | Route asserts `calendars.userId === currentUser.id OR isShared=1` before enqueue; else 403 (D-03 / ASVS V4) |
|
||||
| T-03-07 | Information Disclosure | sync-status leaking another member's outbox row | mitigate | sync-status query filtered `WHERE userId = currentUser.id` |
|
||||
| T-03-08 | Tampering | XSS/oversized payload via title/location/description | mitigate | zod length bounds (title 255, location/description 2000); plain-text storage; rendered as JSX children downstream |
|
||||
| T-03-09 | Tampering | SQL injection via uid/calendarUrl | mitigate | Drizzle parameterized queries; no string interpolation |
|
||||
| T-03-10 | Spoofing | client-supplied etag bypassing conflict detection | mitigate | etag read from calendarEvents server-side at enqueue; client never supplies it |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `pnpm --filter @familysync/api test -- routes/events` GREEN (create, edit, delete, sync-status, 403 ownership).
|
||||
- `pnpm --filter @familysync/api exec tsc --noEmit` passes.
|
||||
- No tsdav import in events.ts (broker boundary): `grep -c "tsdav\|createFastmailClient" apps/api/src/routes/events.ts` returns 0.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- All four write/status endpoints enqueue-only and member-scoped.
|
||||
- D-03 ownership enforced; D-04 edit-as-move pair transactional; D-09 polling endpoint live.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/03-event-write-back-pwa-install/03-03-SUMMARY.md` when done.
|
||||
</output>
|
||||
Reference in New Issue
Block a user