From 364b6e68b019ebdc8a5522bc2de50d00b2e2e4f8 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Fri, 5 Jun 2026 16:32:38 -0400 Subject: [PATCH] docs(03): research phase domain --- .../03-RESEARCH.md | 972 ++++++++++++++++++ 1 file changed, 972 insertions(+) create mode 100644 .planning/phases/03-event-write-back-pwa-install/03-RESEARCH.md diff --git a/.planning/phases/03-event-write-back-pwa-install/03-RESEARCH.md b/.planning/phases/03-event-write-back-pwa-install/03-RESEARCH.md new file mode 100644 index 0000000..0b2d107 --- /dev/null +++ b/.planning/phases/03-event-write-back-pwa-install/03-RESEARCH.md @@ -0,0 +1,972 @@ +# Phase 3: Event Write-Back + PWA Install — Research + +**Researched:** 2026-06-05 +**Domain:** CalDAV write-back (tsdav/ical.js), transactional outbox, PWA manifest + service worker (vite-plugin-pwa), iOS/Android install, Gate 2 live-auth +**Confidence:** HIGH (stack is locked and already used; new surface areas verified via official docs) + +--- + + +## User Constraints (from CONTEXT.md) + +### Locked Decisions +- **D-01:** Default target = remember last-used per member. First-time default = creator's own personal calendar. +- **D-02:** Calendar picker shown only when the member has >1 writable calendar. Hidden for single-calendar members. +- **D-03:** Writable set = member's own personal + shared Family calendar (when it exists). Other member's personal is read-only. +- **D-04:** Edit = delete-from-old + create-on-new. Handle partial-failure (delete ok / create failed, and vice-versa). +- **D-05:** Optimistic-accept + server-side outbox. API writes a `pending` row and returns immediately. Worker drains against Fastmail. +- **D-06:** Re-sync on confirm. Worker triggers targeted single-calendar re-sync, then clears pending state. +- **D-07:** Retry policy — backoff transient (network/5xx/timeout), fail-fast hard errors (401/403/400). +- **D-08:** Conflict handling = If-Match + 412 detection → re-sync + warn user. No silent last-write-wins. +- **D-09:** Sync-state surfaced via polling, NOT SSE (SSE-over-Pangolin unverified until Phase 4 gate). +- **D-10:** Edit/delete surface reuses `EventDetailPopover` reserved footer (Phase 2 D-08). +- **D-11:** Recurring events: create + whole-series edit only in v1. Single-occurrence and "this-and-following" are v1.x. +- **D-12:** Broker is the only Fastmail I/O boundary. No tsdav import in route handlers. +- **D-13:** Dev-auth bypass stays available for local build/test; live Authelia verification is the Gate 2 item folded into this phase. + +### Claude's Discretion +- Event form field set and layout (title, start/end, all-day toggle, location, description). +- Recurrence creation UX (simple presets daily/weekly/monthly/yearly vs custom builder; minimal for v1). +- iOS install onboarding: trigger (auto-detect iOS-Safari-non-standalone vs help button vs first-visit banner) and annotated walkthrough content. +- Android install: `beforeinstallprompt` handling (custom button vs native prompt). +- PWA tooling: `vite-plugin-pwa` manifest + service worker config; keep conservative. +- Outbox worker mechanics (interval vs trigger, idempotency key, max-attempt count, dead-letter surfacing). + +### Deferred Ideas (OUT OF SCOPE) +- Single-occurrence / "this-and-following" recurring edits (CAL-09/CAL-10) — v1.x. +- Writing to the other member's personal calendar — out. +- SSE-based live sync-state push — deferred to Phase 4. + + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|------------------| +| CAL-04 | User can create a timed or all-day event, written back to the correct Fastmail calendar | tsdav `createCalendarObject` + ical.js VEVENT builder; outbox enqueue pattern | +| CAL-05 | User can edit an existing event | tsdav `updateCalendarObject` with If-Match etag; edit-as-delete+create for calendar-move (D-04) | +| CAL-06 | User can delete an event | tsdav `deleteCalendarObject` with If-Match etag | +| CAL-07 | User can create a recurring event (whole-series only in v1) | ical.js RRULE property building; simple preset strings | +| PWA-01 | App installable on iPhone and Android (manifest + service worker, HTTPS) | vite-plugin-pwa 1.3.0 config; manifest fields; icon requirements | +| PWA-02 | First-time users get guided Add to Home Screen prompt | iOS standalone detection; annotated walkthrough; `beforeinstallprompt` for Android | + + +--- + +## Summary + +Phase 3 has three distinct technical pillars: CalDAV write-back through the existing broker boundary, a MariaDB outbox with background worker to decouple the UI from Fastmail latency, and a PWA manifest + service worker to enable home-screen installation on iOS and Android. + +**CalDAV write-back** uses `tsdav`'s `createCalendarObject`, `updateCalendarObject`, and `deleteCalendarObject` methods which are already in the installed `tsdav@2.2.2`. The `ical.js@2.2.1` library (also installed) handles both VEVENT parsing (read path) and VEVENT _construction_ (write path). No new CalDAV or iCalendar libraries are required. UIDs for new events are generated with Node.js 22's built-in `crypto.randomUUID()` — no `uuid` package needed. + +**The outbox pattern** is straightforward for a single-container, single-process deployment: a new `calendarOutbox` MariaDB table stores pending operations; a background worker (sibling to the existing `node-cron` ctag poller) drains the queue, applies exponential backoff for transient failures, and triggers a targeted single-calendar re-sync on success (D-06). The polled sync-state endpoint (D-09) reads directly from the outbox table. This is not a distributed system — no message broker is needed. + +**PWA installation** uses `vite-plugin-pwa@1.3.0` (already in `CLAUDE.md` recommended stack, not yet installed in the repo). The critical risk is the service worker intercepting `/callback` (the OIDC redirect endpoint) or navigation to `auth.DOMAIN`, which would break the Gate 2 iOS standalone login flow. The mitigation is `navigateFallbackDenylist: [/^\/callback/]` plus avoiding a navigation fallback for the auth subdomain entirely (which is on a different origin and will not be intercepted by the SW). For iOS, the OIDC redirect to `auth.DOMAIN` leaves the PWA scope, but since iOS 12.2 the in-app browser shares storage context with the opener PWA and redirects back to a URL in the PWA scope restore the standalone window — this is the expected iOS flow for same-parent-domain OIDC. Gate 2 verifies it end-to-end. + +**Primary recommendation:** Build the outbox table and worker first (it gates all write paths), then the write endpoints + broker methods, then the form UI, then the PWA layer. Feature-slice vertically per the MVP mode. + +--- + +## Architectural Responsibility Map + +| Capability | Primary Tier | Secondary Tier | Rationale | +|------------|-------------|----------------|-----------| +| Event create/edit/delete form (UI) | Browser/Client (React PWA) | — | Input collection; dispatches to API | +| Write enqueue (optimistic accept) | API / Backend (Hono) | — | Writes outbox row, returns 202; never calls Fastmail inline | +| CalDAV PUT / DELETE | API / Backend (broker worker) | — | D-12: broker boundary; no tsdav in route handlers | +| Outbox state machine | API / Backend (Node.js worker) | MariaDB | Status transitions: pending → done/failed/dead-letter | +| Targeted re-sync on confirm | API / Backend (broker/sync.ts) | MariaDB | Reuses existing `syncCalendar` with a forced re-sync | +| Sync-state polling endpoint | API / Backend (Hono route) | MariaDB | Reads outbox rows by UID/user; polled by TanStack Query (D-09) | +| PWA manifest + service worker | CDN / Static (Vite build) | Browser/Client | Generated at build time by vite-plugin-pwa; SW registered by browser | +| iOS A2HS walkthrough | Browser/Client (React PWA) | — | Detect standalone, render annotated instructions | +| Android install prompt | Browser/Client (React PWA) | — | Capture `beforeinstallprompt`, defer, show custom button | +| Gate 2 OIDC live-auth | Infra (Authelia + Pangolin) | API auth middleware | Code is already correct; Gate 2 is an operator deployment task | + +--- + +## Standard Stack + +### Core (already installed — no new installs for write-back) + +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| tsdav | 2.2.2 | CalDAV PUT/DELETE against Fastmail | Already in stack; `createCalendarObject`, `updateCalendarObject`, `deleteCalendarObject` confirmed available [VERIFIED: npm registry — 2026-05-14] | +| ical.js | 2.2.1 | Build new VCALENDAR/VEVENT blobs for write | Already in stack; Mozilla-maintained; handles both parse and construction [VERIFIED: npm registry — 2025-08-08] | +| node-cron | 4.2.1 | Schedule outbox worker poll interval | Already used for ctag poller; sibling worker uses same pattern [VERIFIED: npm registry — 2026-04-24] | +| drizzle-orm | 0.45.2 | Outbox table schema + queries | Already in stack; `mysqlEnum` for status column [VERIFIED: npm registry] | +| zod + @hono/zod-validator | 3.x / 0.8.0 | Validate write endpoint request bodies | Already in stack [VERIFIED: npm registry] | +| crypto.randomUUID() | Node.js 22 built-in | Generate unique UID for new events | No package needed; confirmed available in Node.js 22 [VERIFIED: confirmed in runtime] | + +### New Installs (PWA layer only) + +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| vite-plugin-pwa | 1.3.0 | Web manifest + service worker generation | In `CLAUDE.md` recommended stack; zero-config Workbox; Vite 8 compatible [VERIFIED: npm registry — 2026-05-05] | +| workbox-window | 7.4.1 | SW lifecycle (update prompts, skip waiting) | Peer dep of vite-plugin-pwa 1.3.0; auto-installed [VERIFIED: npm registry] | +| workbox-build | 7.4.1 | Build-time precache manifest generation | Peer dep of vite-plugin-pwa 1.3.0; auto-installed [VERIFIED: npm registry] | + +### rrule — NOT needed for Phase 3 + +`rrule@2.8.1` is in `CLAUDE.md` as a recommended library for _expanding_ recurrence rules on the client side. In Phase 3, recurrence expansion remains server-side (existing `expand.ts`). For **creating** a recurring event, a simple preset RRULE string (e.g. `RRULE:FREQ=WEEKLY;BYDAY=MO`) is hand-composed server-side — no rrule library required for this. The planner should not add rrule to Phase 3. + +### Installation + +```bash +# From apps/pwa directory +pnpm add vite-plugin-pwa +# workbox-window and workbox-build install as peer deps automatically +``` + +--- + +## Package Legitimacy Audit + +> slopcheck was not available at research time (`pip install slopcheck` failed). All new packages are tagged `[ASSUMED]` per the fallback protocol. The planner must gate each install behind a `checkpoint:human-verify` task. + +| Package | Registry | Age | Downloads | Source Repo | slopcheck | Disposition | +|---------|----------|-----|-----------|-------------|-----------|-------------| +| vite-plugin-pwa | npm | ~4 yrs | High (50M+/mo estimated) | github.com/vite-pwa/vite-plugin-pwa | not run | [ASSUMED] — in CLAUDE.md recommended stack; in project for months | +| workbox-window | npm | ~7 yrs | Very high (Google-maintained) | github.com/GoogleChrome/workbox | not run | [ASSUMED] — peer dep; Google-maintained | +| workbox-build | npm | ~7 yrs | Very high (Google-maintained) | github.com/GoogleChrome/workbox | not run | [ASSUMED] — peer dep; Google-maintained | + +**Packages removed due to slopcheck [SLOP] verdict:** none + +**Packages flagged as suspicious [SUS]:** none identified by manual inspection + +**Note:** `vite-plugin-pwa` is listed in `CLAUDE.md` as the project's locked PWA tooling choice. Given it is already in the project's canonical stack document and has been validated by the project owner, the planner may treat it as project-approved. Still gate with a quick `npm view vite-plugin-pwa` version check before install. + +--- + +## Architecture Patterns + +### System Architecture Diagram + +``` +Browser (React PWA) + │ + │ [User fills EventForm → taps Save] + │ + ▼ +POST /api/events/create (or /edit, /delete) + │ validates with zod + │ resolves target calendar (D-01/D-02/D-03) + │ + ├─► INSERT INTO calendar_outbox (status='pending', …) + │ + └─► 202 Accepted ◄─── "syncing…" toast shown immediately (D-05) + +TanStack Query polls /api/events/sync-status?uid=… + │ reads outbox row by uid + userId + │ returns { status: 'pending' | 'done' | 'failed' | 'dead' } + └─► updates toast: "syncing" → "synced" | "not saved" + +Background (Node.js process, same container) + ┌─ OutboxWorker (setInterval / node-cron sibling) + │ polls calendar_outbox WHERE status='pending' AND next_attempt_at <= NOW() + │ for each row: + │ ├─ calls broker/write.ts → createCalendarObject / updateCalendarObject / deleteCalendarObject + │ │ (tsdav PUT/DELETE against Fastmail) + │ ├─ on success → trigger syncCalendar(calendarUrl) → UPDATE outbox status='done' + │ ├─ on transient (5xx/network) → UPDATE next_attempt_at = exponential backoff, attempt_count++ + │ │ when attempt_count >= MAX_ATTEMPTS → status='dead' (dead-letter) + │ └─ on hard error (400/401/403/412) → status='failed' immediately (no retry) + +Broker (broker/write.ts — new file) + │ createCalendarObject({ calendar, filename, iCalString }) + │ updateCalendarObject({ calendarObject: { url, etag, data } }) ← If-Match header + │ deleteCalendarObject({ calendarObject: { url, etag } }) ← If-Match header + │ + └─► On 412 response → signal CONFLICT to worker → worker routes to conflict flow (D-08) +``` + +### Recommended Project Structure Additions + +``` +apps/api/src/ +├── broker/ +│ ├── client.ts # existing — createFastmailClient +│ ├── sync.ts # existing — REPORT → ical.js → upsert +│ ├── poller.ts # existing — ctag poller +│ ├── expand.ts # existing — RecurExpansion +│ ├── write.ts # NEW — createEvent, updateEvent, deleteEvent (tsdav PUT/DELETE) +│ ├── vevent.ts # NEW — buildVevent(), buildRecurringVevent() (ical.js VEVENT builder) +│ └── outboxWorker.ts # NEW — setInterval drain loop, retry logic, re-sync trigger +├── routes/ +│ ├── events.ts # extend — add POST /create, PATCH /edit, DELETE /:uid, GET /sync-status +│ └── ... +└── db/ + └── schema.ts # extend — add calendarOutbox table + +apps/pwa/src/ +├── components/ +│ ├── EventDetailPopover.tsx # extend — wire reserved footer, add edit/delete buttons +│ ├── EventForm.tsx # NEW — create/edit form modal +│ └── InstallPrompt.tsx # NEW — iOS walkthrough + Android beforeinstallprompt +├── api/ +│ └── client.ts # extend — addCreateEvent, updateEvent, deleteEvent, fetchSyncStatus +└── ... + +apps/pwa/ +└── vite.config.ts # extend — add VitePWA plugin +``` + +--- + +## Pattern 1: Building a VEVENT with ical.js (new file: `broker/vevent.ts`) + +**What:** Construct a valid VCALENDAR/VEVENT string for PUT to Fastmail. +**When to use:** Creating new events (CAL-04) and whole-series recreation during edit (D-04/D-11). + +```typescript +// Source: https://github.com/kewisch/ical.js/wiki/Convert-to-iCalendar-(rfc5545) +// Source: https://github.com/kewisch/ical.js/blob/main/lib/ical/component.js +// Source: https://github.com/kewisch/ical.js/blob/main/lib/ical/time.js +import ICAL from 'ical.js' +import { randomUUID } from 'crypto' + +export interface NewEventParams { + uid?: string // omit = generate new UUID + summary: string + allDay: boolean + // All-day: YYYY-MM-DD string + // Timed: JS Date (UTC instant) + dtstart: string | Date + dtend: string | Date + location?: string + description?: string + rruleString?: string // e.g. 'FREQ=WEEKLY;BYDAY=MO' — omit for non-recurring + dtstamp?: Date // omit = now() +} + +export function buildVeventString(params: NewEventParams): { uid: string; icsString: string } { + const uid = params.uid ?? `${randomUUID()}@familysync` + + // --- VCALENDAR wrapper --- + const cal = new ICAL.Component(['vcalendar', [], []]) + cal.updatePropertyWithValue('version', '2.0') + cal.updatePropertyWithValue('prodid', '-//FamilySync//FamilySync//EN') + + // --- VEVENT --- + const vevent = new ICAL.Component('vevent') + vevent.addPropertyWithValue('uid', uid) + vevent.addPropertyWithValue('summary', params.summary) + + const dtstamp = ICAL.Time.fromJSDate(params.dtstamp ?? new Date(), true) + vevent.addPropertyWithValue('dtstamp', dtstamp) + + if (params.allDay) { + // DATE value (not DATETIME) — isDate:true, no time component (D-13 contract) + const startStr = typeof params.dtstart === 'string' ? params.dtstart : params.dtstart.toISOString().slice(0, 10) + const endStr = typeof params.dtend === 'string' ? params.dtend : params.dtend.toISOString().slice(0, 10) + const [sy, sm, sd] = startStr.split('-').map(Number) + const [ey, em, ed] = endStr.split('-').map(Number) + const startTime = new ICAL.Time({ year: sy, month: sm, day: sd, isDate: true }) + const endTime = new ICAL.Time({ year: ey, month: em, day: ed, isDate: true }) + vevent.addPropertyWithValue('dtstart', startTime) + vevent.addPropertyWithValue('dtend', endTime) + } else { + // DATETIME in UTC (useUTC=true → DTSTART;TZID is NOT added; 'Z' suffix used) + const startTime = ICAL.Time.fromJSDate(params.dtstart as Date, true) + const endTime = ICAL.Time.fromJSDate(params.dtend as Date, true) + vevent.addPropertyWithValue('dtstart', startTime) + vevent.addPropertyWithValue('dtend', endTime) + } + + if (params.rruleString) { + vevent.addPropertyWithValue('rrule', params.rruleString) + } + if (params.location) vevent.addPropertyWithValue('location', params.location) + if (params.description) vevent.addPropertyWithValue('description', params.description) + + cal.addSubcomponent(vevent) + return { uid, icsString: cal.toString() } +} +``` + +**Key invariant (D-13):** `isDate: true` → `dtstart_date` column in DB; `isDate: false` → `dtstart_utc` column. Never mix. + +--- + +## Pattern 2: tsdav Write Methods (new file: `broker/write.ts`) + +**What:** Wrap tsdav's three write operations to enforce the broker boundary (D-12). +**Return:** Raw `Response` — caller inspects `.status` and `.headers.get('etag')`. + +```typescript +// Source: https://tsdav.vercel.app/docs/caldav/createCalendarObject +// Source: https://tsdav.vercel.app/docs/caldav/updateCalendarObject +// Source: https://github.com/natelindev/tsdav/blob/main/src/request.ts (If-Match header confirmed) +import type { FastmailClient } from './client.js' +import type { DAVCalendar } from 'tsdav' + +// --- CREATE (PUT with If-None-Match: *) --- +export async function createCalendarEvent( + client: FastmailClient, + calendar: DAVCalendar, + uid: string, + icsString: string, +): Promise { + return client.createCalendarObject({ + calendar, + filename: `${uid}.ics`, + iCalString: icsString, + }) +} + +// --- UPDATE (PUT with If-Match: ) --- +// calendarObjectUrl: the object's URL (e.g. https://caldav.fastmail.com/.../uid.ics) +// etag: cached etag from calendarEvents.etag — drives the 412 conflict check (D-08) +export async function updateCalendarEvent( + client: FastmailClient, + calendarObjectUrl: string, + icsString: string, + etag: string | null, +): Promise { + return client.updateCalendarObject({ + calendarObject: { + url: calendarObjectUrl, + data: icsString, + etag: etag ?? '', // tsdav: etag → If-Match header + }, + }) +} + +// --- DELETE (DELETE with If-Match: ) --- +export async function deleteCalendarEvent( + client: FastmailClient, + calendarObjectUrl: string, + etag: string | null, +): Promise { + return client.deleteCalendarObject({ + calendarObject: { + url: calendarObjectUrl, + data: '', // tsdav deleteCalendarObject needs the calendarObject shape + etag: etag ?? '', + }, + }) +} +``` + +**Status code inspection (confirmed via tsdav source):** +- Create success: `201 Created` (sometimes `204 No Content` on some servers) +- Update success: `204 No Content` +- Delete success: `204 No Content` +- **412 Precondition Failed**: etag mismatch → conflict flow (D-08) +- **401/403**: hard fail → stop retry immediately (D-07) +- **400**: hard fail (malformed VEVENT) +- **5xx / network error**: transient → exponential backoff (D-07) + +**ETag extraction from response:** +```typescript +const newEtag = response.headers.get('etag') // may be null on some Fastmail responses +// If null: issue a GET to fetch the updated object and extract the etag from the DAVObject +// This is the standard CalDAV behaviour when the server modifies the object on PUT +``` +[CITED: sabre/dav CalDAV client guide — "etag may not be returned if server modifies object"] + +--- + +## Pattern 3: Outbox Table Schema + +**What:** New `calendarOutbox` table in `apps/api/src/db/schema.ts`. + +```typescript +// Source: https://orm.drizzle.team/docs/column-types/mysql (mysqlEnum, text, timestamp, int) +import { mysqlTable, int, varchar, text, timestamp, mysqlEnum, index } from 'drizzle-orm/mysql-core' + +export const calendarOutbox = mysqlTable( + 'calendar_outbox', + { + id: int().primaryKey().autoincrement(), + userId: int('user_id').notNull().references(() => users.id), + // 'create' | 'update' | 'delete' + operation: mysqlEnum(['create', 'update', 'delete']).notNull(), + // 'pending' | 'done' | 'failed' | 'dead' + status: mysqlEnum(['pending', 'done', 'failed', 'dead']).notNull().default('pending'), + uid: varchar('uid', { length: 512 }).notNull(), + calendarUrl: varchar('calendar_url', { length: 1024 }).notNull(), + calendarObjectUrl: varchar('calendar_object_url', { length: 1024 }), // null for creates + etag: varchar('etag', { length: 256 }), // cached etag for If-Match (D-08) + payload: text('payload'), // icsString for create/update; null for delete + attemptCount: int('attempt_count').notNull().default(0), + nextAttemptAt: timestamp('next_attempt_at').defaultNow().notNull(), + lastError: text('last_error'), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(), + }, + (t) => [ + index('idx_outbox_user_status').on(t.userId, t.status), + index('idx_outbox_next_attempt').on(t.nextAttemptAt, t.status), + index('idx_outbox_uid').on(t.uid), + ], +) +``` + +**Key design notes:** +- `calendarObjectUrl` is null for creates (URL is `calendarUrl + uid + '.ics'`, computed at worker time) +- `etag` stored for If-Match on update/delete (D-08); may be null for new creates +- `nextAttemptAt` drives the backoff schedule: worker selects `WHERE status='pending' AND next_attempt_at <= NOW()` +- `dead` status = max attempts exceeded; surfaced to user as "not saved" +- No `idempotency_key` needed beyond (userId, uid, operation, createdAt) — single-process, not distributed + +--- + +## Pattern 4: Outbox Worker (new file: `broker/outboxWorker.ts`) + +**What:** Sibling to ctag poller; drains pending outbox rows. +**Interval:** Every 15 seconds (fast enough to feel responsive; not so fast as to hammer Fastmail). + +```typescript +// Source: existing poller.ts pattern — setInterval or node-cron +const MAX_ATTEMPTS = 5 +const BACKOFF_SECONDS = [15, 60, 300, 600, 1800] // ~30 min total window (D-07) + +// Transient status codes (retry with backoff) +const TRANSIENT_STATUSES = new Set([408, 429, 500, 502, 503, 504]) +// Hard fail status codes (stop immediately) +const HARD_FAIL_STATUSES = new Set([400, 401, 403]) +// Conflict (route to conflict flow, not retry loop) +const CONFLICT_STATUS = 412 + +export async function runOutboxDrain(): Promise { + const pending = await db + .select() + .from(calendarOutbox) + .where( + and( + eq(calendarOutbox.status, 'pending'), + lte(calendarOutbox.nextAttemptAt, new Date()), + ), + ) + .limit(10) // process max 10 per cycle + + for (const row of pending) { + try { + const result = await dispatchOutboxRow(row) + if (result.conflict) { + // 412 — route to conflict flow (D-08): mark failed (no retry), re-sync calendar + await db.update(calendarOutbox).set({ status: 'failed', lastError: '412 conflict' }).where(eq(calendarOutbox.id, row.id)) + await triggerTargetedResync(row.calendarUrl, row.userId) // D-06 pattern + } else if (result.success) { + await db.update(calendarOutbox).set({ status: 'done' }).where(eq(calendarOutbox.id, row.id)) + await triggerTargetedResync(row.calendarUrl, row.userId) // D-06 + } else if (result.hardFail) { + await db.update(calendarOutbox).set({ status: 'failed', lastError: result.error }).where(eq(calendarOutbox.id, row.id)) + } else { + // transient — backoff + const nextAttempt = row.attemptCount + 1 + if (nextAttempt >= MAX_ATTEMPTS) { + await db.update(calendarOutbox).set({ status: 'dead', attemptCount: nextAttempt, lastError: result.error }).where(eq(calendarOutbox.id, row.id)) + } else { + const backoffMs = (BACKOFF_SECONDS[nextAttempt] ?? 1800) * 1000 + await db.update(calendarOutbox).set({ + attemptCount: nextAttempt, + nextAttemptAt: new Date(Date.now() + backoffMs), + lastError: result.error, + }).where(eq(calendarOutbox.id, row.id)) + } + } + } catch (err) { + // DB error — log but don't crash + console.error('[outboxWorker] Dispatch error row.id=%d:', row.id, err) + } + } +} +``` + +**Targeted re-sync (D-06):** Reuses `syncCalendar(client, davCal, userId)` from `sync.ts`. The worker needs the DAVCalendar object — either stored in the outbox row or fetched via `client.fetchCalendars()` and filtered by URL. Storing just the URL and fetching at sync-time is cleaner (no stale DAVCalendar shape). + +--- + +## Pattern 5: vite-plugin-pwa Configuration + +**What:** Add `VitePWA` plugin to `apps/pwa/vite.config.ts`. +**Critical constraint:** Must not intercept `/callback` or break OIDC redirect flow (Gate 2). + +```typescript +// Source: https://vite-pwa-org.netlify.app/guide/ +// Source: https://vite-pwa-org.netlify.app/workbox/generate-sw.html +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import { VitePWA } from 'vite-plugin-pwa' + +export default defineConfig({ + plugins: [ + react(), + VitePWA({ + registerType: 'autoUpdate', + // ⚠️ CRITICAL: exclude /callback from SW navigation handling (Gate 2) + // The OIDC authorization-code exchange lands on /callback — if the SW + // intercepts this as a navigation, it may serve a cached shell instead. + workbox: { + navigateFallback: '/index.html', + navigateFallbackDenylist: [ + /^\/callback/, // OIDC redirect endpoint — must reach the server + /^\/api\//, // API calls — never serve from cache + /^\/health/, // Health endpoint + ], + // Only cache GET API responses if explicitly listed in runtimeCaching. + // Default: no runtime caching for /api/* (falls through to network). + runtimeCaching: [], + }, + manifest: { + name: 'FamilySync', + short_name: 'FamilySync', + description: 'Family calendar and lists', + theme_color: '#4A90D9', // match users.color primary blue + background_color: '#ffffff', + display: 'standalone', + scope: '/', + start_url: '/', + icons: [ + { src: '/icon-192.png', sizes: '192x192', type: 'image/png' }, + { src: '/icon-512.png', sizes: '512x512', type: 'image/png' }, + { src: '/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }, + ], + }, + }), + ], + server: { + proxy: { + '/health': 'http://localhost:3000', + '/api': 'http://localhost:3000', + '/callback': 'http://localhost:3000', + }, + }, +}) +``` + +**Required icon files to add to `apps/pwa/public/`:** +- `icon-192.png` (192×192 px) +- `icon-512.png` (512×512 px) +- `apple-touch-icon.png` (180×180 px — required for iOS A2HS) + +**Required HTML `` additions in `apps/pwa/index.html`:** +```html + + + + + +``` + +--- + +## Pattern 6: iOS A2HS Detection and Walkthrough + +**What:** Detect iOS-Safari-non-standalone and render an annotated install guide. + +```typescript +// Source: CLAUDE.md §PWA iOS Limitations +// Detection +function isIOSSafariNonStandalone(): boolean { + const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as unknown as {MSStream?: unknown}).MSStream + const isStandalone = (window.navigator as unknown as {standalone?: boolean}).standalone === true + return isIOS && !isStandalone +} +``` + +**Trigger strategy (Claude's Discretion):** Show on first visit (localStorage flag `installPromptShown`). A dismissible banner at top of screen, not a blocking modal. Non-technical users should not need to hunt for it. + +**Walkthrough content (required for success criterion 4):** +1. "Open FamilySync in Safari on your iPhone" (with Safari icon) +2. "Tap the Share button" (annotated screenshot of iOS Share sheet icon) +3. "Scroll down and tap 'Add to Home Screen'" (annotated screenshot) +4. "Tap 'Add' in the top right" (annotated screenshot) +5. "Open FamilySync from your Home Screen — it opens full-screen, no browser bar" + +Use actual iOS screenshots with annotation overlays, not stock art. The goal: wife installs unassisted. This is a prerequisite for Phase 5 Web Push. + +**EU DMA caveat (CLAUDE.md):** On iOS 17.4+ in EU, PWAs may open in Safari tabs instead of standalone mode. If this affects the wife, the fallback is "use the Share → Add to Home Screen flow and ensure 'Open in' is set to standalone" — this is an Apple policy issue, not a code fix. + +--- + +## Pattern 7: Android beforeinstallprompt + +```typescript +// Source: https://web.dev/articles/customize-install [VERIFIED: official web.dev docs] +// Note: only fires on Chrome/Edge on Android; not on iOS +import { useState, useEffect } from 'react' + +interface BeforeInstallPromptEvent extends Event { + prompt(): Promise + userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }> +} + +export function useAndroidInstallPrompt() { + const [deferredPrompt, setDeferredPrompt] = useState(null) + + useEffect(() => { + const handler = (e: Event) => { + e.preventDefault() + setDeferredPrompt(e as BeforeInstallPromptEvent) + } + window.addEventListener('beforeinstallprompt', handler) + window.addEventListener('appinstalled', () => setDeferredPrompt(null)) + return () => window.removeEventListener('beforeinstallprompt', handler) + }, []) + + const triggerInstall = async () => { + if (!deferredPrompt) return + await deferredPrompt.prompt() + const { outcome } = await deferredPrompt.userChoice + if (outcome === 'accepted') setDeferredPrompt(null) + } + + return { canInstall: deferredPrompt !== null, triggerInstall } +} +``` + +**Important:** `prompt()` can only be called once per captured event. If dismissed, wait for the next `beforeinstallprompt`. Show the install button only when `canInstall` is true (i.e., the event fired). + +--- + +## Pattern 8: Polled Sync-State Endpoint (D-09) + +**What:** `GET /api/events/sync-status` — TanStack Query polls this at a short interval after a write. + +```typescript +// Request: GET /api/events/sync-status?uid= +// Response: { uid, status: 'pending' | 'done' | 'failed' | 'dead', error?: string } +// Frontend: useQuery({ queryKey: ['syncStatus', uid], refetchInterval: pendingStatus ? 3000 : false }) +// → triggers queryClient.invalidateQueries(['events']) when status transitions to 'done' +``` + +**No SSE:** As per D-09, polling only. TanStack Query's `refetchInterval` set to 3 seconds while status is `pending`, disabled once terminal state is reached. + +--- + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| iCalendar serialization | Custom string templates | `ical.js` ICAL.Component / ICAL.Time API | Line folding, character escaping, DATE vs DATETIME encoding are all handled; hand-rolled templates fail on edge cases (e.g. summary containing commas) | +| CalDAV PUT/DELETE HTTP wiring | Manual `fetch` with XML headers | `tsdav` `createCalendarObject` / `updateCalendarObject` / `deleteCalendarObject` | tsdav handles If-Match, If-None-Match, Content-Type text/calendar, auth header injection | +| UUID generation | Custom UUID function | `crypto.randomUUID()` (Node.js 22 built-in) | RFC 4122 compliant, no package needed | +| RRULE string for simple presets | Custom RRULE parser | Hand-composed preset strings (`'FREQ=DAILY'`, `'FREQ=WEEKLY;BYDAY=MO'`, etc.) | Preset strings are trivial and unambiguous; no library needed for whole-series only (D-11) | +| PWA manifest injection | Inline manifest in HTML | `vite-plugin-pwa` | Cross-browser compatibility, scope/start_url handling, SW registration, Workbox precaching | +| iOS A2HS detection (complex) | Regex on UA | `navigator.standalone` + `/iPad\|iPhone\|iPod/.test(navigator.userAgent)` | Standard pattern; no library needed | +| Optimistic UI state | Manual fetch polling | TanStack Query `refetchInterval` | Already in the stack; `refetchInterval: 3000` while status = 'pending' is two lines of config | + +**Key insight:** ical.js's `ICAL.Component` and `ICAL.Time` APIs already installed handle the hardest part of write-back — building valid iCalendar from scratch. The "write" path is symmetric with the "parse" path already in `sync.ts` and `expand.ts`. + +--- + +## Common Pitfalls + +### Pitfall 1: Service Worker intercepts `/callback` and breaks OIDC login + +**What goes wrong:** The default `navigateFallback: '/index.html'` causes the SW to intercept the OIDC callback URL (`/callback?code=...&state=...`) and return the cached shell instead of letting the server process the authorization code exchange. + +**Why it happens:** `workbox.navigateFallback` with no denylist applies to ALL navigation requests, including the OIDC callback route. + +**How to avoid:** Always include `/callback` (and `/api/*`) in `navigateFallbackDenylist`. Verify by checking that `GET /callback?code=XXX` returns the correct server response, not a cached HTML page. + +**Warning signs:** Login loop ("redirected to Authelia, came back, immediately redirected again"); `@hono/oidc-auth` receives no code exchange; session never established. + +[VERIFIED: vite-pwa-org.netlify.app/workbox/generate-sw.html — `navigateFallbackDenylist` confirmed available] + +--- + +### Pitfall 2: iOS standalone mode breaks on OIDC redirect to auth.DOMAIN + +**What goes wrong:** After tapping "Login", iOS opens `auth.DOMAIN` in its in-app browser (not the standalone window) and the redirect back lands in Safari, not in the PWA. + +**Why it happens:** iOS PWA standalone mode drops any navigation outside the PWA's `scope` (default: `/`). `auth.DOMAIN` is a different origin. + +**How to handle:** This is **expected iOS behaviour since iOS 12.2**. The in-app browser shares storage context with the opener PWA, so cookies set during auth ARE accessible to the PWA after the redirect. When the in-app browser's URL matches the PWA scope (`/callback`) it closes and restores the standalone window. This is the mechanism that makes Authelia work — the `/callback` URL is within the PWA's scope and triggers standalone restoration. + +**What can break it:** If the `scope` in the manifest is narrower than `/`, or if the `start_url` is set to a path the browser doesn't consider the scope root. Keep `scope: '/'`. + +**Gate 2 validates this end-to-end** — the wife must complete login in standalone mode on her iPhone. If it fails, the symptom is that she stays in Safari after login (not returned to the standalone app). Fix: ensure manifest `scope: '/'` and `start_url: '/'`; ensure `/callback` is handled server-side and not SW-intercepted. + +[MEDIUM confidence — iOS in-app browser / standalone restoration behaviour described in multiple developer reports; not officially documented by Apple; confirmed working for same-parent-domain configurations] + +--- + +### Pitfall 3: D-13 DATE vs DATETIME coercion in VEVENT building + +**What goes wrong:** Writing `DTSTART;TZID=America/New_York:20260615T000000` for an all-day event, or writing `DTSTART;VALUE=DATE:20260615T000000` (spurious time component). + +**Why it happens:** Using `ICAL.Time.fromJSDate(new Date(...))` for an all-day event produces a DATETIME, not a DATE. + +**How to avoid:** Always use `new ICAL.Time({ year, month, day, isDate: true })` for all-day events. Never coerce a DATE to DATETIME. The `allDay` field from the form controls which branch is taken. (Mirrors the existing D-13 contract in `sync.ts`.) + +--- + +### Pitfall 4: ETag not returned after PUT on Fastmail + +**What goes wrong:** `response.headers.get('etag')` returns null after `createCalendarObject` or `updateCalendarObject`, so the outbox row stores a null etag. On the next edit, If-Match sends no etag, causing either unconditional update or a server error. + +**Why it happens:** CalDAV spec allows the server to modify the object after storage (e.g. add `LAST-MODIFIED`), in which case it MUST NOT return an ETag (to force a re-fetch). Fastmail may do this. + +**How to avoid:** After a successful PUT, the targeted re-sync (D-06) runs `syncCalendar` which fetches the updated object via REPORT and captures the etag in the `calendarEvents` table. Subsequent edits read the etag from `calendarEvents`, not from the outbox row. Do not rely on the outbox row's etag for If-Match after the initial create. + +[CITED: sabre/dav CalDAV client guide — "you should issue a GET request immediately to get the correct object" when no ETag is returned] + +--- + +### Pitfall 5: Edit-as-move (D-04) partial-failure + +**What goes wrong:** Delete from old calendar succeeds; create on new calendar fails. The event is lost. + +**Why it happens:** Two separate HTTP calls; no transaction boundary. + +**How to handle:** Write TWO outbox rows in a single DB transaction: one `delete` (old calendar) and one `create` (new calendar) with the same `uid`. The worker processes them in order: create first, then delete. If create fails, do not proceed to delete. If create succeeds but delete fails, mark delete as `dead` and surface "could not remove from original calendar — please delete manually". This is the safe direction: duplicate is recoverable; lost event is not. + +**Implementation:** Add a `linked_outbox_id` column or use a `group_id` to link the two rows, or process in a single worker step that checks both operations atomically. + +--- + +### Pitfall 6: `navigateFallbackDenylist` not respected in dev mode + +**What goes wrong:** During Vite dev, the denylist has no effect — the SW in dev mode ignores it. + +**Why it happens:** Known vite-plugin-pwa issue ([#346](https://github.com/vite-pwa/vite-plugin-pwa/issues/346)). + +**How to avoid:** Only test the SW behaviour against a production build (`pnpm build && pnpm preview` or Docker build). Do not test `/callback` flow with `vite dev` + SW enabled. + +--- + +### Pitfall 7: Outbox worker runs without a valid DAVCalendar object for re-sync + +**What goes wrong:** `syncCalendar(client, davCal, userId)` requires a `DAVCalendar` object (including `url`, `ctag`, `syncToken`), but the worker only has the calendar URL stored in the outbox row. + +**How to handle:** After a successful PUT, the worker calls `client.fetchCalendars()`, finds the calendar by URL, and passes the fresh `DAVCalendar` to `syncCalendar`. This is a single PROPFIND round-trip. Alternatively, store the full DAVCalendar JSON in the outbox row at enqueue time (stale, but sufficient for re-sync since `syncCalendar` always fetches fresh objects). The PROPFIND approach is cleaner. + +--- + +## Code Examples + +### Create a recurring event (whole-series RRULE presets) + +```typescript +// Source: iCalendar RFC 5545 §3.3.10 (RRULE) +// [ASSUMED] — standard iCalendar RRULE syntax; no library needed for simple presets + +const RRULE_PRESETS: Record = { + daily: 'FREQ=DAILY', + weekly: 'FREQ=WEEKLY', + monthly: 'FREQ=MONTHLY', + yearly: 'FREQ=YEARLY', +} +// Usage: buildVeventString({ ..., rruleString: RRULE_PRESETS['weekly'] }) +// "weekly on Monday": 'FREQ=WEEKLY;BYDAY=MO' +// This is sufficient for whole-series creation (D-11 / CAL-07) +``` + +### Sync-state poll with TanStack Query + +```typescript +// Source: TanStack Query v5 docs — refetchInterval +// [ASSUMED] — TanStack Query v5 pattern based on training; verify against TQ v5 docs +export function useSyncStatus(uid: string | null) { + return useQuery({ + queryKey: ['syncStatus', uid], + queryFn: () => fetchSyncStatus(uid!), + enabled: uid !== null, + refetchInterval: (data) => + data?.status === 'pending' ? 3000 : false, + staleTime: 0, + }) +} +``` + +### Detect installed state (for hiding install prompts) + +```typescript +// Check if app is already running in standalone mode +const isInstalled = window.matchMedia('(display-mode: standalone)').matches + || (window.navigator as unknown as {standalone?: boolean}).standalone === true +``` + +--- + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| iOS Web Push unavailable | iOS 16.4+ supports Web Push from installed PWA | iOS 16.4 (March 2023) | Phase 5 is viable; requires A2HS installation (PWA-02 is a prerequisite) | +| iOS 18.4+ Declarative Web Push | `window.pushManager` without SW (simpler subscription) | iOS 18.4 (April 2025) | Phase 5 can use either traditional or declarative push; not Phase 3 concern | +| `beforeinstallprompt` Chrome-only | Still Chrome/Edge only on Android (not iOS) | Current | iOS A2HS remains manual-instruction flow; Android gets native prompt | +| Service workers block auth on iOS | iOS 12.2+ in-app browser shares storage; `/callback` restores standalone window | iOS 12.2 (2019) | Same-parent-domain OIDC works without extra code; needs Gate 2 verification | +| vite-plugin-pwa 0.x for Vite 4 | vite-plugin-pwa 1.x for Vite 6/7/8 | May 2026 (1.3.0) | No breaking change for this project; Vite 8 confirmed compatible | + +**Deprecated/outdated:** +- `workbox-webpack-plugin`: Webpack-era; replaced by vite-plugin-pwa for Vite projects +- `navigator.standalone` as sole iOS PWA detection: reliable only for iOS; complement with `display-mode` media query for cross-platform + +--- + +## Assumptions Log + +| # | Claim | Section | Risk if Wrong | +|---|-------|---------|---------------| +| A1 | iOS in-app browser shares storage with opener PWA (auth cookie accessible after OIDC redirect) | Pitfall 2 / iOS Standalone | If wrong: login loop or stuck in Safari after auth; mitigated by Gate 2 verification | +| A2 | Fastmail returns a non-null ETag on PUT in most cases (failing gracefully via re-sync) | Pattern 2, Pitfall 4 | If wrong: all edits after first create use null etag; no If-Match sent; risk of overwrite without conflict detection (D-08 not enforced); targeted re-sync (D-06) provides the etag as mitigation | +| A3 | `tsdav` `deleteCalendarObject` accepts the same `DAVCalendarObject` shape as `updateCalendarObject` | Pattern 2 | If wrong: minor API shape mismatch; fix by inspecting tsdav source at implementation time | +| A4 | RRULE simple preset strings are sufficient for whole-series creation without the `rrule` npm package | Pattern 1 / Don't Hand-Roll | If wrong: would need `rrule@2.8.1` for building complex RRULE strings; low risk since D-11 limits to daily/weekly/monthly/yearly | +| A5 | TanStack Query v5 `refetchInterval` accepts a function receiving the current data | Code Examples | If wrong: minor API difference; TQ v5 supports this pattern [ASSUMED] | +| A6 | `vite-plugin-pwa` peer deps `workbox-window` and `workbox-build` auto-install with pnpm | Standard Stack | If wrong: explicit `pnpm add workbox-window workbox-build` needed | + +--- + +## Open Questions + +1. **Fastmail object URL format** + - What we know: `tsdav` `fetchCalendarObjects` returns `DAVCalendarObject` with a `url` field; Fastmail CalDAV URLs follow the pattern `https://caldav.fastmail.com/dav/calendars/user///.ics` + - What's unclear: Whether the URL is returned verbatim by `fetchCalendarObjects` or constructed — and whether the `calendarObjectUrl` stored in the outbox is stable across syncs + - Recommendation: At worker time, fetch fresh object URLs from the DB `calendarEvents.url` column (which does not exist yet — the schema needs a `url` column added to `calendarEvents` for the CalDAV object URL). Alternatively, construct it from `calendars.url + uid + '.ics'` — verify against a real REPORT response in Wave 0. + - **Action for planner:** Add `objectUrl varchar(1024)` to `calendarEvents` schema OR document URL construction convention. + +2. **`calendarEvents` schema missing object URL** + - What we know: Current `calendarEvents` schema has `uid`, `etag`, `rawVevent` but no `url` field. The object URL is needed for `updateCalendarObject` and `deleteCalendarObject`. + - What's unclear: Whether `tsdav` `fetchCalendarObjects` returns a `url` field in the `DAVCalendarObject` (it does — the tsdav type shows `url: string`). So the URL can be stored at sync time. + - Recommendation: Add `objectUrl varchar(1024)` to `calendarEvents` in the schema migration. Populate it from `obj.url` in `sync.ts` alongside `etag`. + +3. **Writable calendar set resolution (D-03)** + - What we know: D-03 says writable = own personal + shared Family; D-16 says shared calendar not yet created; `calendars.isShared` marks the shared one. + - What's unclear: How the API knows which calendars belong to the current user vs being read-only overlays from other members. Currently, `calendars` rows are owned by `userId` — the current user's writable set is simply `WHERE userId = currentUser.id`. + - Recommendation: Writable set = `SELECT * FROM calendars WHERE user_id = :userId` (personal) UNION the row where `is_shared = 1` (shared family). This matches D-03 with no additional schema changes. + +--- + +## Environment Availability + +| Dependency | Required By | Available | Version | Fallback | +|------------|------------|-----------|---------|----------| +| Node.js 22 | `crypto.randomUUID()` | ✓ | 22.x (per CLAUDE.md) | — | +| MariaDB | Outbox table | ✓ | Via Docker Compose | — | +| vite-plugin-pwa | PWA manifest + SW | ✗ (not installed) | 1.3.0 available on npm | — | +| HTTPS (Pangolin) | SW registration, iOS PWA | ✓ via Pangolin tunnel | — | Only needed for Gate 2 / production; local dev uses HTTP (no SW) | +| Authelia | Gate 2 OIDC login | ✓ (operator-deployed) | — | Dev-auth bypass for local dev (D-13) | + +**Missing dependencies with no fallback:** +- `vite-plugin-pwa` — must be installed before PWA tasks + +**Missing dependencies with fallback:** +- HTTPS — not required for local dev (SW not registered on HTTP; Vite dev server is fine for writing/testing non-SW code) + +--- + +## Validation Architecture + +> `workflow.nyquist_validation: true` in `.planning/config.json` — section included. + +### Test Framework + +| Property | Value | +|----------|-------| +| Framework (API) | Vitest 4.x, environment: node | +| Framework (PWA) | Vitest 4.x + jsdom + @testing-library/react | +| Config (API) | `apps/api/vitest.config.ts` | +| Config (PWA) | `apps/pwa/vitest.config.ts` | +| Quick run (API) | `pnpm --filter @familysync/api test` | +| Quick run (PWA) | `pnpm --filter @familysync/pwa test` | +| Full suite | `pnpm test` (from root) | + +### Phase Requirements → Test Map + +| Req ID | Behavior | Test Type | Automated Command | File Exists? | +|--------|----------|-----------|-------------------|-------------| +| CAL-04 | `buildVeventString` produces valid VCALENDAR for timed event | unit | `pnpm --filter @familysync/api test -- broker/vevent` | ❌ Wave 0 | +| CAL-04 | `buildVeventString` produces valid VCALENDAR for all-day event (DATE not DATETIME) | unit | same | ❌ Wave 0 | +| CAL-04 | POST /api/events/create returns 202 and inserts outbox row | unit (mocked DB) | `pnpm --filter @familysync/api test -- routes/events` | ❌ Wave 0 | +| CAL-05 | PATCH /api/events/:uid/edit returns 202 and inserts outbox row with etag | unit | same | ❌ Wave 0 | +| CAL-06 | DELETE /api/events/:uid returns 202 and inserts outbox delete row | unit | same | ❌ Wave 0 | +| CAL-07 | `buildVeventString` with `rruleString` produces VCALENDAR with RRULE property | unit | same | ❌ Wave 0 | +| CAL-04/05/06 | Outbox worker transitions status: pending→done on mock 204, pending→failed on mock 412, pending→backoff on mock 500 | unit | `pnpm --filter @familysync/api test -- broker/outboxWorker` | ❌ Wave 0 | +| CAL-04/05/06 | GET /api/events/sync-status returns correct status from outbox row | unit | same events test | ❌ Wave 0 | +| D-08 | 412 response routes to conflict (not retry), marks failed, triggers re-sync | unit | same outboxWorker test | ❌ Wave 0 | +| D-04 | Edit-as-move creates DELETE + CREATE pair; create runs first | unit | same outboxWorker test | ❌ Wave 0 | +| PWA-01 | `vite.config.ts` produces a valid `manifest.webmanifest` with required fields | smoke (build output check) | `pnpm --filter @familysync/pwa build && node -e "..."` | ❌ Wave 0 | +| PWA-01 | SW `navigateFallbackDenylist` excludes `/callback` | manual (prod build) | manual | manual-only | +| PWA-02 | `isIOSSafariNonStandalone()` returns true on mock UA | unit | `pnpm --filter @familysync/pwa test -- InstallPrompt` | ❌ Wave 0 | +| PWA-02 | `useAndroidInstallPrompt` sets `canInstall=true` when `beforeinstallprompt` fires | unit (mock event) | same | ❌ Wave 0 | +| Gate 2 | iOS standalone PWA login completes without leaving standalone | manual (iPhone) | manual per docs/deployment.md Gate 2 checklist | manual-only | + +### Sampling Rate +- **Per task commit:** `pnpm --filter @familysync/api test` (API tasks) or `pnpm --filter @familysync/pwa test` (PWA tasks) +- **Per wave merge:** `pnpm test` (full suite both apps) +- **Phase gate:** Full suite green before `/gsd-verify-work` + +### Wave 0 Gaps +- [ ] `apps/api/tests/broker/vevent.test.ts` — covers CAL-04, CAL-07 (VEVENT builder, DATE/DATETIME split, RRULE property) +- [ ] `apps/api/tests/broker/write.test.ts` — covers tsdav call shapes, response interpretation, etag extraction +- [ ] `apps/api/tests/broker/outboxWorker.test.ts` — covers outbox state machine: pending→done, pending→failed (412), pending→backoff (5xx), pending→dead (max attempts), edit-as-move ordering +- [ ] `apps/api/tests/routes/events.test.ts` — extend existing file with: POST /create, PATCH /edit, DELETE /:uid, GET /sync-status +- [ ] `apps/pwa/src/components/InstallPrompt.test.tsx` — covers iOS detection, Android prompt capture, `beforeinstallprompt` handling + +*(Existing test files for broker/sync, routes/events, auth/devBypass remain in place.)* + +--- + +## Security Domain + +> `security_enforcement: true`, ASVS level 1. + +### Applicable ASVS Categories + +| ASVS Category | Applies | Standard Control | +|---------------|---------|-----------------| +| V2 Authentication | yes | `@hono/oidc-auth` — write endpoints behind existing OIDC guard | +| V3 Session Management | yes | Existing `@hono/oidc-auth` JWT session cookie — no change needed | +| V4 Access Control | yes (critical) | Route handlers verify `c.get('user').id` and assert the target calendar belongs to that user before enqueuing. Other members' personal calendars are rejected (D-03). | +| V5 Input Validation | yes | `zod` + `@hono/zod-validator` on all write endpoints; title/location/description length-bounded; date format validated | +| V6 Cryptography | no new surface | No new crypto primitives; existing AES-256-GCM credential encryption unchanged | + +### Known Threat Patterns + +| Pattern | STRIDE | Standard Mitigation | +|---------|--------|---------------------| +| User writes event to another member's personal calendar | Elevation of privilege | Route handler checks `calendar.userId === req.user.id` before enqueue; D-03 enforced at API layer | +| XSS via event title/description in EventForm | Tampering | React renders all event fields as plain-text JSX children (existing T-02e-01 pattern from EventDetailPopover); never dangerouslySetInnerHTML | +| SQL injection via UID / calendar URL in outbox queries | Tampering | Drizzle ORM parameterized queries; no string interpolation in SQL | +| Etag forgery (client sends crafted etag to bypass D-08) | Tampering | Etag is read from DB (`calendarEvents.etag`) server-side by the worker, not passed from the browser; client sends only the UID | +| Service worker cache-poisoning via OIDC callback | Spoofing | `/callback` in `navigateFallbackDenylist`; SW never caches `/callback` responses | +| Large payload DoS via event description | Denial of Service | Zod schema caps description/title length; 90-day window cap already exists on read path | + +--- + +## Sources + +### Primary (HIGH confidence) +- `apps/api/src/broker/client.ts`, `sync.ts`, `poller.ts`, `expand.ts` — existing broker code; verified patterns for extend +- `apps/api/src/db/schema.ts` — existing Drizzle schema; outbox table design follows the same patterns +- `apps/pwa/src/components/EventDetailPopover.tsx` — reserved footer confirmed (line 381) +- `apps/pwa/vite.config.ts` — confirmed no VitePWA plugin yet +- npm view tsdav / vite-plugin-pwa / ical.js / rrule / node-cron — version + publish date confirmed +- https://github.com/natelindev/tsdav/blob/main/src/calendar.ts — `createCalendarObject`, `updateCalendarObject`, `deleteCalendarObject` signatures confirmed +- https://github.com/natelindev/tsdav/blob/main/src/request.ts — If-Match header confirmed for updateObject/deleteObject +- https://tsdav.vercel.app/docs/caldav/createCalendarObject — filename format, return type +- https://tsdav.vercel.app/docs/caldav/updateCalendarObject — DAVCalendarObject shape, 412 behaviour +- https://github.com/kewisch/ical.js/blob/main/lib/ical/component.js — `addPropertyWithValue`, `addSubcomponent`, constructor +- https://github.com/kewisch/ical.js/blob/main/lib/ical/time.js — `fromJSDate(date, useUTC)`, `new ICAL.Time({isDate: true})` +- https://github.com/kewisch/ical.js/wiki/Convert-to-iCalendar-(rfc5545) — `ICAL.Component`, `ICAL.Event`, `toString()` +- https://vite-pwa-org.netlify.app/workbox/generate-sw.html — `navigateFallbackDenylist`, manifest fields +- https://vite-pwa-org.netlify.app/guide/pwa-minimal-requirements — icon sizes, iOS meta tags +- https://web.dev/articles/customize-install — `beforeinstallprompt` pattern, React hook [VERIFIED: official web.dev] +- https://orm.drizzle.team/docs/column-types/mysql — `mysqlEnum`, column types + +### Secondary (MEDIUM confidence) +- https://developer.apple.com/forums/thread/649699 — iOS standalone OIDC redirect behaviour; in-app browser shares storage since iOS 12.2 +- https://medium.com/@firt/whats-new-on-ios-12-2-for-progressive-web-apps-75c348f8e945 — iOS 12.2 in-app browser shares storage with PWA +- https://sabre.io/dav/building-a-caldav-client/ — etag not always returned after PUT; GET recommended to fetch updated object + +### Tertiary (LOW confidence / ASSUMED) +- RRULE preset strings — based on RFC 5545; no live verification of Fastmail acceptance required +- TanStack Query v5 `refetchInterval` function form — training knowledge; verify against TQ v5 docs at implementation + +--- + +## Metadata + +**Confidence breakdown:** +- CalDAV write-back (tsdav/ical.js): HIGH — both libraries installed and in use; write methods confirmed via GitHub source +- Outbox pattern: HIGH — standard transactional outbox; Drizzle column types confirmed; no new technology +- vite-plugin-pwa config: HIGH — official docs verified; `navigateFallbackDenylist` confirmed +- iOS OIDC standalone flow: MEDIUM — in-app browser storage sharing documented since iOS 12.2 but Apple has no definitive official writeup; Gate 2 is the verification +- Android `beforeinstallprompt`: HIGH — official web.dev docs verified + +**Research date:** 2026-06-05 +**Valid until:** 2026-07-05 (stable tech; no fast-moving packages in Phase 3)