docs: v1.1 research (stack/features/architecture/pitfalls/summary)

This commit is contained in:
Lucas Berger
2026-06-10 21:34:42 -04:00
parent 9bd0f536fd
commit a31636e718
5 changed files with 1291 additions and 1085 deletions
+509 -350
View File
@@ -1,424 +1,583 @@
# Architecture Research
**Domain:** Self-hosted family calendar + list hub (Fastmail broker + MariaDB + React PWA)
**Researched:** 2026-06-03
**Confidence:** MEDIUM (Fastmail personal-calendar ACL mechanics unconfirmed — see flags below)
**Domain:** FamilySync v1.1 — integration analysis for Operability & Polish milestone
**Researched:** 2026-06-10
**Confidence:** HIGH (grounded in actual codebase)
## Standard Architecture
### System Overview
```mermaid
graph TD
subgraph Public["Public Internet"]
iOS["iOS PWA (Safari)"]
Android["Android PWA (Chrome)"]
end
subgraph Tunnel["Pangolin/Newt Tunnel (no open ports)"]
direction TB
Authelia["Authelia OIDC\n(already deployed)"]
end
subgraph Docker["Docker Compose — Unraid"]
direction TB
PWA["React PWA\n(static, served by API or nginx)"]
API["App API\n(Node/Express or Fastify)"]
subgraph Broker["Fastmail Broker Layer"]
CalDAVClient["CalDAV Client\n(node-caldav / tsdav)"]
Cache["Calendar Cache\n(MariaDB: events + ctag)"]
Poller["Background Poller\n(cron, 5-min interval)"]
end
subgraph Lists["List Domain"]
ListAPI["List CRUD\n(REST endpoints)"]
ListDB["lists / items tables\n(MariaDB)"]
end
subgraph Realtime["Real-time Layer"]
WSHub["WebSocket Hub\n(ws or Socket.IO)"]
RedisPubSub["Redis Pub/Sub\n(optional, single instance OK)"]
end
subgraph Push["Web Push"]
PushSender["VAPID Push Sender\n(web-push npm)"]
SubStore["push_subscriptions table\n(MariaDB)"]
Scheduler["Reminder Scheduler\n(node-cron)"]
end
MariaDB[("MariaDB\n(users, calendars_cache,\nlists, push_subscriptions)")]
Redis[("Redis\n(pub/sub channels)")]
end
subgraph Fastmail["Fastmail (external, source of truth)"]
FM_Shared["Shared Family Calendar"]
FM_Personal["Personal Calendar Collections\n(one per user)"]
end
iOS -->|HTTPS via tunnel| Authelia
Android -->|HTTPS via tunnel| Authelia
Authelia -->|forwards authed request| API
PWA <-->|REST + WebSocket| API
API --> CalDAVClient
API --> ListAPI
API --> WSHub
CalDAVClient <-->|CalDAV over HTTPS\napp password auth| FM_Shared
CalDAVClient <-->|CalDAV over HTTPS\nper-user app password OR shared ACL| FM_Personal
CalDAVClient --> Cache
Poller -->|every 5 min: PROPFIND ctag| CalDAVClient
Poller -->|on ctag change: full sync| Cache
ListAPI --> ListDB
ListDB --> MariaDB
Cache --> MariaDB
WSHub <-->|subscribe/publish| RedisPubSub
RedisPubSub --> Redis
ListAPI -->|on write| RedisPubSub
PushSender --> SubStore
SubStore --> MariaDB
Scheduler -->|check upcoming events| MariaDB
Scheduler --> PushSender
ListAPI -->|on important change| PushSender
```
┌──────────────────────────────────────────────────────────────────┐
│ React PWA (apps/pwa/src/) │
│ ┌──────────────┐ ┌──────────────┐ ┌───────────────────────────┐ │
│ │ EventForm.tsx│ │SettingsSheet │ │ [NEW] SetupWizard / │ │
│ │ + reminder │ │ + Admin tab │ │ AdminSettings │ │
│ │ selector │ │ │ │ │ │
│ └──────┬───────┘ └──────┬───────┘ └────────────┬──────────────┘ │
│ │ api/client.ts (typed fetch wrappers) │ │
└─────────┼──────────────────────────────────────┬─┴───────────────┘
│ │
▼ HTTP / SSE ▼ HTTP
┌──────────────────────────────────────────────────────────────────┐
│ Hono API (apps/api/src/index.ts) │
│ ┌──────────────┐ ┌────────────┐ ┌──────────────────────────────┐│
│ │ routes/ │ │ routes/ │ │ [NEW] routes/admin.ts + ││
│ │ events.ts │ │ push.ts │ │ routes/setup.ts ││
│ │ (enqueue to │ │ │ │ (role-gated credential mgmt, ││
│ │ outbox) │ │ │ │ first-run wizard endpoints) ││
│ └──────┬───────┘ └────────────┘ └──────────────────────────────┘│
│ │ │
│ ┌──────▼──────────────────────────────────────────────────────┐ │
│ │ broker/ │ │
│ │ outboxWorker.ts (15s setInterval + NEW event-driven drain) │ │
│ │ reminderScheduler.ts (1-min setInterval, MODIFIED: per- │ │
│ │ event VALARM lead, variable window) │ │
│ │ poller.ts (5-min setInterval, UNCHANGED) │ │
│ │ vevent.ts [MODIFIED: buildVeventString adds VALARM] │ │
│ │ sync.ts [MODIFIED: extract VALARM -> reminder_lead_minutes] │ │
│ │ crypto.ts (AES-256-GCM, REUSED by admin credential writes) │ │
│ └──────┬──────────────────────────────────────────────────────┘ │
└─────────┼────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ Data layer (apps/api/src/db/) │
│ schema.ts: users (+is_admin), calendars, calendarEvents │
│ (+reminder_lead_minutes), calendarOutbox, │
│ memberCredentials, pushSubscriptions, lists, ... │
│ [NEW] app_config table (setup_complete flag, etc.) │
│ │
│ MariaDB (mariadb:11) + Redis (7-alpine, ioredis for pub/sub) │
└──────────────────────────────────────────────────────────────────┘
```
### Component Responsibilities
| Component | Responsibility | Notes |
|-----------|----------------|-------|
| React PWA | All UI rendering; calendar view, list co-edit, push subscription registration | Static build; served from API container or separate nginx |
| App API | Auth middleware (OIDC token validation), REST endpoints, WebSocket upgrade, orchestrates broker + list + push | Single Node process; Fastify recommended for performance |
| CalDAV Client | Issues PROPFIND/REPORT against caldav.fastmail.com using app password(s); parses iCalendar | Use `tsdav` (TypeScript, actively maintained) or `node-ical` for parsing |
| Calendar Cache | Stores raw VEVENT blobs + ctag per calendar in MariaDB; serves as the read path for the API | Never exposed directly; always through API |
| Background Poller | Cron job running inside API process; checks ctag every 5 min, triggers full REPORT sync on change | 5-min polling is sufficient for family use; no Fastmail push webhook available |
| List CRUD | REST handlers for list/item create-read-update-delete; emits to Redis on every write | Simple; MariaDB is source of truth |
| WebSocket Hub | Maintains open connections per authenticated user; pushes Redis messages to correct connections | Keyed by user ID extracted from OIDC sub claim |
| Redis Pub/Sub | Message bus for list change events; decouples list writes from WebSocket delivery | Single-instance Redis is fine for 2-person household; no clustering needed |
| VAPID Push Sender | Calls browser push services (FCM, APNs Web Push) with encrypted payloads | `web-push` npm package; keys stored in environment, not DB |
| Reminder Scheduler | Cron job: queries events starting in next 15 min (configurable), fires push notifications | Runs inside API process; reads from calendar cache |
| MariaDB | Persistent storage: users, calendar event cache, lists, push subscriptions | Single source for everything the app owns |
| Component | File | Responsibility | v1.1 Status |
|-----------|------|----------------|-------------|
| Event form | `apps/pwa/src/components/EventForm.tsx` | Create/edit event UI | MODIFY: add reminder selector |
| Settings sheet | `apps/pwa/src/components/SettingsSheet.tsx` | Notifications toggle | MODIFY: add Admin section |
| API client | `apps/pwa/src/api/client.ts` | Typed fetch wrappers | MODIFY: admin + setup endpoints |
| Events route | `apps/api/src/routes/events.ts` | Calendar CRUD, outbox enqueue | MODIFY: pass reminder in payload, signal drain |
| Push route | `apps/api/src/routes/push.ts` | VAPID subscription management | UNCHANGED |
| VEVENT builder | `apps/api/src/broker/vevent.ts` | iCalendar string construction | MODIFY: add VALARM |
| CalDAV sync | `apps/api/src/broker/sync.ts` | Fastmail REPORT -> DB upsert | MODIFY: extract VALARM trigger |
| Outbox worker | `apps/api/src/broker/outboxWorker.ts` | CalDAV write-back drain | MODIFY: event-driven trigger subscription |
| Reminder scheduler | `apps/api/src/broker/reminderScheduler.ts` | Push reminders for events | MODIFY: variable VALARM-based lead |
| Poller | `apps/api/src/broker/poller.ts` | 5-min CalDAV sync | UNCHANGED |
| Crypto | `apps/api/src/broker/crypto.ts` | AES-256-GCM encrypt/decrypt | UNCHANGED (reused by admin) |
| DB schema | `apps/api/src/db/schema.ts` | Drizzle table definitions | MODIFY: is_admin, reminder_lead_minutes, app_config |
| Index / wiring | `apps/api/src/index.ts` | App bootstrap + worker startup | MODIFY: mount admin + setup routes |
| [NEW] Admin route | `apps/api/src/routes/admin.ts` | Role-gated credential + calendar mgmt | NEW |
| [NEW] Setup route | `apps/api/src/routes/setup.ts` | First-run wizard endpoints + validation | NEW |
| [NEW] Admin UI | `apps/pwa/src/components/AdminSettings.tsx` | Member credential UI, shared-cal picker | NEW |
| [NEW] Setup wizard | `apps/pwa/src/components/SetupWizard.tsx` | First-run guided bootstrap | NEW |
| [NEW] Outbox trigger | `apps/api/src/lib/outboxTrigger.ts` | In-process EventEmitter for drain signal | NEW |
| [NEW] CI workflow | `.gitea/workflows/ci.yml` | Lint/typecheck/test on PR | NEW |
## Recommended Project Structure
---
```
familysync/
├── apps/
│ ├── api/ # Node backend
│ │ ├── src/
│ │ │ ├── auth/ # OIDC token validation, user upsert
│ │ │ ├── broker/ # Fastmail CalDAV client + cache sync
│ │ │ │ ├── client.ts # tsdav wrapper
│ │ │ │ ├── poller.ts # ctag polling cron
│ │ │ │ ├── sync.ts # REPORT sync → DB write
│ │ │ │ └── expand.ts # RRULE expansion + timezone normalise
│ │ │ ├── calendars/ # REST routes: GET /calendars, GET /events
│ │ │ ├── lists/ # REST routes + Redis emit
│ │ │ ├── push/ # VAPID sender, subscription routes, scheduler
│ │ │ ├── realtime/ # WebSocket server, Redis subscriber
│ │ │ ├── db/ # Knex migrations + query helpers (MariaDB)
│ │ │ └── server.ts # Fastify app bootstrap
│ │ └── Dockerfile
│ └── pwa/ # React PWA
│ ├── src/
│ │ ├── features/
│ │ │ ├── calendar/ # Calendar view, event form
│ │ │ └── lists/ # List view, item row, optimistic updates
│ │ ├── auth/ # OIDC redirect handling, token storage
│ │ ├── push/ # Service worker registration, push consent
│ │ └── api/ # Typed fetch client
│ ├── public/
│ │ └── sw.js # Service worker (push + offline cache)
│ └── Dockerfile
├── db/
│ └── migrations/ # Knex migration files (versioned)
├── docker-compose.yml
└── .env.example
## Feature Integration Analysis
### (a) Per-Event Reminders: VALARM Authoring + Variable-Lead Scheduling
#### Write path — what changes
**`apps/pwa/src/components/EventForm.tsx`** — MODIFY
Add a "Reminder" `<select>` field. Values: `none | 5 | 10 | 15 | 30 | 60` (minutes before). Default `none`. Show in both create and edit modes. On submit, `executeSubmit()` includes `reminderMinutes?: number` in `CreateEventPayload`.
The existing WR-01 "omit recurrence on edit" pattern does NOT apply here — reminder lead is user-configured per save and should always be sent explicitly. Include `reminderMinutes: 0` to mean "remove VALARM".
The reminder selector should be disabled or hidden when `allDay = true` (RFC 5545: duration-form TRIGGER is semantically invalid for all-day events; see Anti-Pattern 4 below).
**`apps/pwa/src/api/client.ts`** — MODIFY
Extend `CreateEventPayload` type:
```typescript
reminderMinutes?: number // 0 = no alarm; positive = minutes before
```
### Structure Rationale
**`apps/api/src/routes/events.ts`** — MODIFY
- **apps/api/src/broker/:** Isolation boundary. All Fastmail I/O lives here. Nothing else imports from `broker/` except the `calendars/` routes and `push/scheduler`. Replacing CalDAV with JMAP later touches only this module.
- **apps/api/src/db/:** Knex chosen over an ORM because MariaDB lacks full Prisma support for some JSON features; Knex gives SQL control without raw strings everywhere.
- **apps/pwa/src/features/:** Feature-sliced structure keeps calendar and list concerns separated. Both features share the same WebSocket connection managed at app root.
Extend `eventFieldsSchema` (line 100) and `outboxPayloadSchema` (line 71 in `outboxWorker.ts`):
```typescript
reminderMinutes: z.number().int().min(0).max(10080).optional() // max = 1 week
```
## Architectural Patterns
The payload is JSON-stringified into `calendar_outbox.payload` (TEXT column). No outbox schema change needed.
### Pattern 1: Broker Cache with ctag-based Invalidation
**`apps/api/src/broker/vevent.ts`** — MODIFY
**What:** The API never fetches from Fastmail on a user request. All calendar reads hit the MariaDB cache. A background poller issues a lightweight CalDAV PROPFIND to check the `getctag` property on each calendar collection every 5 minutes. On change, it issues a `REPORT calendar-query` (or `sync-collection` if the server supports it) to fetch only changed VEVENTs, then upserts into the cache.
**When to use:** Always — Fastmail has no push webhook for calendar changes. Polling is the only option.
**Trade-offs:** 5-min staleness is acceptable for a family calendar. Reduces Fastmail API calls to near-zero during quiet periods. Cache warms on first poll after startup.
Add `reminderMinutes?: number` to `NewEventParams` interface (line 21). In `buildVeventString`, after the RRULE block, add:
```typescript
// broker/poller.ts (sketch)
async function poll(calendarUrl: string, knownCtag: string | null) {
const ctag = await fetchCtag(calendarUrl); // PROPFIND
if (ctag === knownCtag) return; // no change
await syncCalendar(calendarUrl); // REPORT → upsert cache
await db('calendars').where({ url: calendarUrl }).update({ ctag, synced_at: new Date() });
if (!params.allDay && params.reminderMinutes !== undefined && params.reminderMinutes > 0) {
const valarm = new ICAL.Component('valarm')
valarm.addPropertyWithValue('action', 'DISPLAY')
valarm.addPropertyWithValue('description', params.summary)
const trigger = ICAL.Duration.fromSeconds(-(params.reminderMinutes * 60))
const triggerProp = new ICAL.Property('trigger')
triggerProp.setValue(trigger)
valarm.addProperty(triggerProp)
vevent.addSubcomponent(valarm)
}
```
### Pattern 2: Single-Broker-Token with Per-Calendar App Passwords
`ICAL.Duration.fromSeconds` is the correct ical.js API — same pattern as how `ICAL.Recur.fromString` is used for RRULE on line 144. Negative duration = before the event.
**What:** The broker holds credentials for the primary Fastmail account (the project owner). The shared family calendar is readable/writable with that account's app password. For personal calendars of other household members (the wife's personal calendar), Fastmail requires that the owner either:
**`apps/api/src/broker/outboxWorker.ts`** — MODIFY
1. **Share the calendar** with the broker account — the broker then sees it appear at a different URL under its own principal (confirmed: Fastmail supports CalDAV sharing with read or read-write ACL to another Fastmail user).
2. **Provide a separate app password** per user — the broker authenticates as each user independently to read their personal calendar.
In both the `create` branch and `update` branch, pass `reminderMinutes: fields.reminderMinutes` to `buildVeventString`. One-line addition per branch. Also add `reminderMinutes` to `outboxPayloadSchema` for IN-03 re-validation.
Option 1 (sharing to the broker account) is preferred: one credential, no password management per user. The wife logs into Fastmail once, shares her personal calendar with the primary account, done. The broker discovers it via CalDAV principal discovery.
#### Scheduler path — what changes
**Confidence: LOW on exact URL behavior.** Fastmail's sharing documentation confirms the sharing feature exists and follows CalDAV draft ACL standards, but does not explicitly document whether the shared calendar appears in the principal's list discovery. Treat this as a phase research item — test during the broker phase with a real Fastmail multi-user or sharing setup.
**`apps/api/src/db/schema.ts`** — MODIFY
**Trade-offs:** If sharing is not discoverable by principal, fall back to individual app passwords stored encrypted in `.env`. Two-person household means at most 2 credentials.
### Pattern 3: Redis Pub/Sub for List Co-Edit
**What:** On every list/item write that succeeds in MariaDB, the API handler publishes a message to a Redis channel keyed by list ID. The WebSocket hub is subscribed to all relevant channels and pushes the serialized diff to connected clients.
**When to use:** Whenever two users might be editing the same list concurrently. For 2 people, this is "always."
**Trade-offs:** Redis is optional in the stack — if Redis is unavailable, fall back to polling (client polls `/lists/{id}` every 10s). Design the list API to support both modes via a feature flag.
```
Client A writes item → POST /lists/1/items
→ DB insert
→ Redis PUBLISH list:1 { type: "item_added", item: {...} }
→ WebSocket hub receives → pushes to all sockets subscribed to list:1
Client B receives message → appends item to local state (no refetch)
Add to `calendarEvents`:
```typescript
reminderLeadMinutes: int('reminder_lead_minutes'),
// nullable: NULL = no VALARM; positive int = minutes before dtstart
```
### Pattern 4: RRULE Expansion Client-Side from Stored Raw VEVENT
Add `drizzle-kit generate` migration. This is a nullable column addition — safe online DDL on MariaDB 11/InnoDB.
**What:** Store the raw VEVENT blob (including RRULE) in the cache. On read, the API returns the raw iCal data or a partially processed event object that includes the RRULE string. The PWA expands recurrences client-side using the `rrule.js` library for the displayed date window (current month ± 1 month).
**`apps/api/src/broker/sync.ts`** — MODIFY
**When to use:** For this scale. Server-side pre-expansion into a flat events table is over-engineering for a 2-person calendar.
**Trade-offs:** Timezone correctness requires the PWA to use the TZID from the VEVENT and the `Intl` API to resolve DST. The `rrule.js` library handles TZID correctly when fed the VTIMEZONE component. Store all DTSTART values in UTC in the DB alongside the raw blob for efficient range queries.
## Data Flow
### Calendar Read (PWA → API → Cache)
```
User opens calendar view (month X)
→ GET /api/events?start=...&end=...
→ API queries MariaDB: SELECT * FROM events WHERE dtstart BETWEEN ? AND ?
→ Returns event objects (RRULE included)
→ PWA rrule.js expands recurring events for display window
→ Render color-coded events (color from user_calendars.color config)
When upserting a `calendarEvent`, extract VALARM from the parsed VEVENT:
```typescript
const valarm = vevent.getFirstSubcomponent('valarm')
let reminderLeadMinutes: number | null = null
if (valarm) {
const trigger = valarm.getFirstPropertyValue('trigger')
if (trigger instanceof ICAL.Duration) {
reminderLeadMinutes = Math.abs(trigger.toSeconds()) / 60
}
}
```
### Calendar Write (PWA → API → Fastmail)
Store `reminderLeadMinutes` in the DB upsert. This is the ground truth — the scheduler reads from DB, not from the outbox payload.
```
User creates/edits event in PWA
→ POST/PUT /api/events
→ API validates auth, resolves target calendar from event's calendarId
→ broker/client.ts issues CalDAV PUT (new VEVENT) to caldav.fastmail.com
→ On 201/204 success: upsert into local cache immediately (no wait for next poll)
→ Return 201 to PWA
→ PWA updates local state optimistically (already done before response)
**`apps/api/src/broker/reminderScheduler.ts`** — MODIFY (significant rewrite of `runReminderCheck`)
Current behavior:
- Fixed 16-minute catch-up window: `dtstartUtc IN (now, now+16min]`
- Fires only for `calendars.isShared = true` (D-05 constraint)
- Dedup key: bare `uid` in `sentReminders: Map<string, number>`
New behavior:
- Variable lead: query events where the alarm-fire-time falls in the current tick's window
- Alarm-fire-time = `dtstartUtc - INTERVAL reminder_lead_minutes MINUTE`
- Window: `(now - 1min, now + 1min]` gives a 2-minute catch-up on missed ticks (scheduler runs every 60s)
- Events with `reminder_lead_minutes IS NULL` are excluded — no VALARM means no reminder
Drizzle raw SQL expression for the window predicate:
```typescript
sql`${calendarEvents.reminderLeadMinutes} IS NOT NULL
AND DATE_SUB(${calendarEvents.dtstartUtc}, INTERVAL ${calendarEvents.reminderLeadMinutes} MINUTE) > ${new Date(now.getTime() - 60_000)}
AND DATE_SUB(${calendarEvents.dtstartUtc}, INTERVAL ${calendarEvents.reminderLeadMinutes} MINUTE) <= ${new Date(now.getTime() + 60_000)}`
```
### Fastmail Sync Loop (background)
`isShared` restriction (D-05): this is a scope decision. D-05 was originally "shared calendar only" for the fixed-15-min reminder. With per-event VALARM, the user explicitly configured a reminder — it should fire regardless of which calendar the event is on. Recommend removing the `isShared` restriction in v1.1 for reminder-triggered notifications. Event-change notifications (via `eventChangeDispatcher.ts`) are a separate flow and unaffected.
```
Poller tick (every 5 min)
→ PROPFIND each tracked calendar URL → fetch getctag
→ Compare ctag to DB stored value
→ If changed: REPORT calendar-query for the collection
→ Parse iCalendar response
→ For each VEVENT: upsert events table (uid as key), update dtstart UTC
→ Update calendars.ctag
→ If reminder threshold: Scheduler fires Web Push
**Catch-up guarantee is preserved.** The existing 16-min window provided catch-up by keeping missed events visible for 16 minutes. With a 2-minute window (±1min around now) and 1-min ticks, a single missed tick means the next tick has the event in window. A 2-minute outage means at most one missed reminder — acceptable for household use.
**Dedup stays the same.** `sentReminders: Map<uid, dtstartMs>` keyed by bare uid. This correctly prevents double-fire when the event lingers in the window across ticks. No change needed.
---
### (b) Event-Driven Outbox Drain
#### Current state
`startOutboxWorker()` in `outboxWorker.ts` (line 781) runs `runOutboxDrain()` every 15 seconds. All write routes in `routes/events.ts` insert to `calendar_outbox` and return 202 with no drain trigger.
#### Recommended approach: in-process EventEmitter
New file `apps/api/src/lib/outboxTrigger.ts` — follows the exact pattern of `listEmitter.ts` (proven, same single-process deployment):
```typescript
// apps/api/src/lib/outboxTrigger.ts
import { EventEmitter } from 'node:events'
const emitter = new EventEmitter()
export function signalOutboxDrain(): void {
emitter.emit('drain')
}
export function onOutboxDrainSignal(handler: () => void): () => void {
emitter.on('drain', handler)
return () => emitter.off('drain', handler)
}
```
### List Co-Edit (real-time)
**Why not Redis pub/sub:** The outbox drain is single-process by design — the `isDraining` guard in `outboxWorker.ts` (line 156) explicitly documents "SINGLE-PROCESS LIMITATION". Redis pub/sub adds a network hop and operational overhead for zero benefit. Redis is already used for SSE list fan-out — a different problem (multi-browser-tab fan-out).
```
User A edits list item text
→ PATCH /api/lists/1/items/42 { text: "oat milk" }
→ DB UPDATE → success
→ Redis PUBLISH list:1 { op: "update", itemId: 42, text: "oat milk", userId: "A" }
→ WS Hub (subscribed to list:1) receives message
→ Push to all WebSocket connections for users in list:1 EXCEPT sender
→ User B's PWA receives → update item in list state
**`apps/api/src/routes/events.ts`** — MODIFY
After each `db.insert(calendarOutbox)` in the three write handlers (create, edit/same-calendar, edit/move transaction), add:
```typescript
signalOutboxDrain()
```
### Web Push Delivery
Import from `'../lib/outboxTrigger.js'`. Fire-and-forget; no await.
```
Reminder Scheduler (cron, runs every minute)
→ SELECT events WHERE dtstart BETWEEN now+14min AND now+15min
AND reminder_sent = false
→ For each event: find household users who have visibility to that calendar
→ For each user: SELECT push_subscriptions WHERE user_id = ?
→ For each subscription: web-push sendNotification(subscription, payload)
→ Mark reminder_sent = true
**`apps/api/src/broker/outboxWorker.ts`** — MODIFY
In `startOutboxWorker()`:
```typescript
export function startOutboxWorker(): void {
// Event-driven: drain immediately after any enqueue signal
onOutboxDrainSignal(() => {
runOutboxDrain().catch((err: unknown) => {
console.error('[outboxWorker] Signal-triggered drain error:', err)
})
})
// Fallback: 15s periodic drain for recovery + startup catch-up
setInterval(() => {
runOutboxDrain().catch((err: unknown) => {
console.error('[outboxWorker] Interval drain error:', err)
})
}, 15 * 1000)
}
```
### Identity Mapping (OIDC → App User)
**All durability guarantees are preserved.** The `isDraining` concurrency guard handles overlapping invocations from both the signal and the interval. When a signal-triggered drain is running (`isDraining = true`), any concurrent call (signal or interval) is a no-op. Create-before-delete ordering (D-04), etag re-read (WR-02), 412 conflict flow (D-08), transient backoff (D-07) — all unchanged. The trigger path only affects *when* `runOutboxDrain()` is called.
```
User authenticates via Authelia OIDC
→ PWA redirects to /auth/login → Authelia
→ Authelia returns id_token with claims: sub, email, preferred_username, groups
→ API receives id_token in Authorization header (or httpOnly cookie)
→ Validate token signature against Authelia JWKS endpoint
→ Look up users table WHERE oidc_sub = claims.sub
→ If not found: INSERT (first login provisioning) using email as display hint
→ Attach app user_id to request context
→ user_id is used for: calendar color config, push subscriptions, WS channel auth
**Rapid-successive-edits behavior:** Each `signalOutboxDrain()` call may trigger a drain that processes row N while row N+1 hasn't been inserted yet. Row N+1's `nextAttemptAt = defaultNow()`, so it appears in the next drain cycle's `pending + next_attempt_at <= NOW()` query. The 15s fallback interval ensures it is drained within 15 seconds at most.
---
### (c) Admin Role + Setup Wizard Config Storage
#### Admin role flag
**Where:** `users` table in `apps/api/src/db/schema.ts`. Add:
```typescript
isAdmin: boolean('is_admin').default(false).notNull(),
```
**Key:** Use `oidc_sub` (the `sub` claim) as the stable identity anchor. Never use `email` as a unique key — it can change per Authelia docs. Store `oidc_iss + oidc_sub` as the composite stable ID.
**Why DB not env:** Admin designation is per-user, mutable, and tied to identity. Env vars cannot express "user X is admin." The first user to complete the setup wizard is auto-promoted. Subsequent changes go through an admin route action.
## Component Build Order (Dependencies)
**Migration:** `ALTER TABLE users ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT 0` — safe online DDL on MariaDB 11/InnoDB.
Build in this sequence — each layer depends on the previous:
**Route guard:** `requireAdmin` middleware in `apps/api/src/routes/admin.ts` calls `resolveUserId` then checks `users.isAdmin`. Applied to all `/api/admin/*` routes.
```
1. Infrastructure scaffold
docker-compose.yml (API + MariaDB + Redis), DB migrations, .env wiring
**`GET /api/me` extension:** Add `isAdmin: boolean` to the response. The PWA uses this to conditionally show the Admin section in `SettingsSheet`. The `['me']` TanStack Query is already called on app load — no new query needed.
2. Auth layer
OIDC middleware (validate Authelia tokens, user upsert), session/cookie setup
[nothing works without identity]
#### Setup wizard config
3. Fastmail broker — read path only
CalDAV client, calendar discovery, cache sync, poller, event query API
[needed before any calendar UI]
The wizard bootstraps four categories of state:
4. Calendar PWA — read only
React calendar view consuming /api/events, color coding per calendar
[validates broker correctness before write complexity]
| Config | Current Home | v1.1 Approach |
|--------|-------------|----------------|
| VAPID keypair | `.env` (VAPID_PUBLIC_KEY, _PRIVATE_KEY, _SUBJECT) | Stays in env; wizard validates presence + offers generate-and-display |
| AES-256 key | `.env` (APP_PASSWORD_ENCRYPTION_KEY) | Stays in env; wizard validates format (64-char hex) |
| OIDC credentials | `.env` (OIDC_ISSUER, _CLIENT_ID, _SECRET, etc.) | Stays in env; wizard validates OIDC discovery reachability |
| App passwords + shared-cal | DB (member_credentials, calendars.is_shared) | Same DB; wizard is the UI for what was previously a manual DB write |
5. Fastmail broker — write path
CalDAV PUT for create/edit/delete, optimistic cache upsert
[built on confirmed-working read path]
6. Lists domain
MariaDB list/item schema, REST CRUD, basic PWA list UI
[independent of calendar; can parallel-track after step 3]
7. Real-time list sync
Redis pub/sub, WebSocket hub, PWA WebSocket client
[enhancement on top of working list CRUD]
8. Web Push
VAPID key generation, push_subscriptions table, service worker, reminder scheduler
[built last; depends on events being cached and users having identity]
**New `app_config` table** for app-level flags:
```typescript
export const appConfig = mysqlTable('app_config', {
key: varchar('key', { length: 128 }).primaryKey(),
value: text('value').notNull(),
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(),
})
```
**Parallel tracks available:** Lists (step 6) can start in parallel with calendar write (step 5) once auth and broker read are stable.
Initial keys: `setup_complete` (`'0'` or `'1'`), `setup_step` (resumable flow state).
**Why the wizard cannot write `.env`:** Env vars are set at Docker container start via Docker Compose. A running container's process cannot mutate its own env and have those mutations persist across restarts. The correct flow: wizard detects missing env vars, provides a "Generate" button that returns a keypair for the operator to copy, then re-validates after the operator restarts the container with updated env. Alternatively, the wizard writes to a `/app/.env.local` file that the Node.js process reads via dotenv — but this requires the container to have write access to its own image layer or a mounted volume. The simplest Unraid approach: wizard shows values to copy, operator adds to Docker Compose env, restarts.
**`apps/api/src/routes/setup.ts`** — NEW
Endpoints (all except `/status` are admin-only after first-run completes):
- `GET /api/setup/status`**unauthenticated** (mounted before OIDC guard in `index.ts`, like `/health`); returns `{ complete: boolean }`
- `GET /api/setup/env-status` — returns which env vars are present/missing (no values)
- `POST /api/setup/generate-vapid` — calls `webpush.generateVAPIDKeys()`, returns `{ publicKey, privateKey, subject }` for operator to copy; does NOT store in DB
- `GET /api/setup/validate-oidc` — pings `${OIDC_ISSUER}/.well-known/openid-configuration`, returns `{ ok: boolean, error?: string }`
- `POST /api/setup/complete` — sets `app_config.setup_complete = '1'`; sets `users.is_admin = true` for the calling user (promotes wizard-completing user to admin)
**First-run detection in PWA:** `App.tsx` adds a setup-status check before rendering the calendar shell. If `complete = false` and the user is authenticated (or the wizard must be completeable pre-auth), render `<SetupWizard>`. Simplest flow: require authentication first (Authelia protects everything), then the wizard runs as an authenticated user. The `GET /api/setup/status` route being pre-OIDC lets the PWA detect setup state before the OIDC redirect fires — render a "Setup required" splash instead of bouncing the user around.
#### Shared surface: wizard vs admin Settings
`POST /api/admin/credentials` is called by both the setup wizard (step: first app password) and the admin Settings page (ongoing credential management). Build this route once. The wizard in Phase D is a pure frontend consumer of the same admin API surface.
`PATCH /api/admin/calendars/:id/shared` is called by both the setup wizard (step: designate shared calendar) and admin Settings. Same pattern.
**Do not create `/api/setup/credentials` or `/api/setup/calendar-shared`** — these would duplicate `/api/admin/credentials` and `/api/admin/calendars/:id/shared`.
**Deferred: self-service provider onboarding (999.5).** This is a member-initiated flow where each member enters their own app password. The admin credential route is admin-only and takes a `userId` parameter. Self-service onboarding is a separate concern — do not conflate with admin settings in v1.1.
---
### (d) New vs Modified Components — Dependency-Ordered Build Sequence
#### Dependency graph
```
DB schema migration (users.is_admin, calendarEvents.reminder_lead_minutes, app_config)
├─── Admin role + routes (admin.ts) ─────────────────────────┐
│ │ │
│ routes/me.ts (extend response with isAdmin) │
│ │ │
│ AdminSettings.tsx + SettingsSheet.tsx mod │
│ │ │
│ SetupWizard.tsx (reuses admin routes) ◄───┘
│ routes/setup.ts (wizard backend)
│ App.tsx (setup-status gate)
├─── Faster write-back ─────────────────────────────────────
│ lib/outboxTrigger.ts (new)
│ outboxWorker.ts (subscribe to signal)
│ routes/events.ts (call signalOutboxDrain)
│ [no PWA changes]
├─── VALARM write path ──────────────────────────────────────┐
│ broker/vevent.ts (add VALARM to buildVeventString) │
│ outboxWorker.ts (pass reminderMinutes) │
│ routes/events.ts (extend schemas) │
│ EventForm.tsx (reminder selector) │
│ api/client.ts (extend CreateEventPayload) │
│ broker/sync.ts (extract VALARM -> DB) ◄───┘
│ reminderScheduler.ts (variable-lead query)
├─── Gitea CI (.gitea/workflows/ci.yml)
│ [no code dependencies; parallel]
└─── Mobile test harness
[no new backend code; playwright-cli + DEV_AUTH_BYPASS]
```
#### Recommended build order
**Phase A — DB foundation (unblocks everything)**
1. `apps/api/src/db/schema.ts` — add `users.isAdmin`, `calendarEvents.reminderLeadMinutes`, `appConfig` table
2. `drizzle-kit generate` + apply migration to dev DB
3. `apps/api/src/lib/outboxTrigger.ts` — new file, zero dependencies
**Phase B — Faster write-back (low-risk, isolated)**
Prerequisite: `outboxTrigger.ts` from Phase A
4. `apps/api/src/broker/outboxWorker.ts` — subscribe to drain signal in `startOutboxWorker`
5. `apps/api/src/routes/events.ts` — add `signalOutboxDrain()` after each outbox insert (3 call sites)
No PWA changes. Tests: extend `tests/broker/outboxWorker.test.ts`.
**Phase C — Admin role + routes**
6. `apps/api/src/routes/admin.ts` — new file:
- `GET /api/admin/members` — list users with credential status (isAdmin flag determines who sees it)
- `POST /api/admin/credentials` — upsert `memberCredentials` for a given `userId`; reuses `encryptPassword` from `broker/crypto.ts`
- `DELETE /api/admin/credentials/:userId` — remove credential
- `PATCH /api/admin/calendars/:id/shared` — toggle `calendars.isShared`
- All routes: `requireAdmin` middleware
7. `apps/api/src/routes/me.ts` — extend response with `isAdmin: boolean`
8. `apps/api/src/index.ts` — mount `adminRouter` at `/api/admin`
**Phase D — Setup wizard**
Prerequisite: Phase C admin routes
9. `apps/api/src/routes/setup.ts` — new file (wizard backend endpoints)
10. `apps/api/src/index.ts` — mount `setupRouter` at `/api/setup`; mount `GET /api/setup/status` BEFORE the OIDC guard
11. `apps/pwa/src/components/SetupWizard.tsx` — multi-step first-run UI
12. `apps/pwa/src/App.tsx` — setup-status check gate
**Phase E — Admin Settings UI**
Prerequisite: Phase C admin routes + Phase C `isAdmin` in `/api/me`
13. `apps/pwa/src/components/AdminSettings.tsx` — member list, "Set app password" flow, shared-calendar picker
14. `apps/pwa/src/components/SettingsSheet.tsx` — add "Admin" section (shown only when `currentUser.isAdmin`)
15. `apps/pwa/src/api/client.ts` — add admin API fetch wrappers
**Phase F — VALARM authoring (event form + scheduler)**
Prerequisite: Phase A (schema has `reminderLeadMinutes`)
16. `apps/api/src/broker/vevent.ts` — add VALARM to `buildVeventString`; extend `NewEventParams`
17. `apps/api/src/broker/outboxWorker.ts` — pass `reminderMinutes` in create + update branches; add to `outboxPayloadSchema`
18. `apps/api/src/routes/events.ts` — add `reminderMinutes` to `eventFieldsSchema`
19. `apps/api/src/broker/sync.ts` — extract VALARM trigger; store `reminderLeadMinutes` on upsert
20. `apps/pwa/src/components/EventForm.tsx` — add reminder selector
21. `apps/pwa/src/api/client.ts` — extend `CreateEventPayload` type
Then, once sync.ts change is deployed and populating `reminder_lead_minutes`:
22. `apps/api/src/broker/reminderScheduler.ts` — rewrite `runReminderCheck` to use variable lead from `reminder_lead_minutes`; update `isShared` scope per feature decision
**Phase G — Gitea CI (independent, parallel)**
23. `.gitea/workflows/ci.yml` — lint/typecheck/unit/API-integration on PR; MariaDB service container; secrets for `DB_HOST=127.0.0.1`, DB creds, test env vars. Tests live in `apps/api/tests/` — confirm test runner path in CI config.
**Phase H — Mobile test harness (independent)**
24. Playwright-cli mobile viewport configuration + authenticated entry via `DEV_AUTH_BYPASS=true`; no backend changes required.
---
## Component Boundaries and Key Integration Notes
### Shared surface: wizard credential entry vs admin Settings
`POST /api/admin/credentials` is called by both the setup wizard (first app password) and the admin Settings page (ongoing management). Build once in Phase C. Do not duplicate into a `/api/setup/credentials` endpoint.
### VALARM round-trip: write path vs read path
These are independent flows that rendezvous through Fastmail:
- **Write:** `reminderMinutes` travels as a number in the outbox JSON payload → `buildVeventString` emits VALARM TRIGGER:-PT{n}M → tsdav PUT to Fastmail
- **Read:** After the event syncs back from Fastmail (via targeted re-sync in `outboxWorker.ts` or 5-min poll), `sync.ts` parses the VALARM TRIGGER and stores `reminderLeadMinutes` in `calendar_events`. The scheduler reads this column — never the outbox payload.
This means `reminder_lead_minutes` in `calendar_events` is the ground truth. Events created before v1.1 (or by third-party clients) that have no VALARM will have `reminderLeadMinutes = NULL` and the scheduler ignores them.
### `outboxPayloadSchema` vs `eventFieldsSchema` — must stay in sync
`eventFieldsSchema` in `routes/events.ts` (line 100) and `outboxPayloadSchema` in `outboxWorker.ts` (line 71) are intentionally duplicated for defense-in-depth (IN-03). Adding `reminderMinutes` requires updating both. This is documented in the existing IN-03 comment — flag it explicitly when writing the phase plan.
### `isAdmin` in API responses
`GET /api/me` currently returns `{ id, displayName, color }`. Extend to include `isAdmin: boolean`. The PWA uses this to conditionally show the Admin section in SettingsSheet. The `['me']` TanStack Query is already called on app load — no new query, just extend the existing response and the `MeUser` type in `api/client.ts`.
### First-run detection: mount `/api/setup/status` before OIDC guard
In `apps/api/src/index.ts`, `/health` is already mounted before the OIDC guard (line 40). Mount `GET /api/setup/status` with the same pattern:
```typescript
app.get('/api/setup/status', setupStatusHandler) // before oidcAuthMiddleware
```
This lets the PWA detect first-run state before the OIDC redirect fires and render a setup splash instead of an auth bounce loop.
---
## Data Flow Diagrams
### Per-Event Reminder: Create to Notification
```
EventForm (reminder: 15min)
↓ POST /api/events/create { reminderMinutes: 15, ... }
routes/events.ts (schema validates, inserts outbox row, signalOutboxDrain())
↓ [event-driven, ~0s]
outboxWorker.ts runOutboxDrain()
→ buildVeventString({ reminderMinutes: 15 })
→ vevent.ts emits VCALENDAR with VALARM TRIGGER:-PT15M
→ tsdav PUT to Fastmail (.ics)
→ triggerTargetedResync() → sync.ts parses VALARM → stores reminder_lead_minutes=15
↓ (next 1-min scheduler tick)
reminderScheduler.ts
→ SQL WHERE DATE_SUB(dtstart_utc, INTERVAL reminder_lead_minutes MINUTE) IN tick-window
→ dispatchPush() to all subscribers
Browser notification: "Starts in 15 min"
```
### Admin: Set Member App Password
```
AdminSettings.tsx (admin enters app password for member X)
↓ POST /api/admin/credentials { userId: X, email: '...', password: '...' }
routes/admin.ts (requireAdmin guard → resolveUserId → check users.isAdmin)
→ zod validate input
→ encryptPassword() from broker/crypto.ts (AES-256-GCM)
→ db.insert/onDuplicateKeyUpdate on memberCredentials
↓ 200 OK
AdminSettings.tsx invalidates ['admin/members'] query
poller.ts (next 5-min tick): picks up new credential, discovers calendars for that user
```
### Setup Wizard: First-Run Bootstrap
```
App.tsx (on load)
↓ GET /api/setup/status [UNAUTHENTICATED, pre-OIDC]
← { complete: false }
→ render <SetupWizard> (after OIDC login)
Step 1: env-var check → GET /api/setup/env-status
Step 2: DB/OIDC validation → GET /api/setup/validate-oidc
Step 3: VAPID keypair → POST /api/setup/generate-vapid (operator copies to .env, restarts)
Step 4: first app password → POST /api/admin/credentials
Step 5: shared calendar → PATCH /api/admin/calendars/:id/shared
Step 6: POST /api/setup/complete → app_config.setup_complete='1', user.is_admin=true
→ redirect to /
App.tsx (on reload): GET /api/setup/status → { complete: true } → render CalendarShell
```
---
## Anti-Patterns to Avoid
### Anti-Pattern 1: Separate wizard endpoints that duplicate admin routes
The wizard is a frontend flow over the same admin API. Do not create `/api/setup/credentials` alongside `/api/admin/credentials`. One route, two callers (wizard + Settings page).
### Anti-Pattern 2: Storing env-var secrets in the DB
VAPID private keys and `APP_PASSWORD_ENCRYPTION_KEY` must stay in env vars — they are the bootstrap secrets that protect everything else. The wizard generates and displays them for the operator to copy; it never writes them to the `app_config` table or any other DB table.
### Anti-Pattern 3: Reintroducing node-cron
The codebase uses `setInterval` throughout because node-cron 4.2.1 silently skips ticks in the long-running process (documented in every worker file's header comment). Do not use `node-cron` for any new scheduled work in v1.1. The event-driven drain replaces the need for a tighter interval; the 15s `setInterval` fallback is already correct.
### Anti-Pattern 4: Adding VALARM to all-day events
RFC 5545 duration-form TRIGGER (`-PT15M`) is semantically valid only for timed events. For all-day events it requires `RELATED=END` or a DATE-form trigger. Fastmail may accept it regardless, but the semantics are wrong and would yield confusing reminder times. Guard in `buildVeventString`: `if (!params.allDay && params.reminderMinutes > 0)`. Disable the reminder selector in `EventForm.tsx` when `allDay = true`.
### Anti-Pattern 5: Dedup key includes lead time in reminderScheduler
The existing `sentReminders: Map<uid, dtstartMs>` keyed by bare uid is correct and must not be changed to `uid:leadMinutes`. A keyed `uid:lead` would cause double-fire if the event is rescheduled (new dtstart, same uid) or if the lead changes. The bare uid key + dtstart-based pruning is the correct exactly-once guarantee — leave it intact.
### Anti-Pattern 6: Concurrency guard changes in outboxWorker
The `isDraining` module-level boolean guard is the correct concurrency mechanism for the single-process deployment. Do not add a second guard, change it to an async mutex, or introduce Redis-based locking for the drain. The event-driven signal adds a new call site but does not require any guard changes — the existing guard already handles simultaneous invocations.
---
## Integration Points
### External Services
| Service | Integration Pattern | Auth | Gotchas |
|---------|---------------------|------|---------|
| Fastmail CalDAV | HTTP PROPFIND / REPORT / PUT via `tsdav` | App password (Basic Auth over HTTPS) | JMAP for calendars NOT available — CalDAV only. Use app password, not API token. Rate limits undocumented; 5-min polling is conservative. |
| Authelia OIDC | Authorization Code flow from PWA; JWT validation in API | OIDC client_id/secret in API env | Validate against Authelia JWKS URL, not a static key. Use `sub` not `email` as stable identity. |
| Browser Push Services (FCM/APNs Web Push) | VAPID via `web-push` npm | VAPID public/private key pair | iOS requires PWA installed to home screen (outside EU). Push endpoint URL comes from browser `PushManager.subscribe()`. |
| Pangolin/Newt | Transparent tunnel — no changes to app | None (handled at infra layer) | API must trust `X-Forwarded-For` headers from Pangolin; set `trustProxy: true` in Fastify. |
| Service | Integration | v1.1 Notes |
|---------|-------------|------------|
| Fastmail CalDAV | tsdav PUT with VCALENDAR containing VALARM | VALARM is inert to Fastmail's CalDAV server (stored as-is); sync reads it back |
| Authelia OIDC | No change | Setup wizard is post-OIDC; admin routes use existing OIDC session |
### Internal Boundaries
### Internal Module Boundaries
| Boundary | Communication | Notes |
|----------|---------------|-------|
| PWA ↔ API | REST (JSON) + WebSocket | REST for CRUD + auth; WS for real-time list push. Single domain, so no CORS needed in prod. |
| API ↔ Broker | Direct function call (same process) | Broker is a module, not a separate service. Keep it that way — no premature microservice split. |
| API ↔ MariaDB | Knex query builder | All queries go through `db/` module. No raw SQL in route handlers. |
| API ↔ Redis | ioredis, publish on write | WS hub uses a separate ioredis subscriber connection (required by Redis subscribe mode). |
| Scheduler ↔ API | In-process cron (node-cron) | Poller and reminder scheduler run in same Node process. If process dies, they restart with it. |
| `routes/events.ts``outboxWorker.ts` | `signalOutboxDrain()` via EventEmitter | NEW: fire-and-forget, one-way |
| `routes/admin.ts``broker/crypto.ts` | Direct import of `encryptPassword` | REUSE existing module no changes to crypto.ts |
| `routes/setup.ts``db/schema.ts` appConfig | Drizzle queries | NEW table, same db client and connection pool |
| `broker/vevent.ts``ical.js` | `ICAL.Component('valarm')`, `ICAL.Duration.fromSeconds` | EXTEND existing ical.js usage |
| `broker/sync.ts``db/schema.ts` | Store `reminderLeadMinutes` in events upsert | MODIFY existing upsert path in syncCalendar |
| `reminderScheduler.ts``db/schema.ts` | Read `reminder_lead_minutes` in Drizzle query | MODIFY existing query; raw SQL expression |
## Anti-Patterns
### Anti-Pattern 1: Fetching from Fastmail on Every Calendar Request
**What people do:** Proxy each PWA calendar load directly to CalDAV — no cache.
**Why it's wrong:** Fastmail CalDAV has undocumented rate limits. Cold requests are slow (multi-round-trip CalDAV discovery). Calendar loads become unavailable if Fastmail is unreachable.
**Do this instead:** Always read from the MariaDB cache. Write-back is synchronous (PUT + cache upsert). Background poller handles incoming changes from the Fastmail app.
### Anti-Pattern 2: Using Email as the App User Key
**What people do:** Map Authelia `email` claim → `users.email` primary key, assume it's stable.
**Why it's wrong:** OpenID Connect spec does not guarantee `email` stability or uniqueness. Authelia docs explicitly warn against this.
**Do this instead:** Use `oidc_iss` + `oidc_sub` as the stable composite identity. Store email as a display/provisioning hint only.
### Anti-Pattern 3: Storing Recurring Events Only as Expanded Instances
**What people do:** Pre-expand all RRULE events into individual rows in the DB to simplify queries.
**Why it's wrong:** Event edits (changing the series, adding exceptions) then require reconstructing the original RRULE plus EXDATE/RDATE logic — a nightmare. Fastmail is source of truth for RRULE; the cache should reflect it faithfully.
**Do this instead:** Store the raw VEVENT blob. Add a `dtstart_utc` column for range queries. Expand in the PWA for display.
### Anti-Pattern 4: Separate Microservices for Broker, Lists, Push
**What people do:** Split the broker, list API, and push sender into separate containers because "separation of concerns."
**Why it's wrong:** For a 2-person household app on a single Unraid host, this is pure overhead. Three containers to coordinate, three deployment targets, inter-service HTTP overhead with zero benefit.
**Do this instead:** One API container. Three modules within it. Split to services only if one component needs independent scaling (it won't).
### Anti-Pattern 5: Per-User CalDAV Credentials in the PWA
**What people do:** Give each household member their own Fastmail app password and have the PWA authenticate to CalDAV directly.
**Why it's wrong:** Exposes Fastmail credentials client-side; bypasses the broker cache; defeats the aggregation purpose.
**Do this instead:** All Fastmail access goes through the backend broker. The PWA authenticates only to the app API.
## Scaling Considerations
This is a 2-person household app. Scaling is not a concern. The architecture should remain simple.
| Concern | At 2 users (target) |
|---------|---------------------|
| CalDAV polling | Single poller, 5-min interval, negligible load |
| MariaDB | Single instance, no replication needed |
| Redis | Single instance, no clustering |
| WebSocket | Single Node process handles 2 connections trivially |
| Web Push | At most ~10 push subscriptions (phone + desktop per user) |
## Open Research Flags for Phase Research
These items require hands-on investigation during the broker implementation phase — they cannot be resolved by documentation alone:
1. **Fastmail personal-calendar sharing visibility (MEDIUM risk):** Can the broker account's CalDAV principal discovery see calendars that another Fastmail account has shared to it via the in-app sharing UI? If yes, one app password suffices. If no, the fallback is storing an encrypted app password per household member. Test this before building the broker's calendar-discovery logic.
2. **Fastmail CalDAV rate limits (LOW risk):** No documented rate limit for CalDAV. 5-min polling with ctag pre-check (one PROPFIND per calendar per tick) is conservative and unlikely to hit limits. Monitor HTTP 429 responses and back off exponentially if encountered.
3. **iOS Push reliability (LOW risk for this use case):** iOS 16.4+ supports Web Push only when the PWA is installed to the home screen. The wife will be asked to install the PWA as part of onboarding. Canada is not in the EU, so the iOS 17.4 DMA PWA restriction does not apply. Push is a "nice to have" alarm, not a safety-critical channel — missed notifications are acceptable.
---
## Sources
- [Fastmail API Documentation](https://www.fastmail.com/dev/) — CalDAV supported, JMAP calendars pending RFC finalization
- [Fastmail: Calendar Sharing with Other Users](https://www.fastmail.help/hc/en-us/articles/1500000279781-Sharing-calendars-with-other-users) — ACL tiers confirmed; cross-account token access not documented
- [Fastmail: Shared Calendaring Improvements](https://www.fastmail.com/blog/shared-calendar-improvements/) — CalDAV ACL standards alignment, per-user reminders
- [Fastmail: App Passwords](https://www.fastmail.help/hc/en-us/articles/360058752854-App-passwords) — CalDAV uses app passwords, not API tokens
- [Using Fastmail with CalDAV libraries](https://utf9k.net/blog/fastmail-caldav/) — Principal URL format, Cyrus IMAP internals
- [Authelia OIDC Claims](https://www.authelia.com/integration/openid-connect/openid-connect-1.0-claims/) — iss+sub as stable identity anchor
- [CalDAV ctag Extension](https://github.com/apple/ccs-calendarserver/blob/master/doc/Extensions/caldav-ctag.txt)ctag-based efficient polling
- [rrule.js](https://github.com/jkbrzt/rrule) — Client-side RRULE expansion
- [web-push npm](https://github.com/web-push-libs/web-push) — VAPID push sender
- [PWA iOS Push Limitations 2025](https://www.magicbell.com/blog/pwa-ios-limitations-safari-support-complete-guide) — iOS 16.4+ push support, EU exception
- [Redis Pub/Sub + WebSocket pattern](https://ably.com/blog/scaling-pub-sub-with-websockets-and-redis) — Architecture reference
All findings are grounded in direct codebase inspection:
- `apps/api/src/broker/outboxWorker.ts` — drain loop, `isDraining` guard, durability decisions (D-04/D-07/D-08/CR-05)
- `apps/api/src/broker/reminderScheduler.ts` — scheduler logic, dedup map, fixed-window rationale (D-05/D-06/D-12)
- `apps/api/src/broker/vevent.ts``buildVeventString`, `NewEventParams`, ical.js RRULE pattern (VALARM follows same API)
- `apps/api/src/broker/crypto.ts``encryptPassword`/`decryptPassword`; confirmed reusable by admin routes
- `apps/api/src/db/schema.ts` — table definitions; migration impact analysis
- `apps/api/src/routes/events.ts``eventFieldsSchema`, `outboxPayloadSchema` contract, IN-03 duplication requirement
- `apps/api/src/lib/listEmitter.ts` — in-process EventEmitter precedent for `outboxTrigger.ts`
- `apps/pwa/src/components/EventForm.tsx` — full form state machine; integration points for reminder selector
- `apps/pwa/src/components/SettingsSheet.tsx` — existing Settings surface; Admin section attachment point
- `apps/api/src/index.ts` — route mounting order, OIDC guard placement, `isMainModule` guard pattern
- `apps/api/src/routes/me.ts``/api/me` response shape; where `isAdmin` is added
- `apps/api/tests/` — test directory layout confirms tests in `tests/`, not `src/`; `helpers/db.ts` pattern
---
*Architecture research for: FamilySync — self-hosted family calendar + list hub*
*Researched: 2026-06-03*
*Architecture research for: FamilySync v1.1 Operability & Polish*
*Researched: 2026-06-10*
+238 -149
View File
@@ -1,8 +1,14 @@
# Feature Research
**Domain:** Self-hosted family organization hub — shared calendar + shared collaborative lists
**Researched:** 2026-06-03
**Confidence:** HIGH (table stakes and pitfalls well-evidenced across multiple products; differentiators MEDIUM — scoped to 2-person self-hosted context)
**Domain:** Self-hosted family organization hub — operability & polish milestone (v1.1)
**Researched:** 2026-06-10
**Confidence:** HIGH (v1.0 is shipped; v1.1 features are well-understood domain problems with clear prior art)
---
## Scope of This Document
This document covers **only the six v1.1 features**. v1.0 features (calendar, event CRUD, lists, push, OIDC, PWA) are shipped and validated; they are listed as dependencies, not re-researched here.
---
@@ -10,137 +16,115 @@
### Table Stakes (Users Expect These)
Features that must exist on day one. Missing any of these makes the product feel broken, not incomplete.
Features that must exist in v1.1 to avoid the product feeling unfinished for real household use.
| Feature | Why Expected | Complexity | Notes |
|---------|--------------|------------|-------|
| Unified multi-calendar view | Core value — see all schedules at once | MEDIUM | Aggregating Fastmail shared + personal calendars via JMAP/CalDAV broker token. Color per calendar/member is the visual primitive the whole product depends on. |
| Per-member color coding | Cannot tell whose event is whose without it | LOW | Assign a color per user in app config; render all events in that color regardless of source calendar. |
| Day / week / month / agenda views | All competing apps offer these; absence is jarring | MEDIUM | Month view is the hardest (event overflow, multi-day spanning). Agenda view is easiest. Week view is most-used daily driver. |
| Create / edit / delete events | Read-only calendar is not a calendar app | HIGH | Write-back to the correct Fastmail CalDAV calendar via broker token. Recurring event edits are the hard part (see dependency notes). |
| All-day events | School holidays, birthdays, anniversaries | LOW | CalDAV `DATE` vs `DATETIME` distinction. Visual banner across top of day/week grid. |
| Recurring events (create + display) | Weekly team standups, recurring chores, birthdays | HIGH | RRULE parsing/expansion is deceptively complex. DST handling, exception dates (EXDATE), single-instance modification (RECURRENCE-ID) are all non-trivial. Must use a library (rrule.js or equivalent). |
| Event reminders / push notifications | Time-sensitive alerts are the whole point of a calendar | HIGH | Web Push must be wired in from the start. iOS requires PWA installed to Home Screen; service worker reliability post-device-restart is known to be fragile. Needs fallback strategy (see Pitfalls). |
| Shared list create / check-off / reorder | Grocery list is one of the two primary list use cases | LOW | Simple CRUD in MariaDB. Checkbox state toggle + drag-to-reorder. |
| Live list co-edit sync | Both members shop simultaneously; must not diverge | MEDIUM | WebSocket (preferred) or SSE for push. Optimistic updates in UI; server reconciliation. Redis pub/sub if multi-instance is ever needed — not needed for single-host Docker. |
| Multiple named lists | Groceries and gift ideas are different lists | LOW | A `lists` table with name; items reference list_id. |
| OIDC / SSO login (Authelia) | App must not have its own auth system | MEDIUM | OIDC confidential client flow. Session management. Token refresh. Wife must be able to log in without understanding what OAuth is. |
| PWA installability (Add to Home Screen) | Native-app feel without App Store friction | MEDIUM | Web app manifest, service worker, HTTPS. iOS Safari and Chrome Android have slightly different installation prompts. Icon and splash screen assets required. |
| Low-friction onboarding | Wife adoption is a hard constraint | LOW (UX) / MEDIUM (infra) | One URL → login via Authelia → installed PWA. No calendar credentials to enter. No separate account to create. The OIDC flow is the primary risk — it must feel seamless. |
---
| Per-event reminder selector on event form | Every calendar app (Apple, Google, Fastmail) has this. The current hardcoded 15-min is a bug, not a feature. | MEDIUM | Drop-down of offsets (None / 5 min / 10 min / 15 min / 30 min / 1 hour / 2 hours / 1 day / 2 days) written as VALARM TRIGGER:-PTxM/H or TRIGGER:-P1D in the .ics. "None" default = no VALARM element emitted. |
| "None" is the default alarm state | Apple Calendar defaults new events to "None" alert unless the user has changed their Calendar > Settings > Alerts default. Google Calendar defaults to "30 minutes". The PWA should mirror "None" as the explicit no-alarm state — no alarm = no push. | LOW | Must not silently inherit a global default from Fastmail's own app-password user preferences. Emit no VALARM when "None". |
| Preserve existing VALARMs on edit | If an event was created in Apple Calendar or Fastmail's native client with a specific reminder, editing it in the PWA must not silently strip that reminder. | MEDIUM | tsdav + ical.js round-trip: parse VALARM on fetch, display the closest-matching preset (or "custom" fallback), write back on save. Explicitly handle the case where an existing VALARM is not in the preset list. |
| Scheduler honors per-event lead | The push scheduler must fire at `event_start - trigger_offset`, not a hardcoded 15 min. If no VALARM, fire nothing. | MEDIUM | Depends on outbox/scheduler already built in v1.0. Requires storing the absolute fire-time in the DB so the scheduler does not re-parse ical on every tick. |
| Admin Settings UI (role-gated) | Self-hosted apps cannot require SSH/DB-console access for routine admin. The two tasks (rotate app password, mark shared calendar) are operator-level but must be doable from the browser. | MEDIUM | Single admin flag on the users row; admin sees a Settings section hidden from the other member. Both tasks are currently manual DB writes — table stakes to remove that dependency. |
| Initial setup wizard (first run) | Without a wizard, first-time deploy requires hand-editing env files in the right order, running VAPID key generation manually, and hoping the DB connection string is correct. Nextcloud/Gitea/Authelia all ship a first-run wizard for exactly this reason. | HIGH | Gate on a `setup_complete` flag persisted in the DB or a dotfile. Must validate each credential before advancing: DB ping, OIDC discovery endpoint reachable, VAPID keys structurally valid, app password CalDAV test connection. |
| Event-driven outbox drain | Current ~15s latency (polling interval) makes edits feel sluggish when the user sees the event unchanged for 10+ seconds after saving. Every other calendar app (Google, Apple, Fastmail native) round-trips writes in under 2 seconds perceived. | MEDIUM | The existing transactional outbox guarantees durability. The fix is to trigger an immediate drain on INSERT to the outbox table, rather than waiting for the next scheduled tick. |
### Differentiators (Competitive Advantage for This Product)
Features where this product can outperform commercial alternatives specifically because it is self-hosted, private, and purpose-built for exactly two people.
Features that go beyond what a user would minimally expect — meaningful for this specific 2-person self-hosted context.
| Feature | Value Proposition | Complexity | Notes |
|---------|-------------------|------------|-------|
| No ads, no freemium walls | Every commercial app (Cozi, TimeTree, Maple) gates useful features behind paid tiers; Cozi restricts free users to 30-day history | LOW (operational cost) | Self-hosted means no monetization pressure. Zero marginal cost per feature. |
| Full personal calendar overlay | Skylight/Cozi only show a shared family calendar; this app aggregates shared + each member's personal Fastmail calendars into one view | MEDIUM | Requires Fastmail calendar sharing ACLs to be configured so the broker token can read both personal calendars. The "Skylight magic" per PROJECT.md. |
| Privacy — data stays home | Commercial apps store your family's schedule on their servers | LOW (architecture choice) | No data leaves the home network except via the Pangolin tunnel the household already controls. |
| Tailored to exactly two users | Commercial apps design for 46 family members with kids; complexity of permissions, chores, kids accounts is irrelevant overhead | LOW (scope reduction) | No "family manager" role, no parental controls, no per-member permission tiers. Two equals. |
| Event change notifications | Google Family Calendar explicitly does not send notifications when a member creates/edits an event — a documented pain point | MEDIUM | Web Push on list changes AND calendar changes. "Wife added something to the grocery list" push. "Event was changed" push. |
| Optimistic list UX (instant check-off) | OurGroceries is praised specifically for instant sync on check-off; most apps lag | MEDIUM | Optimistic update in React state, WebSocket confirmation, rollback on failure. |
| Multiple reminders per event (up to 2) | Apple Calendar supports multiple alerts per event. The iCalendar spec (RFC 5545) allows multiple VALARM components in one VEVENT. Most household events benefit from a "1 day before" + "30 min before" pair. | MEDIUM | Add a second optional reminder offset selector on the form. Emit two VALARM blocks in the .ics. Parse up to 2 existing VALARMs. Do not expose this in v1.1 if it risks slipping the phase; single-alarm is the floor. |
| All-day event reminder semantics matching Apple Calendar | Apple Calendar fires all-day alerts at 9 AM on the alert day (same day or 1 day before, etc.), not at 00:00. Google Calendar fires at 11:50 PM the night before for a "10-min" all-day offset, which is jarring. The Apple convention (morning-of) is the correct UX for this household's non-technical Apple member. | LOW | When the event is all-day and the reminder offset is "on the day" or "1 day before", the VALARM TRIGGER is written as a day-relative offset (`TRIGGER:-P1D` or `TRIGGER:P0D`). The scheduler fires at 9:00 AM on the resolved day (not midnight). This is a scheduler config constant, not user-settable. |
| CI regression gating on PR | Prevents the class of regressions introduced during v1.0 (tsc passes but runtime breaks, API test failures not caught until manual verify). Gitea Actions is already available on the self-hosted Gitea instance. | MEDIUM | Lint + typecheck (both apps) + unit tests (Vitest) + API integration tests against a MariaDB service container. Fails PR merge if any step fails. Significantly reduces the human verification burden per phase. |
| Mobile-emulated Playwright test harness | The CLAUDE.md preference is to use playwright-cli for validation rather than asking the operator. A mobile-emulated (iPhone viewport, touch, user-agent) authenticated harness lets the assistant catch mobile-only layout regressions, modal overflow, and form usability issues before handing off for iOS-hardware verification. | MEDIUM | Playwright `devices['iPhone 15']` or equivalent device preset, session reuse via `storageState`, DEV_AUTH_BYPASS=true for CI. Does not replace real-device iOS tests (push, standalone mode). |
| Docker image publish from CI | Currently the image is built manually. Auto-publishing on merge to main means Unraid can pull the latest image without an SSH session. | LOW | Gitea Packages registry or Docker Hub. Triggered on PR merge to main (not on every PR). |
---
### Anti-Features (Explicitly Exclude)
### Anti-Features (Deliberately Exclude)
Features that appear reasonable but are wrong for a 2-person self-hosted household. Flag these as scope creep.
Features that appear in commercial products but are wrong for a two-person self-hosted household. Building these would bloat scope without providing value.
| Feature | Why Commercial Apps Have It | Why to Exclude | What to Do Instead |
|---------|----------------------------|-----------------|--------------------|
| Chores / rewards / star system | Skylight's chore-chart is a primary SKU driver; kid motivation is a multi-child household need | Zero children in this household. Chores as a product concept doesn't exist here. | Lists serve any "task" need. A grocery list is a chore list if you want it to be. |
| Meal planning / recipe box | Cozi, FamCal, Maple, Skylight all have it; drives DAU | Adds a distinct domain (recipes, ingredients, nutrition) with high implementation cost. A family calendar + grocery list serves 90% of the coordination need without a recipe database. | Add grocery items manually or via list. If meal planning is ever wanted, it's a separate v3 concern. |
| Kids / sub-accounts without email | FamCal's differentiator — create accounts for children | No children; irrelevant | N/A |
| AI email-to-event import | Sense's primary differentiator; Skylight's Magic Import | Requires email access (out of scope), an LLM backend, and ongoing maintenance. Privacy risk. | Create events manually. Event creation UX should be fast enough that manual entry isn't painful. |
| RSVP / event invite flows | Used in apps targeting external coordination | For a two-person household sharing one calendar, RSVP is moot — both members see all events by default. CalDAV RSVP (iTIP/iMIP) is a substantial protocol on top of the calendar work. | Both users always attend shared events. Personal calendar events are visible but don't need RSVP. |
| Event-level comments / photos (TimeTree-style) | TimeTree's differentiator; useful for larger groups coordinating event details | Two people can just text each other. Adds a chat/media system with storage for near-zero incremental value. | Use SMS/iMessage for event-level discussion as now. |
| Activity feed / audit log | TimeTree, some Cozi Gold features | Useful when you need to know which of 5 family members deleted the dentist appointment. With two users it's obvious. | N/A |
| Accounts at scale / multi-household | Commercial apps target 46 household members, sometimes multiple households | One household, two users. Multi-tenant adds auth and data isolation complexity for zero gain. | Hardcode exactly two accounts in Authelia. |
| Ads / monetization | Cozi free tier is ad-supported | Self-hosted; no revenue model needed | N/A |
| Complex permissions / role tiers | "Family manager", read-only members, etc. | Two equal partners. | Both users have identical write access to all calendars and lists. |
| Offline-first with full conflict resolution | Required for apps targeting users with spotty connectivity | Home WiFi + PWA is the primary use surface. Brief offline tolerance (optimistic updates + retry) is sufficient. CRDTs and full offline sync are engineering overhead without commensurate benefit. | Optimistic updates + graceful "offline" indicator. Retry on reconnect. |
| Push-to-native-calendar (CalDAV subscribe URL) | Useful for Apple Calendar native integration | Optional, not v1. The PWA is the primary interface. Native calendar subscribe is a nice-to-have for the wife if she wants it — document it, don't build UI for it. | CalDAV subscribe URL for Fastmail calendars already works natively; just document how to set it up. |
| Grocery delivery integration (Instacart, etc.) | Maple's differentiator | Third-party API dependency; not needed when the family handles their own shopping | N/A |
| Feature | Why Requested | Why to Exclude | What to Do Instead |
|---------|---------------|----------------|--------------------|
| User-facing notification preferences page (per-member mute, granular notification types) | Every commercial app has this. Feels like a natural extension of reminder settings. | Two users, both presumably want reminders. This adds a settings surface that 100% of users must click through and that creates support burden (why am I not getting reminders?). The non-technical member should never need to configure this. | Set sensible defaults (all push types on) and never ask. If one member doesn't want push, they decline the browser permission prompt — the OS handles it. |
| Reminder "snooze" in the notification payload | RFC 9074 defines a snooze mechanism via sibling VALARM components. Fantastical supports it. | Implementing reliable snooze requires creating a new VALARM in the .ics (PUT back to Fastmail) from the service worker notification click handler. That is a write path triggered from a background service worker — a significant reliability and complexity risk. | Dismiss and re-add a reminder manually if needed. The use case is too rare for this household to justify the implementation risk. |
| Setup wizard re-run / reset | Advanced users might want to re-run parts of the wizard (e.g., rotate VAPID keys). | For a 2-person deployment, the operator can edit the env file or use the Admin Settings page for app-password rotation. A re-runnable wizard adds state-management complexity (partial completion, rollback). | Admin Settings covers the post-setup operational cases (app password rotation, shared calendar designation). VAPID key rotation is a documented manual step (generate, update env, redeploy). |
| Full audit log in Admin Settings | Some self-hosted admin panels (Gitea, Nextcloud) ship an audit log of administrative actions. | Two users. Both equal operators. There is no adversarial scenario between two family members that requires an audit trail. | N/A — omit entirely. |
| Calendar provider abstraction / plugin system | Would make the admin panel a "configure any CalDAV provider" experience. | The constraint is Fastmail + this specific household. A provider abstraction layer adds 3x the surface area for zero current benefit. | Hard-code Fastmail CalDAV principal discovery. Document the URL in the admin panel for transparency. If a second provider is ever needed, add it as a targeted feature in v2. |
| Self-service member onboarding via wizard | The setup wizard bootstraps the operator. Member onboarding (per-member app password collection) is a distinct problem (backlog 999.5). | Mixing these into one wizard creates a flow that only the operator completes — the other member would encounter a half-configured wizard. | Keep operator setup wizard and member onboarding as separate concerns. Admin Settings covers the operator side of member credential management. |
| Health dashboard / status page in Admin Settings | Uptime graphs, service health indicators, DB query stats. Seen in Nextcloud admin. | Single Docker host, two users. If the app is down, both users know immediately. There is no ops team monitoring this. | Docker logs + Unraid dashboard are sufficient. The setup wizard validates connectivity once; that is the only point of truth needed. |
| E2E test suite that runs on real iOS Safari | Complete mobile coverage in CI | Real iOS Safari requires physical device or paid cloud service (BrowserStack). Not reproducible in a self-hosted Gitea runner. | Playwright mobile emulation covers layout/interaction. Real-device iOS tests remain a human gate for push + standalone mode (as in v1.0). |
---
## Feature Dependencies
```
[OIDC Login]
└──required by──> [All other features] (nothing works without auth)
[Per-event reminder UI]
└──requires──> [Event create/edit form] (v1.0, shipped)
└──requires──> [VALARM parse on CalDAV fetch] (tsdav + ical.js, v1.0 partial)
└──requires──> [Push scheduler with per-event fire time] (outbox/scheduler, v1.0 shipped)
└──enhanced by──> [Faster write-back] (drain triggers scheduler sooner)
[CalDAV/JMAP broker token]
└──required by──> [Calendar read]
└──required by──> [Unified calendar view]
└──required by──> [Per-member color coding]
└──required by──> [Day/week/month views]
└──required by──> [Event create/edit/delete]
└──required by──> [All-day events] (DATE type)
└──required by──> [Recurring event display]
└──required by──> [Recurring event edit]
(RECURRENCE-ID, EXDATE — hardest sub-feature)
[Faster write-back]
└──requires──> [Transactional outbox] (v1.0, shipped)
└──enhanced by──> [Per-event reminder UI] (alarm fire times stored at write time)
[Web Push registration]
└──required by──> [Event reminders]
└──required by──> [List change notifications]
└──enhances──> [Live list sync] (push as fallback to WebSocket on reconnect)
[Admin Settings UI]
└──requires──> [OIDC login + role field on users table] (v1.0, shipped — needs admin flag)
└──requires──> [App password encrypted storage] (v1.0, shipped)
└──enhances──> [Setup wizard] (wizard hands off to Admin Settings for post-setup credential rotation)
[PWA installability]
└──required by──> [Web Push on iOS] (iOS only delivers push to installed PWAs)
└──required by──> [Low-friction onboarding] (one URL → installed app)
[Setup wizard]
└──requires──> [DB connection] (before anything else can be validated)
└──requires──> [VAPID key generation utility] (web-push keygen, v1.0 shipped in some form)
└──requires──> [OIDC config storage] (env vars or DB config table)
└──gates──> [All other features] (wizard must complete before app is usable)
└──does NOT require──> [Admin Settings UI] (wizard is first-run only; Admin Settings is post-run)
[MariaDB lists schema]
└──required by──> [Named lists]
└──required by──> [List items CRUD]
└──required by──> [Check-off / reorder]
└──enhanced by──> [Live list sync via WebSocket]
[Gitea CI]
└──requires──> [Gitea act_runner deployed on Unraid host]
└──requires──> [MariaDB service container support in act_runner]
└──requires──> [Vitest unit tests + API integration tests] (v1.0, partially written)
└──enables──> [Mobile-browser test harness] (harness runs as a CI step)
[Service worker]
└──required by──> [PWA installability]
└──required by──> [Web Push]
└──enhances──> [Offline tolerance] (cache shell, retry queue)
[Mobile-browser test harness]
└──requires──> [Playwright + mobile device config]
└──requires──> [DEV_AUTH_BYPASS or stored auth session]
└──enhanced by──> [Gitea CI] (harness most valuable when gating PRs automatically)
```
### Dependency Notes
- **OIDC login must come first.** Everything else is gated on auth. The OIDC flow with Authelia must be smooth enough that the non-technical member can complete it once, then never see it again (persistent session).
- **CalDAV/JMAP broker token is the calendar foundation.** All calendar features depend on proving this integration works reliably before building display or editing UI on top of it.
- **Recurring events require a library.** Implementing RRULE expansion manually is impractical. Use rrule.js (frontend) and a server-side equivalent for reminder scheduling. Single-instance edits (RECURRENCE-ID) and "this and following" edits add significant complexity and should be scoped carefully — basic recurring create/display can ship before full edit support.
- **PWA install is a prerequisite for iOS Web Push.** Web Push on iOS does not work from a Safari tab — only from an installed PWA. This means the install step is not optional for the wife to receive notifications. The onboarding flow must guide her through Add to Home Screen.
- **Live list sync requires WebSocket infrastructure.** This is a dependency on the server-side connection management (Socket.io or native WS). Redis pub/sub is only needed if the backend ever runs as multiple instances — not relevant for single-Docker-host deployment.
- **Faster write-back is a force multiplier.** It makes the per-event reminder UX feel correct — a user sets a reminder, saves, and expects the scheduler to know about it now, not in 15 seconds. Build faster write-back before or alongside per-event reminders.
- **Admin Settings requires an `is_admin` flag.** v1.0 shipped no role distinction. The schema migration to add `users.is_admin` (default false, first user true) is a prerequisite for the Admin Settings route guard and is trivial.
- **Setup wizard gates the app.** Until the wizard completes, no calendar or list feature should render. The wizard is a deploy-time concern for the operator; it is not a UX concern for the non-technical member (she never sees it — the operator runs it once).
- **Gitea CI depends on act_runner.** If the self-hosted Gitea does not yet have an act_runner container deployed, CI cannot run. This is an infrastructure prerequisite, not a code concern.
- **Mobile harness does not block CI.** CI (lint/typecheck/unit/integration) can ship first. The mobile harness is an additive step.
---
## MVP Definition
## MVP Definition (v1.1)
### Launch With (v1)
### Must Ship (table stakes for "operability" claim)
- [ ] OIDC login via Authelia — required for everything else; must be seamless for non-technical user
- [ ] CalDAV/JMAP broker integration — read Fastmail calendars (shared + personal)
- [ ] Unified calendar view with per-member colors — day, week, month views
- [ ] Create / edit / delete events (write-back to Fastmail) — all-day and timed; recurring create/display; single-instance edit is a stretch goal
- [ ] Shared lists — create named list, add/check/reorder items, delete items
- [ ] Live list sync via WebSocket — both members co-edit in real time
- [ ] Web Push notifications — event reminders, list change alerts
- [ ] PWA manifest + service worker — installable on iPhone and Android
- [ ] Guided Add to Home Screen prompt on first visit (iOS)
- [ ] Per-event reminder selector — None / preset offsets — single alarm minimum
- [ ] Scheduler uses per-event VALARM trigger offset, fires nothing on None
- [ ] Existing VALARMs preserved on edit round-trip
- [ ] Admin Settings — rotate/re-enter Fastmail app password per member, mark shared calendar
- [ ] Setup wizard — DB, OIDC, VAPID, app password — validated before completion
- [ ] Faster write-back — event-driven outbox drain, edits land in ~1s perceived
### Add After Validation (v1.x)
### Should Ship (differentiators, don't let them slip the milestone)
- [ ] Recurring event single-instance edit (RECURRENCE-ID) — add after core recurring display is stable and tested
- [ ] "This and following" recurring edit — complex; only add if users report the need
- [ ] Native CalDAV subscribe URL documentation — wife can optionally add to Apple Calendar; no new code needed, just documented
- [ ] Timezone display toggle — show events in a secondary timezone if the household ever travels across zones
- [ ] Gitea CI — lint + typecheck + unit + API integration on PR
- [ ] Mobile-browser test harness — iPhone viewport, authenticated, usable by assistant in playwright-cli
### Future Consideration (v2+)
### Defer (not v1.1 scope)
- [ ] Wall-display / kiosk dashboard — per PROJECT.md, explicitly deferred to v2
- [ ] Upcoming events widget / agenda summary — nice home screen widget-style view for the display
- [ ] Calendar event color override per event — current plan is color per member; per-event override adds UI complexity
- [ ] Multiple reminders per event (2x VALARM) — v1.1 stretch; defer to v1.2 if risky
- [ ] Self-service member onboarding via wizard (backlog 999.5) — separate feature, separate phase
- [ ] Docker image auto-publish from CI — nice to have; ship manually until CI is stable
---
@@ -148,65 +132,170 @@ Features that appear in commercial products but are wrong for a two-person self-
| Feature | User Value | Implementation Cost | Priority |
|---------|------------|---------------------|----------|
| OIDC login | HIGH | MEDIUM | P1 |
| CalDAV/JMAP broker | HIGH | HIGH | P1 |
| Unified calendar view (read) | HIGH | MEDIUM | P1 |
| Per-member color coding | HIGH | LOW | P1 |
| Day / week / month views | HIGH | MEDIUM | P1 |
| Event create/edit/delete | HIGH | HIGH | P1 |
| All-day events | HIGH | LOW | P1 |
| Recurring events (create + display) | HIGH | HIGH | P1 |
| Shared lists CRUD | HIGH | LOW | P1 |
| Live list sync (WebSocket) | HIGH | MEDIUM | P1 |
| Web Push notifications | HIGH | HIGH | P1 |
| PWA installability | HIGH | MEDIUM | P1 |
| List change notifications | MEDIUM | LOW | P1 (shares push infra) |
| Recurring event single-instance edit | MEDIUM | HIGH | P2 |
| "This and following" recurring edit | LOW | HIGH | P3 |
| Native CalDAV subscribe docs | LOW | LOW | P2 |
| Timezone display toggle | LOW | MEDIUM | P3 |
| Wall-display kiosk view | MEDIUM | MEDIUM | P3 (v2) |
| Per-event reminder selector | HIGH | MEDIUM | P1 |
| Scheduler per-event VALARM | HIGH | MEDIUM | P1 |
| Faster write-back | HIGH | MEDIUM | P1 |
| Admin Settings — app password mgmt | HIGH | MEDIUM | P1 |
| Admin Settings — shared calendar toggle | HIGH | LOW | P1 |
| Setup wizard | HIGH | HIGH | P1 |
| Gitea CI (regression gate) | MEDIUM | MEDIUM | P1 |
| Mobile Playwright harness | MEDIUM | LOW | P2 |
| Multiple reminders per event | MEDIUM | MEDIUM | P2 |
| All-day reminder at 9 AM semantics | MEDIUM | LOW | P2 |
| Docker image auto-publish | LOW | LOW | P3 |
**Priority key:**
- P1: Must have for milestone claim
- P2: High value, ship if no risk to P1
- P3: Nice to have, defer
---
## Competitor Feature Analysis
## Per-Feature Expected Behavior Reference
| Feature | Skylight | Cozi | TimeTree | Google Family | This Product |
|---------|----------|------|----------|---------------|--------------|
| Color per member | Yes | Yes | Yes | No | Yes |
| Personal + shared calendar overlay | No (shared only) | No | No | No | Yes (Fastmail aggregation) |
| Multiple calendar views | Yes | Partial (Gold gates month) | Yes | Yes | Yes |
| Recurring events | Yes | Yes | Yes | Yes | Yes (display v1; full edit v1.x) |
| Shared lists | Yes | Yes | No | No | Yes |
| Live list sync | Unknown | Yes | N/A | N/A | Yes (WebSocket) |
| Push notifications on change | Yes | Partial | Yes | No | Yes |
| Event-level comments | No | No | Yes | No | No (anti-feature) |
| Chores / rewards | Yes (primary feature) | Yes | No | No | No (anti-feature) |
| Meal planning | Yes | Yes | No | No | No (anti-feature) |
| AI import | Yes (Magic Import) | No | No | No | No (anti-feature) |
| RSVP | No | No | No | No | No (anti-feature) |
| Self-hosted / private | No | No | No | No | Yes (differentiator) |
| No ads / no paywall | No (Plus plan) | No (Gold plan) | No (Premium) | Yes | Yes |
| Cross-ecosystem (iOS + Android) | App + hardware | App | App | App | PWA (single URL) |
### 1. Per-Event Reminders
**Reminder selector options (matching Apple Calendar + Google Calendar intersection):**
| Label | VALARM TRIGGER value | Notes |
|-------|---------------------|-------|
| None | (no VALARM emitted) | Default for new events |
| 5 minutes before | `TRIGGER:-PT5M` | |
| 10 minutes before | `TRIGGER:-PT10M` | |
| 15 minutes before | `TRIGGER:-PT15M` | Current hardcoded default — becomes an explicit choice |
| 30 minutes before | `TRIGGER:-PT30M` | |
| 1 hour before | `TRIGGER:-PT1H` | |
| 2 hours before | `TRIGGER:-PT2H` | |
| 1 day before | `TRIGGER:-P1D` | |
| 2 days before | `TRIGGER:-P2D` | |
**All-day event semantics:** When saving an all-day event with any reminder, the scheduler fires at 09:00 AM local time on the target day (computed as `allday_date + offset_days`). Do not fire at midnight. Apple Calendar uses 9 AM as the "on the day" time for all-day alerts; this matches the wife's expectation.
**Round-trip on edit:** On form open for an existing event, read the first VALARM TRIGGER duration from the parsed ical.js component and select the nearest matching preset. If the existing TRIGGER does not match any preset (e.g., `TRIGGER:-PT7M` from some other client), display a "Custom (preserve)" option and do not strip it on save unless the user explicitly changes the selector. On save with a preset change, replace the VALARM. On save with "None" selected, remove all VALARMs.
**Scheduler contract:** Store `alarm_fire_at` (absolute UTC timestamp) in the `events` or a separate `event_alarms` table at write time. The scheduler queries `alarm_fire_at <= NOW() AND fired = false` — no ical re-parse at fire time. This is the existing outbox/scheduler pattern already in v1.0; extend it.
### 2. Admin Settings
**Scope (exactly this, no more):**
1. **Per-member Fastmail app password** — masked input to re-enter or rotate the encrypted credential stored in the DB. Show member name + "last updated" timestamp. On save, attempt a CalDAV `PROPFIND` test request with the new credential before committing. Surface pass/fail inline. No separate page — a section within Settings.
2. **Shared calendar designation** — list all synced calendars (display name + Fastmail collection URL) with a radio button selecting which one is `is_shared = true`. Currently requires a direct DB write (D-16 debt). One click + confirm. Show the current selection highlighted.
**Role gate:** A single `users.is_admin TINYINT(1) DEFAULT 0` flag. The first user created during the setup wizard gets `is_admin = 1`. The route `/api/admin/*` and the Admin Settings UI section return 403 for non-admin users. No role management UI — the non-technical member never sees this section.
**What Admin Settings is NOT:** It is not a full server configuration panel, not a user management screen, not an audit log, not a health dashboard. It is two operational tasks that currently require DB console access.
### 3. Setup Wizard
**When it runs:** On first visit to the app when a `setup_complete` record is absent from the DB (or a `SETUP_COMPLETE=false` env flag, whichever is simpler). After completion, a `setup_complete = true` record is written and the wizard never appears again.
**Step order (each step validates before advancing):**
| Step | Fields | Validation |
|------|--------|------------|
| 1. Welcome | None — explains what the wizard does | None |
| 2. Database | Already connected (MariaDB creds are in env at container start). Show "connected" status. | DB ping; show error + instructions if failed |
| 3. App URL | External URL (used for OIDC redirect) | URL format check; attempt a `HEAD` to itself if reachable |
| 4. OIDC | Client ID, client secret, issuer URL, redirect URI (pre-filled) | Fetch `{issuer}/.well-known/openid-configuration`; show discovered endpoints; fail if unreachable |
| 5. Session secret | Auto-generated 32-byte hex string (user can override) | Length >= 32 chars |
| 6. Encryption key | Auto-generated 32-byte hex string for `APP_PASSWORD_ENCRYPTION_KEY` | Length == 32 bytes |
| 7. VAPID keys | Auto-generate button (calls `webpush.generateVAPIDKeys()`) | Structural check — public key is a valid base64url-encoded P-256 point |
| 8. Admin account | Select from OIDC-discovered members OR enter the sub/preferred_username manually | Not empty |
| 9. Fastmail app password | App password for the primary calendar account | Test CalDAV `PROPFIND` to `https://caldav.fastmail.com/dav/principals/user/<email>/`; show pass/fail |
| 10. Confirm + save | Summary of all inputs | Writes config to DB/env; sets `setup_complete`; redirects to app |
**Validation UX:** Inline per-field — show a green checkmark or red error directly below the field as soon as the user leaves it (blur event) or clicks a test button. "Next" button is disabled until the current step passes validation. Show human-readable error messages: "Could not reach the OIDC issuer — is Authelia running?" not "fetch failed: ERR_CONNECTION_REFUSED".
**Back navigation:** Every step allows going back. Already-validated steps retain their values. Do not re-validate automatically on back; re-validate on "Next".
**Failure surface:** If the DB step fails (impossible at startup in Docker but possible in dev), show a non-wizard error page with setup instructions — the wizard itself cannot run without a DB.
### 4. Faster Write-Back
**Target perceived latency:** < 2 seconds from "Save" click to the event appearing correctly in the calendar view. This matches Google Calendar and Fastmail native behavior.
**Current latency:** Up to ~15 seconds (outbox poll interval) + 5-minute CalDAV read-back poller.
**Approach:**
1. The existing optimistic-202 response already updates the React Query cache immediately on save (latency = 0 for the UI). The gap is the CalDAV write-back actually landing, which matters for reminders and cross-device visibility.
2. On INSERT to the `outbox` table, emit an event (in-process EventEmitter or a Redis pub/sub message if the outbox worker is in a separate process) that triggers an immediate drain attempt.
3. On successful CalDAV PUT, emit an SSE `calendar-updated` event to connected clients so React Query invalidates the calendar cache and re-fetches. The re-fetch is the "write landed" confirmation.
4. Reduce the read-back poller interval from 5 min to 30 s as a fallback — not the primary path, just the safety net.
**Durability guarantee preserved:** The outbox row is not deleted until the CalDAV PUT succeeds. The optimistic-202 pattern is unchanged. The drain is just triggered eagerly instead of lazily.
**What "near-immediate" does NOT mean:** CalDAV is a synchronous HTTP PUT. If Fastmail is slow (>1s), the write takes >1s. The goal is to eliminate the artificial polling delay, not to change network physics.
### 5. Gitea CI
**Trigger:** On `pull_request` to `main`.
**Steps:**
1. Checkout
2. pnpm install (cached)
3. TypeScript typecheck — `pnpm -r tsc --noEmit` (both `apps/api` and `apps/pwa`)
4. ESLint — `pnpm -r lint`
5. Vitest unit tests — `pnpm -r test:unit`
6. API integration tests — spin up MariaDB service container, run `pnpm --filter api test:integration` with `DB_HOST=127.0.0.1`
**Service container pattern:** Gitea Actions uses the same `services:` syntax as GitHub Actions. MariaDB `mariadb:11` with `MYSQL_ROOT_PASSWORD`, `MYSQL_DATABASE` env vars. `options: --health-cmd="mariadb-admin ping -h localhost" --health-interval=10s --health-retries=5` to gate the test step on DB readiness.
**Docker image publish:** Separate workflow, trigger `push` to `main` (after PR merge). Builds and pushes to the Gitea Container Registry. Not part of the PR workflow — keeps PR checks fast.
**Known limitation:** Gitea act_runner in Docker has incomplete service volume support. Do not mount host volumes in the services block. The MariaDB service container using env-based config (no volume) is reliable.
### 6. Mobile-Browser Test Harness
**What it is:** A reusable Playwright configuration profile using `devices['iPhone 15']` (or equivalent) with stored auth state (`DEV_AUTH_BYPASS=true` or a saved `storageState` JSON from a prior login), usable by the assistant via `playwright-cli` without re-authenticating on every run.
**What it covers:**
- Mobile viewport layout (bottom nav, drawer sizing, touch targets >= 44px)
- Calendar view rendering at iPhone screen width
- Event form usability on mobile (reminder selector visible, not clipped)
- List co-edit interactions on mobile
**What it does NOT cover:** Real iOS Safari, Web Push delivery, standalone-mode OIDC redirect, iOS-specific service worker quirks. Those remain human gates.
**Integration with CI:** A `test:e2e:mobile` script in `apps/pwa/package.json`. Run in CI as an optional step (allowed to fail without blocking merge) until the harness is proven stable, then graduate to blocking.
**Auth strategy for CI:** `DEV_AUTH_BYPASS=true` with a known test user id. The harness does not run the full OIDC flow — it injects the bypass session directly. This matches the v1.0 dev-stack bring-up pattern.
---
## Competitor / Prior Art Reference
| Feature | Apple Calendar | Google Calendar | Fastmail native | Nextcloud | This Product (v1.1 target) |
|---------|---------------|-----------------|-----------------|-----------|---------------------------|
| Reminder presets | None / 5m / 15m / 30m / 1h / 2h / 1d / 2d / 1w | None / 5m / 10m / 15m / 30m / 1h / 2h / 1d / 2d | None / 15m / 1h / 1d | N/A | None / 5m / 10m / 15m / 30m / 1h / 2h / 1d / 2d |
| Multiple alarms | Yes (up to 5) | Yes (up to 5) | Yes | N/A | V1.1: 1; stretch: 2 |
| All-day reminder time | 9 AM on alert day | 11:50 PM night before (jarring) | Morning | N/A | 9 AM (Apple convention) |
| Admin credential mgmt | N/A | N/A | N/A | Yes (complex) | Minimal: 2 tasks only |
| Setup wizard | N/A | N/A | N/A | Yes (3-step minimal) | 10-step validated |
| Write-back latency | ~1s | ~1s | ~1s | Varies | Target ~1s (from ~15s) |
| CI | N/A | N/A | N/A | GitHub Actions | Gitea Actions |
---
## Sources
- [Skylight Calendar product page](https://myskylight.com/calendar/)
- [Skylight Calendar 2 TechCrunch review, Jan 2026](https://techcrunch.com/2026/01/07/skylight-debuts-calendar-2-to-keep-your-family-organized/)
- [Cozi feature overview](https://www.cozi.com/feature-overview/)
- [Cozi Gold features](https://www.cozi.com/cozi-gold-features/)
- [Maple best family calendar app comparison](https://www.growmaple.com/blog-posts/best-family-calendar-app)
- [Best Family Calendar Apps 2026 — getsense.ai](https://getsense.ai/blog/posts/best-family-calendar-apps-2026)
- [TimeTree review and features](https://toolstack.io/tools/timetree)
- [Google Family Calendar — support.google.com](https://support.google.com/families/answer/7157782)
- [Nylas: The Deceptively Complex World of RRULEs](https://www.nylas.com/blog/calendar-events-rrules/)
- [Mozilla Wiki: Calendar Recurrence and Exceptions](https://wiki.mozilla.org/Calendar:Recurrence_and_Exceptions)
- [PWA iOS limitations 2026 — magicbell.com](https://www.magicbell.com/blog/pwa-ios-limitations-safari-support-complete-guide)
- [PWA push notifications iOS — OneSignal docs](https://documentation.onesignal.com/docs/en/web-push-for-ios)
- [Real-time data sync with WebSockets — GTCSys](https://gtcsys.com/real-time-communication-in-pwas-websockets-server-sent-events-and-webrtc/)
- [Apple Calendar default alert settings (Mac support)](https://support.apple.com/guide/calendar/change-default-alert-settings-icl4407ddb59/mac)
- [Google Calendar notifications (Android)](https://support.google.com/calendar/answer/37242?hl=en)
- [iCalendar RFC 5545 — VALARM component](https://icalendar.org/iCalendar-RFC-5545/3-6-6-alarm-component.html)
- [RFC 9074 — VALARM Extensions](https://datatracker.ietf.org/doc/html/rfc9074)
- [iCalendar TRIGGER property spec](https://icalendar.org/iCalendar-RFC-5545/3-8-6-3-trigger.html)
- [Nextcloud installation wizard](https://docs.nextcloud.com/server/stable/admin_manual/installation/installation_wizard.html)
- [Wizard UI patterns — LogRocket UX](https://blog.logrocket.com/ux-design/creating-setup-wizard-when-you-shouldnt/)
- [NN/G Wizards: Definition and Design Recommendations](https://www.nngroup.com/articles/wizards/)
- [Outbox Pattern — Conduktor](https://www.conduktor.io/glossary/outbox-pattern-for-reliable-event-publishing/)
- [Transactional Outbox with Optimistic Sending](https://www.npiontko.pro/2025/05/26/outbox-pattern-optimistic)
- [Optimistic UI Patterns — Simon Hearne](https://simonhearne.com/2021/optimistic-ui-patterns/)
- [Gitea Actions — Act Runner docs](https://docs.gitea.com/usage/actions/act-runner)
- [Gitea Actions Docker builds](https://blog.diblasio.social/posts/gitea_builder/)
- [Playwright PWA mobile testing](https://dev.to/pritig/how-playwright-simplifies-ui-testing-for-progressive-web-apps-pwas-9n8)
- [Playwright emulation docs](https://playwright.dev/docs/emulation)
---
*Feature research for: FamilySync — self-hosted family organization hub*
*Researched: 2026-06-03*
*Feature research for: FamilySync v1.1 — Operability & Polish*
*Researched: 2026-06-10*
+290 -340
View File
@@ -1,405 +1,398 @@
# Pitfalls Research
# Pitfalls Research — v1.1 Operability & Polish
**Domain:** Fastmail-brokered family calendar + shared-list PWA with Web Push, self-hosted behind Authelia
**Researched:** 2026-06-03
**Confidence:** MEDIUM-HIGH (CalDAV/RRULE/iOS push well-documented in community; Fastmail-specific rate limits and JMAP calendar status confirmed from official docs)
**Domain:** Adding operability features (VALARM reminders, admin Settings, setup wizard, event-driven outbox drain, Gitea CI, Playwright authed-mobile harness) to a shipped Node 22 + Hono + Drizzle/MariaDB + tsdav/ical.js + web-push stack on Unraid/Docker behind Authelia OIDC + Pangolin/Newt.
**Researched:** 2026-06-10
**Confidence:** HIGH — pitfalls derived from direct inspection of the shipped v1.0 source, the v1.0 retrospective, and deep familiarity with ical.js/CalDAV/Gitea Actions semantics. No speculative gaps.
---
## Known Constraints (Do Not Re-Litigate)
These are already burned-in lessons. Every pitfall below is written assuming these hold:
- **No node-cron.** `setInterval` only. node-cron 4.2.1 silently skips all ticks in the long-lived API process.
- **`drizzle-kit generate`+`migrate`, never `push`.** `push` emits a false destructive diff on populated MariaDB.
- **API integration tests need a real MariaDB** (port-bound dev compose, `DB_HOST=127.0.0.1`, `.env` creds). Tests live in `apps/api/tests/`, never `src/`.
- **Outbox guarantees to preserve:** optimistic 202, enqueue-only route handler, create-before-delete ordering (groupId), drain concurrency guard (`isDraining`), fresh-etag-before-PUT (WR-02), per-uid exactly-once dedup.
- **VAPID private key must decode to exactly 32 bytes** — a truncated key causes a silent Apple 403.
- **Authelia omits `name`/`email`/`preferred_username` from the ID token** by default — needs a `claims_policy` or these fields are absent.
---
## Critical Pitfalls
### Pitfall 1: Fastmail Calendars Are CalDAV-Only — JMAP Calendar Is Not Production-Ready
### Pitfall 1: VALARM Round-Trip Strips Existing Alarms From Native Clients on Edit
**What goes wrong:**
Fastmail's developer documentation explicitly states: "Calendars — you can access via CalDAV. We will be opening up JMAP access as well, as soon as the specification is finalized." If you build your calendar broker against JMAP expecting calendar read/write, you will find either no endpoint or an unstable/draft surface. This kills the entire broker strategy.
The current `buildVeventString` in `broker/vevent.ts` constructs a fresh `VCALENDAR` with only the properties it knows about (UID, SUMMARY, DTSTART, DTEND, RRULE, LOCATION, DESCRIPTION). When v1.1 adds VALARM authoring, a naive approach adds `VALARM` components to new-creates only. On an edit (update path in `outboxWorker.ts`), the worker re-builds the VEVENT from form payload — not from the stored `rawVevent`. Any VALARM that was added by a native client (Fastmail app, Apple Calendar) will be silently dropped from the PUT payload. The event arrives at Fastmail without the alarm. The user loses their native-client reminder with no warning.
The inverse is equally dangerous: if the editor sends `reminderMinutes: 0` (meaning "no reminder"), but the code omits the VALARM field instead of explicitly authoring an empty VALARM block, the old alarm from `rawVevent` is neither preserved nor cleared. Whether it survives depends on what the worker does — and with the current reconstruct-from-scratch approach it will be dropped, which is the correct outcome in that case but only by accident.
**Why it happens:**
JMAP for Calendars (RFC draft) has been "almost finalized" for years. It's easy to assume Fastmail's JMAP support is comprehensive because their email/contacts JMAP support is excellent. Calendar is the exception.
`outboxWorker.ts` update path reconstructs the ICS entirely from form fields (using `buildVeventString`). It does not read and preserve non-RRULE sub-components from `rawVevent`. This is a deliberate v1 simplification (the RRULE-preserve path `WR-01` is already the one exception). Adding VALARM to `buildVeventString` handles new-creates correctly but does not cover the "user edited an event that already had a native-app alarm."
**How to avoid:**
Commit to CalDAV as the protocol for all calendar read/write. Do not design the broker around JMAP calendars or leave it open. Use `https://caldav.fastmail.com/dav/principals/user/{email}/` as the base URL — the bare `caldav.fastmail.com` root is not sufficient for library URL discovery.
On the update path in `outboxWorker.ts`, before calling `buildVeventString`, parse `rawVevent` with ical.js and extract all existing `VALARM` sub-components. Merge them: if the outbox payload carries an explicit reminder choice (the new `reminderMinutes` field), replace all extracted VALARMs with the new one (or with none if `reminderMinutes: null`). If the payload carries no reminder field (no explicit user change), carry the extracted VALARMs forward into `buildVeventString` as a `valarms` parameter. This mirrors the WR-01 RRULE-preserve pattern exactly. Extend the `outboxPayloadSchema` with an optional `reminderMinutes: z.number().int().min(0).nullable().optional()` field so the absence of the key is distinguishable from an explicit "no reminder."
**Warning signs:**
- Any design doc that says "CalDAV or JMAP, TBD"
- Libraries that prefer JMAP and fall back silently
- Reminders set in the Fastmail native app disappear after editing the event in FamilySync.
- A shared event with a reminder shows the reminder field as empty after an FamilySync round-trip.
- `rawVevent` in `calendar_events` has `BEGIN:VALARM` but the PUT payload does not.
**Phase to address:**
Calendar broker implementation phase (first phase that touches Fastmail). Lock the protocol decision in the first spike; do not re-evaluate.
Per-event reminders phase (VALARM authoring). The VALARM-preserve logic must land in the same PR as `buildVeventString` VALARM support — not as a follow-up.
---
### Pitfall 2: Recurring Event RRULE Expansion Done in the App Instead of Leveraged from Server
### Pitfall 2: TRIGGER Value-Type Mismatch Silently Produces Broken VALARM
**What goes wrong:**
The app fetches VCALENDAR objects and attempts to expand RRULE recurrences in application code using a generic JS library (e.g., `rrule.js`). The RFC 5545 recurrence model is large: RRULE, RDATE, EXDATE, and RECURRENCE-ID overrides interact. Bugs appear in: yearly events near leap-day, monthly BYDAY rules (e.g., "last Friday"), weekly events near DST transitions, and any series with moved/cancelled individual instances (RECURRENCE-ID).
RFC 5545 §3.8.6.3 defines two legal TRIGGER value types for VALARM:
- `DURATION` (default): `TRIGGER:-PT15M` — fires 15 minutes before DTSTART.
- `DATE-TIME`: `TRIGGER;VALUE=DATE-TIME:20260610T120000Z` — fires at an absolute UTC instant.
ical.js represents these differently. If you set a VALARM TRIGGER using `addPropertyWithValue('trigger', '-PT15M')` (a bare string), ical.js will emit it as `TRIGGER:-PT15M` on some versions and as `TRIGGER;VALUE=TEXT:-PT15M` on others, depending on whether it infers the type. The `VALUE=TEXT` form is not RFC-compliant for VALARM TRIGGER and will be silently ignored by Fastmail and Apple Calendar — the reminder never fires. The ICS looks valid at a glance but produces no alarm.
Separately: `RELATED=END` (fire N minutes before DTEND, not DTSTART) is a valid TRIGGER parameter. If the existing event from a native client uses `TRIGGER;RELATED=END:-PT10M` and the VALARM-preserve code in Pitfall 1 carries it forward, it is preserved correctly. But if the code reconstructs the VALARM from a stored `reminderMinutes` number only, it loses the RELATED parameter and the semantics change.
**Why it happens:**
Developers underestimate RFC 5545 complexity. Square built an internal RRULE library because existing ones couldn't handle the full spec. Even `rrule.js` has known edge cases with DST and complex rules. The CalDAV spec provides server-side expansion (CALDAV:expand) exactly because client expansion is error-prone.
ical.js VALARM construction is not well-documented. The property API requires using `ICAL.Duration` or `ICAL.Time` objects, not bare strings, to get the correct value type in the output. Most examples online use the string form that works in some parsers but is not RFC-compliant.
**How to avoid:**
Use CalDAV's server-side expansion. Issue time-ranged REPORT requests with `<C:expand>` to get back already-expanded instances within a window, rather than fetching raw VCALENDAR and expanding yourself. Only expand locally for display rendering (simple cases). Use `rrule.js` only for the display layer on events you've already confirmed against server expansion.
Build the TRIGGER using `ICAL.Duration.fromSeconds(-reminderMinutes * 60)` and set it as the property value, not as a string. Verify the emitted TRIGGER line does not contain `VALUE=TEXT`. For preserved VALARMs (from `rawVevent`), round-trip the sub-component through ical.js parse→serialize rather than extracting the raw text and reinserting it, to catch any encoding issues.
Add a unit test: build a VALARM with `reminderMinutes: 15`, serialize to ICS, parse back with ical.js, and assert the TRIGGER DURATION value is `-PT15M` with no VALUE parameter other than DURATION (which is the default and is usually omitted).
**Warning signs:**
- Events that appear correct in simple cases but shift by one hour across DST boundaries
- A recurring weekly event showing on the wrong day for specific months
- Cancelled or moved instances reappearing
- ICS output contains `TRIGGER;VALUE=TEXT:-PT15M`.
- Reminders appear in the FamilySync UI but never fire on the device.
- Apple Calendar / Fastmail app shows the event with no alarm after an FamilySync edit.
**Phase to address:**
Calendar fetch/display phase. Define the REPORT request format in the first calendar sync spike, not as a later optimization.
Per-event reminders phase. Unit test the VALARM serialization before any end-to-end reminder test.
---
### Pitfall 3: All-Day Events Interpreted as Timed UTC Events
### Pitfall 3: All-Day Event VALARM Timezone Semantics Are Undefined
**What goes wrong:**
All-day events in iCalendar use `DATE` (not `DATETIME`) values and carry no timezone. If your backend stores or returns them as UTC datetimes, or if the frontend uses `.toISOString()` on the date, an all-day event for "June 5" becomes "June 4 at 20:00 PDT" — it shifts into the previous day. The CalDAV time-range filter also returns wrong results for all-day events when UTC arithmetic is applied.
For an all-day event (DTSTART;VALUE=DATE), the meaning of `TRIGGER:-PT15M` is ambiguous. RFC 5545 requires that a DURATION TRIGGER on an all-day event be evaluated against DTSTART as a DATE — which has no time component — resulting in undefined behavior in most implementations. Apple Calendar interprets it as "15 minutes before midnight of the start date in local time." Fastmail ignores VALARM on all-day events entirely in some tested configurations. Android may fire the alarm at midnight UTC.
**Why it happens:**
Most date libraries default to UTC datetime handling. The distinction between `DTSTART;VALUE=DATE:20260605` and `DTSTART;TZID=America/Toronto:20260605T090000` is easy to conflate. Home Assistant's CalDAV integration has had this exact bug filed multiple times.
The project already correctly excludes all-day events from the reminder scheduler (`reminderScheduler.ts`, `WHERE allDay=false`). But if VALARM is stored on an all-day event (because the user created an all-day event and selected a reminder), the scheduler's WHERE clause means it silently never fires — which is correct behavior — but the user sees a reminder field in the UI and expects it to work.
**How to avoid:**
Represent all-day events as a `{ date: "YYYY-MM-DD", allDay: true }` struct throughout the stack — never coerce to a JS `Date` or SQL `DATETIME`. In MariaDB, store as `DATE` column, not `DATETIME`. In the API response, emit the ISO date string without a time component. In the frontend, detect `allDay` and render accordingly without any timezone conversion.
In the event form UI: disable or hide the reminder selector when `allDay: true`. If the API receives a create/update payload with `allDay: true` and a non-null `reminderMinutes`, strip the alarm and log a warning — do not store a VALARM that will silently not fire. Document this as a known constraint.
In the scheduler, when v1.1 generalizes the lead time: keep the `WHERE allDay=false` guard in the SQL query regardless of how VALARM data is stored. Do not "fix" this by removing the guard when you extend VALARM support.
**Warning signs:**
- All-day events appearing on the day before in certain timezones
- CalDAV time-range queries missing all-day events that should be in range
- All-day event with reminder set produces an ICS with a VALARM on a DATE-typed DTSTART.
- User reports reminder not firing for an all-day birthday event.
- Reminder field enabled in the UI for all-day events.
**Phase to address:**
Calendar data model phase. Define the `allDay` field in the internal schema before writing any persistence or API code.
Per-event reminders phase. UI constraint and API guard belong in the same plan.
---
### Pitfall 4: ETag / Sync-Token Incremental Sync Done Wrong
### Pitfall 4: Duplicate Push When Generalizing the Fixed-Window Dedup to Per-Event Lead Times
**What goes wrong:**
Two common failure modes. First: the app does a full PROPFIND on every poll instead of using WebDAV-Sync (RFC 6578), burning bandwidth and causing delays at scale. Second: the app uses sync-tokens but doesn't handle 403 (token expired/forgotten by server) — some servers, including iCloud, will drop old tokens, and the app must fall back to full resync. Fastmail uses Cyrus IMAP under the hood and can expire tokens.
The current `reminderScheduler.ts` deduplication key is `uid` alone (`sentReminders` Map). The fixed 16-minute catch-up window ensures an event stays in-window across at most a few consecutive 1-minute ticks. When v1.1 changes the lead to a per-event value (e.g., event A has a 30-minute lead, event B has a 2-hour lead), the window must widen — or the scan logic must change — to accommodate variable leads. There are two failure modes:
For write operations: the app updates a VCALENDAR without sending `If-Match: <etag>`, so if the event was concurrently modified (e.g., from the primary user's native Fastmail app), the server returns a 412 and the app either silently drops the write or throws an unhandled error.
1. **Window too narrow for long leads:** If the scheduler still scans only `(now, now+16min]`, events with a 2-hour lead never enter the window and their reminder never fires.
**How to avoid:**
- Always check for `{DAV}sync-token` support via PROPFIND before using it; fall back to getctag polling if absent
- Persist the last sync-token in the database and use it on subsequent syncs
- Handle 403 on sync-token by discarding the token and doing a full resync
- On all PUT/DELETE operations, include `If-Match: <etag>` header; handle 412 by fetching the current state, presenting a merge/overwrite choice (even if just "last write wins" for v1)
**Warning signs:**
- Polling logs show full PROPFIND responses every cycle rather than delta responses
- Errors after periods of inactivity that clear on app restart
- Edits from the native Fastmail app not appearing or being overwritten silently
**Phase to address:**
Calendar sync engine phase. The sync-token + ETag strategy must be in the design before writing the poller — retrofitting is painful.
---
### Pitfall 5: Write-Back Modifying a Single Recurring Instance Corrupts the Series
**What goes wrong:**
When a user edits a single occurrence of a recurring event (e.g., moves next Tuesday's meeting to Wednesday), the correct CalDAV write is to store a VEVENT with `RECURRENCE-ID` as an additional component in the same VCALENDAR resource. A naive implementation either: (a) writes a new standalone event, leaving the original occurrence intact (duplication), or (b) modifies the master RRULE, changing all future occurrences.
Known Nextcloud and ownCloud bugs document exactly this: "CalDAV: Moving single event from recurring events results in duplicated event."
**How to avoid:**
When editing a single instance: fetch the full VCALENDAR, inject a new VEVENT block with `RECURRENCE-ID` matching the original instance's DTSTART, and PUT the entire modified VCALENDAR back with `If-Match`. Do not create a new resource. If editing all future instances, set `UNTIL` or `COUNT` on the master rule and create a new recurring series starting from the edit point.
For v1, consider restricting to "edit all instances" only and deferring single-instance overrides to v2 — the complexity is disproportionate to a two-person household.
**Warning signs:**
- Editing a recurring event produces two events on the calendar
- Other instances of the series shift after an individual edit
- Events with `RECURRENCE-ID` appearing as standalone items
**Phase to address:**
Event edit UI phase. The decision to support or defer single-instance overrides must be made before the edit form is built.
---
### Pitfall 6: DST Transition Shifts Recurring Events by One Hour
**What goes wrong:**
A recurring event created in summer (UTC-4) that spans a DST boundary (clocks fall back to UTC-5) can shift by one hour on every occurrence after the transition if the VTIMEZONE component is malformed or if the server/client disagrees on which TZID to use. This is the single most-reported CalDAV bug across all implementations.
2. **Dedup key collision across rescheduled events:** If an event is rescheduled (DTSTART changes), the uid is the same but the VALARM should fire again for the new time. The current dedup key `uid` alone, with the `sentReminders.set(uid, dtstartMs)` pruning based on the stored dtstart, handles this — but only if the new dtstart causes the map entry to be pruned before the next alarm window. If the user reschedules an event to fire sooner than the original dtstart (e.g., from 3pm to 2pm, currently 2:10pm, 10 minutes after the original reminder already fired), the uid is still in `sentReminders` with the old dtstart (3pm), which has NOT yet passed `now`, so the CR-01 pruning has not removed it. The reminder for 2pm silently does not fire.
**Why it happens:**
The iCalendar spec requires a VTIMEZONE block describing the DST rules for the TZID in use. Many libraries emit a minimal or incorrect VTIMEZONE. Fastmail's Cyrus server uses its own TZID database; if the client sends a different TZID alias (e.g., `Eastern Standard Time` vs `America/New_York`), the server may misinterpret transitions.
The uid-only dedup was designed for the fixed-15-minute lead where the dedup window is short and rescheduling edge cases are low-probability. Per-event leads break both assumptions.
**How to avoid:**
- Always use IANA timezone IDs (e.g., `America/Toronto`) — never Windows-style IDs
- Use a library that generates correct VTIMEZONE blocks from the IANA tz database (e.g., `ical.js` or `node-ical` with `tz-data`)
- Test recurring events specifically across the spring and fall DST boundary dates before any calendar milestone is considered done
Change the dedup key from `uid` alone to `uid + ':' + dtstartMs`. This makes the dedup per-(event, scheduled-time), not per-event. A rescheduled event has a different dtstart and gets a new dedup entry. The sentReminders map still prunes on `dtstartMs <= now`.
For the variable-window query: instead of scanning a fixed `(now, now+16min]` window, store the per-event lead time alongside the VALARM in `calendar_events` (e.g., a `reminderMinutes` column). The scheduler query becomes `WHERE dtstartUtc <= (now + reminderMinutes minutes) AND dtstartUtc > now`. This requires a schema migration.
Add an integration test for the scheduler that covers: (a) event with a 30-minute lead fires at T-30, (b) event rescheduled earlier after the first fire fires again for the new time.
**Warning signs:**
- Events correct in summer that shift by exactly one hour in November
- `TZID` values in emitted iCalendar containing spaces or Windows timezone names
- Events with a long reminder lead never fire.
- Rescheduled event reminder does not fire after the reschedule.
- Scheduler dedup map grows without bound (no uid-dtstart pair is ever pruned because the dtstart moved out from under the map entry).
**Phase to address:**
Calendar write phase. Add a DST-crossing test fixture before the first release.
Per-event reminders phase. Schema migration for `reminderMinutes` column in `calendar_events` is a prerequisite; dedup key change must land in the same plan.
---
### Pitfall 7: Personal Calendar Sharing to the Broker Token Is Not Automatic
### Pitfall 5: Double-Drain When Event-Driven Trigger and 15s setInterval Both Fire
**What goes wrong:**
The architecture assumes one broker API token reads all calendars (shared family + each member's personal). But a Fastmail API token scoped to the primary account cannot read a different member's personal Fastmail calendar unless that calendar has been explicitly shared using Fastmail's CalDAV sharing model (offer + acceptance flow). This is not automatic; it requires setup steps that involve both Fastmail accounts.
The v1.1 event-driven drain adds a trigger (Redis pub/sub message, or direct `runOutboxDrain()` call) that fires immediately when a new row is enqueued. The 15s `setInterval` fallback continues to run. Both can invoke `runOutboxDrain()` concurrently. The existing `isDraining` module-level flag provides single-execution within the same JS tick, but there is a subtler race:
- T=0: Route enqueues row. Event-driven trigger calls `runOutboxDrain()`. `isDraining` is set.
- T=0.5s: Drain in progress. 15s interval fires. `isDraining` is true — no-op. Correct.
- T=1s: Drain completes. `isDraining` reset to false.
- T=1.5s: Event-driven trigger for a SECOND enqueue calls `runOutboxDrain()`. Drain starts.
- T=14s: First 15s tick since startup fires (not 15s after the last drain completed, but 15s after the interval was registered at server start). Calls `runOutboxDrain()`. `isDraining` is true — no-op. Correct.
So far so good — `isDraining` handles this. The failure mode is: **if `runOutboxDrain` is called directly (not through the setInterval wrapper) from the event-driven path, thrown errors will not be caught by the setInterval `.catch()` handler.** An unhandled rejection crashes the process on Node 22 (where unhandledRejection is fatal by default unless a handler is registered). The fix is to always use the same error-caught wrapper: `runOutboxDrain().catch(err => console.error(...))`.
A more dangerous double-drain scenario arises if the event-driven trigger is implemented via ioredis pub/sub and the subscriber receives the same message twice (ioredis at-least-once delivery). Two concurrent `runOutboxDrain()` calls can occur before either sets `isDraining`. The `isDraining` check is not atomic. In the single-process Node.js event loop, two synchronous checks of `isDraining` before any `await` both see `false` and both proceed. The first `await db.select()...` in both drain calls then runs in parallel. Both fetch the same pending rows and dispatch the same CalDAV writes, producing duplicate PUTs.
**Why it happens:**
Fastmail's calendar ACLs follow the CalDAV sharing standard: the owner must share the calendar, and the recipient must accept. A single-account API token only sees calendars in that account's homeset plus calendars shared to it and accepted.
Additionally, the wife's personal calendar may not be on Fastmail at all if she uses Apple Calendar as her primary (iCloud calendar). In that case the broker can never read it via CalDAV — there is no cross-service token.
`isDraining` is a module-level boolean, not a mutex or a DB-level row lock. In the single-process deployment it is correct for the `setInterval` case (the JS event loop ensures only one tick can run at a time). But two synchronous calls to `runOutboxDrain()` before any `await` both pass the `if (isDraining) return` check because the flag is set inside the function body, not before the call.
**How to avoid:**
- Do a proof-of-concept share in the first calendar spike: share the primary user's personal calendar to a test account, accept it, and verify the broker token sees it via PROPFIND of the homeset
- Document the manual setup steps for each member's personal calendar as part of the deployment runbook
- For v1, scope the MVP to the shared family calendar only; add personal calendar overlay only after confirming the share/accept flow works
- If the wife's personal events live in iCloud (not Fastmail), treat her personal calendar as out of scope or accept an ICS subscription URL approach
Wrap the event-driven call in the same caught wrapper. More importantly: do not call `runOutboxDrain()` directly from the pub/sub subscriber. Instead, call a `triggerDrain()` helper that sets `isDraining = true` synchronously before the first await, or simply lets the setInterval do the work and uses the pub/sub message only to shorten the next wait (e.g., trigger a single immediate `runOutboxDrain()` call from within the setInterval handler if a "pending" flag is set, keeping all drain calls single-threaded through the interval). The cleanest approach: keep one drain path (the setInterval), but when an enqueue event arrives, set a `drainRequested` flag; the next setInterval tick checks the flag and drains immediately instead of waiting the full 15s.
**Warning signs:**
- Broker token PROPFIND returns only the shared family calendar, not personal calendars
- Empty calendar list after adding a member
- Duplicate CalDAV PUTs for the same event visible in Fastmail logs.
- Two identical events appearing briefly after an edit.
- 412 conflict errors on the second of two simultaneous drain calls (the first PUT succeeded, the second uses an outdated etag).
**Phase to address:**
Infrastructure/deployment phase and calendar broker spike. This is a prerequisite that blocks "unified view" features.
Event-driven outbox drain phase. The drain trigger design must be reviewed before implementation; the `isDraining` guard docs already note the single-process limitation.
---
### Pitfall 8: iOS Web Push Requires "Add to Home Screen" — and Apple Provides No Install Prompt
### Pitfall 6: Event-Driven Drain Breaks Create-Before-Delete Ordering Under Concurrent Enqueues
**What goes wrong:**
On Android, Chrome shows an "Install" banner or button (beforeinstallprompt). On iOS, there is no equivalent browser prompt. The user must manually tap the Safari share sheet, scroll to find "Add to Home Screen," and tap it. A non-technical user who doesn't know this exists will never install the PWA, and therefore will never receive any push notifications.
The edit-as-move path enqueues two rows (delete old uid, create new uid) in the same request handler. With the 15s poll, both rows are almost always in the DB before the next drain cycle. With event-driven drain (trigger fires on enqueue), a race is possible:
- Request handler enqueues the CREATE row. Event-driven trigger fires immediately. Drain runs. Create is dispatched successfully. `isDraining` resets.
- Request handler (same HTTP request, now at the second DB insert) enqueues the DELETE row. Trigger fires again. Drain runs. The create is status=done. The delete is dispatched.
This is actually the happy path — correct ordering. The dangerous case is if the HTTP handler enqueues the DELETE row first and the CREATE row second (e.g., if the code is written in that order). The event-driven drain fires after the DELETE enqueue, finds the delete row with no done-sibling, and the durable CR-04 gate defers it. When the CREATE is then enqueued and drained, the delete is re-attempted on the next cycle — also correct. But if the delete fires before the create for any reason (e.g., a coding error that enqueues in the wrong order, or the delete row has a lower `next_attempt_at`), the original event is deleted before the new one is confirmed, causing data loss.
A subtler issue: if the event-driven trigger fires between the two DB inserts in the same HTTP handler (possible if the first `await db.insert()` resolves and the trigger fires before the second `await db.insert()` runs), the drain may start before both rows are committed. MySQL/MariaDB default isolation (REPEATABLE READ) means the drain transaction may not see the second row at all until it starts a new transaction. The CR-04 durable gate handles this case: the delete will defer itself because the sibling create is not yet visible. But if the create row is invisible, the drain processes the delete alone, defers it correctly, and then the create arrives. This is safe but results in at least one extra drain cycle for the move. Not a bug, but a latency regression on the event-driven path.
**How to avoid:**
Always enqueue the CREATE row before the DELETE row in the HTTP handler, matching the existing sort-before-dispatch logic in the drain. This is already the intent of D-04 but should be an explicit code comment in the edit-as-move handler.
Do not trigger the event-driven drain between the two enqueue inserts. If the trigger is a direct call, wrap both inserts in a single DB transaction and trigger the drain only after the transaction commits. If the trigger is Redis pub/sub, publish after both inserts.
**Warning signs:**
- Edit-as-move operations produce a "calendar object not found" error from Fastmail (delete reached Fastmail before the create).
- Events occasionally disappear after an edit and reappear after the next poller sync cycle.
- CR-04 deferral log messages (`Deferring delete row...`) appearing frequently for move operations.
**Phase to address:**
Event-driven outbox drain phase. The enqueue ordering requirement and transaction boundary must be specified in the plan.
---
### Pitfall 7: Admin App-Password Update Logged or Echoed in Error Messages
**What goes wrong:**
The admin Settings route receives the new Fastmail app password in the request body. If Zod validation fails (wrong format, too long), the default Zod error message includes the invalid value in the error output: `Invalid value: "xxxx-xxxx-xxxx-xxxx"`. If the Hono error handler returns this Zod error to the client as JSON, the app password appears in: (1) the HTTP response body, (2) any request logging middleware, (3) server logs if the error is caught and `console.error(err)` is called with the full error object.
Separately: `decryptPassword` in `broker/crypto.ts` currently never logs the decrypted value (T-03-13), but the admin update route must call `encryptPassword(newPassword)` after receiving the plaintext. If the route logs the request body at any point before the encrypt call, the password is in the logs.
**Why it happens:**
Apple has explicitly not implemented the Web App Manifest install prompt on iOS. The Add to Home Screen path exists but is discoverable only if you know where to look.
Developers often log `req.body` at the route level for debugging during development. The admin route is new, debugging is natural, and the log line gets committed. Zod error passthrough is the other common source — the validator middleware returns the full error object.
**How to avoid:**
- Implement an in-app installation guide with annotated screenshots specific to iOS Safari (share icon → "Add to Home Screen") that appears on first visit when `display-mode: browser` is detected
- Use `navigator.standalone` to detect whether the app is installed and conditionally show the banner
- Do not assume the wife will find this herself — the onboarding flow for iOS must walk her through it explicitly
In the `@hono/zod-validator` middleware for the app-password body schema, always use a custom `hook` to return a generic `{ error: "Invalid request" }` without the Zod error detail. Never log the request body in the admin/settings routes. Add a lint rule or code review checklist item: no `console.log` in any file under `routes/admin*` or `routes/settings*` that could include body content.
For test coverage: write a unit test that asserts the route returns `400` with no `value` field in the response when given an invalid password. Do not assert on the specific Zod error message.
**Warning signs:**
- No push subscriptions registered for iPhone users
- Wife accessing the app via browser tab URL, not from the home screen
- App password appears in any log output or API response body.
- The Zod error response for the settings route includes a `received` or `message` field containing password-like strings.
**Phase to address:**
PWA setup / onboarding phase. The install guide is not a nice-to-have — it is load-bearing for the non-technical user UX constraint.
Admin Settings phase. Security review of the settings route before first deployment; treat app-password fields the same as `OIDC_CLIENT_SECRET` — never log, never echo.
---
### Pitfall 9: iOS Kills Push Subscriptions Silently After 3 Silent Pushes
### Pitfall 8: Unauthenticated Setup Endpoint Left Live After First Run
**What goes wrong:**
Apple/WebKit enforces `userVisibleOnly: true` strictly. If the service worker receives a push event and fails to display a notification before the event handler terminates — even once accidentally — iOS counts it as a "silent push." After 3 silent pushes, the subscription is permanently revoked without any `pushsubscriptionchange` event (which iOS doesn't support anyway). The user stops receiving notifications without knowing it, and the server continues sending to a dead endpoint.
The setup wizard endpoint must be accessible before any member has authenticated (no credentials exist yet, so OIDC cannot be used to protect it). The typical implementation: mount the setup routes outside the `app.use('/api/*', oidcAuthMiddleware())` guard, and detect "first run" by checking whether any `member_credentials` row (or VAPID env) exists. The failure mode: the first-run check passes once. But if the developer forgets to add an "already-set-up" guard, or the check looks at the wrong table, the endpoint remains callable after setup — allowing anyone who can reach the internal network (or the Pangolin public URL) to overwrite the app password without authentication.
A second failure mode: the wizard validates env vars (VAPID keys, DB connection, app password) but stores the app password directly in the DB or in a temp file instead of in an env var. The architecture requires app passwords to be encrypted at rest using `APP_PASSWORD_ENCRYPTION_KEY`. If the wizard stores the password before the encryption key env is set (it shouldn't be — the wizard is supposed to collect the key or confirm it exists), the encryption call throws and the wizard fails with a 500 that may include the plaintext password in the error.
**Why it happens:**
The most common mistake is calling `showNotification()` without wrapping it in `event.waitUntil()`. Without `waitUntil`, the service worker runtime terminates before the async notification display completes, making it appear silent to iOS.
Separately: Apple's Intelligent Tracking Prevention (ITP) deletes service worker registrations for sites not visited "recently enough," silently invalidating subscriptions.
Setup wizards are one-shot paths that receive less testing than the main app. "First run only" guards are often implemented as booleans that can be reset, or checks that are too broad.
**How to avoid:**
- Always wrap `showNotification()` in `event.waitUntil()` — no exceptions
- Implement a subscription health-check: on every app open, call `pushManager.getSubscription()` and compare the endpoint to the stored server-side endpoint; re-subscribe if they differ or if null
- On the server, handle 410 (Gone) responses from the push service as permanent subscription deletion; remove the subscription record immediately
- Handle 404 and 401 from the push service as potentially expired; remove and force re-subscription on next app open
- Log push delivery success/failure server-side so silent failures are detectable
Implement the "already set up" guard as: check for any row in `member_credentials` AND for the presence of VAPID env vars (not just one or the other). If either is already set, return 423 Locked from all setup endpoints. Once the setup completes successfully, the next request to setup routes returns 423 immediately — no state to reset without a server restart.
Alternatively, use a DB-stored `setup_completed_at` timestamp in a `settings` table (a migration is needed anyway for v1.1 admin features). The wizard marks this column on completion; all setup routes check it first.
Never accept the `APP_PASSWORD_ENCRYPTION_KEY` value via the API. The wizard should validate that the env is already set (by attempting a test encrypt/decrypt), not collect the key. The key stays in the env/Docker secrets layer.
**Warning signs:**
- Push success rate drops from the server perspective with no user-visible errors
- Server has endpoint records but delivers 410/404
- Wife's iPhone stops getting notifications after a week of inactivity
- Setup endpoint returns 200 after the app is already configured.
- Curl to `/api/setup/...` with no auth cookie returns a non-401/423 response.
- Setup route has no test covering the "already set up" scenario.
**Phase to address:**
Push notification implementation phase. The `waitUntil` pattern and subscription health-check must be in the initial implementation, not added later.
Setup wizard phase. The guard must be the first thing implemented; test the guard before testing the happy path.
---
### Pitfall 10: Declarative Web Push vs Standard Web Push — Choose the Right Target
### Pitfall 9: Admin Role Check Bypassed by Missing Middleware Wiring
**What goes wrong:**
WebKit has introduced "Declarative Web Push" (announced mid-2025), which allows notification display without a service worker by using a standardized JSON payload format. If you build against standard Web Push with a service worker and custom payload parsing, and then Apple's ITP clears the service worker, your notifications stop. Declarative Web Push survives ITP because the browser can display the notification natively without running JS.
The admin Settings routes require a role check (only the operator/admin user can manage credentials and toggle `is_shared`). The standard pattern in this codebase is Hono middleware layered on a route prefix. The failure mode: the admin middleware is defined but not wired to the correct prefix. For example, if the admin check is added to `eventsRouter` instead of a new `adminRouter`, or if the route is mounted at `/api/admin` but the middleware guard applies to `/api/settings/*`, admin routes are reachable by any authenticated member.
If you build notifications that depend on custom payload processing in the service worker (e.g., fetching additional data from the server before showing the notification), Declarative Web Push cannot handle that — you'd need the service worker anyway.
**How to avoid:**
Design notification payloads to be self-contained (all display data in the push payload: title, body, icon, URL to open). This satisfies both Declarative Web Push display requirements and standard Web Push service worker display. Do not design a "fetch-to-display" pattern where the service worker hits the API before showing anything — that pattern breaks on iOS after ITP clears the SW.
**Warning signs:**
- Notification payloads that contain only an event ID, requiring a network fetch to render
- Service worker push handler making API calls before `showNotification`
**Phase to address:**
Push notification design phase. Define the payload schema before implementing the server-side push sender.
---
### Pitfall 11: EU Digital Markets Act Breaks iOS PWA Entirely for EU Users
**What goes wrong:**
Since iOS 17.4 (March 2024), in EU countries Apple removed standalone PWA mode under the Digital Markets Act. PWAs open as standard Safari tabs. Push notifications do not work. Add to Home Screen produces a bookmark, not an installed PWA. This affects the entire notification strategy for any EU household.
**Why it matters here:**
The project is Canadian (primary user email: me@lucasberger.ca — `.ca` domain, Unraid self-hosted). If the household is in Canada, this does not apply. Document it as a known non-issue for this deployment, but note it if the household ever relocates or if devices are registered in EU Apple IDs.
**How to avoid:**
Confirm Apple ID region for both household members. If non-EU, proceed without mitigation. If EU, the entire push notification strategy must shift to email or in-app alerts only.
**Phase to address:**
Risk assessment before push implementation. One-time check, not ongoing work.
---
### Pitfall 12: Service Worker Caching Serves Stale Calendar Data
**What goes wrong:**
If the service worker uses a Cache-First strategy for API responses, calendar data shown in the PWA may be hours old. A user adds an event from the native Fastmail app, opens FamilySync, and sees yesterday's calendar. For a family coordination tool this destroys trust immediately.
In a two-person household this is low-severity (both members are trusted), but the `is_shared` toggle can break the whole calendar display for both members if set incorrectly, and the credential management can overwrite the other member's app password.
**Why it happens:**
Cache-First is the default recommendation for PWA shell (HTML/CSS/JS assets) but gets applied to API/data routes by mistake, or by using a broad URL pattern in the Workbox config.
Hono's middleware scoping is based on route prefix at mount time, not at route definition time. A middleware added with `app.use('/api/admin/*', adminGuard)` does not protect routes mounted under `app.route('/api/admin', adminRouter)` unless the `adminRouter` itself also applies the guard. It is easy to apply the middleware in one place and assume it covers the route, but Hono's `.route()` creates an isolated sub-app.
**How to avoid:**
- Use Cache-First only for static assets (JS bundles, CSS, icons) with content-hash filenames
- Use Network-First for all `/api/*` routes; fall back to cache only if offline
- Use Stale-While-Revalidate for calendar data that is acceptable to be slightly stale (list data OK; calendar event times not OK)
- Scope `workbox-recipes` patterns explicitly; never use `.*` to match API routes
Apply the admin middleware inside `adminRouter` itself (`.use('*', adminGuard)`), not only in the parent app. Write an integration test that calls a settings route as a non-admin authenticated user and asserts 403. Do not rely on the parent app's middleware order for sub-app security.
**Warning signs:**
- Network tab shows calendar API responses served from ServiceWorker cache
- Events created elsewhere don't appear after page reload
- Any authenticated user can reach `/api/admin/...` routes without an admin check in the response.
- The admin middleware is defined in `index.ts` but the admin routes are in a separate `adminRouter` with no internal middleware.
**Phase to address:**
PWA service worker configuration phase. Cache strategy per route must be intentional from the start.
Admin Settings phase. Integration test for 403 on non-admin access is the acceptance criterion.
---
### Pitfall 13: Service Worker Update Staleness — App Never Updates for the Wife
### Pitfall 10: App Password and VAPID Keys Stored in DB When They Must Stay in Env
**What goes wrong:**
Safari on iOS respects the HTTP cache for service worker script fetching. If the server sends `Cache-Control: max-age=3600` for the SW script, Safari will not check for updates for an hour. For an installed PWA that the wife only opens occasionally, she can run a version that is days old. Breaking API changes in the backend will cause silent failures.
The setup wizard collects VAPID keypair and validates the Fastmail app password. A tempting shortcut: store the VAPID keys in the `settings` DB table for easy retrieval later. The problem: `VAPID_PRIVATE_KEY` is a signing key — equivalent to a private TLS key. Storing it in the DB means it is:
- Accessible to anyone with DB read access (including `SELECT *` from a misconfigured tool or a Drizzle Studio session left open).
- Included in DB backups, which may be stored less securely.
- Returned by any accidental DB dump to logs.
`APP_PASSWORD_ENCRYPTION_KEY` must never enter the DB at all — it is the key that encrypts everything else. If the wizard stores it in the DB "just for display/verification," the entire encryption model is broken.
**Why it happens:**
Many web servers (Nginx defaults) cache JS assets aggressively. The service worker file (`sw.js`) must be served with `Cache-Control: no-cache` or `max-age=0` specifically to ensure the browser checks for updates on each visit.
The wizard naturally wants to show "current configuration" and make it editable. Pulling values from env vars in a form feels awkward; storing in DB feels clean. The distinction between "secret that must stay in env" and "config that can live in DB" gets blurred.
**How to avoid:**
- Serve the service worker file with `Cache-Control: no-cache` header explicitly
- Serve the web app manifest with `Cache-Control: no-cache`
- Use Vite's default hashed filenames for all other assets (already correct)
- Implement a "new version available" in-app prompt when the SW detects an update (`waiting` state), so the wife knows to tap "refresh"
Hard rule: `VAPID_PRIVATE_KEY` and `APP_PASSWORD_ENCRYPTION_KEY` never touch the DB. They are validated in the wizard by attempting an operation (test encrypt/decrypt, test push send), not by reading or writing their values. `VAPID_PUBLIC_KEY` and `VAPID_SUBJECT` can be stored in DB (they are not secrets). Fastmail app passwords are stored encrypted (AES-256-GCM via `encryptPassword`), which is already implemented.
The wizard's "check env" validation path: call `encryptPassword('test')` — if it throws, `APP_PASSWORD_ENCRYPTION_KEY` is missing or malformed. Call `webpush.setVapidDetails(...)` and catch throws. Never read the key values out of `process.env` into a response body.
**Warning signs:**
- Deployed backend changes not reflected in the app for hours or days
- Console shows `ServiceWorker: new service worker found, not yet activated`
- DB schema has a `vapid_private_key` column.
- Any API response that includes `VAPID_PRIVATE_KEY` or `APP_PASSWORD_ENCRYPTION_KEY` values.
- Wizard stores all config to DB and reads it back on next startup instead of requiring env vars.
**Phase to address:**
PWA build configuration phase. Set the no-cache header in Docker/Nginx config before first deployment.
Setup wizard phase. Schema design review before migration is written. Secret-in-DB is a hard blocker for the phase gate.
---
### Pitfall 14: Calendar Cache Stale vs Fastmail Source of Truth — Double-Write Window
### Pitfall 11: Gitea Actions MariaDB Service Container Readiness Race
**What goes wrong:**
The app caches Fastmail calendar data in MariaDB (or Redis) for performance. A user creates an event via the app (write to Fastmail, update local cache). The primary user simultaneously creates the same-time event from the native Fastmail app. The next poll catches the conflict, but between the write and the poll, the app's cache is wrong. If the poll interval is 5 minutes, the family sees conflicting events for up to 5 minutes.
A worse failure: the write to Fastmail succeeds but the cache update fails (network error mid-transaction). Now the cache is permanently wrong until the next full resync.
**How to avoid:**
- Treat the cache as write-through: invalidate the relevant calendar's cache entry immediately on any write, forcing the next read to pull from Fastmail
- After a write (PUT/POST to Fastmail), always re-fetch the created/updated object to get the server-assigned ETag and any server-side modifications
- Never update the cache with the client's version of the object — only cache objects received from the server
- For v1, a 60-second poll interval is acceptable. Do not optimize this prematurely.
**Warning signs:**
- Event created in the app doesn't appear on the next refresh
- Duplicate events visible for a short window
- ETag mismatch errors on the second consecutive edit of the same event
**Phase to address:**
Calendar broker / cache design phase. Write-through invalidation must be in the cache design, not patched in later.
---
### Pitfall 15: Real-Time List Sync — Missed Updates During Reconnect Gap
**What goes wrong:**
The client connects via WebSocket (or SSE). The connection drops (mobile network switch, brief outage). On reconnect, the client re-subscribes but has missed events that fired during the gap. The list appears consistent to both users but is actually diverged — one user's added item is missing from the other's view.
Gitea Actions (like GitHub Actions) supports `services:` containers. The MariaDB service starts, but the container reaching `healthy` in Docker does not mean MariaDB is accepting connections on port 3306. `mysqld` takes several seconds to initialize after the container starts. If the CI job proceeds to `drizzle-kit migrate` or integration test commands immediately after the service health check passes, it races with MariaDB initialization and fails with `ECONNREFUSED` or `Access denied` errors that look like test failures but are actually timing issues.
**Why it happens:**
The reconnect handler re-subscribes from "now" rather than replaying from a sequence number or version cursor.
The Docker `HEALTHCHECK` for MariaDB using `mysqladmin ping` returns true as soon as the network socket is open, which happens before all privilege tables are initialized. The `healthcheck.interval` in the Gitea service definition controls how often the check runs, but the first check may pass before MariaDB has fully bootstrapped.
**How to avoid:**
- Each list mutation must increment a monotonic version on the row (`updated_at` with microsecond precision is insufficient — use an explicit integer sequence per list)
- On (re)connect, the client sends its last-known sequence; the server replays any mutations with sequence > client's last-known
- Implement exponential backoff with jitter on reconnect (500ms base, 2x multiplier, 30s cap)
- Redis Pub/Sub is appropriate here; if Redis is unavailable, fall back to polling every 5s
Add a `wait-for-it` or `until mysqladmin ping --silent; do sleep 1; done` step in the CI workflow after the service is declared healthy, before running any DB command. Or use a longer `healthcheck.start_period` in the service definition (e.g., 30 seconds). Also: set `MARIADB_ROOT_PASSWORD`, `MARIADB_DATABASE`, `MARIADB_USER`, `MARIADB_PASSWORD` in the service env and use those same credentials in the integration test step — do not assume the root user is reachable from the test runner without a password.
**Warning signs:**
- Items added during a reconnect gap missing from one client's view
- List state inconsistent between the two household members
- CI passes on re-run but fails on first run of a PR (timing-dependent).
- `ECONNREFUSED` or `Error: connect ECONNREFUSED 127.0.0.1:3306` in CI logs.
- Tests that pass locally with a warm MariaDB fail in CI cold-start.
**Phase to address:**
Shared lists implementation phase. The sequence number column must be in the initial schema.
Gitea CI phase. The readiness wait must be in the first draft of the workflow YAML; do not add it after the first CI failures.
---
### Pitfall 16: Authelia OIDC — v4.39+ Breaking Change Drops `groups` from ID Token
### Pitfall 12: Gitea Actions Self-Hosted Runner Missing Node 22 or pnpm
**What goes wrong:**
Authelia v4.39 introduced a breaking change: the `groups` claim is no longer included in the ID token by default — it moved to the userinfo endpoint. If the backend validates authorization based on the `groups` claim in the ID token (a common pattern when following older Authelia docs), group-based access control silently stops working after an Authelia upgrade.
The self-hosted Gitea Actions runner on Unraid may have an older Node.js version globally available, or may have no `pnpm` installation, or may have a `corepack`-managed pnpm that requires activation. If the CI workflow assumes the runner environment matches the dev machine, `pnpm install` fails with `pnpm: command not found`, or `node --version` returns 18 instead of 22.
A related issue: the workflow may use `actions/setup-node` (a GitHub Actions action) which is not available in Gitea Actions, or uses a Gitea-specific variant that requires different configuration.
**Why it happens:**
Gitea Actions is not GitHub Actions. Many popular actions (`actions/checkout`, `actions/setup-node`, `actions/cache`) have Gitea-compatible alternatives, but their names and behavior differ subtly. If the workflow is copied from a GitHub Actions template, some steps silently fail or are skipped.
**How to avoid:**
- Request the `groups` scope explicitly in the OIDC client config
- Validate group membership by calling the userinfo endpoint, not by reading ID token claims
- Or: use Authelia purely for authentication (who are you), not authorization (what can you do) — in a two-person household, all authenticated users are trusted, so groups are irrelevant for FamilySync
In the first CI plan, write a minimal "hello world" workflow that only checks `node --version` and `pnpm --version`. Verify it passes before adding any test steps. Use `actions/setup-node` only if confirmed compatible with the specific Gitea version; otherwise install Node and pnpm explicitly in the workflow using `wget` / `npm install -g pnpm`. Pin the Node version to `22.x` explicitly; do not rely on the runner default.
For Docker image build/publish: verify the runner has Docker daemon access. On Unraid self-hosted runners, Docker may require `--privileged` or specific socket mounts that need runner configuration.
**Warning signs:**
- Group-based middleware that worked before an Authelia upgrade stops denying unauthorized users
- `groups` claim missing from decoded ID token
- `pnpm: command not found` in CI output.
- `node` resolves to a version older than 22 in CI but not locally.
- `actions/setup-node` step shows as skipped or errored in the Gitea Actions UI.
**Phase to address:**
Auth integration phase. Decide whether group claims are needed at all; if not, skip them entirely.
Gitea CI phase. The runner environment probe must be the first CI task — before any test or build steps are designed.
---
### Pitfall 17: Authelia OIDC — SPA Token Silent Renewal Breaks When Authelia Is on a Separate Domain
### Pitfall 13: Docker Registry Push Token Scope Exposes Secrets in Logs
**What goes wrong:**
The standard OIDC silent renewal technique for SPAs uses a hidden iframe that loads Authelia's authorization endpoint. If the browser blocks third-party cookies (Safari ITP does this aggressively), the iframe cannot send Authelia's session cookie, so the silent renewal returns an error and the user is redirected to the login page unexpectedly — often in a loop.
The Gitea CI Docker build/publish step requires credentials for the Docker registry (Docker Hub, Gitea's own container registry, or a self-hosted registry). If the registry token is passed as a `docker login` argument on the command line (e.g., `docker login -u $USER -p $TOKEN`), the token appears in the process list, in the Gitea Actions job log (if command echo is on), and in any runner audit logs. Gitea Actions supports `secrets:` but if the workflow uses `run: docker login -p ${{ secrets.REGISTRY_TOKEN }}`, the secret is masked in the log only if the secret was registered correctly — unregistered secrets are echoed verbatim.
**Why it matters here:**
FamilySync and Authelia may be on different subdomains (e.g., `familysync.home.domain.com` vs `auth.home.domain.com`). If they share the same registrable domain suffix (e.g., both `.home.domain.com`), same-site cookies work. If not, ITP kills the iframe flow.
**Why it happens:**
Docker CLI login via `-p` flag is the most common example in docs. GitHub Actions masks secrets automatically; Gitea Actions masks them only for registered secrets. A token from a CI environment variable that was not added through the Gitea Secrets UI is not masked.
**How to avoid:**
- Ensure FamilySync and Authelia share the same parent domain so Authelia cookies are same-site from the browser's perspective
- Use refresh token rotation instead of iframe-based silent renewal (Authelia supports this)
- Configure the OIDC client in the backend (not the SPA) to hold the refresh token — the SPA calls the backend, which silently renews via the confidential client flow, and returns a new access token without iframe involvement
- Never rely on iframe silent renewal for installed PWAs — service workers intercept the iframe navigation and it behaves unpredictably
Use `docker login --password-stdin` with the token piped via stdin rather than a command-line argument: `echo "${{ secrets.REGISTRY_TOKEN }}" | docker login -u "${{ secrets.REGISTRY_USER }}" --password-stdin registry.example.com`. Register all credentials as Gitea repository secrets, not as environment variables in the workflow YAML. Verify the Gitea version supports secret masking in the Actions log (Gitea ≥ 1.19 for Actions support; secret masking behavior varies by version).
**Warning signs:**
- Users randomly logged out after token expiry with no warning
- Infinite redirect loop between the app and Authelia login page
- Console errors: `Failed to load resource: Frame load interrupted`
- Registry token or password visible as plaintext in the Gitea Actions job log.
- `docker login` command line includes `-p <token>` in the log output.
- `secrets.REGISTRY_TOKEN` is undefined in the workflow (token was set as env var, not secret).
**Phase to address:**
Auth integration phase. The token refresh strategy must be decided before the frontend auth client is chosen.
Gitea CI phase. Credential handling review before any Docker push step is added.
---
### Pitfall 18: Pangolin/Newt Tunnel — WebSocket and SSE May Require Explicit Configuration
### Pitfall 14: Playwright Authed-Mobile Harness Reusing a Stale storage-state
**What goes wrong:**
Pangolin is a tunneled reverse proxy. WebSocket connections require an HTTP Upgrade handshake, and SSE (Server-Sent Events) requires long-lived HTTP connections. A generic proxy configuration that works for standard HTTP requests may silently drop WebSocket connections or close SSE streams after a timeout.
The mobile-emulated Playwright harness uses a saved `storage-state.json` (cookies + localStorage) to bypass the OIDC login flow. If the storage state was captured with a real Authelia session, it contains a session cookie with a finite TTL (typically 1 hour for `@hono/oidc-auth` JWT cookies, or the Authelia session lifetime). After the TTL expires, all Playwright runs with the stale storage state silently fail at the first `/api/*` call — the OIDC middleware redirects to Authelia, and the test gets an HTML login page instead of the expected JSON API response. The test may still pass if it only checks DOM content (which may be the redirected page's HTML), or it may produce a false-positive assertion on the 302 redirect.
A known GitHub issue (#1034 on the fosrl/pangolin repo) documents exactly this: HTTP loads fine through Pangolin but WebSocket connections to `wss://` fail.
**Why it happens:**
Playwright storage state is file-based and not automatically refreshed. Developers capture it once and check it in (or store it locally), then forget to renew it. In `DEV_AUTH_BYPASS=true` mode this does not apply (no session cookie needed), but if the harness is meant to test the production auth path, the bypass is not active and the session cookie must be valid.
**How to avoid:**
- Verify WebSocket pass-through in a dedicated infrastructure spike before building any real-time feature
- Confirm Pangolin's timeout settings for long-lived connections and extend them appropriately
- If WebSocket through Pangolin proves unreliable, use SSE (unidirectional, standard HTTP, more proxy-friendly) for server-to-client push and short-poll for client confirmations
- Test the full round-trip (WebSocket from an iPhone over Pangolin) before considering real-time sync "done"
Do not use a static stored storage state for tests that run against the production OIDC path. Instead, implement a programmatic login helper that runs the OIDC authorization code flow at the start of each test session (or once per test run) and stores the resulting session. For the `DEV_AUTH_BYPASS` dev environment, the harness sets `DEV_AUTH_BYPASS=true` and skips the storage state entirely. The mobile viewport emulation does not require real OIDC — use `DEV_AUTH_BYPASS` for the automated harness; keep real OIDC tests as manual/human gates.
**Warning signs:**
- WebSocket connects locally but fails in production (public URL)
- SSE stream closes after 60 seconds with no activity
- Real-time updates work on Android (same network) but not iPhone (over tunnel)
- Playwright runs fail with `Expected 200 OK but got 302 Found` after leaving the storage state untouched for more than one day.
- Tests that exercise `/api/*` routes return HTML (the Authelia login page) instead of JSON.
- The same test suite passes reliably in `DEV_AUTH_BYPASS=true` mode but fails intermittently in production-auth mode.
**Phase to address:**
Infrastructure spike phase, before real-time list sync is implemented.
Mobile-browser testing phase. The storage state strategy must be decided before the first test is written — programmatic refresh or bypass-only.
---
### Pitfall 15: Production Service Worker Intercepting Playwright Requests
**What goes wrong:**
The installed Vite PWA service worker (`sw.js`) is registered in the browser when the PWA is visited. Playwright's Chromium instance can load and activate the service worker from a previous test run (persisted in the browser's profile directory). On subsequent test runs, the service worker intercepts API calls — potentially returning cached responses from the previous run rather than making network requests to the test server. This causes:
- API requests returning stale 200 responses when the test server is not running.
- `queryClient.invalidateQueries` not triggering new network requests (SW returns cached response).
- Tests that verify freshly-created data returning old data.
**Why it happens:**
Workbox's cache-first strategy for static assets and stale-while-revalidate for API routes persist across browser sessions in the Playwright profile. A new Playwright context does not clear the service worker registration unless explicitly reset.
**How to avoid:**
Use `browserContext.clearCookies()` and `browserContext.clearPermissions()` in the test setup, but also explicitly unregister service workers: `await page.evaluate(() => navigator.serviceWorker.getRegistrations().then(r => Promise.all(r.map(sw => sw.unregister()))))` before any navigation. Or launch Playwright with `serviceWorkers: 'block'` in the context options, which prevents the SW from intercepting requests entirely. For tests that specifically test offline/SW behavior, use a separate context without the block.
**Warning signs:**
- Network tab in Playwright traces shows `(ServiceWorker)` as the response source.
- Tests pass on a clean browser profile but fail on a profile that has visited the PWA before.
- API requests complete instantly with stale data in the Playwright trace.
**Phase to address:**
Mobile-browser testing phase. The Playwright context setup must explicitly handle service worker state before the first test is written.
---
@@ -407,14 +400,13 @@ Infrastructure spike phase, before real-time list sync is implemented.
| Shortcut | Immediate Benefit | Long-term Cost | When Acceptable |
|----------|-------------------|----------------|-----------------|
| Full PROPFIND poll on every sync interval instead of WebDAV-Sync | Simpler code | Bandwidth waste; shows up immediately at any real-world poll frequency | Never — implement sync-token from day one |
| Client-side RRULE expansion instead of server CALDAV:expand | Avoids REPORT query complexity | DST and override bugs that are very hard to diagnose | Never for authoritative display; OK for UI-only preview |
| Storing all-day events as DATETIME in MariaDB | Avoids DATE type handling | Timezone shift bugs on display | Never |
| Skip `If-Match` ETag on write-back | Simpler write path | Silent data loss on concurrent edits | Acceptable for v1 only if single-writer constraint is documented; still risky |
| iframe silent token renewal instead of refresh token rotation | Less backend work | Breaks on iOS Safari ITP; causes random logouts | Never for this stack |
| Cache-First strategy for API responses | Fast perceived performance | Stale calendar/list data shown as current | Never for data routes |
| Notification payload requiring server fetch before display | Richer notifications | Violates `userVisibleOnly`; kills iOS subscription after 3 events | Never |
| Single-instance recurring event override deferred | Significantly simpler write logic | Users frustrated when editing "just this one" is impossible | Acceptable for v1 — document clearly |
| Building VALARM on top of `buildVeventString` without the preserve-on-edit path | Faster to implement | Strips native-client alarms on every edit; user data loss | Never — preserve path must ship with VALARM authoring |
| uid-only dedup key in sentReminders when lead times become variable | No migration needed | Duplicate pushes or missed re-fires after reschedule | Never for production; acceptable in tests with a fixed lead |
| Calling `runOutboxDrain()` directly from event trigger instead of setting a flag | Simpler code | Bypasses `isDraining` atomicity, potential double-drain | Never — always funnel through the single setInterval-controlled path |
| Setup wizard that accepts `APP_PASSWORD_ENCRYPTION_KEY` via the API | Simpler UX for initial setup | Entire encryption model is broken | Never — key stays in env/secrets only |
| Static storage-state.json checked into the repo | Zero-effort Playwright auth | Tests fail silently after TTY expiry; potential credential leak | Never — programmatic refresh or DEV_AUTH_BYPASS only |
| `docker login -p $TOKEN` in CI command | Quick to write | Token appears in CI logs if secret not masked | Never — always use --password-stdin |
| No readiness wait for MariaDB service in CI | Simpler YAML | Flaky CI: timing-dependent ECONNREFUSED failures | Never — readiness wait is 3 lines and prevents ghost failures |
---
@@ -422,29 +414,17 @@ Infrastructure spike phase, before real-time list sync is implemented.
| Integration | Common Mistake | Correct Approach |
|-------------|----------------|------------------|
| Fastmail CalDAV | Using bare `caldav.fastmail.com` as base URL | Use `https://caldav.fastmail.com/dav/principals/user/{email}/` |
| Fastmail CalDAV | Assuming JMAP supports calendars | CalDAV only; JMAP calendar spec not yet production at Fastmail |
| Fastmail CalDAV | Assuming broker token sees all members' personal calendars | Explicit share+accept required per calendar per account |
| CalDAV ETag | Assuming ETag is always returned after PUT | Sometimes absent; always re-fetch after write |
| CalDAV sync-token | Not handling 403 on expired token | Fall back to full PROPFIND resync when token is rejected |
| CalDAV recurring events | Writing RECURRENCE-ID override as a new resource | Must be an additional VEVENT in the same VCALENDAR resource |
| iOS Web Push | Calling `requestPermission()` outside a click handler | Permission silently denied; must be in direct user gesture handler |
| iOS Web Push | Not wrapping `showNotification()` in `event.waitUntil()` | 3 silent pushes = permanent subscription revocation |
| iOS Web Push | Not handling 410 Gone from push service | Dead subscriptions accumulate; subscription table grows without cleanup |
| Authelia OIDC v4.39+ | Reading `groups` from ID token | Fetch from userinfo endpoint or skip groups entirely |
| Authelia OIDC | iframe silent renewal with ITP | Use refresh token rotation via confidential backend client |
| Pangolin tunnel | Assuming HTTP proxy config handles WebSocket | Requires explicit WS upgrade passthrough and timeout config |
---
## Performance Traps
| Trap | Symptoms | Prevention | When It Breaks |
|------|----------|------------|----------------|
| Full calendar PROPFIND on every poll | High Fastmail API usage; slow sync | Use WebDAV-Sync (sync-token) for delta fetches | From first deployment |
| Expanding RRULE in-process for a 2-year window | CPU spike on backend; slow calendar load | Limit expansion to visible time window via CALDAV:expand | Any calendar with >30 recurring events |
| Fetching all VCALENDAR objects to find changed ones | N+1 CalDAV requests per sync | Use PROPFIND to get ETags first; only GET objects with changed ETags | Immediately on any non-trivial calendar |
| WebSocket broadcast to all clients on every list mutation | Unnecessary events to clients not viewing that list | Filter broadcasts by list membership/subscription | Once any second device is connected |
| ical.js VALARM | Using `addPropertyWithValue('trigger', '-PT15M')` (string) | Build `ICAL.Duration.fromSeconds(-N*60)` and use the Duration object as the property value |
| ical.js VALARM | Not round-tripping preserved VALARMs through ical.js parse→serialize | Parse the sub-component from rawVevent and re-add via ical.js API; do not insert raw text |
| outboxWorker VALARM | Rebuilding VEVENT from scratch drops native-client VALARMs on update | Extend buildVeventString to accept a `valarms` parameter; populate from rawVevent extract on update path |
| reminderScheduler dedup | uid-only Map key breaks when per-event leads vary | Key on `uid + ':' + dtstartMs`; prune by dtstartMs |
| event-driven drain | Calling `runOutboxDrain()` from pub/sub subscriber before `isDraining` is set | Use a single drain path via `drainRequested` flag checked in the setInterval callback |
| Gitea Actions | Using GitHub Actions-specific action IDs | Probe the runner first; use Gitea-compatible alternatives or install tools explicitly |
| Gitea Actions MariaDB | Relying on container health == connection ready | Add explicit `mysqladmin ping` retry loop after healthcheck passes |
| Playwright mobile harness | Static storage-state.json with expiring session cookie | Use `DEV_AUTH_BYPASS=true` for automated harness; programmatic OIDC login for real-auth tests |
| Playwright + Vite PWA | Service worker from previous run intercepting requests | Set `serviceWorkers: 'block'` or unregister SWs explicitly in test context setup |
| Setup wizard | Accepting `APP_PASSWORD_ENCRYPTION_KEY` via the POST body | Validate the env is present by performing a test operation; never accept the key value over the network |
| Admin settings route | Zod error passthrough leaking app-password input | Custom `hook` in zod-validator: return generic 400, never the Zod error object |
---
@@ -452,39 +432,26 @@ Infrastructure spike phase, before real-time list sync is implemented.
| Mistake | Risk | Prevention |
|---------|------|------------|
| Storing Fastmail API token in client-accessible storage | Token leak → full Fastmail account calendar access | Token lives only in backend env vars; never exposed to frontend |
| Forwarding raw VCALENDAR to the frontend | iCalendar payloads can contain injected properties | Parse and reconstruct a safe JSON event object server-side |
| Not validating OIDC `aud` claim | Any Authelia client can impersonate FamilySync users | Verify `aud` matches your registered client ID on every token validation |
| Push subscription endpoint stored unencrypted with PII | Subscription URLs are tied to device identity | Store encrypted; treat as sensitive PII; delete on logout |
| Trusting `display-mode: standalone` for access control | Cannot be relied on for security | Standalone check is UX-only; all access control at API layer via token |
---
## UX Pitfalls
| Pitfall | User Impact | Better Approach |
|---------|-------------|-----------------|
| No iOS install guide in the app | Wife never installs PWA; never gets push notifications | Show install CTA with Safari-specific screenshots on first browser visit |
| Push permission requested on first page load | iOS silently denies; permission cannot be re-requested | Request permission only after explaining why, inside a tapped button |
| "Notification" as the only feedback for list changes | Missed if phone silent; no in-app indicator | Show badge/dot on list items changed since last viewed; push is secondary |
| Calendar event edit with no conflict warning | Overwrites event changed in native Fastmail app | Show "this event was modified elsewhere" warning on 412; offer overwrite or reload |
| No offline message | App appears broken when offline | Show "offline — viewing cached data" banner; list edits queue for sync |
| Recurring event edit options not explained | User edits "this event" not knowing it changes all future events | For v1, edit-all only with clear label "This changes all future events" |
| Admin route not protected inside adminRouter (only in parent app) | Any authenticated member can call admin endpoints | Apply guard middleware inside the sub-router, not only in the parent app mount |
| Setup endpoint lacks "already-set-up" guard | Post-setup endpoint rewrites credentials without auth | Check `member_credentials` existence + VAPID env on every setup route invocation; return 423 if already configured |
| VAPID_PRIVATE_KEY stored in DB | Private signing key accessible to DB-level access | VAPID private key in env/secrets only; DB stores public key and subject only |
| App-password in Zod error response | Plaintext credential in HTTP response and server logs | Custom Zod hook for all routes that accept credential input |
| `docker login -p` in CI YAML | Registry token in CI logs | `--password-stdin` only; token as Gitea secret, not YAML env var |
---
## "Looks Done But Isn't" Checklist
- [ ] **Calendar sync:** Often missing ETag/sync-token handling — verify the poller uses delta sync, not full PROPFIND, on the second and subsequent runs
- [ ] **All-day events:** Often missing allDay flag — verify a June 5 all-day event appears on June 5 in UTC-5 timezone, not June 4
- [ ] **Recurring events:** Often missing DST boundary test — verify a weekly recurring event set in July still shows at the correct hour in November
- [ ] **iOS push:** Often missing subscription health-check — verify a fresh subscription is created after ITP clears the service worker (simulate by clearing site data in Safari settings)
- [ ] **iOS push:** Often missing `event.waitUntil()` — verify in Safari DevTools that push events show as resolved, not terminated
- [ ] **Personal calendar sharing:** Often assumed to be automatic — verify broker token PROPFIND actually returns the wife's personal calendar URL
- [ ] **Write-back:** Often missing 412 handling — verify the app does not silently drop an edit when the event was modified concurrently in the native app
- [ ] **Service worker update:** Often ships with default asset caching headers — verify `sw.js` is served with `Cache-Control: no-cache`
- [ ] **Auth token renewal:** Often untested at expiry — verify the app does not redirect to login when the access token expires mid-session
- [ ] **Pangolin WebSocket:** Often only tested on LAN — verify WebSocket or SSE works end-to-end over the Pangolin public URL before shipping real-time sync
- [ ] **VALARM authoring:** Often ships create-only — verify that editing an event with a native-client alarm in rawVevent does not drop that alarm from the PUT payload.
- [ ] **VALARM serialization:** Often emits `VALUE=TEXT` — verify the ICS output has `TRIGGER:-PT15M` (DURATION type, no VALUE parameter) or `TRIGGER;VALUE=DURATION:-PT15M` — never `VALUE=TEXT`.
- [ ] **All-day reminders:** Often enabled in the UI for all-day events — verify the reminder selector is disabled or hidden when `allDay: true`.
- [ ] **Variable-lead dedup:** Often keeps the uid-only key — verify the dedup map key is updated to include dtstart so a rescheduled event fires again.
- [ ] **Event-driven drain:** Often calls `runOutboxDrain()` directly — verify the trigger path sets `drainRequested` or calls through the same error-caught wrapper as the setInterval path.
- [ ] **Setup wizard "already-set-up" guard:** Often untested — verify a second POST to any setup endpoint after initial setup returns 423, not 200.
- [ ] **Admin route 403:** Often not tested — verify a non-admin authenticated user gets 403 from admin routes, not 200 or 404.
- [ ] **VAPID key in DB:** Often slips in as "config" — verify the DB schema has no column for `vapid_private_key` or `app_password_encryption_key`.
- [ ] **Gitea CI MariaDB readiness:** Often assumed — verify CI logs show the mysqladmin ping retry loop completing, not the job proceeding immediately after service declared healthy.
- [ ] **Playwright storage state expiry:** Often passes on day one — verify tests still pass 25 hours after the storage state was captured (session cookie expired).
---
@@ -492,15 +459,14 @@ Infrastructure spike phase, before real-time list sync is implemented.
| Pitfall | Recovery Cost | Recovery Steps |
|---------|---------------|----------------|
| JMAP calendar assumption baked in | HIGH | Rewrite broker protocol layer; API contract may change |
| All-day event stored as DATETIME | HIGH | Migration required; all calendar cache invalid; must resync |
| No ETag/sync-token on poller | MEDIUM | Swap polling logic; no data loss, but adds a sprint |
| Push subscription table full of dead iOS endpoints | LOW | Run cleanup job: send to each endpoint, delete 410/404 responses |
| Personal calendars not shared to broker | LOW | Manual Fastmail share + accept; no code change |
| VTIMEZONE DST bug in emitted iCalendar | MEDIUM | Swap tz library; clear Fastmail events and re-sync |
| Service worker cache serving stale data | LOW | Add route-specific Workbox config; redeploy |
| Authelia groups claim removed after upgrade | LOW | Update claim source to userinfo endpoint; or remove group checks |
| Pangolin WebSocket drops | MEDIUM | Switch real-time transport from WS to SSE; no data model change |
| VALARM strips native alarms on edit | MEDIUM | Add valarms preserve path to buildVeventString + outboxWorker update branch; no migration needed; existing rawVevent data is authoritative |
| TRIGGER VALUE=TEXT bug | LOW | Fix Duration construction in buildVeventString; no data migration (rawVevent already has correct alarms from server) |
| uid-only dedup causing duplicate push | LOW | Change Map key to uid:dtstartMs; restart clears in-memory state; no DB change |
| Double-drain from concurrent triggers | MEDIUM | Refactor event-driven trigger to drainRequested flag; requires load testing to confirm no more duplicate PUTs |
| Admin route bypassed (no inner guard) | LOW | Add `.use('*', adminGuard)` inside adminRouter; deploy |
| VAPID private key in DB | HIGH | Rotate VAPID keypair; clear all push subscriptions (all devices must re-subscribe); remove DB column via migration |
| CI flaky MariaDB race | LOW | Add readiness wait loop to workflow YAML; re-run |
| Playwright storage state stale | LOW | Switch to DEV_AUTH_BYPASS mode for automated tests; remove static state file |
---
@@ -508,51 +474,35 @@ Infrastructure spike phase, before real-time list sync is implemented.
| Pitfall | Prevention Phase | Verification |
|---------|------------------|--------------|
| JMAP calendar not available | Calendar broker spike (Phase 1) | Confirm CalDAV endpoint returns events before any further work |
| RRULE client-side expansion | Calendar fetch design (Phase 1) | REPORT with CALDAV:expand in the first integration test |
| All-day event DATE vs DATETIME | Data model design (Phase 1) | Schema review; allDay field present; timezone test |
| ETag / sync-token incremental sync | Calendar sync engine (Phase 1-2) | Second poll must use sync-token; verify in network logs |
| Write-back RECURRENCE-ID corruption | Event edit UI (Phase 2-3) | Integration test: edit instance, verify series unchanged |
| DST timezone shift | Calendar write phase (Phase 2) | Dedicated DST fixture test before milestone sign-off |
| Personal calendar sharing | Infrastructure/deployment (Phase 1) | Manual proof-of-concept share before unified view is built |
| iOS install guide missing | PWA onboarding (Phase 2) | Test with non-technical user; do not consider done until wife installs |
| iOS push subscription killed (silent push) | Push implementation (Phase 2-3) | Verify `event.waitUntil()` pattern; test 3 consecutive pushes |
| Declarative Web Push payload design | Push design (Phase 2) | Payload schema review before server-side push sender is built |
| EU DMA restriction | Risk assessment (Phase 1) | One-time check of Apple ID regions; document conclusion |
| Service worker stale cache (data routes) | PWA SW config (Phase 2) | Network tab audit: API calls must not be served from cache |
| SW update staleness | PWA build config (Phase 2) | Verify `sw.js` has `Cache-Control: no-cache` in Nginx config |
| Calendar cache double-write | Cache design (Phase 1-2) | Write-through invalidation test: write → immediate re-fetch from Fastmail |
| List sync reconnect gap | Lists implementation (Phase 2) | Disconnect mid-edit test; verify item appears after reconnect |
| Authelia groups claim breaking change | Auth integration (Phase 1) | Decode ID token; confirm no groups dependency; document decision |
| Authelia SPA silent renewal | Auth integration (Phase 1) | Test token expiry behavior before frontend is considered done |
| Pangolin WebSocket passthrough | Infrastructure spike (Phase 1) | WebSocket smoke test over public URL before real-time feature is built |
| VALARM strips native alarms on edit | Per-event reminders (VALARM authoring) | Integration test: create event via native client with alarm, edit via FamilySync, verify PUT payload contains original VALARM |
| TRIGGER VALUE=TEXT serialization | Per-event reminders (VALARM authoring) | Unit test: serialize VALARM, parse back, assert no VALUE=TEXT |
| All-day event VALARM silently no-ops | Per-event reminders (VALARM authoring) | UI test: all-day event form has no reminder field or field is disabled |
| Variable-lead dedup produces duplicate push | Per-event reminders (scheduler generalization) | Unit test: fire reminder, reschedule event earlier, fire again — assert two pushes sent |
| Double-drain from concurrent event-driven trigger | Event-driven outbox drain | Load test: enqueue 10 rows rapidly, assert each CalDAV PUT issued exactly once |
| Event-driven drain breaks create-before-delete | Event-driven outbox drain | Integration test: edit-as-move under rapid enqueue; original event not deleted before new one created |
| Admin app-password echoed in error | Admin Settings | Unit test: POST invalid password to settings route; assert response has no credential value |
| Unauthenticated setup endpoint stays live | Setup wizard | Integration test: POST to setup endpoint after first-run completes; assert 423 |
| Admin role check missing inside sub-router | Admin Settings | Integration test: non-admin authenticated user hits admin route; assert 403 |
| VAPID key stored in DB | Setup wizard | Schema review before migration is written; CI lint check for column names containing `private_key` |
| Gitea CI MariaDB readiness race | Gitea CI | CI log audit: readiness loop appears before any `drizzle-kit migrate` invocation |
| Gitea runner missing Node 22 / pnpm | Gitea CI | First CI job: node/pnpm version probe step before any install or test |
| Docker registry token in CI logs | Gitea CI | CI log audit: no plaintext token visible; all registry credentials use --password-stdin |
| Playwright storage state stale | Mobile-browser testing | Test suite passes on day 2 without recapturing storage state (DEV_AUTH_BYPASS mode eliminates TTL) |
| Production service worker intercepts Playwright | Mobile-browser testing | Playwright context uses `serviceWorkers: 'block'`; verified in trace that no responses are SW-sourced |
---
## Sources
- [Fastmail API Documentation](https://www.fastmail.com/dev/) — confirms CalDAV only for calendars; JMAP calendar pending
- [Fastmail CalDAV URL discovery](https://utf9k.net/blog/fastmail-caldav/) — principal URL format requirement
- [Fastmail shared calendar improvements](https://www.fastmail.com/blog/shared-calendar-improvements/) — ACL sharing model, secretary mode
- [Sabre/DAV: Building a CalDAV client](https://sabre.io/dav/building-a-caldav-client/) — ETag, sync-token, UID, VTIMEZONE pitfalls
- [RFC 5545 RRULE](https://icalendar.org/iCalendar-RFC-5545/3-8-5-3-recurrence-rule.html) — recurrence set construction
- [RFC 5545 EXDATE](https://icalendar.org/iCalendar-RFC-5545/3-8-5-1-exception-date-times.html) — exception date handling
- [CalDAV ETag not updating after event edit — Google Issue Tracker](https://issuetracker.google.com/issues/153145152) — real-world ETag inconsistency
- [Home Assistant: CalDAV all-day events UTC issue](https://github.com/home-assistant/core/issues/25814) — DATE vs DATETIME bug documented in production
- [Nextcloud: recurring event single move duplicates event](https://github.com/owncloud/core/issues/24591) — RECURRENCE-ID write-back failure
- [DST CalDAV shift bugs (khal)](https://github.com/pimutils/khal/issues/755) — VTIMEZONE DST implementation errors
- [PWA Push Notifications on iOS 2026 (WebsCraft)](https://webscraft.org/blog/pwa-pushspovischennya-na-ios-u-2026-scho-realno-pratsyuye?lang=en) — current iOS push state
- [MagicBell: PWA iOS Limitations 2026](https://www.magicbell.com/blog/pwa-ios-limitations-safari-support-complete-guide) — iOS 16.4+ requirement, EU DMA
- [iOS push subscriptions terminated after 3 notifications (DEV Community)](https://dev.to/progressier/how-to-fix-ios-push-subscriptions-being-terminated-after-3-notifications-39a7) — `event.waitUntil()` requirement
- [iOS Web Push device unregisters spontaneously (Firebase SDK issue)](https://github.com/firebase/firebase-js-sdk/issues/8010) — ITP-driven subscription loss
- [WebKit: Meet Declarative Web Push](https://webkit.org/blog/16535/meet-declarative-web-push/) — new WebKit push model, ITP resilience
- [Apple Developer Forums: Web Push on iOS](https://developer.apple.com/forums/thread/732594) — subscription expiry, `pushsubscriptionchange` not supported
- [Authelia OIDC Clients Configuration](https://www.authelia.com/configuration/identity-providers/openid-connect/clients/) — redirect URI, PKCE, groups claim
- [Authelia OIDC FAQ](https://www.authelia.com/integration/openid-connect/frequently-asked-questions/) — breaking changes including groups in ID token
- [OIDC SPA: Third-party cookies and session restoration](https://docs.oidc-spa.dev/resources/third-party-cookies-and-session-restoration) — iframe silent renewal + ITP
- [Pangolin WebSocket issue #1034](https://github.com/fosrl/pangolin/issues/1034) — WebSocket upgrade failure through tunnel
- [WebSocket reconnection guide (WebSocket.org)](https://websocket.org/guides/reconnection/) — state sync on reconnect, missed event gap
- Direct inspection of `apps/api/src/broker/outboxWorker.ts`, `reminderScheduler.ts`, `vevent.ts`, `crypto.ts`, `index.ts`, `db/schema.ts`
- `.planning/RETROSPECTIVE.md` — v1.0 lessons: node-cron skip, drizzle push destructive diff, VAPID truncation, tsc vs vitest divergence
- `CLAUDE.md` memory entries: `node-cron-skips-in-long-running-process.md`, `drizzle-mariadb-push-unsafe.md`, `authelia-idtoken-claims.md`
- RFC 5545 §3.8.6.3 — VALARM TRIGGER value types (DURATION vs DATE-TIME)
- RFC 5545 §3.3.10 — RRULE value type semantics
- ical.js source (`lib/ical/property.js`) — property value type inference for TRIGGER
- Gitea Actions documentation — services container healthcheck semantics, secret masking behavior
- Playwright docs — `browserContext.serviceWorkers`, `storageState`, context lifecycle
---
*Pitfalls research for: Fastmail-brokered family calendar + shared-list PWA with Web Push, self-hosted behind Authelia*
*Researched: 2026-06-03*
*Pitfalls research for: FamilySync v1.1 Operability & Polish*
*Researched: 2026-06-10*
+189 -4
View File
@@ -1,12 +1,171 @@
# Stack Research
**Domain:** Self-hosted family calendar + shared-lists PWA on Fastmail
**Researched:** 2026-06-03
**Researched:** 2026-06-03 (v1.0) / 2026-06-10 (v1.1 additions)
**Confidence:** MEDIUM-HIGH (calendar sharing cross-account caveat: LOW; rest HIGH)
---
## Recommended Stack
## v1.1 Stack Additions — Operability & Polish
This section covers ONLY what is new for v1.1. The rest of the file (below) documents the v1.0 stack, which is unchanged.
### What needs NO new dependency
| Feature | Existing tool that covers it | Why no addition needed |
|---------|------------------------------|------------------------|
| Per-event reminders (VALARM) | `ical.js` + `tsdav` + existing write path | VALARM is a VCALENDAR component; ical.js parses/emits it; tsdav handles the PUT. No new library. |
| Outbox drain event-driven wake | `ioredis` pub/sub (already in stack) | Publish a `caldav:drain` event on Redis after a write; outbox worker subscribes and drains immediately. Zero new deps. |
| Admin Settings UI (app passwords + shared calendar) | Existing Drizzle schema + AES-256-GCM crypto (already in `apps/api`) | Role-gated Hono routes + React form. Schema already has the tables. |
| Setup wizard — DB connectivity probe | `mysql2` (already in stack) | Attempt a `mysql2` connect with the env-supplied credentials; resolve/reject gives pass/fail. |
| Setup wizard — VAPID key validation | `web-push` + Node.js built-in `crypto` (already in stack) | `Buffer.from(key, 'base64url').length === 32` for the private key; `web-push.generateVAPIDKeys()` for a fresh keypair; no extra library. |
| Setup wizard — OIDC discovery probe | Node.js 22 built-in `fetch` | `fetch(issuer + '/.well-known/openid-configuration')` and check for `200` + `authorization_endpoint` field. Native fetch in Node 22; zero extra library. |
| Setup wizard — env-var presence checks | `zod` (already in stack) | A `z.object({...}).safeParse(process.env)` at startup is the entire validation. Already used for request body validation. |
### What IS new for v1.1
**Two additions only:** `@playwright/test` for the mobile test harness, and the Gitea Actions workflow files (YAML only — no new runtime dep).
---
### New: @playwright/test (dev dependency, apps/pwa)
**Purpose:** Mobile-viewport + device-emulation + authenticated test harness. The existing `playwright-cli` global binary is an interactive/agentic tool not designed for CI spec files — it does not expose `storageState` save/restore, device emulation presets (`devices['iPhone 15 Pro']`), or a programmatic config (`playwright.config.ts`) needed to run mobile tests on a self-hosted runner.
**Package:** `@playwright/test`
**Current version:** 1.60.0 (verified npm, June 2026)
**Install scope:** `devDependencies` in `apps/pwa` only (not the monorepo root; only the PWA workspace needs browser tests).
**Why this and not playwright-cli alone:**
- `playwright-cli` (the global binary) does not support `storageState` file save/restore — the mechanism required to inject an Authelia session into a test context without re-running the full OIDC redirect flow on every test run.
- `@playwright/test` provides `devices` registry (iPhone 15 Pro, Pixel 5, etc.) which sets `viewport`, `userAgent`, `isMobile`, `hasTouch` together as a named preset.
- `@playwright/test` is the only path to a `playwright.config.ts` that defines a `setup` project (do login once, write `storageState` to `.auth/user.json`) and a `mobile` project that consumes it — the pattern needed for an authenticated, mobile-emulated CI run against the DEV_AUTH_BYPASS entry point.
- `playwright-cli` and `@playwright/test` coexist: `playwright-cli` continues to be the interactive verification tool during development; `@playwright/test` is the CI spec runner.
**Authentication strategy for OIDC-gated PWA:**
Authelia cannot be bypassed in a normal CI environment. The approach is to use the existing `DEV_AUTH_BYPASS=true` env flag (already implemented in `apps/api`) which injects user 1's session without an OIDC redirect. The `setup` project navigates to the app with `DEV_AUTH_BYPASS` active, waits for the authenticated state, then calls `context.storageState({ path: '.auth/user.json' })`. All subsequent test projects set `storageState: '.auth/user.json'` in their `use` config. This avoids any need to mock Authelia or run a real OIDC provider in CI.
**Device presets to use:**
```typescript
// playwright.config.ts (apps/pwa)
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'mobile-safari',
use: { ...devices['iPhone 15 Pro'], storageState: '.auth/user.json' },
dependencies: ['setup'],
},
{
name: 'mobile-chrome',
use: { ...devices['Pixel 5'], storageState: '.auth/user.json' },
dependencies: ['setup'],
},
],
});
```
**Version compatibility:** `@playwright/test@1.60.0` — installs its own browser binaries. In CI (Gitea Actions), use `npx playwright install --with-deps chromium` in the workflow to install only Chromium (smallest footprint). The `catthehacker/ubuntu:act-latest` job container includes Node 20+ and system deps needed by Playwright.
**Do NOT install:** `playwright` (the library package) separately — `@playwright/test` bundles it. Do not install `@playwright/test` at the monorepo root; it belongs only in `apps/pwa`.
---
### New: Gitea Actions workflow files (.gitea/workflows/)
No new runtime npm packages. Workflow files are YAML only.
**Syntax compatibility:** Gitea Actions uses the same YAML syntax as GitHub Actions (`on:`, `jobs:`, `steps:`, `services:`, `uses:`). Workflow files live in `.gitea/workflows/` (not `.github/workflows/`). GitHub Actions actions (`actions/checkout@v4`, `docker/login-action@v3`, `docker/build-push-action@v5`) are usable directly; act_runner fetches them from their origin repos.
**Runner label:** The registered self-hosted runner should be labeled (e.g., `self-hosted` or `unraid`). Use `runs-on: self-hosted` in all job definitions. Do NOT use `runs-on: ubuntu-latest` — that label is only resolved by GitHub's hosted runners; a Gitea self-hosted runner with `ubuntu-latest` label works but needs explicit configuration.
**Job container image:** Use `container: image: catthehacker/ubuntu:act-latest` for jobs that need a rich Linux environment (lint/typecheck/test). This image is the standard act runner image: includes Node.js, npm, git, curl, and system libs for Playwright. For jobs that only need Docker CLI (image build/push), no `container` key is needed if the runner is in Docker socket-mount mode.
**MariaDB service container pattern:**
```yaml
jobs:
api-integration:
runs-on: self-hosted
container:
image: catthehacker/ubuntu:act-latest
services:
mariadb:
image: mariadb:11
env:
MARIADB_ROOT_PASSWORD: testroot
MARIADB_DATABASE: familysync_test
MARIADB_USER: familysync
MARIADB_PASSWORD: testpass
options: >-
--health-cmd="healthcheck.sh --connect --innodb_initialized"
--health-interval=10s
--health-timeout=5s
--health-retries=5
steps:
- uses: actions/checkout@v4
- run: npm ci
working-directory: apps/api
- run: npm run db:migrate
working-directory: apps/api
env:
DB_HOST: mariadb
DB_PORT: 3306
DB_NAME: familysync_test
DB_USER: familysync
DB_PASSWORD: testpass
- run: npm test
working-directory: apps/api
env:
DB_HOST: mariadb
```
**Critical note on MariaDB 11 health check:** MariaDB 11.x Docker images removed the `mysqladmin` binary. The health check must use `healthcheck.sh --connect --innodb_initialized` (the script ships in the official image). Using `mysqladmin ping` will cause the service container to remain unhealthy and block the job indefinitely.
**Docker build + push to Gitea container registry:**
```yaml
jobs:
build-push:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ${{ vars.GITEA_REGISTRY }} # e.g. git.bergerhouse.ca
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- uses: docker/build-push-action@v5
with:
context: .
file: apps/api/Dockerfile
push: true
tags: |
${{ vars.GITEA_REGISTRY }}/${{ gitea.repository_owner }}/familysync-api:${{ gitea.sha }}
${{ vars.GITEA_REGISTRY }}/${{ gitea.repository_owner }}/familysync-api:latest
```
**Secrets required:** `REGISTRY_USER` and `REGISTRY_PASSWORD` — a Gitea Personal Access Token with `write:package` scope. Gitea does NOT inject a built-in `GITEA_TOKEN` that grants container-registry push; a PAT is required. Store credentials in the repo's Settings > Secrets > Actions.
**Docker-in-Docker consideration:** If the runner is operating in Docker socket-mount mode (the default for the Gitea act_runner Docker container), the `docker` CLI inside a `catthehacker/ubuntu:act-latest` job container can reach the host Docker daemon via the mounted socket — sufficient for `docker/build-push-action`. If the runner is in DinD mode, additional config is needed (custom DinD image + `DOCKER_HOST=tcp://docker:2376`). The socket-mount mode is simpler and sufficient for this use case.
**Workflow file structure recommendation:**
```
.gitea/workflows/
ci.yml # lint + typecheck + vitest unit (runs on every PR push)
integration.yml # API integration tests against MariaDB service container (runs on PR to main)
build.yml # Docker build + push to Gitea registry (runs on merge to main)
mobile-test.yml # Playwright mobile tests (runs on PR to main)
```
---
## Recommended Stack (v1.0 baseline — unchanged)
### Core Technologies
@@ -45,6 +204,7 @@
| ESLint + Prettier | Lint + format | Standard config; no bikeshedding needed |
| Docker Compose | Local dev + production parity | Match Unraid stack exactly in dev |
| Vitest | Unit + integration tests | Vite-native, same config as frontend |
| **@playwright/test** | **v1.1 NEW — Mobile PWA test harness** | **devDependency in apps/pwa only; 1.60.0** |
---
@@ -61,8 +221,12 @@ npm install web-push zod openid-client
npm install react react-dom @tanstack/react-query zustand
npm install -D vite vite-plugin-pwa
# Dev
# Dev (monorepo root)
npm install -D typescript drizzle-kit vitest @types/node @types/web-push
# Dev (apps/pwa only — v1.1)
npm install -D @playwright/test
npx playwright install --with-deps chromium
```
---
@@ -204,6 +368,8 @@ Redis (`ioredis`) is only needed if multiple Node containers run behind a load b
| @hono/oidc-auth | express-openid-connect | express-openid-connect is Express-specific; Hono middleware is the correct fit |
| SSE | WebSockets | WebSockets are bidirectional; list sync is server→client only; SSE is simpler and proxy-friendly |
| CalDAV | JMAP | JMAP calendars not available on Fastmail as of 2026 |
| @playwright/test | playwright-cli alone | playwright-cli lacks storageState save/restore and device presets needed for CI; both coexist |
| PAT for Gitea registry | secrets.GITEA_TOKEN / built-in token | Gitea does not inject a built-in token with container-registry push scope; PAT required |
---
@@ -219,6 +385,9 @@ Redis (`ioredis`) is only needed if multiple Node containers run behind a load b
| PostgreSQL | Not in the Unraid stack; hard constraint | MariaDB |
| NestJS | Massive framework overhead for a two-user household app | Hono |
| Firebase/FCM as push broker | Third-party dependency; VAPID direct push works without it | web-push (VAPID) |
| `mysqladmin ping` health check with MariaDB 11 | mysqladmin not shipped in mariadb:11 image; silently blocks CI | `healthcheck.sh --connect --innodb_initialized` |
| `runs-on: ubuntu-latest` on Gitea self-hosted runner | Label only resolves on GitHub's hosted infrastructure | `runs-on: self-hosted` (or the runner's registered label) |
| Any validation library for setup wizard | zod + mysql2 + web-push + Node 22 fetch cover all checks natively | Use existing stack |
---
@@ -231,6 +400,8 @@ Redis (`ioredis`) is only needed if multiple Node containers run behind a load b
| @hono/oidc-auth@1.8.x | hono@4.x, oauth4webapi | Peer-depends on hono 4.x |
| ical.js@2.x | rrule@2.8.x | Use together: ical.js parses the RRULE string, pass to `new RRule(RRule.parseString(...))` |
| web-push@3.6.x | Node.js 18+ | VAPID uses Web Crypto; works in Node.js 18+ natively |
| @playwright/test@1.60.x | Node.js 18+ | Install Chromium only in CI (`npx playwright install --with-deps chromium`) |
| mariadb:11 service container | GitHub/Gitea Actions | Health check must use `healthcheck.sh`; `mysqladmin` removed in 11.x |
---
@@ -242,6 +413,10 @@ Redis (`ioredis`) is only needed if multiple Node containers run behind a load b
3. **EU DMA regression:** If either household member is in the EU on iOS 17.4+, PWA standalone mode is broken and push will not work. Confirm geographic context is outside EU — this is the project owner's constraint to verify.
4. **Gitea act_runner Docker socket access in Unraid container:** The Unraid Gitea Actions runner container needs `/var/run/docker.sock` mounted for the Docker build job to reach the host daemon. Verify the runner container's compose config has the socket mount before the build workflow runs. Without it, `docker/build-push-action` will fail silently.
5. **Playwright mobile tests and DEV_AUTH_BYPASS in CI:** The mobile test harness depends on `DEV_AUTH_BYPASS=true` being available in CI. This means the CI run starts the API with that flag — confirm it is only set in the test environment, never in the production image/deploy step.
---
## Sources
@@ -261,8 +436,18 @@ Redis (`ioredis`) is only needed if multiple Node containers run behind a load b
- [PWA iOS Limitations 2026](https://www.magicbell.com/blog/pwa-ios-limitations-safari-support-complete-guide) — iOS 16.4 minimum; home screen required; EU DMA regression
- [Authelia Express.js Integration](https://www.authelia.com/integration/openid-connect/clients/expressjs/) — Authorization code + PKCE flow; client_secret_basic
- [@hono/oidc-auth GitHub](https://github.com/honojs/middleware/tree/main/packages/oidc-auth) — Storage-less JWT session cookies; Version 1.8.3
- [@playwright/test npm](https://www.npmjs.com/package/@playwright/test) — Version 1.60.0 current; storageState, devices registry confirmed
- [Playwright Authentication docs](https://playwright.dev/docs/auth) — storageState save/restore pattern, worker-scoped fixture
- [Playwright Emulation docs](https://playwright.dev/docs/emulation) — devices['iPhone 15 Pro'], isMobile, viewport, userAgent presets
- [Gitea Container Registry docs](https://docs.gitea.com/usage/packages/container) — Registry URL format, PAT required for push
- [Automating Docker builds with Gitea Actions](https://www.vanmeeuwen.dev/blog/automating-docker-builds-with-gitea-actions) — docker/login-action@v3 + docker/build-push-action workflow pattern
- [Gitea Official Tutorial — Automating Release Versioning](https://about.gitea.com/resources/tutorials/automating-release-versioning-with-gitea-actions-to-the-gitea-package-registry) — Complete workflow YAML with docker/setup-buildx-action, registry secrets
- [Gitea runner-images](https://gitea.com/gitea/runner-images) — catthehacker/ubuntu:act-latest as recommended job container
- [MariaDB 11 health check fix](https://github.com/mage-os/github-actions/issues/365) — mysqladmin removed in mariadb:11.4; healthcheck.sh required
- [MySQL in GitHub Actions (ovirium.com)](https://ovirium.com/blog/how-to-make-mysql-work-in-your-github-actions/) — Service container pattern; ports, env, options syntax (GitHub-compatible = Gitea-compatible)
- [DEV Community — Docker-in-Docker with Gitea Actions](https://dev.to/tmlr/the-definitive-guide-to-safe-docker-in-docker-with-gitea-actions-331l) — DinD vs socket-mount tradeoffs; socket-mount recommended for homelab
---
*Stack research for: FamilySync — self-hosted family calendar + shared-lists PWA on Fastmail*
*Researched: 2026-06-03*
*Researched: 2026-06-03 (v1.0 baseline) / 2026-06-10 (v1.1 Operability & Polish additions)*
+65 -242
View File
@@ -1,278 +1,101 @@
# Project Research Summary
**Project:** FamilySync
**Domain:** Self-hosted family calendar + shared-lists PWA (Fastmail-brokered, Authelia-authed, Unraid-hosted)
**Researched:** 2026-06-03
**Confidence:** MEDIUM-HIGH overall; LOW on personal-calendar CalDAV ACL behavior (the critical unresolved risk)
**Project:** FamilySync — v1.1 "Operability & Polish"
**Domain:** Self-hosted family calendar + lists PWA (operability/admin milestone on a shipped v1.0)
**Researched:** 2026-06-10
**Confidence:** HIGH (all findings grounded in direct v1.0 source inspection + v1.0 retrospective)
## Executive Summary
FamilySync is a purpose-built two-person household coordination hub: a React PWA that aggregates Fastmail-hosted calendars (shared family + each member's personal) into a unified color-coded view, adds shared collaborative lists with live co-edit sync, and delivers Web Push alerts — all behind Authelia OIDC with zero app-store friction. The project has resolved its JMAP-vs-CalDAV open question: Fastmail does not expose calendars over JMAP. CalDAV via `tsdav` is the only available protocol and the one to build against exclusively. The broker-cache architecture (background ctag polling + MariaDB cache) is mandatory because Fastmail provides no push webhook; all calendar reads hit the local cache, not Fastmail on-demand.
v1.1 adds six operability/polish features to the proven v1.0 stack (Node 22 + Hono + Drizzle/MariaDB + tsdav/ical.js + web-push, React 19 + Vite + Schedule-X PWA, on Unraid/Docker behind Authelia OIDC + Pangolin/Newt, self-hosted Gitea with an Actions runner). The core stack is unchanged. Every feature reuses existing capabilities; **only one new dependency is warranted — `@playwright/test` (dev, `apps/pwa` scope)** for the authenticated mobile test harness. No new runtime packages: the setup wizard's validations are all covered by `zod` + `mysql2` + native `fetch` + `Buffer`/`web-push`.
The single biggest v1 risk is whether the broker's single app password can discover and read the wife's personal Fastmail calendar after it has been shared via Fastmail's in-app CalDAV ACL flow. Fastmail's documentation confirms the sharing feature exists and follows CalDAV draft ACL standards, but does not explicitly state whether shared calendars appear in the sharing account's principal discovery. This must be spiked and confirmed in Phase 1, before any work is committed to the personal-calendar overlay feature. The fallback is per-member app passwords (at most two credentials for this household). A deeper risk: if the wife's personal calendar lives on iCloud rather than Fastmail, the broker cannot read it at all — this must be confirmed as part of the same spike. Canada is unaffected by the EU DMA PWA restriction; iOS push applies to both household members without the DMA caveat.
The six features: (1) **per-event reminders** — VALARM authoring on the event form + a scheduler that honors each event's lead instead of the hardcoded 15-min; (2) **event-driven outbox drain** — cut perceived write-back latency from ~15s to ~1s; (3) **admin Settings** — role-gated UI to manage encrypted app passwords and designate the shared calendar; (4) **initial setup wizard** — first-run validated bootstrap of env/VAPID/DB/app-password; (5) **Gitea CI** — regression gate on PR + Docker image publish; (6) **mobile-emulated authed Playwright harness**.
iOS Web Push has multiple silent failure modes that are not obvious until production: the PWA must be installed to Home Screen (no browser-tab push on iOS), `showNotification()` must be wrapped in `event.waitUntil()` or iOS permanently revokes the subscription after three silent pushes, and Apple's ITP can silently clear service worker registrations. Recurring events are the highest-complexity table-stakes feature — use CalDAV server-side `CALDAV:expand` in REPORT requests to get pre-expanded instances rather than relying solely on client-side RRULE expansion, and defer single-instance RECURRENCE-ID edits to v1.x. The recommended stack has strong convergence across all four research threads: Hono + Drizzle (mysql2/MariaDB) + tsdav + ical.js + rrule + vite-plugin-pwa + web-push + @hono/oidc-auth, with SSE (preferred over WebSockets for proxy resilience through Pangolin) + optional Redis for list sync.
Three preservation rules are non-negotiable and drive the design: VALARM authoring must **preserve native-client alarms on edit** (never rebuild-from-scratch and silently strip); the outbox durability guarantees (optimistic-202, create-before-delete ordering, drain concurrency guard, fresh-etag-before-PUT, per-uid exactly-once dedup) must be **unchanged** when the drain goes event-driven; and wizard-collected secrets (VAPID private key, `APP_PASSWORD_ENCRYPTION_KEY`) must **stay in env — never touch the DB or any response body**.
## Key Findings
### Recommended Stack
The stack is tightly constrained by the existing infra (MariaDB, no PostgreSQL; Unraid Docker Compose; Authelia OIDC; Pangolin tunnel) and the Fastmail CalDAV protocol decision. All four research files converged on the same library choices with no meaningful disagreement.
No stack change. One new dev dependency; everything else reuses v1.0.
**Core technologies:**
- **Hono 4.x + @hono/node-server** — HTTP framework; Web Standards-native, first-class TypeScript, built-in `streamSSE` helper for SSE, WebSocket via node adapter. Lighter than Express; better TypeScript than Fastify for this size.
- **Drizzle ORM 0.45.x + mysql2 3.x** — Type-safe MariaDB access; no binary engine (unlike Prisma); wire-compatible with MariaDB via `mysql` dialect; `drizzle-kit` for migrations.
- **tsdav 2.2.2** — The only maintained TypeScript CalDAV client; handles PROPFIND, REPORT, PUT, DELETE against Fastmail's `caldav.fastmail.com` endpoint.
- **ical.js 2.2.1** — Mozilla-maintained iCalendar parser; parse raw VCALENDAR/VEVENT payloads returned by tsdav; handles VTIMEZONE, RDATE, EXDATE.
- **rrule 2.8.1** — RRULE string expansion for UI display layer (calendar window ±1 month); use only after confirming against server-side CALDAV:expand for authoritative instances.
- **vite-plugin-pwa 1.3.0 (injectManifest strategy)** — Workbox integration for service worker; use `injectManifest` not `generateSW` for explicit SW control required by Web Push subscription management.
- **web-push 3.6.7** — VAPID push sender; handles APNs Web Push (iOS) and FCM (Android); design payloads as self-contained Declarative Web Push JSON from day one (ITP-resilient on iOS 18.4+).
- **@hono/oidc-auth 1.8.3** — Storage-less JWT session cookies; authorization-code + PKCE against Authelia's OIDC endpoint. Do NOT use `oidc-client-ts` (browser-side SPA library; wrong layer).
- **React 19 + TanStack Query 5 + Zustand 5** — TanStack Query owns all server state; Zustand owns pure UI state (selected date, color assignments, drawer state).
- **SSE via Hono `streamSSE`** — Preferred over WebSockets for list sync; unidirectional server-to-client, more proxy-resilient through Pangolin. Verify Pangolin WS pass-through in an infra spike if WS is chosen instead.
- **ioredis 5.x** — Redis pub/sub for broadcasting list-change events; optional for single-container deployment (in-process EventEmitter works for one Node process).
**Critical decisions locked:**
- CalDAV only, not JMAP — no reconsideration
- Principal URL: `https://caldav.fastmail.com/dav/principals/user/{email}/` — bare root is insufficient
- IANA timezone IDs only (e.g., `America/Toronto`) — never Windows-style IDs in emitted VTIMEZONE
- Serve `sw.js` with `Cache-Control: no-cache` — Safari respects HTTP cache for SW scripts
**Core additions:**
- **`@playwright/test`** (dev, `apps/pwa`): the global `playwright-cli` binary is interactive tooling and exposes no `storageState`/`devices` presets — `@playwright/test` is required for CI spec files doing authenticated, device-emulated runs. The two coexist. Auth via the existing `DEV_AUTH_BYPASS` avoids mocking Authelia.
- **Gitea Actions workflows** (`.gitea/workflows/*.yml`, no npm packages): GitHub-Actions-compatible syntax but `runs-on: self-hosted`; job image `catthehacker/ubuntu:act-latest`; MariaDB service container `mariadb:11` with `healthcheck.sh --connect --innodb_initialized` (NOT `mysqladmin ping` — removed in MariaDB 11); Docker push via `docker/login-action@v3` + `docker/build-push-action@v5` needs a Gitea PAT with `write:package` scope (no built-in token has registry push rights).
- **Setup wizard validation — zero new deps:** env presence via `zod.safeParse`, DB via `mysql2` connect, VAPID via `Buffer.from(key,'base64url').length === 32`, OIDC via native `fetch('/.well-known/openid-configuration')`.
### Expected Features
**Must have (table stakes — v1):**
- Unified multi-calendar view with per-member color coding (shared family + personal calendars)
- Day / week / month / agenda views
- Create / edit / delete events with CalDAV write-back (all-day and timed)
- Recurring events: display and create (via CALDAV:expand); single-instance edit deferred to v1.x
- Event push notifications (reminders) and list-change notifications
- Shared lists: create named lists, add/check/reorder/delete items
- Live list co-edit sync (SSE or WebSocket)
- OIDC login via Authelia — must feel seamless for non-technical user
- PWA installability (Add to Home Screen) with guided iOS install UX — load-bearing, not optional
**Must have (table stakes):**
- Per-event reminder selector with preset offsets (None / 5m / 10m / 15m / 30m / 1h / 2h / 1d / 2d); **"None" is the default** (no VALARM, no push). All-day events fire at 9 AM on the alert day (Apple convention). Existing VALARMs round-trip — never silently stripped.
- Admin Settings scoped to exactly two tasks: rotate/re-enter a member's app password (with inline CalDAV test) and toggle `calendars.is_shared`. Single `users.is_admin` boolean gate.
- Setup wizard: validated first-run steps (DB, app URL, OIDC, session secret auto-gen, encryption key auto-gen, VAPID auto-gen + structural check, admin account, Fastmail app password CalDAV PROPFIND test). Inline per-field validation; Next disabled until step passes.
- Faster write-back: target < 2s perceived; trigger an immediate drain on enqueue, keep the interval as fallback.
**Should have (differentiators — v1):**
- Personal calendar overlay (shared + each member's personal in one view) — THE differentiator; blocked on CalDAV ACL spike
- Event change push notifications ("partner changed an event") — documented Google Family Calendar gap
- Optimistic list check-off with instant feedback
**Should have (competitive / differentiator):**
- Gitea CI PR gate + on-merge Docker publish.
- Mobile Playwright harness (`devices['iPhone 15']`, stored auth via `DEV_AUTH_BYPASS`).
- Multiple alarms per event (2× VALARM) — stretch, defer to v1.2.
**Defer (v2+):**
- Wall-display / kiosk dashboard — explicitly out of scope per PROJECT.md
- Single-instance recurring event edit (RECURRENCE-ID) — high complexity, low v1 priority; ship "edit all instances" only with clear label
- "This and following" recurring edit
- Timezone display toggle
- Meal planning, chore system, AI import, RSVP, event comments — confirmed anti-features for this household
**Anti-features (explicitly OUT — scope creep for a 2-member household):** notification-preferences UI, reminder snooze, wizard re-run, audit log, health dashboard, user management, provider abstraction (stays backlog 999.1), self-service member onboarding mixed into the wizard (stays backlog 999.5), real-device iOS CI.
### Architecture Approach
The architecture is a single Docker Compose stack on Unraid: one Node/Hono API container (broker + list + push + scheduler in one process), MariaDB, and optional Redis. The broker pattern is mandatory: all calendar reads hit the MariaDB cache; a background ctag poller (5-min interval) issues lightweight PROPFIND to detect changes and REPORT to sync. No Fastmail push webhook exists. The PWA serves as the sole frontend; Authelia handles all authentication upstream via Pangolin tunnel. Pangolin/Newt WebSocket pass-through must be verified in an infrastructure spike before real-time sync is built.
Findings grounded in the actual v1.0 codebase. Integration points (real paths):
**Major components:**
1. **CalDAV Broker** (`broker/`) — tsdav wrapper, ctag poller, REPORT sync, RRULE expand, MariaDB cache write. All Fastmail I/O isolated here; nothing else imports from this module.
2. **App API** (Hono) — OIDC session middleware, REST routes for calendars/events/lists, SSE hub, push subscription routes.
3. **Reminder Scheduler** — In-process node-cron; queries events within 15-min window, fires VAPID push.
4. **Lists Domain** — REST CRUD + Redis pub/sub emit on every write; SSE hub delivers to connected clients.
5. **React PWA** — Calendar views (rrule.js for display expansion), list co-edit UI, push subscription registration, guided iOS install flow.
6. **MariaDB** — Persistent store: users, calendar event cache (raw VEVENT blob + dtstart_utc + ctag), lists/items, push_subscriptions.
1. **DB migration**`users.is_admin BOOLEAN DEFAULT 0`, `calendar_events.reminder_lead_minutes INT NULL`, an `app_config`/setup-state table. Foundation; blocks the role-gated and reminder work. (generate+migrate, never `push`.)
2. **Event-driven drain** — in-process `EventEmitter` (`lib/outboxTrigger.ts`, mirroring existing `listEmitter.ts`); signal after `db.insert(calendarOutbox)` in the three write handlers in `routes/events.ts`; subscribe in `startOutboxWorker()`. The existing `isDraining` guard already covers concurrent invocations. **Redis pub/sub is wrong here** — the drain is single-process by design.
3. **VALARM write path**`buildVeventString` in `broker/vevent.ts` gains a `reminderMinutes?` param using `ICAL.Component('valarm')` + `ICAL.Duration.fromSeconds` (same ical.js surface as RRULE). `eventFieldsSchema` (routes/events.ts) and `outboxPayloadSchema` (outboxWorker.ts) must change in sync (flagged by the IN-03 comment).
4. **Variable-lead scheduler**`reminder_lead_minutes` populated by `sync.ts` parsing the VALARM TRIGGER; scheduler query uses `DATE_SUB(dtstart_utc, INTERVAL reminder_lead_minutes MINUTE)` over a ±1-min window; dedup key becomes compound `uid:dtstartMs`. Drop the `isShared`-only restriction for reminder pushes (a user who set an alarm wants it regardless of calendar).
5. **Admin role + setup wizard**`routes/admin.ts` with `requireAdmin` middleware reusing `broker/crypto.ts`; setup wizard and admin Settings are two frontend consumers of the same `/api/admin/*` + `/api/setup/*` routes (do not duplicate). `GET /api/setup/status` mounts **before** the OIDC guard (like `/health`). VAPID/AES keys stay in env — wizard generates + displays for the operator to copy.
**Key patterns:**
- Broker cache + ctag polling — never proxy calendar reads to Fastmail on-demand
- Store raw VEVENT blob + `dtstart_utc` column (DATE type for all-day) — never pre-expand RRULE into rows
- Write-through cache invalidation — after CalDAV PUT, re-fetch server's version; never cache the client's version
- Use `oidc_iss + oidc_sub` as stable identity composite key — never `email`
- All-day events: `{ date: "YYYY-MM-DD", allDay: true }` throughout the stack — never coerce to JS Date or SQL DATETIME
### Recommended Phase Structure (dependency-ordered)
### Critical Pitfalls
Starting at **Phase 7** (continues v1.0 numbering). Critical path with two fully independent parallel tracks:
1. **Personal calendar CalDAV ACL (LOW confidence — must spike in Phase 1)** — Broker token may not discover the wife's personal calendar after sharing unless Fastmail's share+accept flow causes it to appear in principal discovery. Additionally, the wife's personal calendar may be on iCloud, making broker access impossible. Spike before committing to the overlay feature. Fallback: per-member app passwords (two credentials max).
1. **DB foundation** (migration: is_admin, reminder_lead_minutes, setup-state) — blocks the role/reminder work.
2. **Faster write-back** (event-driven drain) — small, low-risk, immediate benefit; independent after migration.
3. **Admin role + routes** — precondition for both admin Settings and setup wizard.
4. **Setup wizard** (backend + UI) — depends on the admin/role + config plumbing.
5. **Admin Settings UI** — depends on admin role; shares routes with the wizard.
6. **Per-event reminders** (VALARM authoring + variable-lead scheduler) — independent track, largest scope; keep authoring + preserve-on-edit + scheduler in one phase.
7. **Gitea CI** — fully independent; start with a runner-probe step.
8. **Mobile Playwright harness** — fully independent.
2. **All-day events stored as DATETIME**`DATE` (not `DATETIME`) in iCalendar; storing as UTC shifts events to the previous day in negative-offset timezones. Store as `DATE` column in MariaDB; emit ISO date string without time component; detect `allDay: true` in the frontend. This bug has hit Home Assistant's CalDAV integration in production.
Tracks 7 and 8 have no code dependencies and can run parallel to anything.
3. **iOS push subscription silent revocation** — iOS permanently revokes push subscriptions after 3 silent pushes (push event received but no visible notification). Always wrap `showNotification()` in `event.waitUntil()`. Payloads must be self-contained (no fetch-before-display). Implement subscription health-check on every app open. Handle 410 Gone immediately.
### Critical Pitfalls (phase-mapped)
4. **Recurring event RECURRENCE-ID write corruption** — Editing a single occurrence requires injecting a new VEVENT block with RECURRENCE-ID into the same VCALENDAR resource. For v1, restrict to "edit all instances" only with a clear label; defer single-instance override to v1.x.
1. **VALARM round-trip strips native alarms** (reminders phase) — update path must extract + preserve existing VALARM from `rawVevent`, not rebuild from scratch.
2. **TRIGGER serialized as VALUE=TEXT** (reminders phase) — use `ICAL.Duration.fromSeconds(-n*60)`, verify ICS has no `VALUE=TEXT`.
3. **All-day reminder semantics undefined** (reminders phase) — disable selector when `allDay` in UI; guard API.
4. **Per-event lead breaks uid-only dedup** (reminders phase) — rescheduled events go invisible; fix with `uid:dtstartMs` compound key.
5. **Event-driven drain double-execution** (write-back phase) — use a `drainRequested` flag checked by the interval callback, not direct concurrent `runOutboxDrain()` calls; preserves create-before-delete + etag handling.
6. **Setup endpoint reachable post-setup** (wizard phase) — guard checked on every invocation (member_credentials AND VAPID env present → 423), not just at startup; never log/echo the app password; secrets never persisted to DB.
7. **Gitea ghost failures** (CI phase) — MariaDB readiness race (healthy ≠ accepting connections) and runner env assumptions (Node 22 + pnpm not guaranteed) — start with a probe-only workflow.
5. **Pangolin WebSocket pass-through** — Known issue (#1034) where WebSocket connections fail through Pangolin even when HTTP works. Verify SSE or WS pass-through in an infra spike before building real-time list sync. SSE is more proxy-resilient and is the preferred transport.
## Research Flags (deeper investigation during planning)
6. **ETag / sync-token incremental sync** — Use WebDAV-Sync (sync-token) for delta fetches from day one; fall back to ctag polling if sync-token returns 403. Include `If-Match: <etag>` on CalDAV PUT; handle 412 (concurrent modification).
- **Reminders phase:** confirm Fastmail CalDAV accepts the chosen TRIGGER value type; exact `rawVevent` round-trip on the update path.
- **Wizard phase:** OIDC discovery failure handling (retry/timeout/fallback).
- **CI phase:** probe the actual Unraid Gitea runner (Docker socket mount, Node/pnpm versions) before designing pipelines.
- **Mobile harness:** fixed user (DEV_AUTH_BYPASS user 1) vs parameterized; prod service worker must be neutralized in the base context.
7. **iOS install guide is load-bearing** — iOS has no browser install prompt. The in-app guided flow (annotated screenshots: share icon → Add to Home Screen) is the only path to PWA installation and therefore push notifications for the wife.
**Well-documented — skip research:** event-driven drain (EventEmitter proven in `listEmitter.ts`), admin/wizard routes (standard Hono + zod + React forms), Playwright device/storageState APIs.
## Implications for Roadmap
## Open Questions to Resolve in Requirements
Suggested build order based on the dependency graph across all four research files:
1. Admin tab visibility — both members are operators; should the non-technical member see it?
2. Drain transport — confirm single-container deployment (direct in-process signal) vs any multi-replica need (would require Redis).
3. Playwright auth — DEV_AUTH_BYPASS-only vs programmatic OIDC; fixed vs parameterized user.
### Phase 1: Foundation + CalDAV Broker Spike
## Confidence
**Rationale:** Nothing works without auth. The CalDAV broker is the riskiest unknown (personal calendar ACL) and must be validated before any calendar UI is built. Pangolin SSE/WS pass-through also must be confirmed before real-time sync is designed. Both are binary go/no-go decisions.
**Delivers:** Working Docker Compose stack; Authelia OIDC login; CalDAV broker read path confirmed against real Fastmail account; personal-calendar ACL spike result (go/no-go on the overlay feature); wife's calendar location confirmed (Fastmail vs iCloud); Pangolin SSE pass-through verified.
**Addresses:**
- OIDC login — required by all other features
- CalDAV protocol confirmed (JMAP ruled out)
- Personal calendar sharing ACL confirmed or fallback decided
- Infrastructure scaffold (Docker Compose, MariaDB schema, drizzle-kit migrations, .env)
**Pitfalls to avoid:**
- JMAP assumption — confirm CalDAV endpoint returns events before proceeding
- Email as identity key — use `oidc_iss + oidc_sub` from schema day one
- Authelia groups claim — skip groups; all authenticated users are equal in this household
- Pangolin WebSocket — smoke test SSE over public URL in this phase
**Research flag:** NEEDS research-phase — CalDAV ACL sharing mechanics and Pangolin WebSocket behavior are both implementation-dependent; cannot be resolved from documentation alone.
---
### Phase 2: Calendar Display
**Rationale:** Build read-only calendar UI on top of the confirmed broker. Validates broker correctness before adding write complexity. Color coding, multi-view layout, and RRULE display are substantial enough to be their own phase.
**Delivers:** Unified calendar view (month/week/day/agenda); per-member color coding; all-day event display; recurring event display via CALDAV:expand; no write-back yet.
**Addresses:**
- Unified multi-calendar view
- Per-member color coding
- Day / week / month / agenda views
- Recurring event display (RRULE + EXDATE)
**Pitfalls to avoid:**
- All-day events as DATETIME — `allDay: true` flag + `DATE` column, no timezone coercion
- RRULE client-side expansion only — use CALDAV:expand in REPORT; rrule.js for display window only
- DST shift on recurring events — test with events crossing spring/fall DST boundary before milestone sign-off
- Service worker cache-first for API routes — `Network-First` for `/api/*` routes
**Research flag:** Standard patterns; no research phase needed.
---
### Phase 3: Event Write-Back + PWA Install
**Rationale:** Adds create/edit/delete on top of confirmed read path. PWA installability is paired here because the service worker is required for both offline shell caching and push registration in Phase 5.
**Delivers:** Full CRUD events written back to Fastmail via CalDAV PUT; all-day and timed events; guided iOS Add to Home Screen onboarding flow; PWA manifest + service worker (injectManifest strategy).
**Addresses:**
- Create / edit / delete events
- PWA installability
- iOS install guide (load-bearing for wife's push notifications)
**Pitfalls to avoid:**
- Write-back without `If-Match` ETag — include on all PUT/DELETE; handle 412
- Recurring RECURRENCE-ID corruption — v1 ships "edit all instances" only with clear label
- Write-through cache — after PUT, re-fetch server version before caching
- SW served with aggressive cache headers — `Cache-Control: no-cache` on `sw.js` and manifest
- SW update staleness — in-app "new version available" prompt on SW `waiting` state
**Research flag:** Standard patterns; no research phase needed.
---
### Phase 4: Shared Lists + Live Sync
**Rationale:** Lists domain is architecturally independent of the calendar broker (shares only auth and MariaDB). Serialized here to reduce WIP; could parallel-track with Phase 3 after Phase 1 is complete.
**Delivers:** Named shared lists; item CRUD (add/check/reorder/delete); live co-edit sync via SSE + optional Redis pub/sub; optimistic check-off; list sequence number for reconnect replay.
**Addresses:**
- Shared lists CRUD
- Live list co-edit sync
- Reconnect gap handling
**Pitfalls to avoid:**
- Missed updates during reconnect — monotonic sequence number per list in initial schema; server replays on reconnect
- Pangolin SSE timeout — verify long-lived connections survive; configure proxy timeouts
- WebSocket broadcast to all clients — filter SSE delivery by list subscription
**Research flag:** Standard patterns; no research phase needed.
---
### Phase 5: Web Push Notifications
**Rationale:** Push is built last because it depends on events being cached (Phase 2), users having stable identity + push_subscriptions table (Phase 1), and the service worker being in place (Phase 3).
**Delivers:** VAPID key pair; push_subscriptions table; push permission request UX (inside tap handler only); reminder scheduler (events within 15-min window); event-change and list-change push alerts; subscription health-check on app open; 410/404 cleanup; Declarative Web Push compatible payload format.
**Addresses:**
- Event reminders
- List-change notifications
- iOS push reliability
**Pitfalls to avoid:**
- `showNotification()` without `event.waitUntil()` — three silent pushes = permanent subscription revocation on iOS
- Fetch-before-display push handler — payload must be self-contained (title, body, URL); no API call inside push event handler
- Dead subscription accumulation — handle 410 Gone immediately; delete record
- EU DMA check — one-time: confirm both Apple IDs are non-EU; document result (expected: Canada, not affected)
- Declarative Web Push payload format — define the JSON schema before implementing server-side push sender
**Research flag:** Standard patterns for VAPID; iOS-specific behavior is well-documented in PITFALLS.md. No research phase needed, but the `waitUntil` pattern and subscription health-check must be in the initial implementation.
---
### Phase Ordering Rationale
- Phase 1 (auth + broker spike) is an unconditional prerequisite — nothing works without auth, and the personal-calendar ACL spike is a binary gate for the product's primary differentiator
- Phase 2 (calendar display) precedes Phase 3 (write-back) — validates broker correctness before adding write complexity
- Phase 4 (lists) could parallel-track with Phase 3 — no calendar dependency; serialized here to reduce WIP
- Phase 5 (push) is last by dependency: requires SW (Phase 3), event cache (Phase 2), stable user identity (Phase 1)
- The personal-calendar overlay feature is gated on the Phase 1 ACL spike result. If the spike confirms sharing works, overlay ships in Phase 2. If the wife's calendar is on iCloud, the overlay is cut from v1 scope with shared-family-calendar-only as the fallback.
### Research Flags
**Phases needing deeper research during planning:**
- **Phase 1 (CalDAV broker spike):** Fastmail personal-calendar CalDAV ACL sharing behavior — must be confirmed with a real account before Phase 2 calendar UI is built. Highest-risk item in the project.
- **Phase 1 (infra spike):** Pangolin SSE/WebSocket pass-through — known issue #1034; confirm transport choice and configure timeouts before real-time sync is built.
**Phases with standard patterns (skip research phase):**
- **Phase 2 (calendar display):** Calendar grid layout, CALDAV:expand, color coding — well-documented patterns.
- **Phase 3 (write-back + PWA):** CalDAV PUT mechanics, vite-plugin-pwa injectManifest — well-documented.
- **Phase 4 (lists + SSE):** REST CRUD + SSE + Redis pub/sub — standard patterns.
- **Phase 5 (push):** VAPID, web-push npm, iOS-specific patterns — well-documented in community sources; implementation-sensitive but not research-sensitive.
## Confidence Assessment
| Area | Confidence | Notes |
|------|------------|-------|
| Stack | HIGH | Strong library convergence across all four research threads; official sources for Fastmail CalDAV-only decision |
| Features | HIGH | Table stakes and anti-features well-evidenced across 5+ competing products; differentiators confirmed against PROJECT.md |
| Architecture | MEDIUM | Broker-cache pattern well-established; personal-calendar ACL behavior is the LOW-confidence gap |
| Pitfalls | MEDIUM-HIGH | CalDAV/RRULE/iOS push pitfalls well-documented in community; Fastmail-specific rate limits undocumented |
**Overall confidence:** MEDIUM-HIGH
### Gaps to Address
- **Personal calendar CalDAV ACL (critical):** Whether the broker token discovers the wife's personal calendar after Fastmail share+accept is unconfirmed. Must be spiked in Phase 1. If the wife uses iCloud Calendar as primary, personal overlay is impossible via CalDAV broker. Handle during Phase 1 planning: design the spike, define success criteria, define go/no-go decision point.
- **Wife's calendar location:** PROJECT.md assumes personal calendars are Fastmail-hosted. If the wife's primary calendar is iCloud, the unified view's differentiator is cut from v1. Confirm before Phase 2.
- **Fastmail CalDAV rate limits:** Not documented. 5-min ctag polling (one lightweight PROPFIND per calendar per tick) is conservative. Monitor for HTTP 429 in production; implement exponential backoff.
- **Declarative Web Push payload format:** `web-push` npm does not natively generate the `"web_push": 8030` JSON schema. VAPID remains the transport layer; the payload JSON must be hand-crafted to satisfy Declarative Web Push for iOS 18.4+. Define the payload schema in Phase 5 planning before implementing the server-side push sender.
## Sources
### Primary (HIGH confidence)
- [Fastmail API Documentation](https://www.fastmail.com/dev/) — CalDAV-only for calendars; JMAP calendar pending RFC 8984 finalization
- [Fastmail App Passwords](https://www.fastmail.help/hc/en-us/articles/360058752854-App-passwords) — App password scope covers CalDAV; OAuth not applicable
- [Fastmail Calendar Sharing](https://www.fastmail.help/hc/en-us/articles/1500000279781-Sharing-calendars-with-other-users) — ACL tiers confirmed; cross-account token access undocumented
- [Authelia OIDC Clients Configuration](https://www.authelia.com/configuration/identity-providers/openid-connect/clients/) — PKCE, groups claim, breaking changes
- [WebKit: Meet Declarative Web Push](https://webkit.org/blog/16535/meet-declarative-web-push/) — Safari 18.4+, ITP resilience
- [CalDAV ctag Extension](https://github.com/apple/ccs-calendarserver/blob/master/doc/Extensions/caldav-ctag.txt) — ctag-based polling
### Secondary (MEDIUM confidence)
- [MagicBell: PWA iOS Limitations 2026](https://www.magicbell.com/blog/pwa-ios-limitations-safari-support-complete-guide) — iOS 16.4+, Home Screen required, EU DMA
- [Sabre/DAV: Building a CalDAV client](https://sabre.io/dav/building-a-caldav-client/) — ETag, sync-token, VTIMEZONE pitfalls
- [Pangolin WebSocket issue #1034](https://github.com/fosrl/pangolin/issues/1034) — Known WebSocket upgrade failure through tunnel
- [Using Fastmail with CalDAV libraries](https://utf9k.net/blog/fastmail-caldav/) — Principal URL format, Cyrus IMAP internals
- tsdav, ical.js, rrule, web-push, @hono/oidc-auth npm packages — versions and compatibility confirmed
### Tertiary (LOW confidence)
- [Fastmail: Shared Calendaring Improvements](https://www.fastmail.com/blog/shared-calendar-improvements/) — CalDAV ACL standards alignment; cross-account broker token discovery unconfirmed
- [iOS push subscriptions terminated after 3 notifications](https://dev.to/progressier/how-to-fix-ios-push-subscriptions-being-terminated-after-3-notifications-39a7) — `event.waitUntil()` requirement; community-sourced
- [Home Assistant CalDAV all-day events UTC issue](https://github.com/home-assistant/core/issues/25814) — DATE vs DATETIME bug in production; real-world evidence
---
*Research completed: 2026-06-03*
*Ready for roadmap: yes*
| Domain | Confidence | Notes |
|--------|------------|-------|
| Stack | HIGH | v1.0 proven; one dev dep; Gitea syntax compatible (MariaDB 11 healthcheck caveat noted) |
| Features | HIGH | All have prior art; scoped to a tiny household |
| Architecture | HIGH | Grounded in real v1.0 source; component boundaries + build order sound |
| Pitfalls | HIGH | 15 pitfalls from codebase review + RFC 5545 + v1.0 retrospective, each mapped to a phase |
| Unraid CI runner | MEDIUM | Runner Docker-socket/Node/pnpm state unknown until probed |