docs(03): create phase plan (8 plans, 5 waves)

This commit is contained in:
Lucas Berger
2026-06-05 16:57:01 -04:00
parent 6f5110266d
commit 9dd08d28d1
9 changed files with 1438 additions and 1 deletions
+25 -1
View File
@@ -93,7 +93,31 @@ Plans:
5. The installed PWA opens full-screen without browser chrome on both iOS and Android
6. **(Carried from Phase 1 Gate 2, D-14)** Live Authelia OIDC login works over the public Pangolin URL — including the **iOS standalone-PWA** flow: the wife can install to Home Screen and complete login without the redirect breaking out of standalone mode; sessions persist (AUTH-01/02) and members get distinct stable colors (AUTH-03). Verify per `docs/deployment.md` Gate 2 checklist; this is the first real external deploy (local Newt test rig is sufficient — Unraid prod is optional until go-live).
**Plans**: TBD
**Plans**: 8 plans
Plans:
**Wave 1**
- [ ] 03-01-PLAN.md — Foundation: calendarOutbox table + calendarEvents.objectUrl (pushed), vite-plugin-pwa install + legitimacy gate, sync.ts objectUrl, full Wave 0 RED test scaffold
**Wave 2** *(blocked on Wave 1)*
- [ ] 03-02-PLAN.md — TDD: VEVENT builder (vevent.ts, D-13 DATE/DATETIME + RRULE) + tsdav write wrappers (write.ts, D-12 broker boundary)
- [ ] 03-03-PLAN.md — Write API: POST/PATCH/DELETE events + GET sync-status, enqueue-only, D-03 ownership, D-04 edit-as-move pair (CAL-04/05/06/07)
**Wave 3** *(blocked on Wave 2)*
- [ ] 03-04-PLAN.md — TDD: outbox worker state machine (D-05/06/07/08 retry/backoff/dead/conflict, edit-as-move ordering) + index.ts wiring
- [ ] 03-05-PLAN.md — Frontend create/edit slice: write client calls + Zustand keys + EventForm (D-01/02/11) + New Event FAB
- [ ] 03-07-PLAN.md — PWA install: VitePWA manifest + auth-safe SW denylist + icons + InstallPrompt (iOS walkthrough + Android prompt) (PWA-01/02)
**Wave 4** *(blocked on Wave 3)*
- [ ] 03-06-PLAN.md — Delete + sync feedback: popover Edit/Delete footer + DeleteConfirmationDialog + SyncStateToast polling (D-06/08/09) (CAL-05/06)
**Wave 5** *(blocked on Wave 4)*
- [ ] 03-08-PLAN.md — Gate 2 live verification: real Authelia OIDC over Pangolin + iOS standalone login + end-to-end Fastmail write round-trips (success criterion 6, D-14/D-15)
**UI hint**: yes
### Phase 4: Shared Lists + Live Sync
@@ -0,0 +1,245 @@
---
phase: 03-event-write-back-pwa-install
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- apps/api/src/db/schema.ts
- apps/api/src/broker/sync.ts
- apps/api/tests/broker/vevent.test.ts
- apps/api/tests/broker/write.test.ts
- apps/api/tests/broker/outboxWorker.test.ts
- apps/api/tests/routes/events.test.ts
- apps/pwa/src/components/InstallPrompt.test.tsx
- apps/pwa/package.json
- apps/pwa/vite.config.ts
autonomous: false
requirements: [CAL-04, CAL-05, CAL-06, CAL-07, PWA-01, PWA-02]
user_setup: []
must_haves:
truths:
- "calendar_outbox table exists in the live MariaDB schema after drizzle-kit push"
- "calendar_events has an object_url column populated by sync.ts from obj.url"
- "vite-plugin-pwa is installed and importable in apps/pwa"
- "All Wave 0 RED test files exist and fail (no implementation yet)"
artifacts:
- path: "apps/api/src/db/schema.ts"
provides: "calendarOutbox table + calendarEvents.objectUrl column"
contains: "calendarOutbox"
- path: "apps/api/tests/broker/vevent.test.ts"
provides: "RED stubs for VEVENT builder (CAL-04/CAL-07)"
- path: "apps/api/tests/broker/outboxWorker.test.ts"
provides: "RED stubs for outbox state machine (D-07/D-08/D-04)"
key_links:
- from: "apps/api/src/broker/sync.ts"
to: "calendarEvents.objectUrl"
via: "upsert sets objectUrl from obj.url"
pattern: "objectUrl"
---
<objective>
Stand up the shared foundation for Phase 3: the `calendarOutbox` table and the
`calendarEvents.objectUrl` column (both pushed live to MariaDB), the `vite-plugin-pwa`
dependency, and the complete Wave 0 RED test scaffold for every behavior this phase
implements. No write logic, no worker, no UI is built here — only the substrate the
later vertical slices stand on.
Purpose: D-05 (server-side outbox) and the CalDAV write path (CAL-04/05/06) cannot
exist without the outbox table and a stored CalDAV object URL. Per the Nyquist rule,
every implementing task in this phase references a test file that MUST exist (RED)
before implementation. This plan creates those files.
Output: extended schema (pushed), populated `objectUrl` on sync, installed PWA plugin,
five RED test files.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/03-event-write-back-pwa-install/03-CONTEXT.md
@.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/db/schema.ts
@apps/api/src/broker/sync.ts
</context>
<artifacts_this_phase_produces>
New symbols introduced across Phase 3 (excluded from drift verification):
- DB: `calendarOutbox` table (`calendar_outbox`), `calendarEvents.objectUrl` column (`object_url`)
- Backend files: `apps/api/src/broker/vevent.ts` (`buildVeventString`, `NewEventParams`), `apps/api/src/broker/write.ts` (`createCalendarEvent`, `updateCalendarEvent`, `deleteCalendarEvent`), `apps/api/src/broker/outboxWorker.ts` (`runOutboxDrain`, `startOutboxWorker`, `RRULE_PRESETS`)
- Backend routes: `POST /api/events/create`, `PATCH /api/events/:uid/edit`, `DELETE /api/events/:uid`, `GET /api/events/sync-status`
- Frontend files: `apps/pwa/src/components/EventForm.tsx`, `apps/pwa/src/components/InstallPrompt.tsx`, `apps/pwa/src/components/SyncStateToast.tsx`, `apps/pwa/src/components/DeleteConfirmationDialog.tsx`
- Frontend client fns: `createEvent`, `updateEvent`, `deleteEvent`, `fetchSyncStatus`, `fetchWritableCalendars`
- Zustand keys: `eventFormOpen`, `eventFormMode`, `eventFormUid`, `deleteDialogOpen`, `deleteDialogUid`, `lastSyncedUid`
- Dep: `vite-plugin-pwa` (+ peer `workbox-window`, `workbox-build`)
- PWA assets: `apps/pwa/public/icon-192.png`, `icon-512.png`, `apple-touch-icon.png`, generated `manifest.webmanifest` + service worker
</artifacts_this_phase_produces>
<tasks>
<task type="checkpoint:human-verify" gate="blocking-human">
<name>Task 1: [BLOCKING] Verify vite-plugin-pwa package legitimacy before install</name>
<read_first>
- .planning/phases/03-event-write-back-pwa-install/03-RESEARCH.md (§Package Legitimacy Audit — all three packages tagged [ASSUMED], slopcheck unavailable)
- apps/pwa/package.json (confirm vite-plugin-pwa not yet present)
- CLAUDE.md (§Recommended Stack — vite-plugin-pwa 1.3.0 is the locked PWA tooling)
</read_first>
<action>Verify legitimacy of vite-plugin-pwa and peers (workbox-window, workbox-build) before the Task 2 install per the steps below: npm version check + npmjs.com repository confirmation. This is the T-03-SC supply-chain gate, mandatory because RESEARCH.md tagged all three packages [ASSUMED] (slopcheck unavailable).</action>
<what-built>Nothing yet — this gate precedes the install. RESEARCH.md tagged `vite-plugin-pwa`, `workbox-window`, `workbox-build` as `[ASSUMED]` because slopcheck could not run. The legitimacy gate is mandatory before any package-manager install (T-03-SC).</what-built>
<how-to-verify>
1. Run `npm view vite-plugin-pwa version` and confirm it resolves to 1.3.0 (or newer 1.x).
2. Visit https://www.npmjs.com/package/vite-plugin-pwa — confirm repository is github.com/vite-pwa/vite-plugin-pwa, high weekly downloads, recent publish.
3. Confirm `workbox-window` and `workbox-build` resolve to github.com/GoogleChrome/workbox (Google-maintained).
4. Confirm `vite-plugin-pwa` appears in CLAUDE.md §Recommended Stack (project-approved).
</how-to-verify>
<acceptance_criteria>
- `npm view vite-plugin-pwa version` returns a 1.x version.
- Operator confirms the npm repository links match github.com/vite-pwa and github.com/GoogleChrome.
</acceptance_criteria>
<resume-signal>Type "approved" to proceed with install, or describe a mismatch.</resume-signal>
</task>
<task type="auto">
<name>Task 2: Extend Drizzle schema — calendarOutbox table + calendarEvents.objectUrl; install vite-plugin-pwa</name>
<files>apps/api/src/db/schema.ts, apps/pwa/package.json</files>
<read_first>
- apps/api/src/db/schema.ts (existing — imports at lines 1-12; calendarEvents table lines 80-130; calendars/users for references())
- .planning/phases/03-event-write-back-pwa-install/03-RESEARCH.md (§Pattern 3 — outbox column definitions, indexes; §Open Questions Q2 — objectUrl)
- .planning/phases/03-event-write-back-pwa-install/03-PATTERNS.md (§schema.ts — exact import + table + references patterns)
</read_first>
<action>
Add `mysqlEnum` to the `drizzle-orm/mysql-core` import in schema.ts (existing import block has mysqlTable, varchar, text, int, date, timestamp, boolean, index, unique).
Add a new exported `calendarOutbox = mysqlTable('calendar_outbox', {...})` per RESEARCH.md Pattern 3 with columns: `id` (int autoincrement PK), `userId` int('user_id') notNull references users.id onDelete cascade, `operation` mysqlEnum(['create','update','delete']) notNull, `status` mysqlEnum(['pending','done','failed','dead']) notNull default 'pending', `uid` varchar(512) notNull, `calendarUrl` varchar('calendar_url',1024) notNull, `calendarObjectUrl` varchar('calendar_object_url',1024) (nullable), `etag` varchar(256) (nullable), `payload` text (nullable), `attemptCount` int('attempt_count') notNull default 0, `nextAttemptAt` timestamp('next_attempt_at') defaultNow notNull, `lastError` text('last_error'), `createdAt` timestamp defaultNow notNull, `updatedAt` timestamp onUpdateNow. Add a `groupId` varchar('group_id', 64) nullable column to link the delete+create pair for edit-as-move (D-04, RESEARCH.md Pitfall 5). Add three indexes: `idx_outbox_user_status` on (userId, status), `idx_outbox_next_attempt` on (nextAttemptAt, status), `idx_outbox_uid` on (uid).
On the existing `calendarEvents` table, add `objectUrl: varchar('object_url', { length: 1024 })` (nullable) immediately after the `etag` column — this stores the CalDAV object URL for If-Match update/delete (D-08, RESEARCH.md Open Q2).
From the apps/pwa directory, install vite-plugin-pwa: `pnpm --filter @familysync/pwa add vite-plugin-pwa` (workbox-window and workbox-build install as peer deps). Do NOT configure the plugin yet (that is Plan 06).
</action>
<verify>
<automated>cd /home/luc/Projects/familysync && pnpm --filter @familysync/api exec tsc --noEmit && grep -q "calendar_outbox" apps/api/src/db/schema.ts && grep -q "object_url" apps/api/src/db/schema.ts && grep -q '"vite-plugin-pwa"' apps/pwa/package.json</automated>
</verify>
<acceptance_criteria>
- `grep -c "calendar_outbox" apps/api/src/db/schema.ts` returns ≥1.
- `grep -c "object_url" apps/api/src/db/schema.ts` returns ≥1.
- `apps/pwa/package.json` dependencies/devDependencies include `vite-plugin-pwa`.
- `pnpm --filter @familysync/api exec tsc --noEmit` exits 0.
</acceptance_criteria>
<done>calendarOutbox table and calendarEvents.objectUrl exist in schema.ts; vite-plugin-pwa installed; types compile.</done>
</task>
<task type="auto">
<name>Task 3: Populate calendarEvents.objectUrl in sync.ts</name>
<files>apps/api/src/broker/sync.ts</files>
<read_first>
- apps/api/src/broker/sync.ts (existing — the calendarEvents upsert at lines ~100-130 sets etag from obj.etag; objectUrl is added alongside)
- .planning/phases/03-event-write-back-pwa-install/03-RESEARCH.md (§Open Questions Q1/Q2 — obj.url is returned by tsdav fetchCalendarObjects)
</read_first>
<action>
In `syncCalendar`, in the `for (const obj of objects)` loop, set `objectUrl: obj.url ?? null` in BOTH the `.values({...})` block and the `.onDuplicateKeyUpdate({ set: {...} })` block of the calendarEvents upsert, right next to the existing `etag: obj.etag ?? null` lines. `obj.url` is the CalDAV object URL needed by update/delete (D-08). Do not change any other behavior; D-13 DATE/DATETIME split is unaffected.
</action>
<verify>
<automated>cd /home/luc/Projects/familysync && grep -c "objectUrl: obj.url" apps/api/src/broker/sync.ts | grep -qx 2 && pnpm --filter @familysync/api test -- broker/sync</automated>
</verify>
<acceptance_criteria>
- `grep -c "objectUrl: obj.url" apps/api/src/broker/sync.ts` returns exactly 2 (values + onDuplicateKeyUpdate).
- Existing `broker/sync` test suite stays green.
</acceptance_criteria>
<done>sync.ts stores obj.url into calendarEvents.objectUrl on every upsert; sync tests pass.</done>
</task>
<task type="auto" tdd="true">
<name>Task 4: Create Wave 0 RED test scaffold for all Phase 3 behaviors</name>
<files>apps/api/tests/broker/vevent.test.ts, apps/api/tests/broker/write.test.ts, apps/api/tests/broker/outboxWorker.test.ts, apps/api/tests/routes/events.test.ts, apps/pwa/src/components/InstallPrompt.test.tsx</files>
<read_first>
- .planning/phases/03-event-write-back-pwa-install/03-RESEARCH.md (§Validation Architecture — Phase Requirements → Test Map; Wave 0 Gaps list)
- .planning/phases/03-event-write-back-pwa-install/03-PATTERNS.md (§Drizzle DB mock in tests, §OIDC mock in tests — exact vi.mock shapes)
- apps/api/tests/routes/events.test.ts (existing — extend, do not overwrite; copy its db + oidc mock setup)
- apps/api/tests/broker/sync.test.ts (analog for outboxWorker.test.ts structure)
</read_first>
<behavior>
Write FAILING (RED) tests — import the not-yet-existing modules so they error/fail. Cover, per RESEARCH.md Test Map:
- vevent.test.ts: buildVeventString produces VCALENDAR with VEVENT for a timed event (DTSTART with Z/UTC); for an all-day event a DATE value (no time component, no TZID) per D-13; with rruleString produces an RRULE property (CAL-04, CAL-07).
- write.test.ts: createCalendarEvent calls client.createCalendarObject with `${uid}.ics` filename; updateCalendarEvent passes etag into the calendarObject (If-Match); deleteCalendarEvent passes etag; each returns the raw Response (mock client).
- outboxWorker.test.ts: runOutboxDrain transitions pending→done on mock 204; pending→failed on mock 412 (and triggers re-sync, no retry); pending→backoff (nextAttemptAt advanced, attemptCount++) on mock 500; pending→dead at MAX_ATTEMPTS; edit-as-move emits a create row processed BEFORE the linked delete row (D-04/D-07/D-08).
- events.test.ts (extend existing): POST /api/events/create returns 202 + inserts a pending outbox row; PATCH /api/events/:uid/edit returns 202 + inserts row with etag; DELETE /api/events/:uid returns 202 + inserts delete row; GET /api/events/sync-status?uid= returns the outbox status; create rejects writing to a calendar not owned by the user with 403 (D-03 / V4 access control).
- InstallPrompt.test.tsx: isIOSSafariNonStandalone() returns true for a mock iOS Safari non-standalone UA and false in standalone; useAndroidInstallPrompt sets canInstall=true when a mock beforeinstallprompt event dispatches.
</behavior>
<action>
Create the five test files with the behaviors above using Vitest. Use the existing Drizzle and OIDC mock patterns from PATTERNS.md verbatim. Where the implementation module does not exist yet, the import will fail — that is the intended RED state. For events.test.ts, EXTEND the existing file (append new describe blocks); do not delete existing GET /api/events tests. Mark any behavior that is manual-only (none here — Gate 2 manual checks live in Plan 07) out of scope. Do NOT write implementation code in this plan.
</action>
<verify>
<automated>cd /home/luc/Projects/familysync && test -f apps/api/tests/broker/vevent.test.ts && test -f apps/api/tests/broker/write.test.ts && test -f apps/api/tests/broker/outboxWorker.test.ts && test -f apps/pwa/src/components/InstallPrompt.test.tsx && (pnpm --filter @familysync/api test -- broker/vevent 2>&1 | grep -Eq "fail|error|No test|Cannot find")</automated>
</verify>
<acceptance_criteria>
- All five test files exist.
- `pnpm --filter @familysync/api test -- broker/vevent` reports failures or unresolved imports (RED — implementation not present).
- The existing GET /api/events describe block is still present in events.test.ts (`grep -c "GET /api/events" apps/api/tests/routes/events.test.ts` ≥1).
</acceptance_criteria>
<done>Five RED test files exist and fail because their target modules are unimplemented; existing tests preserved.</done>
</task>
<task type="checkpoint:human-action" gate="blocking-human">
<name>Task 5: [BLOCKING] Push schema to MariaDB (drizzle-kit push)</name>
<read_first>
- apps/api/src/db/schema.ts (modified — must contain calendarOutbox + objectUrl before push)
- .planning/STATE.md (§Pending Todos — local-dev env requires sourcing .env and DB_HOST=localhost)
</read_first>
<action>Run the Drizzle schema push against the live MariaDB so the calendar_outbox table and calendar_events.object_url column exist before verification (types compile from the schema file, not the DB, so this is mandatory). Use the env-loaded push command below; abort on any reported destructive operation.</action>
<what-built>The schema file now declares the `calendar_outbox` table and `calendar_events.object_url` column. The live MariaDB has NOT been altered — types compile from the schema file, not the live DB, so verification would falsely pass without this push.</what-built>
<how-to-verify>
1. Run the push (env must be loaded, MariaDB up): `set -a; source .env; set +a && DB_HOST=localhost pnpm --filter @familysync/api exec drizzle-kit push`.
2. If drizzle-kit prompts for confirmation on a non-destructive create, accept it. If it reports a DESTRUCTIVE change, STOP and report — do not drop data.
3. Confirm the table exists: `mysql ... -e "SHOW TABLES LIKE 'calendar_outbox'; SHOW COLUMNS FROM calendar_events LIKE 'object_url';"`.
</how-to-verify>
<acceptance_criteria>
- `SHOW TABLES LIKE 'calendar_outbox'` returns one row.
- `SHOW COLUMNS FROM calendar_events LIKE 'object_url'` returns one row.
- drizzle-kit push reported no unexpected destructive operation.
</acceptance_criteria>
<resume-signal>Type "pushed" once the table and column exist in MariaDB, or report a destructive-change warning.</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| package registry → build | New npm dependency (vite-plugin-pwa) enters the supply chain |
| schema file → live DB | drizzle-kit push mutates the production schema |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-03-SC | Tampering | vite-plugin-pwa + workbox peer deps install | mitigate | Blocking human-verify legitimacy checkpoint (Task 1) before install; `npm view` version check; npmjs.com repo confirmation |
| T-03-01 | Tampering | drizzle-kit push | mitigate | Blocking human-action checkpoint (Task 5); abort on any reported destructive operation |
| T-03-02 | Information Disclosure | calendar_outbox stores payload/etag | accept | Outbox rows are server-side only, never exposed to frontend; payload is the member's own VEVENT |
</threat_model>
<verification>
- `pnpm --filter @familysync/api exec tsc --noEmit` passes.
- `calendar_outbox` table and `calendar_events.object_url` exist in live MariaDB (Task 5).
- vite-plugin-pwa present in apps/pwa/package.json.
- Five RED test files exist and fail (no implementation).
</verification>
<success_criteria>
- Outbox table + objectUrl column pushed live (the schema-push blocking requirement is satisfied here for the schema introduced this wave).
- PWA tooling installed and legitimacy-gated.
- Complete Wave 0 RED scaffold in place for every later implementing task.
</success_criteria>
<output>
Create `.planning/phases/03-event-write-back-pwa-install/03-01-SUMMARY.md` when done.
</output>
@@ -0,0 +1,160 @@
---
phase: 03-event-write-back-pwa-install
plan: 02
type: tdd
wave: 2
depends_on: ["03-01"]
files_modified:
- apps/api/src/broker/vevent.ts
- apps/api/src/broker/write.ts
- apps/api/tests/broker/vevent.test.ts
- apps/api/tests/broker/write.test.ts
autonomous: true
requirements: [CAL-04, CAL-05, CAL-06, CAL-07]
user_setup: []
must_haves:
truths:
- "buildVeventString produces a valid VCALENDAR/VEVENT for timed, all-day, and recurring events"
- "All-day events serialize as DATE (no time component, no TZID) per D-13 — never coerced to DATETIME"
- "createCalendarEvent / updateCalendarEvent / deleteCalendarEvent route all Fastmail writes through tsdav with correct If-Match/If-None-Match"
artifacts:
- path: "apps/api/src/broker/vevent.ts"
provides: "buildVeventString(NewEventParams) → { uid, icsString }"
exports: ["buildVeventString", "NewEventParams", "RRULE_PRESETS"]
min_lines: 40
- path: "apps/api/src/broker/write.ts"
provides: "tsdav PUT/DELETE wrappers (broker boundary, D-12)"
exports: ["createCalendarEvent", "updateCalendarEvent", "deleteCalendarEvent"]
key_links:
- from: "apps/api/src/broker/vevent.ts"
to: "ical.js ICAL.Component / ICAL.Time"
via: "VEVENT construction"
pattern: "ICAL\\.(Component|Time)"
- from: "apps/api/src/broker/write.ts"
to: "tsdav createCalendarObject/updateCalendarObject/deleteCalendarObject"
via: "FastmailClient methods"
pattern: "(create|update|delete)CalendarObject"
---
<objective>
Build the two pure broker primitives every write slice depends on: `vevent.ts`
(construct a valid iCalendar VEVENT from form params) and `write.ts` (wrap tsdav's
three CalDAV write methods to enforce the broker boundary, D-12). These are the most
testable units in the phase — defined input → defined ICS/HTTP output — so they are
built TDD against the RED stubs from Plan 01.
Purpose: CAL-04/05/06/07 all reduce to "produce the right VEVENT and PUT/DELETE it
through tsdav." Getting the D-13 DATE-vs-DATETIME split and the If-Match wiring right
here means the worker (Plan 03) and endpoints (Plan 04) just orchestrate.
Output: `vevent.ts`, `write.ts`, both GREEN against their Plan 01 test files.
</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/broker/client.ts
@apps/api/src/broker/sync.ts
</context>
<tasks>
<task type="tdd" tdd="true">
<name>Task 1: GREEN — buildVeventString VEVENT builder (vevent.ts)</name>
<files>apps/api/src/broker/vevent.ts, apps/api/tests/broker/vevent.test.ts</files>
<read_first>
- apps/api/tests/broker/vevent.test.ts (RED stubs from Plan 01 — these define the contract)
- .planning/phases/03-event-write-back-pwa-install/03-RESEARCH.md (§Pattern 1 — full buildVeventString reference incl. NewEventParams; §Pitfall 3 — DATE vs DATETIME)
- apps/api/src/broker/sync.ts (lines ~89-101 — the existing D-13 isDate split this must mirror in reverse)
- .planning/phases/03-event-write-back-pwa-install/03-PATTERNS.md (§vevent.ts — ICAL import, D-13 split, error isolation)
</read_first>
<behavior>
RED → GREEN. Tests assert:
- Timed event: output contains `BEGIN:VEVENT`, `DTSTART:` with a `Z` UTC suffix (no TZID param), matching UID and SUMMARY.
- All-day event (allDay:true): DTSTART is a DATE value (`VALUE=DATE` or 8-digit YYYYMMDD with no `T`/time), NO TZID, NO time component (D-13). End is also DATE.
- Recurring: passing `rruleString: 'FREQ=WEEKLY'` yields an `RRULE:FREQ=WEEKLY` line.
- location/description optional properties appear only when provided.
- omitting `uid` generates a `<uuid>@familysync` UID via crypto.randomUUID().
</behavior>
<action>
Implement `buildVeventString(params: NewEventParams): { uid: string; icsString: string }` exactly per RESEARCH.md Pattern 1. Export the `NewEventParams` interface and a `RRULE_PRESETS` map (`daily:'FREQ=DAILY'`, `weekly:'FREQ=WEEKLY'`, `monthly:'FREQ=MONTHLY'`, `yearly:'FREQ=YEARLY'`). Use `import ICAL from 'ical.js'` and `import { randomUUID } from 'crypto'`. For all-day use `new ICAL.Time({ year, month, day, isDate: true })`; for timed use `ICAL.Time.fromJSDate(date, true)` (useUTC=true → Z suffix, no TZID). Always add VERSION 2.0 and PRODID `-//FamilySync//FamilySync//EN`. Use `.js`-suffixed relative imports if any. Never coerce DATE→DATETIME.
</action>
<verify>
<automated>cd /home/luc/Projects/familysync && pnpm --filter @familysync/api test -- broker/vevent</automated>
</verify>
<acceptance_criteria>
- `pnpm --filter @familysync/api test -- broker/vevent` is GREEN (all assertions pass).
- All-day test asserts no `T000000`/time component and no `TZID` in the DATE DTSTART.
- `grep -q "RRULE_PRESETS" apps/api/src/broker/vevent.ts`.
</acceptance_criteria>
<done>buildVeventString passes all vevent.test.ts cases including the D-13 DATE-vs-DATETIME split and RRULE serialization.</done>
</task>
<task type="tdd" tdd="true">
<name>Task 2: GREEN — tsdav write wrappers (write.ts)</name>
<files>apps/api/src/broker/write.ts, apps/api/tests/broker/write.test.ts</files>
<read_first>
- apps/api/tests/broker/write.test.ts (RED stubs from Plan 01 — the contract)
- .planning/phases/03-event-write-back-pwa-install/03-RESEARCH.md (§Pattern 2 — full write.ts reference; status-code interpretation; §Pitfall 4 — etag may be null)
- apps/api/src/broker/client.ts (FastmailClient type; .js import convention; named-export style)
- .planning/phases/03-event-write-back-pwa-install/03-PATTERNS.md (§write.ts — header/imports/exports pattern)
</read_first>
<behavior>
RED → GREEN. With a mock FastmailClient, tests assert:
- createCalendarEvent({client, calendar, uid, icsString}) calls `client.createCalendarObject` with `filename === \`${uid}.ics\`` and the iCalString, and returns the raw Response.
- updateCalendarEvent(client, calendarObjectUrl, icsString, etag) calls `client.updateCalendarObject` with calendarObject `{ url, data, etag }` — etag drives the If-Match header.
- deleteCalendarEvent(client, calendarObjectUrl, etag) calls `client.deleteCalendarObject` with `{ url, etag }`.
- A null etag is passed through as `''` (no crash).
</behavior>
<action>
Implement `createCalendarEvent`, `updateCalendarEvent`, `deleteCalendarEvent` per RESEARCH.md Pattern 2 as named exports returning `Promise<Response>`. Import `FastmailClient` from `./client.js` and `DAVCalendar` from `tsdav`. These functions are the ONLY place outside client.ts/sync.ts/poller.ts that touch tsdav write methods (D-12 broker boundary). Do not interpret status codes here — return the raw Response so the worker (Plan 03) classifies transient/hard/conflict. If `deleteCalendarObject` requires a `data` field, pass `''`.
</action>
<verify>
<automated>cd /home/luc/Projects/familysync && pnpm --filter @familysync/api test -- broker/write && pnpm --filter @familysync/api exec tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- `pnpm --filter @familysync/api test -- broker/write` is GREEN.
- `grep -Eq "createCalendarObject|updateCalendarObject|deleteCalendarObject" apps/api/src/broker/write.ts` (all three present).
- tsc --noEmit passes.
</acceptance_criteria>
<done>write.ts wraps all three tsdav write methods with correct filenames/If-Match wiring; tests GREEN; types compile.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| broker → Fastmail CalDAV | Only write.ts issues PUT/DELETE to Fastmail (D-12) |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-03-03 | Tampering | VEVENT field serialization (summary/location/description with special chars) | mitigate | ical.js ICAL.Component handles line-folding + escaping (commas, semicolons, newlines); never hand-roll ICS strings (RESEARCH §Don't Hand-Roll) |
| T-03-04 | Spoofing | etag forgery to bypass conflict detection | mitigate | etag is sourced server-side (calendarEvents.etag) by the worker, never accepted from the browser; write.ts only forwards what the server supplies |
| T-03-05 | Elevation of Privilege | write.ts called with another member's calendar | accept (here) | Calendar ownership is enforced at the route layer (Plan 04, V4); write.ts is a low-level primitive with no auth context |
</threat_model>
<verification>
- `pnpm --filter @familysync/api test -- broker/vevent` GREEN.
- `pnpm --filter @familysync/api test -- broker/write` GREEN.
- `pnpm --filter @familysync/api exec tsc --noEmit` passes.
</verification>
<success_criteria>
- VEVENT builder correct for timed, all-day (DATE), and recurring events.
- tsdav write wrappers enforce the broker boundary with correct If-Match/filename wiring.
</success_criteria>
<output>
Create `.planning/phases/03-event-write-back-pwa-install/03-02-SUMMARY.md` when done.
</output>
@@ -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>
@@ -0,0 +1,165 @@
---
phase: 03-event-write-back-pwa-install
plan: 04
type: tdd
wave: 3
depends_on: ["03-02", "03-03"]
files_modified:
- apps/api/src/broker/outboxWorker.ts
- apps/api/src/index.ts
- apps/api/tests/broker/outboxWorker.test.ts
autonomous: true
requirements: [CAL-04, CAL-05, CAL-06, CAL-07]
user_setup: []
must_haves:
truths:
- "The worker drains pending outbox rows, builds the VEVENT, PUTs/DELETEs via the broker, and triggers a targeted single-calendar re-sync on success (D-06)"
- "Transient failures (5xx/network/timeout) back off exponentially within a bounded window; max attempts → dead (D-07)"
- "Hard failures (400/401/403) stop immediately as failed (D-07)"
- "412 conflicts route OUT of the retry loop into the conflict flow: mark failed, re-sync, no overwrite (D-08)"
- "Edit-as-move processes the create row before the linked delete row; create-fail aborts the delete (D-04)"
- "The worker is started from index.ts as a sibling to the ctag poller"
artifacts:
- path: "apps/api/src/broker/outboxWorker.ts"
provides: "runOutboxDrain + startOutboxWorker (state machine, retry/backoff, re-sync)"
exports: ["runOutboxDrain", "startOutboxWorker"]
min_lines: 60
- path: "apps/api/src/index.ts"
provides: "startOutboxWorker() wired at startup"
contains: "startOutboxWorker"
key_links:
- from: "apps/api/src/broker/outboxWorker.ts"
to: "broker/write.ts"
via: "create/update/deleteCalendarEvent"
pattern: "(create|update|delete)CalendarEvent"
- from: "apps/api/src/broker/outboxWorker.ts"
to: "broker/sync.ts syncCalendar"
via: "targeted re-sync on confirm (D-06)"
pattern: "syncCalendar"
- from: "apps/api/src/index.ts"
to: "startOutboxWorker"
via: "background worker startup"
pattern: "startOutboxWorker"
---
<objective>
Build the outbox worker — the load-bearing async engine of D-05/06/07/08. It drains
pending `calendar_outbox` rows, builds the VEVENT (Plan 02 `vevent.ts`), writes through
the broker (Plan 02 `write.ts`), classifies the response (transient/hard/conflict),
and on success triggers a targeted single-calendar re-sync (Plan 03 endpoints filled the
queue; existing `sync.ts` re-syncs). Then wire it into `index.ts` beside the ctag poller.
Purpose: this closes the create/edit/delete loop end-to-end — after this plan a queued
write actually reaches Fastmail and the cache becomes authoritative. Built TDD because
the state machine (backoff, dead-letter, 412 routing, edit-as-move ordering) is the
highest-risk logic in the phase.
Output: `outboxWorker.ts` GREEN against Plan 01's state-machine tests; worker started at boot.
</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/broker/poller.ts
@apps/api/src/broker/sync.ts
@apps/api/src/index.ts
</context>
<tasks>
<task type="tdd" tdd="true">
<name>Task 1: GREEN — outbox drain state machine (outboxWorker.ts)</name>
<files>apps/api/src/broker/outboxWorker.ts, apps/api/tests/broker/outboxWorker.test.ts</files>
<read_first>
- apps/api/tests/broker/outboxWorker.test.ts (RED state-machine stubs from Plan 01 — the contract)
- .planning/phases/03-event-write-back-pwa-install/03-RESEARCH.md (§Pattern 4 — full runOutboxDrain reference; status classification sets; §Pitfall 5 edit-as-move; §Pitfall 7 DAVCalendar fetch for re-sync; §Pitfall 4 etag re-fetch)
- apps/api/src/broker/poller.ts (analog — runX/startX pair, node-cron schedule, per-item error isolation, decrypt-then-client pattern, Drizzle select/where/limit)
- apps/api/src/broker/sync.ts (syncCalendar signature: client, davCal, userId)
- apps/api/src/broker/write.ts (create/update/deleteCalendarEvent — from Plan 02)
- apps/api/src/broker/vevent.ts (buildVeventString — from Plan 02)
- .planning/phases/03-event-write-back-pwa-install/03-PATTERNS.md (§outboxWorker.ts — exact poller-derived patterns)
</read_first>
<behavior>
RED → GREEN. With mocked db, write.ts, sync.ts, and Fastmail client, tests assert:
- pending row + mock create response 204/201 → status='done' AND triggerTargetedResync called for that calendarUrl (D-06).
- mock response 412 → status='failed', re-sync triggered, NO retry, NO overwrite (D-08 conflict flow).
- mock response 500 (transient) → status stays 'pending', attemptCount incremented, nextAttemptAt advanced by the backoff schedule (D-07).
- transient failures repeated until attemptCount === MAX_ATTEMPTS → status='dead'.
- mock response 401/403/400 (hard) → status='failed' immediately, no retry (D-07).
- edit-as-move pair (shared groupId): the 'create' row is dispatched before the linked 'delete' row; if create fails, the delete is NOT executed (D-04 — duplicate is recoverable, lost event is not).
</behavior>
<action>
Implement `runOutboxDrain()` and `startOutboxWorker()` per RESEARCH.md Pattern 4. Constants: `MAX_ATTEMPTS=5`, `BACKOFF_SECONDS=[15,60,300,600,1800]`, `TRANSIENT_STATUSES={408,429,500,502,503,504}`, `HARD_FAIL_STATUSES={400,401,403}`, `CONFLICT_STATUS=412`. Select `WHERE status='pending' AND next_attempt_at <= NOW()` limit 10. For each row: load the owning member's credential+client (decrypt via crypto.js + createFastmailClient like poller.ts), build the VEVENT via `buildVeventString` from the row payload for create/update, call the matching write.ts function, classify the Response status. On success or 412 call `triggerTargetedResync(calendarUrl, userId)` which fetches calendars via `client.fetchCalendars()`, finds the DAVCalendar by url (Pitfall 7), and calls `syncCalendar` — this captures the fresh etag/objectUrl (Pitfall 4). Update outbox status with the Drizzle update pattern. Order edit-as-move: process rows ordered so a row with `operation='create'` and a groupId runs before its sibling `operation='delete'`; on create failure skip the linked delete. Per-row try/catch logs without crashing the loop; never log decrypted passwords (T-03-04). `startOutboxWorker` schedules `runOutboxDrain` every 15s (node-cron `*/15 * * * * *` or setInterval), mirroring `startBrokerPoller`.
</action>
<verify>
<automated>cd /home/luc/Projects/familysync && pnpm --filter @familysync/api test -- broker/outboxWorker && pnpm --filter @familysync/api exec tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- outboxWorker.test.ts GREEN for all six behaviors (done, 412-conflict, backoff, dead, hard-fail, edit-as-move order).
- `grep -q "syncCalendar" apps/api/src/broker/outboxWorker.ts` (D-06 re-sync).
- `grep -Eq "412|CONFLICT_STATUS" apps/api/src/broker/outboxWorker.ts` (D-08).
- tsc --noEmit passes.
</acceptance_criteria>
<done>The outbox worker drains, writes, classifies, re-syncs, and handles backoff/dead/conflict/edit-as-move exactly per D-04/06/07/08; tests GREEN.</done>
</task>
<task type="auto">
<name>Task 2: Wire startOutboxWorker into index.ts beside the ctag poller</name>
<files>apps/api/src/index.ts</files>
<read_first>
- apps/api/src/index.ts (existing — startBrokerPoller() is called near the bottom; mirror placement/import style)
- apps/api/src/broker/outboxWorker.ts (from Task 1 — exports startOutboxWorker)
</read_first>
<action>
Add `import { startOutboxWorker } from './broker/outboxWorker.js'` next to the existing poller import. Call `startOutboxWorker()` immediately after the existing `startBrokerPoller()` call, with a one-line comment noting it drains the D-05 outbox every 15s. Do not move or alter the poller, route mounts, OIDC guard, or server-start guard.
</action>
<verify>
<automated>cd /home/luc/Projects/familysync && grep -q "startOutboxWorker()" apps/api/src/index.ts && pnpm --filter @familysync/api exec tsc --noEmit && pnpm --filter @familysync/api test</automated>
</verify>
<acceptance_criteria>
- `grep -c "startOutboxWorker()" apps/api/src/index.ts` ≥1.
- Full API test suite GREEN; tsc --noEmit passes.
</acceptance_criteria>
<done>The outbox worker starts at API boot alongside the poller; full API suite green.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| worker → Fastmail | The worker is the only component that drains the outbox to Fastmail |
| stored payload → VEVENT | Member-supplied payload is reconstructed into an ICS PUT |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-03-11 | Repudiation | silent last-write-wins on concurrent edit | mitigate | 412 If-Match conflict routes to conflict flow (re-sync + warn), never overwrites (D-08) |
| T-03-12 | Denial of Service | a poison row retrying forever | mitigate | MAX_ATTEMPTS=5 then dead-letter; bounded backoff window (~30 min) per D-07 |
| T-03-13 | Information Disclosure | logging decrypted app password during dispatch | mitigate | Per-item catch logs `err.message` only; never the credential (poller T-03-04 pattern) |
| T-03-14 | Tampering | partial-failure data loss on edit-as-move | mitigate | create-before-delete ordering; create-fail aborts delete; delete-fail surfaces "remove manually" (D-04) |
</threat_model>
<verification>
- `pnpm --filter @familysync/api test` full suite GREEN (includes outboxWorker + routes/events from Plan 03).
- `grep -c "startOutboxWorker()" apps/api/src/index.ts` ≥1.
- No tsdav import outside broker/: worker uses write.ts/client.ts only.
</verification>
<success_criteria>
- End-to-end backend write loop closed: endpoint → outbox → worker → Fastmail → re-sync → cache authoritative.
- D-04/D-06/D-07/D-08 all enforced and tested.
</success_criteria>
<output>
Create `.planning/phases/03-event-write-back-pwa-install/03-04-SUMMARY.md` when done.
</output>
@@ -0,0 +1,179 @@
---
phase: 03-event-write-back-pwa-install
plan: 05
type: execute
wave: 3
depends_on: ["03-03"]
files_modified:
- apps/pwa/src/api/client.ts
- apps/pwa/src/store/calendarStore.ts
- apps/pwa/src/components/EventForm.tsx
- apps/pwa/src/components/CalendarShell.tsx
autonomous: true
requirements: [CAL-04, CAL-05, CAL-07]
user_setup: []
must_haves:
truths:
- "A member can tap 'New Event', fill the form, and save — POST /api/events/create fires and the form closes"
- "The form supports timed and all-day events, a recurrence preset (None/Daily/Weekly/Monthly/Yearly), title/location/description"
- "The calendar picker is hidden when the member has exactly one writable calendar (D-02)"
- "Edit mode pre-populates the form and calls PATCH /api/events/:uid/edit"
artifacts:
- path: "apps/pwa/src/components/EventForm.tsx"
provides: "create/edit modal form (bottom sheet on phone, dialog on desktop)"
min_lines: 80
- path: "apps/pwa/src/api/client.ts"
provides: "createEvent, updateEvent, fetchWritableCalendars typed calls"
exports: ["createEvent", "updateEvent", "fetchWritableCalendars"]
key_links:
- from: "apps/pwa/src/components/EventForm.tsx"
to: "/api/events/create"
via: "createEvent mutation"
pattern: "createEvent"
- from: "apps/pwa/src/components/CalendarShell.tsx"
to: "EventForm"
via: "New Event FAB toggles eventFormOpen"
pattern: "eventFormOpen"
---
<objective>
Build the create/edit event UI: the typed write client calls, the Zustand form-state
keys, the `EventForm` modal (timed/all-day/recurring fields, conditional calendar
picker), and the "New Event" FAB/toolbar entry on the calendar shell. This is the
front half of the create and edit vertical slices — after this plan a member can
open the form and submit a write (delete + sync feedback land in Plan 06).
Purpose: CAL-04 (create timed/all-day) and CAL-07 (create recurring) become user-reachable.
Built against the UI Design Contract (03-UI-SPEC.md) for fields, copy, tokens, and
interaction; reuses the Phase 2 EventDetailPopover overlay/focus-trap/responsive pattern (D-10).
Output: EventForm + client write calls + store keys + FAB, all wired to the Plan 03 API.
</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-UI-SPEC.md
@.planning/phases/03-event-write-back-pwa-install/03-PATTERNS.md
@apps/pwa/src/api/client.ts
@apps/pwa/src/store/calendarStore.ts
@apps/pwa/src/components/EventDetailPopover.tsx
@apps/pwa/src/components/CalendarShell.tsx
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Typed write client calls + Zustand form-state keys</name>
<files>apps/pwa/src/api/client.ts, apps/pwa/src/store/calendarStore.ts</files>
<read_first>
- apps/pwa/src/api/client.ts (existing — fetch function + interface-first pattern; CalendarOccurrence shape)
- apps/pwa/src/store/calendarStore.ts (existing — CalendarStore interface + create() pattern)
- .planning/phases/03-event-write-back-pwa-install/03-UI-SPEC.md (§State Management Contract — Zustand keys; §EventForm fields → request shape)
- .planning/phases/03-event-write-back-pwa-install/03-PATTERNS.md (§client.ts — POST/PATCH fetch shape; §Zustand UI state)
</read_first>
<behavior>
Tests (extend pwa test suite where one exists, else add a small client unit test):
- createEvent posts to /api/events/create with credentials:'include' and JSON body; returns { uid } on 202.
- updateEvent PATCHes /api/events/:uid/edit.
- fetchWritableCalendars GETs the writable-calendar set.
- The Zustand store exposes the new keys with correct defaults.
</behavior>
<action>
In client.ts add exported interfaces `CreateEventPayload` (title, allDay, start, end, optional location, description, recurrence: 'none'|'daily'|'weekly'|'monthly'|'yearly', calendarUrl?), `CreateEventResponse` ({ uid }), `WritableCalendar` ({ url, displayName, color, isShared }). Add `createEvent(payload): Promise<CreateEventResponse>` (POST), `updateEvent(uid, payload): Promise<CreateEventResponse>` (PATCH `/api/events/${uid}/edit`), and `fetchWritableCalendars(): Promise<WritableCalendar[]>` (GET `/api/events/writable-calendars` — if Plan 03 did not add this endpoint, derive the writable set on the client from the existing calendars data; document which). All follow the existing fetch shape with credentials:'include' and `if (!res.ok) throw`.
In calendarStore.ts extend `CalendarStore` with `eventFormOpen: boolean`, `eventFormMode: 'create'|'edit'`, `eventFormUid: string|null`, plus setters `setEventForm(open, mode?, uid?)`. Defaults: closed, mode 'create', uid null. Keep all server data out of Zustand (D — server state stays in TanStack Query).
</action>
<verify>
<automated>cd /home/luc/Projects/familysync && pnpm --filter @familysync/pwa exec tsc --noEmit && grep -q "createEvent" apps/pwa/src/api/client.ts && grep -q "eventFormOpen" apps/pwa/src/store/calendarStore.ts && pnpm --filter @familysync/pwa test</automated>
</verify>
<acceptance_criteria>
- `grep -Eq "createEvent|updateEvent" apps/pwa/src/api/client.ts`.
- `grep -q "eventFormOpen" apps/pwa/src/store/calendarStore.ts`.
- PWA tsc --noEmit passes; existing PWA tests stay green.
</acceptance_criteria>
<done>Write client calls and form-state Zustand keys exist and type-check.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: EventForm modal (create + edit) per UI Design Contract</name>
<files>apps/pwa/src/components/EventForm.tsx</files>
<read_first>
- .planning/phases/03-event-write-back-pwa-install/03-UI-SPEC.md (§EventForm — field order/types/required; §CalendarPicker D-02; §Recurrence picker; §Copywriting Contract; §Interaction Contract all-day toggle + recurrence + keyboard; §Spacing/Typography/Color tokens)
- apps/pwa/src/components/EventDetailPopover.tsx (analog — backdrop+dialog structure ~202-221, Escape+focus-trap useEffect ~143-159, responsive isPhone/dialogStyle ~165-199, design tokens, XSS plain-text rule)
- .planning/phases/03-event-write-back-pwa-install/03-PATTERNS.md (§EventForm.tsx — modal/overlay, focus trap, TanStack mutation, Zustand)
- apps/pwa/src/api/client.ts (createEvent/updateEvent/fetchWritableCalendars from Task 1)
</read_first>
<behavior>
Tests (EventForm.test.tsx): renders title/all-day/start/end/recurrence/location/description fields; toggling "All day" hides time inputs; calendar picker is absent when one writable calendar and present when two (D-02); empty title shows "Title is required"; end-before-start shows "End time must be after start"; submitting calls the createEvent mutation in create mode and updateEvent in edit mode; Escape and backdrop close the form.
</behavior>
<action>
Implement `EventForm.tsx` as a modal overlay reusing the EventDetailPopover backdrop+dialog+focus-trap+responsive pattern (bottom sheet on phone, centered 480px dialog on desktop). Fields and order exactly per UI-SPEC §EventForm. All-day toggle (`role="switch"`) hides start/end time inputs and applies the auto-advance rule; defaults start 09:00/end 10:00 when toggled off. Recurrence as a segmented select (`role="radiogroup"` or `<select>`) of None/Daily/Weekly/Monthly/Yearly (D-11 whole-series; map to the recurrence enum). Calendar picker rendered only when `fetchWritableCalendars()` returns >1 (D-02); default selection = last-used (read from a localStorage key) else personal (D-01). Use `useMutation` (TanStack Query) calling `createEvent`/`updateEvent` by `eventFormMode`; on success close the form (`setEventForm(false)`) and set `lastSyncedUid` (added in Plan 06; if absent, store the returned uid in a placeholder for now). Validation: empty title and end-before-start show the exact UI-SPEC error copy in `--color-destructive`. All spacing/color via tokens; all field values rendered as plain-text JSX children (XSS guard); 44px min touch targets; `role="dialog"` `aria-modal="true"` `aria-label` "New Event"/"Edit Event"; focus the Title input on open; Escape/backdrop close. Edit mode pre-populates fields from the occurrence identified by `eventFormUid` (read from the TanStack `['events']` cache like EventDetailPopover does).
</action>
<verify>
<automated>cd /home/luc/Projects/familysync && pnpm --filter @familysync/pwa test -- EventForm && pnpm --filter @familysync/pwa exec tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- EventForm.test.tsx GREEN (fields, all-day toggle, D-02 picker visibility, validation copy, create vs edit mutation, Escape/backdrop close).
- `grep -q 'aria-modal="true"' apps/pwa/src/components/EventForm.tsx`.
- No `dangerouslySetInnerHTML` in EventForm.tsx.
</acceptance_criteria>
<done>EventForm renders all contract fields, enforces D-02/D-11/validation, and submits create/edit; tests GREEN.</done>
</task>
<task type="auto">
<name>Task 3: Mount EventForm + add "New Event" FAB/toolbar trigger on CalendarShell</name>
<files>apps/pwa/src/components/CalendarShell.tsx</files>
<read_first>
- apps/pwa/src/components/CalendarShell.tsx (existing — where EventDetailPopover is mounted; toolbar/nav structure)
- .planning/phases/03-event-write-back-pwa-install/03-UI-SPEC.md (§Interaction Contract — Create opens from FAB (phone) or toolbar button (desktop); Copywriting "New Event" + Plus icon)
- apps/pwa/src/store/calendarStore.ts (eventFormOpen / setEventForm from Task 1)
</read_first>
<action>
Mount `<EventForm />` in CalendarShell (conditionally rendered while `eventFormOpen`). Add a "New Event" entry point: a floating action button (Plus icon, lucide-react) bottom-right on phone and a toolbar button on tablet/desktop, both calling `setEventForm(true, 'create')`. Use the dark neutral primary fill (`--color-text-primary` bg, white label) per UI-SPEC — never an accent color. 44px min touch target. Do not alter existing read-only calendar rendering.
</action>
<verify>
<automated>cd /home/luc/Projects/familysync && grep -q "EventForm" apps/pwa/src/components/CalendarShell.tsx && grep -q "setEventForm" apps/pwa/src/components/CalendarShell.tsx && pnpm --filter @familysync/pwa test && pnpm --filter @familysync/pwa exec tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- CalendarShell mounts EventForm and a "New Event" trigger that opens it in create mode.
- Full PWA suite green; tsc --noEmit passes.
</acceptance_criteria>
<done>A member can open the create form from the calendar; EventForm is mounted and wired to Zustand.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| form input → API | Member-typed event fields cross to the write API |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-03-15 | Tampering | XSS via event title/location/description in the form | mitigate | All values rendered as plain-text JSX children; never dangerouslySetInnerHTML (Phase 2 T-02e-01 pattern); server re-validates with zod (Plan 03) |
| T-03-16 | Elevation of Privilege | client offering a non-writable calendar in the picker | mitigate | Picker is populated only from the member's writable set; server enforces D-03 ownership regardless (Plan 03 is authoritative) |
</threat_model>
<verification>
- `pnpm --filter @familysync/pwa test` green (EventForm + existing).
- `pnpm --filter @familysync/pwa exec tsc --noEmit` passes.
- EventForm reachable from CalendarShell; D-02 picker conditional; D-11 recurrence presets present.
</verification>
<success_criteria>
- CAL-04 and CAL-07 create paths are user-reachable through EventForm → POST /api/events/create.
- Edit mode pre-populates and PATCHes; calendar picker honors D-01/D-02.
</success_criteria>
<output>
Create `.planning/phases/03-event-write-back-pwa-install/03-05-SUMMARY.md` when done.
</output>
@@ -0,0 +1,185 @@
---
phase: 03-event-write-back-pwa-install
plan: 06
type: execute
wave: 4
depends_on: ["03-05", "03-03"]
files_modified:
- apps/pwa/src/api/client.ts
- apps/pwa/src/store/calendarStore.ts
- apps/pwa/src/components/EventDetailPopover.tsx
- apps/pwa/src/components/DeleteConfirmationDialog.tsx
- apps/pwa/src/components/SyncStateToast.tsx
- apps/pwa/src/components/CalendarShell.tsx
autonomous: true
requirements: [CAL-05, CAL-06]
user_setup: []
must_haves:
truths:
- "The EventDetailPopover footer shows Edit and Delete actions (D-10)"
- "Tapping Edit opens EventForm pre-populated; tapping Delete opens a two-tap confirmation dialog"
- "Confirming delete calls DELETE /api/events/:uid and shows the sync toast"
- "After any write the SyncStateToast polls /api/events/sync-status and shows Syncing/Saved/Didn't save; on done it invalidates the events query (D-06/D-09)"
- "A 412 conflict shows the conflict copy and re-fetches the calendar (D-08)"
artifacts:
- path: "apps/pwa/src/components/SyncStateToast.tsx"
provides: "polled sync-state feedback toast (D-05/D-09)"
min_lines: 40
- path: "apps/pwa/src/components/DeleteConfirmationDialog.tsx"
provides: "two-tap destructive delete confirmation"
key_links:
- from: "apps/pwa/src/components/SyncStateToast.tsx"
to: "/api/events/sync-status"
via: "useQuery refetchInterval while pending"
pattern: "syncStatus|sync-status"
- from: "apps/pwa/src/components/EventDetailPopover.tsx"
to: "DeleteConfirmationDialog"
via: "Delete footer button opens deleteDialog"
pattern: "deleteDialogOpen"
---
<objective>
Complete the edit/delete vertical slices and the write-feedback loop: wire the
EventDetailPopover reserved footer to Edit/Delete actions (D-10), add the two-tap
`DeleteConfirmationDialog`, and add the `SyncStateToast` that polls `/api/events/sync-status`
(D-09) to surface Syncing → Saved / Didn't save, invalidating the events cache on
confirm (D-06) and showing the conflict copy on 412 (D-08).
Purpose: CAL-05 (edit) and CAL-06 (delete) become user-reachable, and every write
(create from Plan 05 included) gets the non-blocking optimistic feedback the
non-technical member depends on (D-05). No SSE — polling only (D-09).
Output: edit/delete footer, delete dialog, sync toast + polling, all per the UI Design Contract.
</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-UI-SPEC.md
@.planning/phases/03-event-write-back-pwa-install/03-PATTERNS.md
@apps/pwa/src/components/EventDetailPopover.tsx
@apps/pwa/src/api/client.ts
@apps/pwa/src/store/calendarStore.ts
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: deleteEvent + fetchSyncStatus client calls; delete/sync Zustand keys</name>
<files>apps/pwa/src/api/client.ts, apps/pwa/src/store/calendarStore.ts</files>
<read_first>
- apps/pwa/src/api/client.ts (existing + Plan 05 additions — fetch shape)
- apps/pwa/src/store/calendarStore.ts (existing + Plan 05 form keys)
- .planning/phases/03-event-write-back-pwa-install/03-RESEARCH.md (§Pattern 8 — sync-status response { uid, status, error? })
- .planning/phases/03-event-write-back-pwa-install/03-UI-SPEC.md (§State Management Contract — deleteDialogOpen/deleteDialogUid/lastSyncedUid; ['syncStatus', uid] key)
</read_first>
<action>
Add `deleteEvent(uid): Promise<void>` (DELETE `/api/events/${uid}`, credentials:'include', throw on !ok) and `fetchSyncStatus(uid): Promise<{ uid: string; status: 'pending'|'done'|'failed'|'dead'; error?: string }>` (GET `/api/events/sync-status?uid=`). Export the SyncStatus type. Extend the Zustand store with `deleteDialogOpen: boolean`, `deleteDialogUid: string|null`, `lastSyncedUid: string|null` plus setters `setDeleteDialog(open, uid?)` and `setLastSyncedUid(uid)`. Defaults closed/null.
</action>
<verify>
<automated>cd /home/luc/Projects/familysync && grep -q "fetchSyncStatus" apps/pwa/src/api/client.ts && grep -q "deleteDialogOpen" apps/pwa/src/store/calendarStore.ts && pnpm --filter @familysync/pwa exec tsc --noEmit && pnpm --filter @familysync/pwa test</automated>
</verify>
<acceptance_criteria>
- `grep -Eq "deleteEvent|fetchSyncStatus" apps/pwa/src/api/client.ts`.
- `grep -q "lastSyncedUid" apps/pwa/src/store/calendarStore.ts`.
- PWA tsc --noEmit passes; existing tests green.
</acceptance_criteria>
<done>deleteEvent/fetchSyncStatus and delete/sync Zustand keys exist and type-check.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: SyncStateToast with polled sync-status (D-05/D-06/D-08/D-09)</name>
<files>apps/pwa/src/components/SyncStateToast.tsx, apps/pwa/src/components/CalendarShell.tsx</files>
<read_first>
- .planning/phases/03-event-write-back-pwa-install/03-UI-SPEC.md (§SyncStateToast — states/icons/copy/colors/position/auto-dismiss; §Interaction Contract sync-state feedback; §Copywriting toast strings; role=status/alert)
- .planning/phases/03-event-write-back-pwa-install/03-RESEARCH.md (§Pattern 8 / §Code Examples useSyncStatus — refetchInterval 3000 while pending)
- apps/pwa/src/api/client.ts (fetchSyncStatus from Task 1)
- apps/pwa/src/components/EventDetailPopover.tsx (token usage + lucide icon import pattern)
</read_first>
<behavior>
Tests (SyncStateToast.test.tsx) with mocked fetchSyncStatus:
- status 'pending' renders "Syncing…" + spinner, role="status".
- status 'done' renders "Saved", auto-dismiss after 2s, and triggers queryClient.invalidateQueries(['events']).
- status 'failed' (generic) renders "Didn't save. Try again." role="alert", persists with a dismiss button.
- status 'failed' with a 412/conflict error renders the conflict copy and invalidates ['events'].
- status 'dead' renders "Not saved. Check your connection.".
- refetchInterval is active (3000) only while pending.
</behavior>
<action>
Implement `SyncStateToast.tsx`: a `useQuery(['syncStatus', uid], fetchSyncStatus, { enabled: uid!==null, refetchInterval: d => d?.status==='pending' ? 3000 : false, staleTime:0 })` keyed on `lastSyncedUid` from Zustand. Render the toast per UI-SPEC states table (Loader2/Check/AlertCircle icons, exact copy, exact colors/tokens, bottom-of-screen position, auto-dismiss done after 2s, persistent failed/dead with an X dismiss that clears lastSyncedUid). On transition to 'done' OR a 412-conflict, call `queryClient.invalidateQueries({ queryKey: ['events'] })` (D-06/D-08). Use role="status" for pending/done and role="alert" for failed/dead. Mount `<SyncStateToast />` in CalendarShell (always rendered; renders nothing when lastSyncedUid is null). Set `lastSyncedUid` from the EventForm create/edit mutations (Plan 05 stored the uid; wire it via setLastSyncedUid) and from the delete flow (Task 3). No SSE (D-09).
</action>
<verify>
<automated>cd /home/luc/Projects/familysync && pnpm --filter @familysync/pwa test -- SyncStateToast && grep -q "invalidateQueries" apps/pwa/src/components/SyncStateToast.tsx && pnpm --filter @familysync/pwa exec tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- SyncStateToast.test.tsx GREEN for all five states + conflict + refetchInterval-while-pending.
- `grep -q "refetchInterval" apps/pwa/src/components/SyncStateToast.tsx`.
- No SSE / EventSource reference in the toast: `grep -c "EventSource" apps/pwa/src/components/SyncStateToast.tsx` returns 0.
</acceptance_criteria>
<done>SyncStateToast polls sync-status, renders all contract states, invalidates events on done/conflict, mounted in the shell.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: EventDetailPopover Edit/Delete footer + DeleteConfirmationDialog</name>
<files>apps/pwa/src/components/EventDetailPopover.tsx, apps/pwa/src/components/DeleteConfirmationDialog.tsx, apps/pwa/src/components/CalendarShell.tsx</files>
<read_first>
- apps/pwa/src/components/EventDetailPopover.tsx (lines ~380-388 reserved footer; button style ~235-251; Zustand+TanStack usage ~109-137)
- .planning/phases/03-event-write-back-pwa-install/03-UI-SPEC.md (§EventDetailPopover extended footer; §DeleteConfirmationDialog layout/copy/colors; §Interaction Contract delete interaction 1-6; §Copywriting delete strings)
- .planning/phases/03-event-write-back-pwa-install/03-PATTERNS.md (§EventDetailPopover.tsx — replace reserved footer, button style, design tokens, XSS guard)
- apps/pwa/src/store/calendarStore.ts (setEventForm, setDeleteDialog, setLastSyncedUid)
- apps/pwa/src/api/client.ts (deleteEvent)
</read_first>
<behavior>
Tests: EventDetailPopover footer renders an "Edit" button (opens EventForm in edit mode with the event's uid, closes popover) and a "Delete" button (`--color-destructive`, opens DeleteConfirmationDialog). DeleteConfirmationDialog renders heading "Delete event?" + body, a Cancel that closes without deleting, and a "Delete" (red, 48px) that calls deleteEvent, sets lastSyncedUid, closes both surfaces. Escape closes the dialog without deleting; focus trapped.
</behavior>
<action>
Replace the EventDetailPopover reserved footer (`aria-hidden` placeholder) with a flex space-between row: a left "Edit" ghost button (Edit2 icon, `--color-text-primary`, opens `setEventForm(true,'edit', occurrence.uid)` and closes the popover) and a right "Delete" ghost button (Trash2 icon, `--color-destructive`, calls `setDeleteDialog(true, occurrence.uid)`). Remove `aria-hidden`. Implement `DeleteConfirmationDialog.tsx` as a centered modal (max-width 320px, backdrop `--color-overlay`, focus trap, Escape-to-cancel) per UI-SPEC: heading "Delete event?", body "This will be removed from your Fastmail calendar.", Cancel (ghost) and Delete (filled `--color-destructive`, white label, Trash2, 48px). On Delete: call `deleteEvent(deleteDialogUid)` via a TanStack mutation, `setLastSyncedUid(uid)` so the toast tracks it, close the dialog and popover; on the calendar, optimistic removal is acceptable but server state wins on refetch (no silent loss). Mount `<DeleteConfirmationDialog />` in CalendarShell (rendered while deleteDialogOpen). All tokens/touch-targets/plain-text-children per the contract.
</action>
<verify>
<automated>cd /home/luc/Projects/familysync && pnpm --filter @familysync/pwa test && grep -q "deleteDialogOpen" apps/pwa/src/components/EventDetailPopover.tsx && grep -q "Delete event?" apps/pwa/src/components/DeleteConfirmationDialog.tsx && pnpm --filter @familysync/pwa exec tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- Footer Edit opens EventForm edit mode; Delete opens the confirmation dialog (tests GREEN).
- DeleteConfirmationDialog requires explicit confirm; Cancel/Escape do not delete.
- `grep -c 'aria-hidden="true"' apps/pwa/src/components/EventDetailPopover.tsx` does not count the old footer placeholder (it is replaced).
- Full PWA suite green; tsc --noEmit passes.
</acceptance_criteria>
<done>Edit/Delete reachable from the popover; two-tap delete confirmation fires DELETE and feeds the sync toast.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| delete action → API | A destructive operation crosses to the write API |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-03-17 | Tampering | accidental/irreversible delete | mitigate | Mandatory two-tap DeleteConfirmationDialog; no inline single-tap delete; no "don't ask again" (UI-SPEC) |
| T-03-18 | Repudiation | silent data loss on failed delete sync | mitigate | failed/dead toast persists until dismissed; server-authoritative refetch restores the event; no silent loss (D-08) |
| T-03-19 | Information Disclosure | sync-status of another member surfaced in toast | mitigate | sync-status is member-scoped server-side (Plan 03 T-03-07); toast only queries the current member's uid |
</threat_model>
<verification>
- `pnpm --filter @familysync/pwa test` green (SyncStateToast, DeleteConfirmationDialog, popover footer + existing).
- `pnpm --filter @familysync/pwa exec tsc --noEmit` passes.
- No SSE/EventSource in any Phase 3 sync-feedback component (D-09).
</verification>
<success_criteria>
- CAL-05 edit and CAL-06 delete are user-reachable from the popover.
- Every write surfaces non-blocking polled sync feedback; 412 conflict shows the warning + re-fetch (D-08); done invalidates events (D-06).
</success_criteria>
<output>
Create `.planning/phases/03-event-write-back-pwa-install/03-06-SUMMARY.md` when done.
</output>
@@ -0,0 +1,160 @@
---
phase: 03-event-write-back-pwa-install
plan: 07
type: execute
wave: 3
depends_on: ["03-01"]
files_modified:
- apps/pwa/vite.config.ts
- apps/pwa/index.html
- apps/pwa/public/icon-192.png
- apps/pwa/public/icon-512.png
- apps/pwa/public/apple-touch-icon.png
- apps/pwa/src/components/InstallPrompt.tsx
- apps/pwa/src/components/CalendarShell.tsx
autonomous: true
requirements: [PWA-01, PWA-02]
user_setup: []
must_haves:
truths:
- "The production build emits a valid manifest.webmanifest with name/icons/display:standalone/scope:/ and a service worker"
- "The service worker's navigateFallbackDenylist excludes /callback, /api/, /health so the OIDC redirect is never intercepted (Gate 2 risk)"
- "On iOS Safari non-standalone, a first-visit install banner appears with a 5-step annotated Add-to-Home-Screen walkthrough"
- "On Android, an Install banner appears only when beforeinstallprompt fires and triggers the native prompt"
- "Neither install surface renders when the app is already installed (display-mode: standalone)"
artifacts:
- path: "apps/pwa/vite.config.ts"
provides: "VitePWA manifest + SW config with auth-safe denylist"
contains: "navigateFallbackDenylist"
- path: "apps/pwa/src/components/InstallPrompt.tsx"
provides: "iOS walkthrough banner/sheet + Android beforeinstallprompt banner"
min_lines: 80
key_links:
- from: "apps/pwa/vite.config.ts"
to: "OIDC /callback"
via: "navigateFallbackDenylist excludes /callback"
pattern: "callback"
- from: "apps/pwa/src/components/InstallPrompt.tsx"
to: "iOS standalone detection"
via: "isIOSSafariNonStandalone + display-mode media query"
pattern: "standalone"
---
<objective>
Make FamilySync installable (PWA-01) and guide first-time install (PWA-02): add the
`vite-plugin-pwa` manifest + service worker with an auth-safe `navigateFallbackDenylist`,
the required iOS `<head>` meta/icons, the PWA icon assets, and the `InstallPrompt`
component handling both the iOS annotated Add-to-Home-Screen walkthrough and the Android
`beforeinstallprompt` flow.
Purpose: PWA-01/02 are prerequisites for Phase 5 Web Push — the non-technical member must
be able to install unassisted. The single hard constraint is that the service worker MUST
NOT intercept the OIDC `/callback` (Gate 2 / Pitfall 1) or break iOS standalone login.
Output: configured VitePWA build, install icons + meta, InstallPrompt mounted in the shell.
</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-UI-SPEC.md
@.planning/phases/03-event-write-back-pwa-install/03-RESEARCH.md
@.planning/phases/03-event-write-back-pwa-install/03-PATTERNS.md
@apps/pwa/vite.config.ts
</context>
<tasks>
<task type="auto">
<name>Task 1: VitePWA manifest + service worker (auth-safe denylist) + iOS head/icons</name>
<files>apps/pwa/vite.config.ts, apps/pwa/index.html, apps/pwa/public/icon-192.png, apps/pwa/public/icon-512.png, apps/pwa/public/apple-touch-icon.png</files>
<read_first>
- apps/pwa/vite.config.ts (existing — keep the proxy block incl. /callback verbatim; add VitePWA to plugins)
- .planning/phases/03-event-write-back-pwa-install/03-RESEARCH.md (§Pattern 5 — full VitePWA config; required icons; head meta; §Pitfall 1 /callback denylist; §Pitfall 6 dev-mode SW caveat)
- .planning/phases/03-event-write-back-pwa-install/03-UI-SPEC.md (§PWA Manifest Contract — exact field values; §SW critical denylist)
- apps/pwa/index.html (existing head to extend)
</read_first>
<action>
Add the `VitePWA` plugin to the existing `plugins` array in vite.config.ts per RESEARCH.md Pattern 5: `registerType:'autoUpdate'`; `workbox.navigateFallback:'/index.html'`; `workbox.navigateFallbackDenylist: [/^\/callback/, /^\/api\//, /^\/health/]` (CRITICAL — OIDC + API must reach the server); `workbox.runtimeCaching: []` (no API caching). `manifest`: name "FamilySync", short_name "FamilySync", description "Family calendar and lists", theme_color "#4A90D9", background_color "#FFFFFF", display "standalone", scope "/", start_url "/", icons 192/512/512-maskable per the contract. Keep the existing `server.proxy` block (including `/callback`) exactly as-is.
Generate the three icon PNGs into apps/pwa/public/: `icon-192.png` (192×192), `icon-512.png` (512×512), `apple-touch-icon.png` (180×180). Create a simple solid `#4A90D9` background with a white "F" / calendar glyph using an available CLI tool (ImageMagick `convert`, `sharp` via a one-off node script, or similar). If no image tool is available, set autonomous:false is NOT needed — emit minimal valid PNGs programmatically (node Buffer / sharp). The icons must be valid PNGs at the exact pixel dimensions.
Add to apps/pwa/index.html `<head>` the five entries from RESEARCH.md Pattern 5: apple-touch-icon link (180×180), theme-color meta (#4A90D9), apple-mobile-web-app-capable yes, apple-mobile-web-app-status-bar-style default, apple-mobile-web-app-title FamilySync.
</action>
<verify>
<automated>cd /home/luc/Projects/familysync && pnpm --filter @familysync/pwa build && test -f apps/pwa/dist/manifest.webmanifest && node -e "const m=require('./apps/pwa/dist/manifest.webmanifest');if(m.display!=='standalone'||m.scope!=='/'||!m.icons.some(i=>i.sizes==='512x512'))process.exit(1)" && grep -q "navigateFallbackDenylist" apps/pwa/vite.config.ts && grep -q "apple-touch-icon" apps/pwa/index.html</automated>
</verify>
<acceptance_criteria>
- Production build emits `apps/pwa/dist/manifest.webmanifest` with display:standalone, scope:/, and a 512×512 icon.
- `grep -c "/^\\\\/callback/" apps/pwa/vite.config.ts` ≥1 (denylist present) — and `/callback` is in navigateFallbackDenylist.
- Three icon PNGs exist in apps/pwa/public at the correct dimensions (`file apps/pwa/public/icon-192.png` reports 192 x 192).
- index.html contains the five iOS head entries.
</acceptance_criteria>
<done>Build produces a valid installable manifest + auth-safe SW; iOS icons/meta present.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: InstallPrompt — iOS walkthrough banner/sheet + Android beforeinstallprompt</name>
<files>apps/pwa/src/components/InstallPrompt.tsx, apps/pwa/src/components/CalendarShell.tsx</files>
<read_first>
- apps/pwa/src/components/InstallPrompt.test.tsx (RED stubs from Plan 01 — isIOSSafariNonStandalone + useAndroidInstallPrompt contract)
- .planning/phases/03-event-write-back-pwa-install/03-RESEARCH.md (§Pattern 6 iOS detection + 5-step walkthrough; §Pattern 7 useAndroidInstallPrompt; §Code Examples isInstalled display-mode check)
- .planning/phases/03-event-write-back-pwa-install/03-UI-SPEC.md (§InstallPrompt iOS banner/sheet + Android banner; §Copywriting install strings; §Interaction Contract iOS/Android install; localStorage installPromptDismissed)
- apps/pwa/src/components/EmptyState.tsx (partial analog — informational surface, token usage)
- apps/pwa/src/components/EventDetailPopover.tsx (token + lucide icon import conventions)
</read_first>
<behavior>
Tests (InstallPrompt.test.tsx → GREEN): `isIOSSafariNonStandalone()` true for a mock iOS Safari non-standalone UA, false in standalone; `useAndroidInstallPrompt` sets `canInstall=true` when a mock `beforeinstallprompt` dispatches and calls preventDefault; the component renders nothing when `display-mode: standalone` matches; the iOS banner renders the heading "Install FamilySync" with a "How to install" link; the Android banner renders an "Install" button only when canInstall.
</behavior>
<action>
Implement `InstallPrompt.tsx` with `isIOSSafariNonStandalone()` and `useAndroidInstallPrompt()` per RESEARCH.md Patterns 6/7. Render nothing if already installed (`window.matchMedia('(display-mode: standalone)').matches` or `navigator.standalone`). iOS branch: a dismissible first-visit banner (Smartphone icon, heading/body/CTA per UI-SPEC copy) gated by `localStorage.installPromptDismissed`; "How to install" opens a full-screen bottom-sheet with the 5 annotated steps (exact step copy from UI-SPEC; annotation overlay color `--color-member-2` #F5A623; "Done" closes). Android branch: banner shown only when `canInstall`, with an "Install" button calling `triggerInstall()` then dismiss. Use tokens for all spacing/color, 44px touch targets, plain-text JSX children, `role="banner"`, dismiss `aria-label="Dismiss install prompt"`. Mount `<InstallPrompt />` in CalendarShell (top-level, below nav). Annotated screenshot images may be placeholder assets referenced by path under public/ (real screenshots can be dropped in later); the component structure and copy must be complete and correct.
</action>
<verify>
<automated>cd /home/luc/Projects/familysync && pnpm --filter @familysync/pwa test -- InstallPrompt && grep -q "InstallPrompt" apps/pwa/src/components/CalendarShell.tsx && grep -q "display-mode: standalone" apps/pwa/src/components/InstallPrompt.tsx && pnpm --filter @familysync/pwa exec tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- InstallPrompt.test.tsx GREEN (iOS detection, Android prompt capture, standalone-hides, banner copy).
- `grep -q "isIOSSafariNonStandalone" apps/pwa/src/components/InstallPrompt.tsx`.
- InstallPrompt mounted in CalendarShell.
- Full PWA suite green; tsc --noEmit passes.
</acceptance_criteria>
<done>iOS guided walkthrough + Android prompt work; nothing shows when already installed; mounted in the shell.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| service worker → navigation | The SW can intercept navigations including the OIDC callback |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-03-20 | Spoofing | SW serving a cached shell for /callback, breaking OIDC code exchange / iOS standalone login | mitigate | `navigateFallbackDenylist: [/^\/callback/, /^\/api\//, /^\/health/]`; verified against a production build (Pitfall 1/6); Gate 2 confirms end-to-end (Plan 08) |
| T-03-21 | Tampering | SW caching authenticated API responses | mitigate | `runtimeCaching: []` — no /api caching; /api in denylist |
| T-03-22 | Information Disclosure | manifest/icons leaking nothing sensitive | accept | Static public assets only; no secrets in manifest |
</threat_model>
<verification>
- `pnpm --filter @familysync/pwa build` emits valid manifest.webmanifest + SW.
- `/callback`, `/api/`, `/health` all in navigateFallbackDenylist.
- `pnpm --filter @familysync/pwa test` green (InstallPrompt + existing); tsc --noEmit passes.
</verification>
<success_criteria>
- PWA-01: app installs to Home Screen (manifest + SW, standalone) on iOS and Android.
- PWA-02: first-time guided install (iOS walkthrough + Android prompt); never shown when installed.
- OIDC `/callback` is never SW-intercepted (Gate 2 prerequisite).
</success_criteria>
<output>
Create `.planning/phases/03-event-write-back-pwa-install/03-07-SUMMARY.md` when done.
</output>
@@ -0,0 +1,165 @@
---
phase: 03-event-write-back-pwa-install
plan: 08
type: execute
wave: 5
depends_on: ["03-04", "03-06", "03-07"]
files_modified:
- .planning/phases/03-event-write-back-pwa-install/03-GATE2-RESULTS.md
autonomous: false
requirements: [CAL-04, CAL-05, CAL-06, PWA-01, PWA-02]
user_setup:
- service: authelia
why: "Gate 2 verifies real Authelia OIDC login over the public Pangolin URL including the iOS standalone-PWA flow (success criterion 6, D-14)"
env_vars:
- name: OIDC_AUTH_EXTERNAL_URL
source: "Set to the public familysync URL (e.g. https://familysync.<domain>) so redirect_uri is correct behind Pangolin"
dashboard_config:
- task: "Register FamilySync as an OIDC confidential client (code flow + PKCE S256, client_secret_basic) and ensure the redirect_uri matches the public URL"
location: "Authelia configuration"
- task: "Expose familysync through Pangolin/Newt (Mode A local test rig is sufficient — Unraid prod optional until go-live, D-15)"
location: "Pangolin / Newt connector"
must_haves:
truths:
- "A real member completes Authelia OIDC login over the public Pangolin URL in a desktop browser; the session persists across a browser restart"
- "On iPhone, the member installs FamilySync to the Home Screen and completes login WITHOUT the redirect breaking out of standalone mode"
- "Each member shows a distinct, stable color (AUTH-03) in the live deploy"
- "Create, edit, and delete an event end-to-end through the live deploy; the change appears in the native Fastmail app on the next sync (CAL-04/05/06)"
- "The installed PWA opens full-screen with no browser chrome on iOS and Android (PWA-01/02)"
artifacts:
- path: ".planning/phases/03-event-write-back-pwa-install/03-GATE2-RESULTS.md"
provides: "Recorded Gate 2 verification results (pass/fail per checklist row)"
key_links:
- from: "live deploy"
to: "docs/deployment.md Gate 2 checklist"
via: "operator executes each row"
pattern: "Gate 2"
---
<objective>
Execute and record the Phase 1 Gate 2 live-verification carried into Phase 3 (success
criterion 6, D-14/D-15): real Authelia OIDC login over the public Pangolin URL — most
critically the iOS standalone-PWA login flow — plus session persistence, distinct stable
member colors, and an end-to-end create/edit/delete through the live stack. This is the
first real external auth test and the load-bearing check for the non-technical member.
Purpose: all prior plans build behind the dev-auth bypass (D-13). Nothing has proven the
OIDC redirect survives iOS standalone mode or that writes round-trip to Fastmail in a real
deploy. This plan closes that gap and records the outcome.
Output: 03-GATE2-RESULTS.md with a pass/fail line per Gate 2 checklist row.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@docs/deployment.md
@.planning/phases/03-event-write-back-pwa-install/03-RESEARCH.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Build and deploy FamilySync to the Mode A local test rig behind Pangolin</name>
<files>.planning/phases/03-event-write-back-pwa-install/03-GATE2-RESULTS.md</files>
<read_first>
- docs/deployment.md (§Mode A local test rig setup; §Pangolin idle/read timeout requirements; §Gate 2 checklist at line ~215)
- .planning/PROJECT.md (D-14 dev-auth bypass context; D-15 Mode A local Newt rig)
- apps/api/src/index.ts (OIDC guard mounts only when devBypassActive is false — production build must NOT set DEV_AUTH_BYPASS)
</read_first>
<action>
Per docs/deployment.md Mode A: produce a production build (NODE_ENV=production so the OIDC guard is mounted, dev-bypass OFF), serve the PWA static build + API, and expose it through the local Newt connector / Pangolin test subdomain. Confirm `OIDC_AUTH_EXTERNAL_URL` is set to the public URL and the Authelia client redirect_uri matches. Confirm the public `/health` responds over the tunnel. Create `03-GATE2-RESULTS.md` and record the deploy details (URL, build SHA, date) as the header before the checklist. If any infra step requires operator-only credentials/config, stop and surface it via the checkpoint in Task 2 rather than guessing.
</action>
<verify>
<automated>cd /home/luc/Projects/familysync && pnpm --filter @familysync/pwa build && pnpm --filter @familysync/api build && test -f .planning/phases/03-event-write-back-pwa-install/03-GATE2-RESULTS.md</automated>
</verify>
<acceptance_criteria>
- Both apps build clean for production.
- 03-GATE2-RESULTS.md exists with the deploy header (URL, build, date).
- Public `/health` reachable through the tunnel (record the curl result in the file).
</acceptance_criteria>
<done>A production build is live on the Mode A rig behind Pangolin; results file scaffolded.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking-human">
<name>Task 2: [Gate 2] Live Authelia OIDC + iOS standalone login + distinct colors</name>
<read_first>
- docs/deployment.md (§Gate 2 checklist, esp. row 4 — iOS PWA Add-to-Home-Screen + standalone login)
- .planning/phases/03-event-write-back-pwa-install/03-RESEARCH.md (§Pitfall 2 — iOS standalone OIDC redirect; symptom = stuck in Safari after login; fix = scope:'/' + /callback not SW-intercepted)
</read_first>
<action>Execute the docs/deployment.md Gate 2 checklist on the live public URL: real Authelia OIDC desktop login, session persistence across a browser restart, distinct stable per-member colors, and the load-bearing iOS Add-to-Home-Screen + standalone login (redirect must not break out of standalone). Record each row PASS/FAIL in 03-GATE2-RESULTS.md; on iOS failure apply the Pitfall-2 remedy and retest.</action>
<what-built>The full Phase 3 stack (write-back + outbox worker + PWA install) is deployed to the Mode A rig behind the public Pangolin URL with real Authelia OIDC (dev-bypass OFF). The service worker denylist (Plan 07) keeps `/callback` server-handled.</what-built>
<how-to-verify>
Work through docs/deployment.md §Gate 2 checklist on the live public URL and record each row in 03-GATE2-RESULTS.md:
1. Desktop browser: open the public URL, complete Authelia OIDC login, land on the app — no Fastmail credentials prompted.
2. Restart the browser, revisit — still logged in (session persists, AUTH-02).
3. Confirm each of the two members shows a distinct, stable color (AUTH-03).
4. iPhone: open in Safari, follow the in-app iOS install walkthrough, Add to Home Screen, launch standalone. Complete login — confirm the redirect does NOT break out of standalone (you stay in the app, not dropped to Safari). This is the load-bearing check (Pitfall 2).
5. Confirm the installed PWA opens full-screen with no browser chrome on iOS and Android (PWA-01).
</how-to-verify>
<acceptance_criteria>
- Each Gate 2 checklist row recorded PASS in 03-GATE2-RESULTS.md, especially the iOS standalone login row.
- If iOS standalone login FAILS: record the symptom, apply the Pitfall-2 remedy (verify manifest scope:'/' + start_url:'/', confirm /callback is in the SW denylist and reaches the server), redeploy, retest.
</acceptance_criteria>
<resume-signal>Type "gate2 pass" with the iOS standalone result, or describe the failure (e.g. "stuck in Safari after login").</resume-signal>
</task>
<task type="checkpoint:human-verify" gate="blocking-human">
<name>Task 3: [Gate 2] End-to-end create / edit / delete round-trips to Fastmail</name>
<read_first>
- .planning/phases/03-event-write-back-pwa-install/03-UI-SPEC.md (§Interaction Contract — sync-state feedback, delete interaction)
- .planning/phases/03-event-write-back-pwa-install/03-CONTEXT.md (success criteria 1-3: create/edit/delete appear in native Fastmail on next sync)
</read_first>
<action>On the live deploy, perform create (timed, all-day, weekly recurring), edit, and delete through the UI and confirm each round-trips to the native Fastmail app on the next sync; observe the SyncStateToast Syncing→Saved flow and (if reproducible) a 412 conflict re-fetch. Record each result in 03-GATE2-RESULTS.md.</action>
<what-built>Create/edit/delete UI (EventForm, popover footer, delete dialog), the outbox worker, and the polled SyncStateToast are all live on the deploy.</what-built>
<how-to-verify>
On the live deploy (logged in as a real member):
1. Create a timed event and an all-day event (and one weekly recurring event). Confirm the "Syncing…" toast → "Saved", and that each event appears in the native Fastmail app within the next sync cycle (CAL-04/CAL-07).
2. Edit an existing event's title and time; confirm the change persists in Fastmail (CAL-05).
3. Delete an event via the two-tap confirmation; confirm it disappears from all views on the next sync (CAL-06).
4. (Optional, if reproducible) Trigger a 412 conflict by editing the same event from the Fastmail app first; confirm the conflict toast appears and the calendar re-fetches (D-08).
</how-to-verify>
<acceptance_criteria>
- Create (timed + all-day + recurring), edit, and delete each recorded PASS in 03-GATE2-RESULTS.md with confirmation they round-tripped to the native Fastmail app.
- Sync toast behavior (Syncing → Saved; persistent error on failure) observed and recorded.
</acceptance_criteria>
<resume-signal>Type "writeback pass" with the Fastmail round-trip results, or describe any write that did not appear.</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| public internet → Pangolin → app | First real external exposure of the auth + write path |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-03-23 | Spoofing | dev-auth bypass accidentally active in the live deploy | mitigate | Production build sets NODE_ENV=production → bypass forced OFF, OIDC unconditionally mounted (index.ts); verify the login is real Authelia, not the dev user |
| T-03-24 | Information Disclosure | OIDC redirect_uri mismatch leaking codes or failing login | mitigate | OIDC_AUTH_EXTERNAL_URL set to the public URL; Authelia client redirect_uri matches (deployment.md) |
| T-03-25 | Tampering | SW intercepting /callback in the live build | mitigate | Plan 07 denylist verified against the production build; Gate 2 row 4 confirms standalone login end-to-end |
</threat_model>
<verification>
- Both apps build for production; public /health reachable through the tunnel.
- 03-GATE2-RESULTS.md records PASS for: desktop OIDC login, session persistence, distinct colors, iOS standalone login, full-screen install, and create/edit/delete Fastmail round-trips.
</verification>
<success_criteria>
- Success criterion 6 satisfied: live Authelia OIDC over Pangolin works including iOS standalone-PWA login; sessions persist; distinct stable colors.
- Success criteria 1-5 confirmed live: create/edit/delete round-trip to Fastmail; installable + full-screen on iOS and Android.
</success_criteria>
<output>
Create `.planning/phases/03-event-write-back-pwa-install/03-08-SUMMARY.md` when done.
</output>