Files
familysync/.planning/milestones/v1.0-phases/03-event-write-back-pwa-install/03-RESEARCH.md
T
Lucas Berger 982438dc10 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.
2026-06-11 20:35:18 -04:00

66 KiB
Raw Blame History

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>

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. </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

</phase_requirements>


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

# 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)
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).

// 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: truedtstart_date column in DB; isDate: falsedtstart_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').

// 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<Response> {
  return client.createCalendarObject({
    calendar,
    filename: `${uid}.ics`,
    iCalString: icsString,
  });
}

// --- UPDATE (PUT with If-Match: <etag>) ---
// 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<Response> {
  return client.updateCalendarObject({
    calendarObject: {
      url: calendarObjectUrl,
      data: icsString,
      etag: etag ?? '', // tsdav: etag → If-Match header
    },
  });
}

// --- DELETE (DELETE with If-Match: <etag>) ---
export async function deleteCalendarEvent(
  client: FastmailClient,
  calendarObjectUrl: string,
  etag: string | null,
): Promise<Response> {
  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:

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.

// 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).

// 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<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

  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).

// 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 <head> additions in apps/pwa/index.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" />

Pattern 6: iOS A2HS Detection and Walkthrough

What: Detect iOS-Safari-non-standalone and render an annotated install guide.

// 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

// 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<void>;
  userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}

export function useAndroidInstallPrompt() {
  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);
  }, []);

  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.

// Request: GET /api/events/sync-status?uid=<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).

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)

// Source: iCalendar RFC 5545 §3.3.10 (RRULE)
// [ASSUMED] — standard iCalendar RRULE syntax; no library needed for simple presets

const RRULE_PRESETS: Record<string, string> = {
  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

// 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)

// 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 (RESOLVED)

  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/<email>/<calendar-slug>/<uid>.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.
    • Resolution: RESOLVED — objectUrl column added to calendarEvents in plan 03-01 Task 2 and populated from obj.url in sync.ts (03-01 Task 3); the worker reads the stored object URL rather than reconstructing it.
  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.
    • Resolution: RESOLVED — same as Q1: calendarEvents.objectUrl (object_url varchar(1024)) added in plan 03-01 Task 2 and set from obj.url in sync.ts (03-01 Task 3).
  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.
    • Resolution: RESOLVED via Option A (server-side endpoint) — GET /api/events/writable-calendars (plan 03-03 Task 3) is the authoritative owner of the D-03 writable set (userId = currentUser.id OR isShared = true); the PWA picker consumes it verbatim (03-05 Task 1) and never derives writability client-side.

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
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.)


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)

Secondary (MEDIUM confidence)

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)