style(13-03): apply Prettier formatting across repo

Mechanical reformat — no logic changes. 398 files changed, 19125
insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc
(singleQuote:true, semi:true, tabWidth:2, trailingComma:all,
printWidth:100). Isolated per D-13-08 for reviewability.
This commit is contained in:
Lucas Berger
2026-06-11 20:35:18 -04:00
parent 4bc0445173
commit 982438dc10
398 changed files with 19050 additions and 16382 deletions
@@ -7,9 +7,11 @@
---
<user_constraints>
## 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.
@@ -25,6 +27,7 @@
- **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.
@@ -33,22 +36,25 @@
- 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.
</user_constraints>
</user_constraints>
<phase_requirements>
## 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 |
| 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 |
</phase_requirements>
---
@@ -69,18 +75,18 @@ Phase 3 has three distinct technical pillars: CalDAV write-back through the exis
## 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 |
| 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 |
---
@@ -88,22 +94,22 @@ Phase 3 has three distinct technical pillars: CalDAV write-back through the exis
### 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] |
| 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] |
| 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
@@ -123,11 +129,11 @@ pnpm add vite-plugin-pwa
> 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 |
| 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
@@ -154,7 +160,7 @@ POST /api/events/create (or /edit, /delete)
├─► 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' }
@@ -221,65 +227,69 @@ apps/pwa/
// 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'
import ICAL from 'ical.js';
import { randomUUID } from 'crypto';
export interface NewEventParams {
uid?: string // omit = generate new UUID
summary: string
allDay: boolean
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()
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`
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')
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 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)
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)
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)
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)
vevent.addPropertyWithValue('rrule', params.rruleString);
}
if (params.location) vevent.addPropertyWithValue('location', params.location)
if (params.description) vevent.addPropertyWithValue('description', params.description)
if (params.location) vevent.addPropertyWithValue('location', params.location);
if (params.description) vevent.addPropertyWithValue('description', params.description);
cal.addSubcomponent(vevent)
return { uid, icsString: cal.toString() }
cal.addSubcomponent(vevent);
return { uid, icsString: cal.toString() };
}
```
@@ -296,8 +306,8 @@ export function buildVeventString(params: NewEventParams): { uid: string; icsStr
// 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'
import type { FastmailClient } from './client.js';
import type { DAVCalendar } from 'tsdav';
// --- CREATE (PUT with If-None-Match: *) ---
export async function createCalendarEvent(
@@ -310,7 +320,7 @@ export async function createCalendarEvent(
calendar,
filename: `${uid}.ics`,
iCalString: icsString,
})
});
}
// --- UPDATE (PUT with If-Match: <etag>) ---
@@ -326,9 +336,9 @@ export async function updateCalendarEvent(
calendarObject: {
url: calendarObjectUrl,
data: icsString,
etag: etag ?? '', // tsdav: etag → If-Match header
etag: etag ?? '', // tsdav: etag → If-Match header
},
})
});
}
// --- DELETE (DELETE with If-Match: <etag>) ---
@@ -340,14 +350,15 @@ export async function deleteCalendarEvent(
return client.deleteCalendarObject({
calendarObject: {
url: calendarObjectUrl,
data: '', // tsdav deleteCalendarObject needs the calendarObject shape
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`
@@ -357,11 +368,13 @@ export async function deleteCalendarEvent(
- **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
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"]
---
@@ -372,22 +385,32 @@ const newEtag = response.headers.get('etag') // may be null on some Fastmail res
```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'
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),
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
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'),
@@ -399,10 +422,11 @@ export const calendarOutbox = mysqlTable(
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()`
@@ -418,57 +442,67 @@ export const calendarOutbox = mysqlTable(
```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)
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])
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])
const HARD_FAIL_STATUSES = new Set([400, 401, 403]);
// Conflict (route to conflict flow, not retry loop)
const CONFLICT_STATUS = 412
const CONFLICT_STATUS = 412;
export async function runOutboxDrain(): Promise<void> {
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
.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)
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
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
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))
await db
.update(calendarOutbox)
.set({ status: 'failed', lastError: result.error })
.where(eq(calendarOutbox.id, row.id));
} else {
// transient — backoff
const nextAttempt = row.attemptCount + 1
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))
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))
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)
console.error('[outboxWorker] Dispatch error row.id=%d:', row.id, err);
}
}
}
@@ -486,9 +520,9 @@ export async function runOutboxDrain(): Promise<void> {
```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'
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
plugins: [
@@ -501,9 +535,9 @@ export default defineConfig({
workbox: {
navigateFallback: '/index.html',
navigateFallbackDenylist: [
/^\/callback/, // OIDC redirect endpoint — must reach the server
/^\/api\//, // API calls — never serve from cache
/^\/health/, // Health endpoint
/^\/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).
@@ -513,7 +547,7 @@ export default defineConfig({
name: 'FamilySync',
short_name: 'FamilySync',
description: 'Family calendar and lists',
theme_color: '#4A90D9', // match users.color primary blue
theme_color: '#4A90D9', // match users.color primary blue
background_color: '#ffffff',
display: 'standalone',
scope: '/',
@@ -533,21 +567,23 @@ export default defineConfig({
'/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 `<head>` additions in `apps/pwa/index.html`:**
```html
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
<meta name="theme-color" content="#4A90D9">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="FamilySync">
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180" />
<meta name="theme-color" content="#4A90D9" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="FamilySync" />
```
---
@@ -560,15 +596,19 @@ export default defineConfig({
// 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
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)
@@ -586,34 +626,34 @@ Use actual iOS screenshots with annotation overlays, not stock art. The goal: wi
```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'
import { useState, useEffect } from 'react';
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
prompt(): Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
export function useAndroidInstallPrompt() {
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null)
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(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)
}, [])
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)
}
if (!deferredPrompt) return;
await deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === 'accepted') setDeferredPrompt(null);
};
return { canInstall: deferredPrompt !== null, triggerInstall }
return { canInstall: deferredPrompt !== null, triggerInstall };
}
```
@@ -638,15 +678,15 @@ export function useAndroidInstallPrompt() {
## 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 |
| 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`.
@@ -745,11 +785,11 @@ export function useAndroidInstallPrompt() {
// [ASSUMED] — standard iCalendar RRULE syntax; no library needed for simple presets
const RRULE_PRESETS: Record<string, string> = {
daily: 'FREQ=DAILY',
weekly: 'FREQ=WEEKLY',
daily: 'FREQ=DAILY',
weekly: 'FREQ=WEEKLY',
monthly: 'FREQ=MONTHLY',
yearly: 'FREQ=YEARLY',
}
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)
@@ -765,10 +805,9 @@ export function useSyncStatus(uid: string | null) {
queryKey: ['syncStatus', uid],
queryFn: () => fetchSyncStatus(uid!),
enabled: uid !== null,
refetchInterval: (data) =>
data?.status === 'pending' ? 3000 : false,
refetchInterval: (data) => (data?.status === 'pending' ? 3000 : false),
staleTime: 0,
})
});
}
```
@@ -776,23 +815,25 @@ export function useSyncStatus(uid: string | null) {
```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
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 |
| 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
@@ -800,14 +841,14 @@ const isInstalled = window.matchMedia('(display-mode: standalone)').matches
## 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 |
| # | 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 |
---
@@ -836,18 +877,20 @@ const isInstalled = window.matchMedia('(display-mode: standalone)').matches
## 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) |
| 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)
---
@@ -858,50 +901,52 @@ const isInstalled = window.matchMedia('(display-mode: standalone)').matches
### Test Framework
| Property | Value |
|----------|-------|
| Framework (API) | Vitest 4.x, environment: node |
| 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) |
| 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 |
| CAL-04/05/07 | GET /api/events/writable-calendars returns D-03 writable set; never another member's read-only personal (V4) | unit | `pnpm --filter @familysync/api test -- routes/events` | ❌ 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 |
| 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 |
| CAL-04/05/07 | GET /api/events/writable-calendars returns D-03 writable set; never another member's read-only personal (V4) | unit | `pnpm --filter @familysync/api test -- routes/events` | ❌ 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.)*
_(Existing test files for broker/sync, routes/events, auth/devBypass remain in place.)_
---
@@ -911,30 +956,31 @@ const isInstalled = window.matchMedia('(display-mode: standalone)').matches
### 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 |
| 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 |
| 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)
@@ -953,11 +999,13 @@ const isInstalled = window.matchMedia('(display-mode: standalone)').matches
- 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
@@ -966,6 +1014,7 @@ const isInstalled = window.matchMedia('(display-mode: standalone)').matches
## 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