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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user