Files
familysync/.planning/phases/03-event-write-back-pwa-install/03-03-PLAN.md
T

14 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, user_setup, must_haves
phase plan type wave depends_on files_modified autonomous requirements user_setup must_haves
03-event-write-back-pwa-install 03 execute 2
03-01
apps/api/src/routes/events.ts
apps/api/tests/routes/events.test.ts
true
CAL-04
CAL-05
CAL-06
CAL-07
truths artifacts key_links
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)
GET /api/events/writable-calendars returns the member's writable set per D-03 — own personal + shared Family (read-write); never the other member's read-only personal
path provides contains
apps/api/src/routes/events.ts create/edit/delete write endpoints + sync-status + writable-calendars, all enqueue-only (broker boundary) /writable-calendars
from to via pattern
apps/api/src/routes/events.ts calendarOutbox db.insert(calendarOutbox) calendarOutbox
from to via pattern
apps/api/src/routes/events.ts calendars (ownership check) WHERE userId = currentUser.id calendars.userId
Add the write API surface to the events router: `POST /create`, `PATCH /:uid/edit`, `DELETE /:uid`, `GET /sync-status`, and `GET /writable-calendars`. 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). writable-calendars exposes the member's authorized write target set (D-03) so the client picker (Plan 05) renders only legal targets and honors the D-02 single-calendar hide rule.

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). The writable-calendars endpoint is the authoritative owner of the D-03 writable-set authorization — the client never derives it.

Output: extended events.ts, GREEN against the create/edit/delete/sync-status/writable-calendars tests from Plan 01.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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 Task 1: GREEN — write endpoints (create/edit/delete) with ownership enforcement apps/api/src/routes/events.ts, apps/api/tests/routes/events.test.ts - 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) 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.
cd /home/luc/Projects/familysync && pnpm --filter @familysync/api test -- routes/events && pnpm --filter @familysync/api exec tsc --noEmit - 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. 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. Task 2: GREEN — GET /api/events/sync-status polled endpoint (D-09) apps/api/src/routes/events.ts, apps/api/tests/routes/events.test.ts - 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) 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). cd /home/luc/Projects/familysync && pnpm --filter @familysync/api test -- routes/events && grep -q "/sync-status" apps/api/src/routes/events.ts - 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. GET /api/events/sync-status returns the member-scoped outbox status; tests GREEN. Task 3: GREEN — GET /api/events/writable-calendars (D-03 writable set, authoritative) apps/api/src/routes/events.ts, apps/api/tests/routes/events.test.ts - apps/api/tests/routes/events.test.ts (extend — add a `GET /api/events/writable-calendars` describe block alongside the create/edit/delete/sync-status stubs) - apps/api/src/routes/events.ts (existing GET / handler — mirror its auth + db.select + try/catch shape) - apps/api/src/db/schema.ts (`calendars` table — `url`, `displayName`, `color`, `userId`, `isShared` columns) - .planning/phases/03-event-write-back-pwa-install/03-RESEARCH.md (§Open Questions Q3 — writable-set resolution query; §Security Domain V4 — D-03 access control) - .planning/phases/03-event-write-back-pwa-install/03-CONTEXT.md (D-02 picker-visibility, D-03 writable set) - .planning/phases/03-event-write-back-pwa-install/03-PATTERNS.md (§events.ts, §Auth guard in write route handlers) Add `eventsRouter.get('/writable-calendars', ...)`. Resolve the current member id with the same dev-bypass + `getAuth(c)` pattern as the write endpoints (401 if neither). This endpoint is the AUTHORITATIVE owner of the D-03 writable-set authorization — the client (Plan 05) consumes it verbatim and never derives the set itself.
Per RESEARCH.md Open Q3: select the writable set = rows in `calendars WHERE userId = currentUser.id` (the member's own personal calendar(s)) UNION rows WHERE `isShared = 1` (the shared Family calendar, when read-write to the household). Express this as a single Drizzle query with `WHERE eq(calendars.userId, currentUser.id) OR eq(calendars.isShared, true)`. The other member's personal calendar (a row with a different `userId` and `isShared = 0/false`) MUST NOT appear — it is a read-only overlay only (D-03), never a write target.

Map each row to the response shape `{ calendars: [{ url, displayName, color, isShared }] }` (exactly the `WritableCalendar` shape Plan 05's `fetchWritableCalendars` consumes). Wrap the db work in try/catch returning 503 per the existing GET handler pattern. Do NOT include any Fastmail call (broker boundary).
cd /home/luc/Projects/familysync && pnpm --filter @familysync/api test -- routes/events && grep -q "/writable-calendars" apps/api/src/routes/events.ts && pnpm --filter @familysync/api exec tsc --noEmit - writable-calendars test GREEN: returns only the member's own personal calendar(s) plus the shared (`isShared=1`) calendar. - The test asserts another member's personal calendar (different userId, isShared=false) is NEVER returned (D-03 / V4). - Response items expose `url`, `displayName`, `color`, `isShared` (the picker's `WritableCalendar` shape). - `grep -c "/writable-calendars" apps/api/src/routes/events.ts` ≥1. GET /api/events/writable-calendars returns the D-03 writable set (own personal + shared Family), never another member's read-only personal; response matches the Plan 05 WritableCalendar shape; tests GREEN.

<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, treat-as-writable, 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
T-03-11 Elevation of Privilege writable-calendars surfacing another member's personal calendar as a write target mitigate Query restricted to userId = currentUser.id OR isShared = true; another member's isShared=false personal row is never returned; client treats the response as authoritative and the write endpoints re-enforce D-03 on enqueue
</threat_model>
- `pnpm --filter @familysync/api test -- routes/events` GREEN (create, edit, delete, sync-status, writable-calendars, 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.

<success_criteria>

  • All five write/status/writable-calendars endpoints enqueue-only and member-scoped.
  • D-03 ownership enforced on both the write path and the writable-calendars listing; D-04 edit-as-move pair transactional; D-09 polling endpoint live. </success_criteria>
Create `.planning/phases/03-event-write-back-pwa-install/03-03-SUMMARY.md` when done.