docs(01-foundation-broker-spike): create phase plan (4 plans, 3 waves) + SKELETON + validation map

This commit is contained in:
Lucas Berger
2026-06-04 08:16:48 -04:00
parent 25460c9bd9
commit 3c51e423ff
7 changed files with 977 additions and 33 deletions
@@ -0,0 +1,250 @@
---
phase: 01-foundation-broker-spike
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- package.json
- pnpm-workspace.yaml
- .gitignore
- .env.example
- docker-compose.yml
- docker-compose.dev.yml
- apps/api/package.json
- apps/api/tsconfig.json
- apps/api/Dockerfile
- apps/api/drizzle.config.ts
- apps/api/vitest.config.ts
- apps/api/src/index.ts
- apps/api/src/db/schema.ts
- apps/api/src/db/client.ts
- apps/api/src/routes/health.ts
- apps/api/tests/helpers/db.ts
- apps/api/tests/health.test.ts
- apps/api/tests/auth/user.test.ts
- apps/api/tests/broker/crypto.test.ts
- apps/api/tests/broker/sync.test.ts
- apps/api/tests/broker/poller.test.ts
- apps/pwa/package.json
- apps/pwa/tsconfig.json
- apps/pwa/vite.config.ts
- apps/pwa/index.html
- apps/pwa/src/main.tsx
- apps/pwa/src/App.tsx
autonomous: false
requirements: [CAL-01]
user_setup:
- service: mariadb
why: "Local stack DB; provisioned via Docker Compose (no external account)"
env_vars:
- name: DB_PASSWORD
source: "Choose any strong password; set in .env (consumed by both mariadb and api services)"
- name: DB_ROOT_PASSWORD
source: "Choose any strong password; set in .env (MariaDB root)"
must_haves:
truths:
- "docker compose up brings MariaDB healthy and the Hono API serving"
- "GET /health returns 200 and proves a real DB round-trip (write then read)"
- "The React PWA builds and renders a shell that fetches /health"
- "npx drizzle-kit push applies the users/member_credentials/calendars/calendar_events schema to the live MariaDB"
- "vitest runs and the Wave 0 test files exist and execute (red or green)"
artifacts:
- path: "apps/api/src/db/schema.ts"
provides: "Drizzle mysqlTable definitions for users, member_credentials, calendars, calendar_events"
contains: "mysqlTable('users'"
- path: "apps/api/src/db/client.ts"
provides: "drizzle(mysql2 pool) singleton export `db`"
exports: ["db"]
- path: "apps/api/src/routes/health.ts"
provides: "GET /health with real DB read/write"
- path: "docker-compose.yml"
provides: "api + mariadb + redis services with mariadb healthcheck"
contains: "healthcheck"
- path: "apps/api/vitest.config.ts"
provides: "Node-environment vitest config"
- path: "apps/pwa/src/App.tsx"
provides: "React shell that fetches /health"
key_links:
- from: "apps/api/src/routes/health.ts"
to: "apps/api/src/db/client.ts"
via: "db query"
pattern: "from ['\"].*db/client"
- from: "apps/pwa/src/App.tsx"
to: "/health"
via: "fetch"
pattern: "fetch\\(.*health"
---
<objective>
Stand up the FamilySync walking skeleton: a pnpm monorepo (apps/api Hono + apps/pwa Vite/React 19), the full Drizzle/MariaDB schema applied to a live MariaDB via Docker Compose, a `/health` route that performs a real DB write-then-read, and a React shell that fetches it. Also create the Wave 0 test harness (vitest config + the failing/stub test files the rest of Phase 1 fills in).
This is the thinnest end-to-end slice that proves the full stack runs: Browser (React) → Hono API → MariaDB and back. No auth, no Fastmail yet — those are Wave 2 slices built on this skeleton.
Purpose: Establish the architectural backbone (directory layout, schema, Docker stack, test runner) that every later plan and phase inherits. Avoids re-litigating scaffold decisions.
Output: Running Docker stack, applied DB schema, a green `/health` slice, and the Wave 0 test files.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@./CLAUDE.md
@.planning/phases/01-foundation-broker-spike/01-CONTEXT.md
@.planning/phases/01-foundation-broker-spike/01-RESEARCH.md
@.planning/phases/01-foundation-broker-spike/01-VALIDATION.md
@.planning/phases/01-foundation-broker-spike/SKELETON.md
</context>
<artifacts_produced>
## Artifacts this phase produces (Plan 01)
New files: `package.json` (root workspace), `pnpm-workspace.yaml`, `.gitignore`, `.env.example`, `docker-compose.yml`, `docker-compose.dev.yml`, `apps/api/package.json`, `apps/api/tsconfig.json`, `apps/api/Dockerfile`, `apps/api/drizzle.config.ts`, `apps/api/vitest.config.ts`, `apps/api/src/index.ts`, `apps/api/src/db/schema.ts`, `apps/api/src/db/client.ts`, `apps/api/src/routes/health.ts`, `apps/api/tests/helpers/db.ts`, `apps/api/tests/health.test.ts`, `apps/api/tests/auth/user.test.ts`, `apps/api/tests/broker/crypto.test.ts`, `apps/api/tests/broker/sync.test.ts`, `apps/api/tests/broker/poller.test.ts`, `apps/pwa/package.json`, `apps/pwa/tsconfig.json`, `apps/pwa/vite.config.ts`, `apps/pwa/index.html`, `apps/pwa/src/main.tsx`, `apps/pwa/src/App.tsx`.
New exported symbols: `db` (Drizzle client singleton); Drizzle tables `users`, `memberCredentials`, `calendars`, `calendarEvents`; route `healthRouter`.
New DB tables: `users` (id, oidc_iss, oidc_sub, display_name, color, created_at; unique oidc_iss+oidc_sub), `member_credentials` (id, user_id, encrypted_password, fastmail_email, created_at, updated_at), `calendars` (id, user_id, url, display_name, color, ctag, sync_token, last_synced_at), `calendar_events` (id, calendar_id, uid, etag, raw_vevent, dtstart_utc, dtstart_date, all_day, updated_at; unique calendar_id+uid).
New route paths: `GET /health`.
New env vars: `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, `DB_NAME`, `DB_ROOT_PASSWORD`.
</artifacts_produced>
<tasks>
<task type="auto">
<name>Task 1: Scaffold monorepo, Docker Compose stack, and Vitest harness</name>
<files>package.json, pnpm-workspace.yaml, .gitignore, .env.example, docker-compose.yml, docker-compose.dev.yml, apps/api/package.json, apps/api/tsconfig.json, apps/api/Dockerfile, apps/api/vitest.config.ts, apps/pwa/package.json, apps/pwa/tsconfig.json, apps/pwa/vite.config.ts, apps/pwa/index.html, apps/pwa/src/main.tsx, apps/api/tests/helpers/db.ts, apps/api/tests/auth/user.test.ts, apps/api/tests/broker/crypto.test.ts, apps/api/tests/broker/sync.test.ts, apps/api/tests/broker/poller.test.ts, apps/api/tests/health.test.ts</files>
<read_first>
- .planning/phases/01-foundation-broker-spike/01-RESEARCH.md (§ "Recommended Project Structure", § "Pattern 6: Docker Compose Layout", § "Standard Stack" with pinned versions, § "Validation Architecture" Wave 0 gaps + vitest config)
- ./CLAUDE.md (locked stack + "What NOT to Use" + "Version Compatibility" tables — authoritative)
- .planning/phases/01-foundation-broker-spike/01-VALIDATION.md (Wave 0 Requirements, Per-Task Verification Map)
</read_first>
<action>
Create a pnpm workspace at repo root: `pnpm-workspace.yaml` listing `apps/*`; root `package.json` with `packageManager` and workspace scripts. NOTE: dev machine Node is v20.20.2 but Docker runs node:22-alpine (per RESEARCH Environment Availability) — set tsconfig `target: ES2023` so both work. Vitest config from VALIDATION/RESEARCH uses `pnpm vitest run`; align all scripts to pnpm.
`apps/api`: install pinned versions from RESEARCH § Standard Stack — hono@4.12.23, @hono/node-server@2.0.4, @hono/oidc-auth@1.8.3, @hono/zod-validator@0.8.0, drizzle-orm@0.45.2, mysql2@3.22.4, tsdav@2.2.2, ical.js@2.2.1, zod@3.25.x (pin ^3.25.0 — NOT v4, NOT 3.24.x), node-cron@4 (^4.2.1); devDeps drizzle-kit@0.31.10, vitest@^4.1.8, typescript@5.x, @types/node. tsconfig strict:true, module/moduleResolution NodeNext, target ES2023, outDir dist. Dockerfile per RESEARCH (node:22-alpine, COPY dist + pwa build to ./public, CMD node dist/index.js).
`apps/pwa`: install react@19, react-dom@19, @tanstack/react-query@5.101.0, zustand@5.0.14; devDeps vite@8.0.16, @vitejs/plugin-react, typescript. Create index.html, src/main.tsx (mounts App with QueryClientProvider). vite.config.ts with proxy of `/health` and `/api` to http://localhost:3000 for dev.
`apps/api/vitest.config.ts`: environment 'node', globals true (exact config in RESEARCH § Validation Architecture).
docker-compose.yml per RESEARCH Pattern 6: services `api` (build ./apps/api, env DB_* + placeholders for OIDC/encryption used by later plans, depends_on mariadb service_healthy, port 3000:3000), `mariadb` (image mariadb:11, MARIADB_* env, named volume mariadb_data, healthcheck using healthcheck.sh --connect --innodb_initialized interval 10s retries 5), `redis` (redis:7-alpine, present but unused in Phase 1). docker-compose.dev.yml overrides for local dev (bind mounts, expose mariadb 3306). `.env.example` lists every env var referenced (DB_HOST=mariadb, DB_PORT=3306, DB_USER=familysync, DB_NAME=familysync, DB_PASSWORD, DB_ROOT_PASSWORD, plus OIDC_* and APP_PASSWORD_ENCRYPTION_KEY placeholders for later plans). `.gitignore` excludes node_modules, dist, .env (NEVER commit .env — security: secrets at rest).
Create the Wave 0 test files as RED stubs that import the not-yet-existing modules from later plans, each with `it.todo` or a failing assertion plus a comment naming the plan that fills it: `tests/helpers/db.ts` (Drizzle test-DB fixture against the Docker MariaDB or a throwaway schema), `tests/auth/user.test.ts` (upsertUser color round-robin + identity stability — Plan 02), `tests/broker/crypto.test.ts` (AES-GCM roundtrip + IV uniqueness — Plan 03), `tests/broker/sync.test.ts` (allDay → dtstart_date vs dtstart_utc, UID upsert — Plan 03), `tests/broker/poller.test.ts` (ctag unchanged → no write — Plan 03), `tests/health.test.ts` (GET /health 200 — filled by Task 2 below). Do NOT place fenced code in this plan; follow the patterns in RESEARCH.
</action>
<verify>
<automated>cd apps/api && pnpm install && pnpm vitest run --reporter=dot; test -f ../../docker-compose.yml && grep -q "healthcheck" ../../docker-compose.yml && grep -q "mariadb:11" ../../docker-compose.yml</automated>
</verify>
<acceptance_criteria>
- `pnpm-workspace.yaml` lists `apps/*`; root `package.json` declares pnpm workspace
- `apps/api/package.json` pins zod at `^3.25.0` (NOT `^4`, NOT `3.24.x`) and lists hono@4.12.23, drizzle-orm@0.45.2, mysql2@3.22.4, tsdav@2.2.2, ical.js@2.2.1, node-cron@^4.2.1
- `docker-compose.yml` defines api + mariadb + redis services; mariadb uses image `mariadb:11` and has a `healthcheck` block; api `depends_on` mariadb with `condition: service_healthy`
- `.gitignore` contains `.env`; `.env.example` lists DB_HOST, DB_PORT, DB_USER, DB_PASSWORD, DB_NAME, DB_ROOT_PASSWORD
- `apps/api/vitest.config.ts` sets `environment: 'node'`
- All five Wave 0 test files exist under apps/api/tests/ and `pnpm vitest run` executes them (pass or fail, not "no tests found")
</acceptance_criteria>
<done>pnpm install succeeds in apps/api; vitest discovers and runs the Wave 0 test files; docker-compose.yml validates with mariadb healthcheck.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Drizzle schema + DB client + /health slice (end-to-end skeleton)</name>
<files>apps/api/drizzle.config.ts, apps/api/src/db/schema.ts, apps/api/src/db/client.ts, apps/api/src/routes/health.ts, apps/api/src/index.ts, apps/api/tests/health.test.ts, apps/pwa/src/App.tsx</files>
<read_first>
- .planning/phases/01-foundation-broker-spike/01-RESEARCH.md (§ "Pattern 2: Drizzle/MariaDB Schema" — full schema, drizzle.config.ts, DB client singleton; § "Serving PWA static files from Hono")
- .planning/phases/01-foundation-broker-spike/01-CONTEXT.md (D-04, D-10, D-13 — schema constraints: encrypted creds keyed by identity, oidc_iss+oidc_sub composite key, all-day DATE vs DATETIME)
- apps/api/vitest.config.ts (created in Task 1)
</read_first>
<behavior>
- GET /health returns 200 with JSON `{ ok: true, db: "up" }` only after a real DB write+read round-trip succeeds
- GET /health returns 503 if the DB round-trip throws
- schema.ts exports `users` with columns oidc_iss, oidc_sub, display_name, color and a unique constraint on (oidc_iss, oidc_sub)
- calendar_events has BOTH dtstart_utc (timestamp, nullable) and dtstart_date (date, nullable) plus all_day boolean — never a single coerced column
</behavior>
<action>
Create `src/db/schema.ts` exporting the four mysqlTable definitions exactly per RESEARCH Pattern 2: `users` (id autoincrement PK, oidc_iss varchar(512), oidc_sub varchar(256), display_name varchar(256) nullable, color varchar(7) notNull, created_at timestamp; unique key on oidc_iss+oidc_sub per D-10), `memberCredentials` (user_id FK cascade, encrypted_password text, fastmail_email varchar(256), timestamps; index on user_id per D-04), `calendars` (user_id FK, url varchar(1024), display_name, color, ctag varchar(512), sync_token varchar(1024), last_synced_at), `calendarEvents` (calendar_id FK cascade, uid varchar(512), etag, raw_vevent text, dtstart_utc timestamp nullable, dtstart_date date nullable, all_day boolean default false, updated_at; unique key on calendar_id+uid; indexes on dtstart_utc and dtstart_date — per D-13 all-day uses DATE, never coerced to DATETIME).
Create `src/db/client.ts` exporting `db = drizzle({ client: pool, schema, mode: 'default' })` using a mysql2 createPool from DB_* env (connectionLimit 10). Create `drizzle.config.ts` (dialect 'mysql', schema ./src/db/schema.ts, out ./src/db/migrations, dbCredentials from env).
Create `src/routes/health.ts` exporting `healthRouter` (Hono): GET / performs a real round-trip — write a transient row to a `health_check` scratch table OR do `SELECT 1` + an INSERT/DELETE against `users` count; the slice must prove an actual DB read AND write (Walking Skeleton requirement). Return `{ ok: true, db: 'up' }` on success, 503 on failure. Mount under `/health` in `src/index.ts` BEFORE any auth (health must be unauthenticated). Wire `serve({ fetch: app.fetch, port: 3000 })` and serveStatic for ./public.
Fill `tests/health.test.ts` GREEN: mock or use the test-DB fixture to assert GET /health returns 200 `{ ok: true }`.
Update `apps/pwa/src/App.tsx`: a thin shell that fetches `/health` via React Query and renders "stack: up" / "stack: down". This is the one real UI interaction wired to the API for the skeleton.
</action>
<verify>
<automated>cd apps/api && pnpm vitest run tests/health.test.ts --reporter=verbose && pnpm exec tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- `src/db/schema.ts` exports `users`, `memberCredentials`, `calendars`, `calendarEvents`
- `users` has a unique constraint covering oidc_iss + oidc_sub (grep: `oidcIss` and `oidcSub` both present in a unique/composite key)
- `calendar_events` schema has separate `dtstart_utc` (timestamp) and `dtstart_date` (date) columns plus `all_day` boolean
- `src/db/client.ts` exports `db`
- `tests/health.test.ts` passes: GET /health returns 200 with `ok: true`
- `apps/pwa/src/App.tsx` calls `fetch`/React Query against `/health`
- `pnpm exec tsc --noEmit` exits 0 in apps/api
</acceptance_criteria>
<done>tsc clean; health test green; App.tsx wired to /health; schema exports all four tables with all-day DATE separation.</done>
</task>
<task type="checkpoint:human-action" gate="blocking">
<name>Task 3: [BLOCKING] Bring up stack and run drizzle-kit push</name>
<what-built>Docker Compose stack (MariaDB + Hono API) and the Drizzle schema in src/db/schema.ts.</what-built>
<how-to-verify>
1. Copy `.env.example` to `.env` and set `DB_PASSWORD` and `DB_ROOT_PASSWORD` to strong values.
2. Run: `docker compose up -d mariadb` and wait for the healthcheck to report healthy (`docker compose ps` shows mariadb `healthy`).
3. Apply the schema to the live MariaDB. From `apps/api` with DB_HOST pointing at the running container (use `docker-compose.dev.yml` exposed port 3306, DB_HOST=127.0.0.1):
`DB_HOST=127.0.0.1 DB_PORT=3306 DB_USER=familysync DB_NAME=familysync DB_PASSWORD=<your value> pnpm exec drizzle-kit push`
Run it non-interactively; it must exit 0.
4. Confirm the four tables exist: `docker compose exec mariadb mariadb -ufamilysync -p<DB_PASSWORD> familysync -e "SHOW TABLES;"` — expect users, member_credentials, calendars, calendar_events.
5. Run `docker compose up -d` (full stack) and `curl -s http://localhost:3000/health` — expect `{"ok":true,...}`.
</how-to-verify>
<resume-signal>Type "approved" once drizzle-kit push exits 0, SHOW TABLES lists all four tables, and /health returns ok:true — or describe the failure.</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Docker host → MariaDB container | DB credentials cross here; never hardcoded, sourced from .env |
| .env file → process env | Secrets (DB passwords, later OIDC + encryption key) loaded here; .env never committed |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-01-01 | Information Disclosure | .env with DB + future OIDC/encryption secrets | mitigate | `.gitignore` excludes `.env`; only `.env.example` (no real values) is committed |
| T-01-02 | Tampering | MariaDB container | mitigate | Dedicated `familysync` DB user (not root) for the app; root password separate and unused by api service |
| T-01-03 | Elevation of Privilege | /health route | accept | Intentionally unauthenticated and read-mostly; returns no secrets or user data, only `{ ok, db }` |
| T-01-SC | Tampering | pnpm installs (hono, drizzle, mysql2, tsdav, ical.js, node-cron, react, vite) | accept | All packages reviewed [OK] in RESEARCH § Package Legitimacy Audit (multi-year histories, official repos); no [ASSUMED]/[SUS]/[SLOP] packages |
</threat_model>
<verification>
- `pnpm install` succeeds across the workspace
- `pnpm exec tsc --noEmit` clean in apps/api
- `pnpm vitest run` executes all Wave 0 files; health test green
- `docker compose up` brings mariadb to healthy and api serving
- `drizzle-kit push` exits 0; SHOW TABLES lists the four tables
- `curl /health` returns `{ ok: true }`
</verification>
<success_criteria>
- Monorepo scaffold (apps/api + apps/pwa) builds and type-checks
- Docker stack runs MariaDB (healthy) + Hono API
- Schema applied to live DB via drizzle-kit push (all four tables present)
- /health proves a real DB read+write round-trip; React shell renders its result
- Wave 0 test files exist and run; health test green
</success_criteria>
<output>
Create `.planning/phases/01-foundation-broker-spike/01-01-SUMMARY.md` when done.
</output>
@@ -0,0 +1,196 @@
---
phase: 01-foundation-broker-spike
plan: 02
type: execute
wave: 2
depends_on: ["01-01"]
files_modified:
- apps/api/src/auth/middleware.ts
- apps/api/src/auth/user.ts
- apps/api/src/routes/me.ts
- apps/api/src/index.ts
- apps/api/tests/auth/user.test.ts
- apps/pwa/src/App.tsx
- apps/pwa/src/api/client.ts
- .env.example
autonomous: true
requirements: [AUTH-01, AUTH-02, AUTH-03]
must_haves:
truths:
- "An unauthenticated request to /api/* is redirected to Authelia's authorize endpoint (302)"
- "After login, the OIDC callback upserts a users row keyed by oidc_iss + oidc_sub (never email)"
- "Each member is auto-assigned a stable, distinct color from a curated palette on first login; re-login returns the same color"
- "Session persists via @hono/oidc-auth refresh-token rotation — no iframe, refresh held backend-side"
- "GET /api/me returns the authenticated user's identity + color"
- "The PWA shell renders the logged-in member's name and color swatch"
artifacts:
- path: "apps/api/src/auth/user.ts"
provides: "upsertUser(oidcIss, oidcSub, displayName) with round-robin color assignment"
exports: ["upsertUser", "COLOR_PALETTE"]
- path: "apps/api/src/auth/middleware.ts"
provides: "oidcAuthMiddleware wiring + getAuth → upsertUser bridge"
- path: "apps/api/src/routes/me.ts"
provides: "GET /api/me → { user: { id, displayName, color } }"
exports: ["meRouter"]
key_links:
- from: "apps/api/src/routes/me.ts"
to: "apps/api/src/auth/user.ts"
via: "upsertUser call"
pattern: "upsertUser\\("
- from: "apps/api/src/index.ts"
to: "@hono/oidc-auth"
via: "oidcAuthMiddleware on /api/*"
pattern: "oidcAuthMiddleware"
- from: "apps/pwa/src/App.tsx"
to: "/api/me"
via: "React Query fetch"
pattern: "api/me"
---
<objective>
Deliver the OIDC authentication vertical slice: wire `@hono/oidc-auth` against the already-deployed Authelia, upsert a stable user identity keyed by `oidc_iss + oidc_sub` on first authenticated request, auto-assign a stable per-member color from a curated palette, expose `GET /api/me`, and render the logged-in member (name + color) in the PWA shell.
After this plan a real user can: hit the app, get redirected to Authelia, log in, and land on a shell that shows their name and their assigned color — with the session persisting across the access-token refresh window. This is AUTH-01/02/03 end to end.
Purpose: Authentication is the gate for every other feature; the identity + color row is consumed by the calendar broker (Plan 03) and all later phases.
Output: Working Authelia OIDC login, stable identity + color, /api/me, authenticated PWA shell.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@./CLAUDE.md
@.planning/phases/01-foundation-broker-spike/01-CONTEXT.md
@.planning/phases/01-foundation-broker-spike/01-RESEARCH.md
@.planning/phases/01-foundation-broker-spike/01-01-SUMMARY.md
</context>
<artifacts_produced>
## Artifacts this phase produces (Plan 02)
New files: `apps/api/src/auth/middleware.ts`, `apps/api/src/auth/user.ts`, `apps/api/src/routes/me.ts`, `apps/pwa/src/api/client.ts`.
New exported symbols: `upsertUser` (auth/user.ts), `COLOR_PALETTE` (auth/user.ts), `meRouter` (routes/me.ts), `setupAuth`/`oidc middleware mount` (auth/middleware.ts).
New route paths: `GET /api/me`, `GET /callback` (OIDC callback handled by processOAuthCallback).
New env vars: `OIDC_AUTH_SECRET`, `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_REDIRECT_URI`, `OIDC_AUTH_EXTERNAL_URL`.
</artifacts_produced>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: User upsert + stable color assignment (AUTH-03)</name>
<files>apps/api/src/auth/user.ts, apps/api/tests/auth/user.test.ts</files>
<read_first>
- .planning/phases/01-foundation-broker-spike/01-RESEARCH.md (§ "User upsert with color assignment" code example, § "Pattern 2" users schema)
- .planning/phases/01-foundation-broker-spike/01-CONTEXT.md (D-06 color auto-assign round-robin by join order; D-10 identity = oidc_iss+oidc_sub never email)
- apps/api/src/db/schema.ts (users table from Plan 01)
- apps/api/tests/auth/user.test.ts (RED stub from Plan 01 — fill GREEN here)
</read_first>
<behavior>
- upsertUser called with a new (oidcIss, oidcSub) inserts a row and assigns COLOR_PALETTE[userCount % palette.length]
- upsertUser called twice with the same (oidcIss, oidcSub) returns the SAME row and SAME color (idempotent, no duplicate insert)
- Two distinct oidcSub values receive DISTINCT colors (until palette wraps)
- Identity lookup uses oidc_iss AND oidc_sub — never email/displayName
</behavior>
<action>
Create `src/auth/user.ts` exporting `COLOR_PALETTE` (a curated array of >=4 visually-distinct, accessible hex hues per D-06 / Claude's Discretion — e.g. calm blue, warm coral, forest green, soft purple; exact values Claude's choice) and `upsertUser(oidcIss, oidcSub, displayName?)`. Logic per RESEARCH example: SELECT existing by `and(eq(users.oidcIss, oidcIss), eq(users.oidcSub, oidcSub))`; if present return it; else COUNT existing users, assign `COLOR_PALETTE[count % length]`, INSERT, return the new row. Use `$returningId()` then re-select (mysql2 has no RETURNING). Never key on email.
Fill `tests/auth/user.test.ts` GREEN using the test-DB fixture (tests/helpers/db.ts): assert (a) first insert assigns palette[0]; (b) second user assigns palette[1]; (c) re-upsert of user 1 returns the identical row + color and does not create a duplicate; (d) lookup is by iss+sub.
</action>
<verify>
<automated>cd apps/api && pnpm vitest run tests/auth/user.test.ts --reporter=verbose</automated>
</verify>
<acceptance_criteria>
- `src/auth/user.ts` exports `upsertUser` and `COLOR_PALETTE` (length >= 4)
- tests/auth/user.test.ts passes all four cases (insert color, second distinct color, idempotent re-upsert, identity by iss+sub)
- grep confirms `users.oidcIss` and `users.oidcSub` used in the WHERE; no `users.email` lookup exists
</acceptance_criteria>
<done>user.test.ts green; color assignment deterministic and stable; identity keyed on iss+sub.</done>
</task>
<task type="auto">
<name>Task 2: Authelia OIDC middleware + /api/me + authenticated PWA shell (AUTH-01/02)</name>
<files>apps/api/src/auth/middleware.ts, apps/api/src/routes/me.ts, apps/api/src/index.ts, .env.example, apps/pwa/src/api/client.ts, apps/pwa/src/App.tsx</files>
<read_first>
- .planning/phases/01-foundation-broker-spike/01-RESEARCH.md (§ "Pattern 1: @hono/oidc-auth Middleware Wiring" incl. env vars + Authelia client YAML, § "Hono app bootstrap", § "Pitfall 1: OIDC_AUTH_EXTERNAL_URL", § "Pitfall 7: client secret plain vs hashed")
- .planning/phases/01-foundation-broker-spike/01-CONTEXT.md (D-11 skip groups claim; D-12 backend holds refresh token, no iframe)
- apps/api/src/auth/user.ts (upsertUser from Task 1)
- apps/api/src/index.ts (Hono bootstrap from Plan 01)
</read_first>
<action>
Create `src/auth/middleware.ts`: configure `oidcAuthMiddleware()` from @hono/oidc-auth. In `src/index.ts`: register `app.get('/callback', (c) => processOAuthCallback(c))` BEFORE the auth middleware, then `app.use('/api/*', oidcAuthMiddleware())`. Keep `/health` (Plan 01) unauthenticated — mount it before the /api guard. Required env vars (add real placeholders to .env.example): OIDC_AUTH_SECRET (32+ char), OIDC_ISSUER (Authelia base URL — middleware fetches /.well-known/openid-configuration), OIDC_CLIENT_ID, OIDC_CLIENT_SECRET (PLAIN secret per Pitfall 7, NOT the pbkdf2 hash), OIDC_REDIRECT_URI (https://familysync.<domain>/callback), OIDC_AUTH_EXTERNAL_URL (https://familysync.<domain> — MANDATORY behind Pangolin per Pitfall 1, else redirect_uri mismatch). Do NOT request the `groups` scope (D-11). Scopes: openid, profile, email only.
Create `src/routes/me.ts` exporting `meRouter` (Hono): GET / calls `getAuth(c)` to read `iss`, `sub`, `email`, then `upsertUser(auth.iss, auth.sub, auth.email)` and returns `{ user: { id, displayName, color } }`. Mount `app.route('/api/me', meRouter)`.
Session persistence (AUTH-02) is handled by @hono/oidc-auth refresh-token rotation (backend-held refresh token, no iframe — D-12). Note in a code comment that OIDC_AUTH_REFRESH_INTERVAL / OIDC_AUTH_EXPIRES govern this; defaults are acceptable for v1.
PWA: create `apps/pwa/src/api/client.ts` with a typed `fetchMe()` (GET /api/me, credentials: 'include'). Update `App.tsx`: React Query `useQuery(['me'], fetchMe)`; on 401/redirect the browser follows Authelia (full-page). Render the member's displayName and a color swatch using `user.color`. Keep the /health indicator from Plan 01.
Also record the Authelia client registration YAML (from RESEARCH Pattern 1) in the SUMMARY so the operator can paste it into Authelia's configuration.yml — this is the only human-side config (no code change in this repo).
</action>
<verify>
<automated>cd apps/api && pnpm exec tsc --noEmit && grep -q "oidcAuthMiddleware" src/index.ts && grep -q "OIDC_AUTH_EXTERNAL_URL" ../../.env.example && grep -q "upsertUser" src/routes/me.ts</automated>
</verify>
<acceptance_criteria>
- `src/index.ts` registers `/callback` via processOAuthCallback BEFORE `oidcAuthMiddleware` and guards `/api/*`
- `/health` remains reachable without authentication (mounted before the /api guard)
- `.env.example` lists OIDC_AUTH_SECRET, OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_REDIRECT_URI, OIDC_AUTH_EXTERNAL_URL
- `src/routes/me.ts` calls `getAuth` then `upsertUser` and returns user id/displayName/color
- No `groups` scope requested anywhere (grep -v '^#' src | grep -c "groups" == 0 in auth code)
- `apps/pwa/src/App.tsx` fetches `/api/me` and renders `user.color`
- `pnpm exec tsc --noEmit` exits 0
</acceptance_criteria>
<done>tsc clean; /api/* guarded by OIDC, /callback wired, /health still public; /api/me returns identity+color; PWA renders the member; Authelia client YAML captured in SUMMARY.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser → Pangolin → Hono /api/* | Untrusted client; only authenticated requests cross (OIDC session cookie) |
| Authelia → /callback | OIDC authorization-code exchange; PKCE + state validate the callback |
| Hono → Authelia token endpoint | Backend confidential client; client_secret + refresh token never reach the browser |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-02-01 | Spoofing | OIDC redirect_uri | mitigate | Authelia validates exact match; OIDC_REDIRECT_URI env must equal the registered URI; OIDC_AUTH_EXTERNAL_URL set so Pangolin Host header cannot forge the redirect (Pitfall 1) |
| T-02-02 | Spoofing | CSRF on /callback | mitigate | @hono/oidc-auth uses PKCE (state + code_verifier); require_pkce true, S256 in Authelia client |
| T-02-03 | Tampering | OIDC session JWT cookie | mitigate | Cookie signed with OIDC_AUTH_SECRET (32+ char), httpOnly + Secure + SameSite; verified every request |
| T-02-04 | Information Disclosure | Refresh token / client_secret | mitigate | Backend-only (D-12); never serialized to frontend; not logged; OIDC_CLIENT_SECRET is the plain secret in env, never committed |
| T-02-05 | Elevation of Privilege | /api/* without auth | mitigate | oidcAuthMiddleware mounted on /api/*; no guest access (ASVS V4) |
| T-02-06 | Spoofing | Identity confusion via mutable email | mitigate | Identity keyed on oidc_iss + oidc_sub, never email (D-10) |
</threat_model>
<verification>
- `pnpm exec tsc --noEmit` clean
- user.test.ts green (from Task 1)
- index.ts mounts oidcAuthMiddleware on /api/*, /callback before it, /health public
- /api/me returns identity + color
- .env.example complete (OIDC_* + OIDC_AUTH_EXTERNAL_URL)
- Manual (Plan 04 deploy): unauthenticated /api/me → 302 to Authelia; after login lands on shell with name + color
</verification>
<success_criteria>
- AUTH-01: unauthenticated /api/* redirects to Authelia; login lands authenticated (verified live in Plan 04)
- AUTH-02: session persists via backend refresh-token rotation (no iframe)
- AUTH-03: stable identity (iss+sub) + stable distinct per-member color, asserted by unit tests
- /api/me returns the member; PWA shell shows name + color
</success_criteria>
<output>
Create `.planning/phases/01-foundation-broker-spike/01-02-SUMMARY.md` when done. Include the Authelia client registration YAML for the operator.
</output>
@@ -0,0 +1,239 @@
---
phase: 01-foundation-broker-spike
plan: 03
type: execute
wave: 2
depends_on: ["01-01"]
files_modified:
- apps/api/src/broker/crypto.ts
- apps/api/src/broker/client.ts
- apps/api/src/broker/sync.ts
- apps/api/src/broker/poller.ts
- apps/api/src/routes/events.ts
- apps/api/tests/broker/crypto.test.ts
- apps/api/tests/broker/sync.test.ts
- apps/api/tests/broker/poller.test.ts
- .env.example
autonomous: true
requirements: [CAL-01]
must_haves:
truths:
- "App passwords are encrypted at rest with AES-256-GCM (key from env) and decrypt losslessly; never exposed to the frontend"
- "The broker creates a Fastmail CalDAV client (Basic auth, app password) and fetches calendars via PROPFIND"
- "syncCalendar parses VEVENTs with ical.js and upserts them into calendar_events, storing all-day events as DATE (dtstart_date) never DATETIME"
- "The poller skips DB writes when a calendar's ctag is unchanged (sync-token with ctag fallback)"
- "GET /api/events returns cached events from MariaDB (never a live Fastmail call per request)"
artifacts:
- path: "apps/api/src/broker/crypto.ts"
provides: "encryptPassword/decryptPassword (AES-256-GCM, key from APP_PASSWORD_ENCRYPTION_KEY)"
exports: ["encryptPassword", "decryptPassword"]
- path: "apps/api/src/broker/client.ts"
provides: "createFastmailClient(email, appPassword) → tsdav DAVClient"
exports: ["createFastmailClient"]
- path: "apps/api/src/broker/sync.ts"
provides: "syncCalendar: REPORT → ical.js → calendar_events upsert"
exports: ["syncCalendar"]
- path: "apps/api/src/broker/poller.ts"
provides: "startBrokerPoller (node-cron 5-min) with ctag change detection"
exports: ["startBrokerPoller"]
- path: "apps/api/src/routes/events.ts"
provides: "GET /api/events → cached events from DB"
exports: ["eventsRouter"]
key_links:
- from: "apps/api/src/broker/poller.ts"
to: "apps/api/src/broker/crypto.ts"
via: "decryptPassword before client creation"
pattern: "decryptPassword\\("
- from: "apps/api/src/broker/sync.ts"
to: "apps/api/src/db/client.ts"
via: "calendarEvents upsert"
pattern: "calendarEvents"
- from: "apps/api/src/routes/events.ts"
to: "apps/api/src/db/client.ts"
via: "cache read (no live CalDAV)"
pattern: "from ['\"].*db/client"
---
<objective>
Deliver the CalDAV broker vertical slice: AES-256-GCM encryption for Fastmail app passwords, a tsdav-based broker that discovers calendars (PROPFIND) and fetches events (REPORT), an ical.js sync that caches VEVENTs into MariaDB with correct all-day DATE handling, a 5-minute node-cron poller with ctag change detection, and `GET /api/events` reading the cache.
After this plan the backend can read a real Fastmail calendar (given a stored credential) and serve cached events from `/api/events` — the data half of "see a real cached event on the landing page." The final wiring (broker startup + events route mount in index.ts, event display in the PWA) lands in Plan 04 to keep this plan parallel with the auth slice.
Purpose: CAL-01 — read shared Fastmail calendar via broker and cache locally. The broker module is the sole holder of Fastmail I/O (hard boundary); nothing else imports tsdav or credentials.
Output: crypto helper, broker client/sync/poller, /api/events router, all unit-tested.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@./CLAUDE.md
@.planning/phases/01-foundation-broker-spike/01-CONTEXT.md
@.planning/phases/01-foundation-broker-spike/01-RESEARCH.md
@.planning/phases/01-foundation-broker-spike/01-01-SUMMARY.md
</context>
<artifacts_produced>
## Artifacts this phase produces (Plan 03)
New files: `apps/api/src/broker/crypto.ts`, `apps/api/src/broker/client.ts`, `apps/api/src/broker/sync.ts`, `apps/api/src/broker/poller.ts`, `apps/api/src/routes/events.ts`.
New exported symbols: `encryptPassword`, `decryptPassword` (broker/crypto.ts), `createFastmailClient` (broker/client.ts), `syncCalendar` (broker/sync.ts), `startBrokerPoller` (broker/poller.ts), `eventsRouter` (routes/events.ts).
New route paths: `GET /api/events` (mounted in Plan 04).
New env vars: `APP_PASSWORD_ENCRYPTION_KEY` (64-char hex = 32 bytes).
</artifacts_produced>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: AES-256-GCM app-password encryption (CAL-01 security)</name>
<files>apps/api/src/broker/crypto.ts, apps/api/tests/broker/crypto.test.ts, .env.example</files>
<read_first>
- .planning/phases/01-foundation-broker-spike/01-RESEARCH.md (§ "Pattern 4: AES-GCM App-Password Encryption" — full encrypt/decrypt + key generation)
- .planning/phases/01-foundation-broker-spike/01-CONTEXT.md (D-04 encrypted-at-rest, key from env, backend-only)
- apps/api/tests/broker/crypto.test.ts (RED stub from Plan 01 — fill GREEN here)
</read_first>
<behavior>
- encryptPassword(plaintext) then decryptPassword(result) returns the original plaintext (lossless roundtrip)
- Two encryptPassword calls on the same plaintext produce DIFFERENT ciphertext (fresh random 96-bit IV each time)
- decryptPassword throws if the auth tag is tampered (GCM integrity)
- The stored payload is JSON with iv, authTag, ciphertext (all hex)
</behavior>
<action>
Create `src/broker/crypto.ts` per RESEARCH Pattern 4: read `APP_PASSWORD_ENCRYPTION_KEY` (64-char hex → 32-byte Buffer). `encryptPassword(plaintext)`: randomBytes(12) IV, createCipheriv('aes-256-gcm', KEY, iv), update+final, getAuthTag, return JSON.stringify({iv, authTag, ciphertext} as hex). `decryptPassword(stored)`: parse JSON, createDecipheriv, setAuthTag, update+final → utf8. Use `node:crypto` (built into Node 22) — do NOT hand-roll a cipher. Never log plaintext or the key.
Add `APP_PASSWORD_ENCRYPTION_KEY` to `.env.example` with a comment showing the generator: `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`.
Fill `tests/broker/crypto.test.ts` GREEN: set a fixed test key in the test, assert (a) roundtrip lossless; (b) two encrypts of same plaintext differ; (c) tampering authTag causes decrypt to throw.
</action>
<verify>
<automated>cd apps/api && pnpm vitest run tests/broker/crypto.test.ts --reporter=verbose</automated>
</verify>
<acceptance_criteria>
- `src/broker/crypto.ts` exports `encryptPassword` and `decryptPassword` using `node:crypto` aes-256-gcm
- tests/broker/crypto.test.ts passes: roundtrip, IV-uniqueness, tamper-detection
- `.env.example` lists `APP_PASSWORD_ENCRYPTION_KEY` with the generator comment
- grep: no `console.log` of plaintext or KEY in crypto.ts
</acceptance_criteria>
<done>crypto.test.ts green; encrypt/decrypt lossless; IV unique; tamper throws.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Broker client + sync (ical.js parse, all-day DATE) + events route (CAL-01)</name>
<files>apps/api/src/broker/client.ts, apps/api/src/broker/sync.ts, apps/api/src/routes/events.ts, apps/api/tests/broker/sync.test.ts</files>
<read_first>
- .planning/phases/01-foundation-broker-spike/01-RESEARCH.md (§ "Pattern 3: CalDAV Broker (tsdav)" — createFastmailClient, fetchCalendars, fetchCalendarObjects, syncCalendar with ical.js; § "Pitfall 1" principal URL; § "Pitfall 2/3" all-day DATE)
- .planning/phases/01-foundation-broker-spike/01-CONTEXT.md (D-09 CalDAV-only via tsdav, app password; D-13 raw VEVENT blob + dtstart_utc, all-day as DATE, only cache server-returned objects)
- apps/api/src/db/schema.ts (calendars, calendarEvents from Plan 01)
- apps/api/tests/broker/sync.test.ts (RED stub from Plan 01 — fill GREEN here)
</read_first>
<behavior>
- syncCalendar given a timed VEVENT writes dtstart_utc (timestamp), dtstart_date NULL, all_day false
- syncCalendar given an all-day VEVENT writes dtstart_date (YYYY-MM-DD), dtstart_utc NULL, all_day true (NEVER coerce DATE to DATETIME — Pitfall 3)
- re-syncing the same UID updates the existing row (onDuplicateKeyUpdate on calendar_id+uid) — no duplicate
- only server-returned objects are cached (raw VEVENT blob stored verbatim — D-13)
</behavior>
<action>
Create `src/broker/client.ts` exporting `createFastmailClient(email, appPassword)` per RESEARCH Pattern 3: `createDAVClient({ serverUrl: 'https://caldav.fastmail.com', credentials: {username: email, password: appPassword}, authMethod: 'Basic', defaultAccountType: 'caldav' })`. Note Pitfall 1: tsdav discovery resolves the principal URL `https://caldav.fastmail.com/dav/principals/user/{email}/`. Create one client per credential (Pitfall 3 — discovery is a round-trip); broker module owns lifetime.
Create `src/broker/sync.ts` exporting `syncCalendar(client, davCal, userId)`: upsert the `calendars` row (url, displayName, ctag, syncToken, lastSyncedAt; onDuplicateKeyUpdate). `fetchCalendarObjects` → for each obj: `ICAL.parse` → Component → getFirstSubcomponent('vevent'); read dtstart (ICAL.Time), uid; `allDay = dtstart.isDate`. Upsert into `calendarEvents` keyed on calendar_id+uid: rawVevent = obj.data verbatim, etag, allDay, and per D-13/Pitfall 3 — if allDay: dtstart_date = dtstart.toString().slice(0,10), dtstart_utc = null; else dtstart_utc = dtstart.toJSDate(), dtstart_date = null. Defensive ctag/syncToken: `davCal.ctag ?? davCal.syncToken ?? null` (Pitfall 6).
Create `src/routes/events.ts` exporting `eventsRouter` (Hono): GET / reads from `calendarEvents` via `db` (cache only — NEVER call Fastmail per request, ARCHITECTURE anti-pattern), returns the rows (id, uid, allDay, dtstart_utc, dtstart_date, raw_vevent or a minimal shape). This router is mounted in Plan 04.
Fill `tests/broker/sync.test.ts` GREEN with a mocked tsdav client returning captured raw VEVENT strings (timed + all-day fixtures — Wave 0 fixture requirement). Assert the dtstart_utc vs dtstart_date split, all_day flag, and UID-upsert idempotency.
</action>
<verify>
<automated>cd apps/api && pnpm vitest run tests/broker/sync.test.ts --reporter=verbose && pnpm exec tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- `src/broker/client.ts` exports `createFastmailClient`; serverUrl is caldav.fastmail.com, authMethod 'Basic'
- `src/broker/sync.ts` exports `syncCalendar`; stores rawVevent verbatim and splits all-day → dtstart_date, timed → dtstart_utc
- tests/broker/sync.test.ts passes: timed event → dtstart_utc set + dtstart_date null; all-day → dtstart_date set + dtstart_utc null + all_day true; same-UID re-sync updates not duplicates
- `src/routes/events.ts` exports `eventsRouter`; reads from db only (no `createFastmailClient` import in the route)
- `pnpm exec tsc --noEmit` exits 0
</acceptance_criteria>
<done>sync.test.ts green; all-day DATE handling correct; events route reads cache only; tsc clean.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: node-cron poller with ctag change detection (CAL-01)</name>
<files>apps/api/src/broker/poller.ts, apps/api/tests/broker/poller.test.ts</files>
<read_first>
- .planning/phases/01-foundation-broker-spike/01-RESEARCH.md (§ "Fetching calendars + ctag polling" poller code; § "Pitfall 4" node-cron v4; § "Pitfall 6" ctag/syncToken null defensiveness)
- .planning/phases/01-foundation-broker-spike/01-CONTEXT.md (D-13 sync-token with ctag fallback from day one; broker is hard boundary)
- apps/api/src/broker/sync.ts (syncCalendar from Task 2)
- apps/api/src/broker/crypto.ts (decryptPassword from Task 1)
- apps/api/tests/broker/poller.test.ts (RED stub from Plan 01 — fill GREEN here)
</read_first>
<behavior>
- The poll function loads ALL member_credentials (N-credential per-member app password model — D-02), decrypts each app password, creates a client, fetches calendars
- For a calendar whose current ctag equals the stored ctag, syncCalendar is NOT called (skip — no DB write)
- For a calendar with a changed/absent ctag, syncCalendar IS called
- Decryption happens via decryptPassword before client creation (credentials never logged)
</behavior>
<action>
Create `src/broker/poller.ts` exporting `startBrokerPoller()` (and an internal `runPoll()` exported for tests). Per RESEARCH poller pattern: `schedule('*/5 * * * *', runPoll)` using node-cron v4 (Pitfall 4 — basic 5-field cron API is stable). `runPoll`: select all `memberCredentials`; for each, `decryptPassword`, `createFastmailClient`, `fetchCalendars`; for each davCal, compare `davCal.ctag ?? davCal.syncToken ?? null` to the stored calendars row ctag — if equal and non-null, `continue` (skip); else `syncCalendar`. Make `runPoll` injectable/testable (accept the db + client factory or use module mocks) so the unit test can assert skip-on-unchanged without hitting Fastmail.
Fill `tests/broker/poller.test.ts` GREEN: mock fetchCalendars to return a calendar with a known ctag matching a stored row → assert syncCalendar spy NOT called; then a changed ctag → assert syncCalendar IS called.
</action>
<verify>
<automated>cd apps/api && pnpm vitest run tests/broker/poller.test.ts --reporter=verbose</automated>
</verify>
<acceptance_criteria>
- `src/broker/poller.ts` exports `startBrokerPoller`; uses node-cron `schedule('*/5 * * * *', ...)`
- poller decrypts via `decryptPassword` before creating a client (grep: `decryptPassword(`)
- tests/broker/poller.test.ts passes: unchanged ctag → no syncCalendar; changed ctag → syncCalendar called
- grep: no logging of decrypted password or app password in poller.ts
</acceptance_criteria>
<done>poller.test.ts green; ctag skip logic correct; credentials decrypted not logged.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| member_credentials (DB) → broker | App passwords stored encrypted; only broker/crypto.ts decrypts; never leaves backend |
| Broker → Fastmail CalDAV | Outbound Basic auth over TLS; sole holder of Fastmail I/O |
| Hono /api/events → browser | Returns only cached event data; never credentials or raw app passwords |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-03-01 | Information Disclosure | Fastmail app password at rest | mitigate | AES-256-GCM with 96-bit IV + auth tag (crypto.ts); key from APP_PASSWORD_ENCRYPTION_KEY env, never committed/logged (ASVS V6) |
| T-03-02 | Information Disclosure | App password leaking via /api/events | mitigate | events route reads only calendar_events (event data); never joins/returns member_credentials; broker is the only credential reader (D-04) |
| T-03-03 | Tampering | Encrypted-credential integrity | mitigate | GCM auth tag verified on decrypt; tampered ciphertext throws, never silently used |
| T-03-04 | Information Disclosure | Credentials in logs | mitigate | No console logging of decrypted passwords or the encryption key in client.ts / poller.ts |
| T-03-05 | Tampering | Caching client-side event versions | mitigate | Only server-returned objects cached (raw VEVENT verbatim — D-13, Pitfall 14); no write-back in Phase 1 |
| T-03-SC | Tampering | tsdav / ical.js / node-cron installs | accept | All [OK] in RESEARCH § Package Legitimacy Audit (tsdav 3+ yrs official repo, ical.js Mozilla-maintained, node-cron 8+ yrs); no [ASSUMED]/[SUS]/[SLOP] |
</threat_model>
<verification>
- `pnpm exec tsc --noEmit` clean
- crypto.test.ts, sync.test.ts, poller.test.ts all green
- broker module is the only importer of tsdav / credentials (grep: tsdav imported only under src/broker/)
- /api/events reads cache only (no createFastmailClient import in routes/events.ts)
</verification>
<success_criteria>
- App passwords encrypted at rest (AES-256-GCM), lossless roundtrip, tamper-detecting
- Broker discovers calendars and syncs VEVENTs into the cache with correct all-day DATE handling
- Poller skips unchanged calendars (ctag detection)
- /api/events serves cached events without a live Fastmail call
- All three unit test files green
</success_criteria>
<output>
Create `.planning/phases/01-foundation-broker-spike/01-03-SUMMARY.md` when done.
</output>
@@ -0,0 +1,192 @@
---
phase: 01-foundation-broker-spike
plan: 04
type: execute
wave: 3
depends_on: ["01-02", "01-03"]
files_modified:
- apps/api/src/routes/sse.ts
- apps/api/src/index.ts
- apps/api/src/broker/spike.ts
- apps/pwa/src/App.tsx
- apps/pwa/src/components/EventProof.tsx
- apps/pwa/src/api/client.ts
- .planning/phases/01-foundation-broker-spike/CAL-08-DECISION.md
autonomous: false
requirements: [CAL-08, CAL-01, AUTH-01, AUTH-02]
must_haves:
truths:
- "The full app is wired: broker poller starts on boot, /api/events + /api/me + /api/sse are mounted behind OIDC, /health public"
- "The landing page shows the logged-in member (name + color) AND one real cached Fastmail event as broker proof"
- "A CAL-08 spike confirms Lucas's app password reads BOTH the shared family calendar and his personal calendar; the go/no-go decision is recorded in a committed doc"
- "GET /api/sse/heartbeat streams events over the real Pangolin tunnel for 5+ minutes without the proxy closing the stream (smoke test result recorded)"
- "Both members can authenticate through Authelia over the public URL and land on the shell (verified live)"
artifacts:
- path: "apps/api/src/routes/sse.ts"
provides: "GET /api/sse/heartbeat (streamSSE) — Pangolin pass-through smoke test"
exports: ["sseRouter"]
- path: "apps/api/src/broker/spike.ts"
provides: "CAL-08 spike script: createFastmailClient → fetchCalendars → print URLs"
- path: ".planning/phases/01-foundation-broker-spike/CAL-08-DECISION.md"
provides: "Documented go/no-go for personal-calendar overlay"
contains: "Decision:"
- path: "apps/pwa/src/components/EventProof.tsx"
provides: "Renders one cached event from /api/events"
key_links:
- from: "apps/api/src/index.ts"
to: "apps/api/src/broker/poller.ts"
via: "startBrokerPoller on boot"
pattern: "startBrokerPoller\\(\\)"
- from: "apps/api/src/index.ts"
to: "apps/api/src/routes/events.ts"
via: "app.route /api/events"
pattern: "/api/events"
- from: "apps/pwa/src/components/EventProof.tsx"
to: "/api/events"
via: "React Query fetch"
pattern: "api/events"
---
<objective>
Close the Phase 1 walking skeleton end to end: mount the events + SSE routers and start the broker poller in the Hono bootstrap, render one real cached Fastmail event next to the logged-in member on the landing page (broker proof), run the CAL-08 personal-calendar spike and record a go/no-go decision, and verify the whole stack live through the real Pangolin tunnel (Authelia login + SSE pass-through smoke test).
After this plan a real user reaches the public URL, authenticates through Authelia, and sees a single screen confirming: their name, their assigned color, and one real event read from Fastmail — the complete proof that auth + broker + cache + tunnel all work together. This satisfies all five Phase 1 success criteria.
Purpose: This is the integration + gate slice. CAL-08 is the project's highest-risk go/no-go; the SSE smoke test de-risks Phase 4 transport. Both are decided here, with documented outcomes.
Output: fully wired app, landing page with member + event proof, CAL-08 decision doc, SSE smoke-test result, live deployment confirmation.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@./CLAUDE.md
@.planning/phases/01-foundation-broker-spike/01-CONTEXT.md
@.planning/phases/01-foundation-broker-spike/01-RESEARCH.md
@.planning/phases/01-foundation-broker-spike/01-VALIDATION.md
@.planning/phases/01-foundation-broker-spike/01-02-SUMMARY.md
@.planning/phases/01-foundation-broker-spike/01-03-SUMMARY.md
</context>
<artifacts_produced>
## Artifacts this phase produces (Plan 04)
New files: `apps/api/src/routes/sse.ts`, `apps/api/src/broker/spike.ts`, `apps/pwa/src/components/EventProof.tsx`, `.planning/phases/01-foundation-broker-spike/CAL-08-DECISION.md`.
New exported symbols: `sseRouter` (routes/sse.ts), `EventProof` component, `fetchEvents` (added to pwa/src/api/client.ts).
New route paths: `GET /api/sse/heartbeat`; mounts (from Plans 02/03) finalized in index.ts: `/api/me`, `/api/events`, `/api/sse`.
Modified: `apps/api/src/index.ts` (mount events/sse routers, startBrokerPoller on boot), `apps/pwa/src/App.tsx` (render EventProof), `apps/pwa/src/api/client.ts` (fetchEvents).
</artifacts_produced>
<tasks>
<task type="auto">
<name>Task 1: Wire broker + routes into bootstrap, add SSE smoke endpoint, render event proof</name>
<files>apps/api/src/routes/sse.ts, apps/api/src/index.ts, apps/pwa/src/api/client.ts, apps/pwa/src/components/EventProof.tsx, apps/pwa/src/App.tsx</files>
<read_first>
- .planning/phases/01-foundation-broker-spike/01-RESEARCH.md (§ "Pattern 5: Pangolin SSE Smoke Test" streamSSE; § "Hono app bootstrap with all middleware" — full mount order)
- .planning/phases/01-foundation-broker-spike/01-CONTEXT.md (D-08 SSE smoke test; Claude's Discretion: landing page = thin shell showing one cached event as broker proof)
- apps/api/src/index.ts (current bootstrap from Plans 01/02)
- apps/api/src/routes/events.ts (eventsRouter from Plan 03), apps/api/src/broker/poller.ts (startBrokerPoller from Plan 03), apps/api/src/routes/me.ts (meRouter from Plan 02)
</read_first>
<action>
Create `src/routes/sse.ts` exporting `sseRouter` per RESEARCH Pattern 5: GET /heartbeat using `streamSSE` from 'hono/streaming' — emit a `heartbeat` event every 10s with `{ ts, id }` until `stream.aborted`. Mount under `/api/sse` (so it sits behind oidcAuthMiddleware per the SSE-auth threat).
Update `src/index.ts` to the final bootstrap (RESEARCH "Hono app bootstrap"): order = `/callback` (processOAuthCallback) → `/health` (public, Plan 01) → `app.use('/api/*', oidcAuthMiddleware())``app.route('/api/me', meRouter)``app.route('/api/events', eventsRouter)``app.route('/api/sse', sseRouter)``startBrokerPoller()` → serveStatic(./public) → `serve({ port: 3000 })`. Confirm /health stays before the /api guard.
PWA: add `fetchEvents()` to `src/api/client.ts` (GET /api/events, credentials include). Create `src/components/EventProof.tsx`: React Query `['events']`, render the first event's title/date (parse from the returned shape) or an empty-state "No cached events yet". Update `App.tsx` to render member (name + color, from Plan 02) AND `<EventProof />` together — the single broker-proof landing screen (Claude's Discretion landing page).
</action>
<verify>
<automated>cd apps/api && pnpm exec tsc --noEmit && grep -q "startBrokerPoller()" src/index.ts && grep -q "'/api/events'" src/index.ts && grep -q "'/api/sse'" src/index.ts && grep -q "streamSSE" src/routes/sse.ts</automated>
</verify>
<acceptance_criteria>
- `src/routes/sse.ts` exports `sseRouter` using `streamSSE`; heartbeat every 10s
- `src/index.ts` mounts /api/me, /api/events, /api/sse behind oidcAuthMiddleware, keeps /callback and /health before the guard, and calls `startBrokerPoller()` on boot
- `apps/pwa/src/components/EventProof.tsx` fetches `/api/events` and renders an event or empty state
- `apps/pwa/src/App.tsx` renders both the member (name+color) and EventProof
- `pnpm exec tsc --noEmit` exits 0; `pnpm vitest run` (all prior unit tests) green
</acceptance_criteria>
<done>tsc clean; full route mount + poller boot; SSE heartbeat endpoint; landing page shows member + cached event proof.</done>
</task>
<task type="checkpoint:human-action" gate="blocking">
<name>Task 2: [BLOCKING] CAL-08 spike — confirm app password reads shared + personal calendars, record go/no-go</name>
<what-built>The CAL-08 spike script (apps/api/src/broker/spike.ts) using createFastmailClient → fetchCalendars to enumerate calendar collections for Lucas's account. Phase 1 uses ONLY Lucas's app password (D-03); the wife's Fastmail-hosted personal calendar (D-01) is added in Phase 2 — CAL-08 is structurally proven by the per-member N-credential model without her credential present.</what-built>
<how-to-verify>
1. Generate a Fastmail app password for Lucas's account (Fastmail Settings → Privacy & Security → App Passwords → "Mail, Contacts & Calendars" scope). NOTE: this is a human-only step — no API exists to mint a Fastmail app password.
2. Insert it encrypted into member_credentials for Lucas (use a one-off node script calling encryptPassword from Plan 03, or run the spike script with the app password passed via env for the read-only enumeration).
3. Run the spike: `cd apps/api && APP_PASSWORD_ENCRYPTION_KEY=<key> FASTMAIL_EMAIL=<lucas@fastmail> FASTMAIL_APP_PASSWORD=<app pw> pnpm exec tsx src/broker/spike.ts` (the script must print every returned davCal.url + displayName + whether ctag/syncToken is present).
4. Confirm in the output: (a) the SHARED family calendar collection appears; (b) Lucas's PERSONAL calendar collection appears. Record both URLs.
5. Trigger one real sync (let the poller run or call runPoll once) and confirm at least one event row lands in calendar_events: `docker compose exec mariadb mariadb -ufamilysync -p<pw> familysync -e "SELECT id, uid, all_day FROM calendar_events LIMIT 5;"`.
6. Write `.planning/phases/01-foundation-broker-spike/CAL-08-DECISION.md` with: `Decision: GO` (expected per D-05) or `Decision: NO-GO` + the fallback (shared-family-only for v1, CAL-08 → v1.x). Include the discovered calendar URLs and which ctag/syncToken fields Fastmail actually returned (resolves RESEARCH Open Questions 1 + Assumptions A1/A2/A3).
7. Reload the app over its URL and confirm the landing page shows one real cached event.
</how-to-verify>
<resume-signal>Type "approved" once CAL-08-DECISION.md records GO (or NO-GO + fallback), at least one event row is cached, and the landing page shows it — or describe the failure.</resume-signal>
</task>
<task type="checkpoint:human-action" gate="blocking">
<name>Task 3: [BLOCKING] Live deployment — Authelia login (both members) + Pangolin SSE smoke test</name>
<what-built>The full stack deployed through the real Pangolin tunnel + Authelia from day one (D-07 — validate OIDC redirect/HTTPS/same-site cookies in the real topology): Authelia OIDC login, /api/me identity+color, and the /api/sse/heartbeat endpoint.</what-built>
<how-to-verify>
1. Register the FamilySync OIDC client in Authelia using the YAML captured in 01-02-SUMMARY (client_id familysync, redirect_uris https://familysync.<domain>/callback, require_pkce true S256, client_secret_basic, grant types authorization_code+refresh_token, scopes openid/profile/email — NO groups). Hash the secret with `authelia crypto hash --sha512 <secret>` for the YAML; put the PLAIN secret in the app's OIDC_CLIENT_SECRET env (Pitfall 7). Reload Authelia.
2. Expose FamilySync through Pangolin under the SAME parent domain as Authelia (Pitfall 1 — same-site cookies). Set OIDC_AUTH_EXTERNAL_URL + OIDC_REDIRECT_URI to the public URL. Bring up the stack: `docker compose up -d`.
3. AUTH-01: From an external network, open https://familysync.<domain> → confirm redirect to Authelia authorize endpoint → log in → land on the shell with name + color, NO Fastmail credential prompt.
4. AUTH-02: Fully close the browser, reopen the URL → confirm no re-login (session persisted). Optionally wait past the access-token refresh interval and confirm /api/me still 200s.
5. Repeat step 3 for the SECOND member (wife) — confirm she gets a DISTINCT color (AUTH-03 across members).
6. SSE smoke (D-08): from an external network run `curl -N https://familysync.<domain>/api/sse/heartbeat` (with an authenticated session cookie, since SSE is behind /api/*). Keep it open 5+ minutes; confirm heartbeat events keep arriving and the proxy does not cut the stream. Record PASS/FAIL in the SUMMARY (FAIL → note Pangolin idle-timeout for Phase 4; per RESEARCH Open Question 3).
</how-to-verify>
<resume-signal>Type "approved" once both members can log in over the public URL with distinct stable colors, sessions persist across restart, and the SSE smoke result (PASS/FAIL) is recorded — or describe the failure.</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Public internet → Pangolin → Hono | Untrusted; only authenticated /api/* requests proceed; /health + /callback are the only pre-auth routes |
| SSE stream (browser ↔ /api/sse) | Long-lived; must remain behind OIDC, must not leak data beyond heartbeat |
| Spike credential handling | Lucas's app password used once for enumeration; stored encrypted, never logged/committed |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-04-01 | Elevation of Privilege | /api/sse/heartbeat | mitigate | Mounted under /api/* behind oidcAuthMiddleware; no unauthenticated SSE access (ASVS V4) |
| T-04-02 | Information Disclosure | SSE payload | accept | Heartbeat carries only `{ ts, id }` — no user data or secrets |
| T-04-03 | Spoofing | Same-site session cookie behind Pangolin | mitigate | FamilySync + Authelia share parent domain (Pitfall 1/17); OIDC_AUTH_EXTERNAL_URL set so redirect_uri matches |
| T-04-04 | Information Disclosure | Fastmail app password during spike | mitigate | Passed via env for one-off enumeration or stored encrypted via Plan 03 crypto; never echoed to logs or committed; spike output prints only calendar URLs/displayNames, never the password |
| T-04-05 | Tampering | client_secret plain vs hashed | mitigate | Plain secret only in OIDC_CLIENT_SECRET env (Pitfall 7); Authelia YAML holds the pbkdf2-sha512 hash; .env never committed |
| T-04-SC | Tampering | tsx (dev runner for spike) | accept | tsx is a widely-used TypeScript runner; spike script is dev-only, not shipped in the Docker image |
</threat_model>
<verification>
- `pnpm exec tsc --noEmit` clean; `pnpm vitest run` full suite green (all Wave 0 tests now filled)
- index.ts mounts all routers + starts poller; /health public, /api/* guarded
- CAL-08-DECISION.md committed with `Decision: GO|NO-GO` + calendar URLs + ctag/syncToken findings
- Live: both members log in over Pangolin with distinct stable colors; session persists across restart
- Live: landing page shows member + one real cached event
- SSE smoke result (PASS/FAIL) recorded in SUMMARY
</verification>
<success_criteria>
- SC1 (AUTH-01): both members authenticate via Authelia over the public URL, land on home, no Fastmail prompt
- SC2 (AUTH-02): sessions persist across browser restart
- SC3 (AUTH-03): each member has a stable distinct color
- SC4 (CAL-01): broker fetched + cached ≥1 real event; shown on the landing page
- SC5 (CAL-08): go/no-go decision documented in CAL-08-DECISION.md
- Bonus (D-08): Pangolin SSE pass-through smoke result recorded for Phase 4
</success_criteria>
<output>
Create `.planning/phases/01-foundation-broker-spike/01-04-SUMMARY.md` when done. Include the SSE smoke-test result and a pointer to CAL-08-DECISION.md.
</output>
@@ -1,8 +1,8 @@
---
phase: 1
slug: foundation-broker-spike
status: draft
nyquist_compliant: false
status: planned
nyquist_compliant: true
wave_0_complete: false
created: 2026-06-04
---
@@ -18,8 +18,8 @@ created: 2026-06-04
| Property | Value |
|----------|-------|
| **Framework** | vitest (Vite-native, shared backend + frontend per CLAUDE.md) |
| **Config file** | none — Wave 0 installs vitest + workspace config |
| **Quick run command** | `pnpm vitest run --changed` |
| **Config file** | `apps/api/vitest.config.ts` (environment: node) — created Plan 01 Task 1 (Wave 0) |
| **Quick run command** | `pnpm vitest run --reporter=dot` |
| **Full suite command** | `pnpm vitest run` |
| **Estimated runtime** | ~30 seconds |
@@ -27,7 +27,7 @@ created: 2026-06-04
## Sampling Rate
- **After every task commit:** Run `pnpm vitest run --changed`
- **After every task commit:** Run `pnpm vitest run --reporter=dot`
- **After every plan wave:** Run `pnpm vitest run`
- **Before `/gsd-verify-work`:** Full suite must be green
- **Max feedback latency:** 60 seconds
@@ -36,48 +36,54 @@ created: 2026-06-04
## Per-Task Verification Map
> Populated by the planner against the final task IDs. Each phase success criterion maps to at least one automated or manual verification below.
> Mapped to final task IDs. Each phase success criterion maps to at least one automated or manual verification.
| Success Criterion | Requirement | Verification approach | Test Type |
|-------------------|-------------|-----------------------|-----------|
| SC1 — OIDC login lands on home, no Fastmail creds | AUTH-01 | E2E: unauthenticated request to protected route 302→Authelia; authenticated session reaches home | manual (real Authelia) + integration (middleware mounted) |
| SC2 — Sessions persist across browser restart | AUTH-02 | Integration: signed JWT session cookie issued; refresh-token interval configured; unit test cookie attrs (httpOnly, same-site, maxAge) | integration + manual |
| SC3 — Stable distinct member color | AUTH-03 | Unit: color assignment is deterministic round-robin by join order, persisted on user row keyed by oidc_iss+oidc_sub, unchanged on re-login | unit |
| SC4 — Broker fetches + caches ≥1 real event | CAL-01 | Integration: broker PROPFIND discovers collections, REPORT returns VEVENT, parsed + written to cache table (raw blob + dtstart_utc/date); all-day DATE handling unit-tested | integration + manual (real Fastmail) |
| SC5 — CAL-08 go/no-go documented | CAL-08 | Manual: spike confirms app password reads shared + personal collections; decision recorded in a committed doc | manual (decision artifact) |
| Success Criterion | Requirement | Plan/Task | Verification approach | Test Type |
|-------------------|-------------|-----------|-----------------------|-----------|
| SC1 — OIDC login lands on home, no Fastmail creds | AUTH-01 | 01-02 Task 2 (wiring) + 01-04 Task 3 (live) | Middleware mounted on /api/*, /callback wired (tsc/grep); live: unauth /api/me 302→Authelia, login lands on shell | integration + manual (real Authelia/Pangolin) |
| SC2 — Sessions persist across browser restart | AUTH-02 | 01-02 Task 2 + 01-04 Task 3 (live) | Backend refresh-token rotation configured (no iframe); live: close browser, revisit, no re-login | integration + manual |
| SC3 — Stable distinct member color | AUTH-03 | 01-02 Task 1 (unit) + 01-04 Task 3 (2nd member, live) | Unit: round-robin palette by join order, idempotent re-upsert, identity by iss+sub (`tests/auth/user.test.ts`); live: 2nd member distinct color | unit + manual |
| SC4 — Broker fetches + caches ≥1 real event | CAL-01 | 01-03 Task 2/3 (unit) + 01-04 Task 2 (live spike) | Unit: sync all-day/timed split + UID upsert (`tests/broker/sync.test.ts`), ctag skip (`tests/broker/poller.test.ts`), crypto roundtrip (`tests/broker/crypto.test.ts`); live: ≥1 event row cached + shown on landing page | unit + manual (real Fastmail) |
| SC5 — CAL-08 go/no-go documented | CAL-08 | 01-04 Task 2 | Manual spike: app password reads shared + personal collections; `CAL-08-DECISION.md` committed with `Decision: GO|NO-GO` + fallback | manual (decision artifact) |
| Bonus — Pangolin SSE pass-through | D-08 (de-risks Phase 4) | 01-04 Task 1 (endpoint) + Task 3 (smoke) | `/api/sse/heartbeat` via streamSSE (tsc/grep); live: stream alive 5+ min over public URL, PASS/FAIL recorded | integration + manual |
*Status tracked per task by the planner. Status legend: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
*Status legend: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
---
## Wave 0 Requirements
## Wave 0 Requirements (created in Plan 01 Task 1)
- [ ] `vitest` + workspace test config installed (no framework exists — greenfield)
- [ ] Shared test fixtures: in-memory/throwaway MariaDB or test schema for Drizzle integration tests
- [ ] `apps/api/vitest.config.ts` — Node-environment vitest config
- [ ] `apps/api/tests/helpers/db.ts` — Drizzle test-DB fixtures
- [ ] `apps/api/tests/health.test.ts` — /health 200 (filled Plan 01 Task 2)
- [ ] `apps/api/tests/auth/user.test.ts` — upsertUser color/identity (filled Plan 02 Task 1)
- [ ] `apps/api/tests/broker/crypto.test.ts` — AES-GCM roundtrip/IV/tamper (filled Plan 03 Task 1)
- [ ] `apps/api/tests/broker/sync.test.ts` — all-day DATE split + UID upsert (filled Plan 03 Task 2)
- [ ] `apps/api/tests/broker/poller.test.ts` — ctag skip detection (filled Plan 03 Task 3)
- [ ] Fastmail CalDAV fixtures: captured raw VEVENT samples (timed + all-day) for offline parser unit tests
- [ ] Crypto helper test vectors for AES-256-GCM encrypt/decrypt round-trip
---
## Manual-Only Verifications
| Behavior | Requirement | Why Manual | Test Instructions |
|----------|-------------|------------|-------------------|
| Authelia OIDC end-to-end login | AUTH-01 | Requires real Authelia + Pangolin topology; no mock substitutes for redirect/cookie behavior | Reach public URL → redirected to Authelia → authenticate → land on home with no Fastmail prompt |
| Session persistence across restart | AUTH-02 | Browser-restart behavior not unit-testable | Log in, fully close browser, revisit URL → no re-login |
| Real Fastmail event fetch + cache | CAL-01 | Requires real app password + live calendar | Run broker against Lucas's account → confirm ≥1 event row cached |
| CAL-08 personal-calendar ACL spike | CAL-08 | Spike produces a human go/no-go judgement | Confirm app password reads shared + personal collections via PROPFIND/REPORT; record GO/NO-GO + fallback |
| Pangolin SSE pass-through smoke test | (D-08, de-risks Phase 4) | Idle-timeout behavior only observable over real public URL | Open long-lived SSE endpoint over Pangolin URL ≥5 min → confirm stream not cut |
| Behavior | Requirement | Why Manual | Plan/Task |
|----------|-------------|------------|-----------|
| Authelia OIDC end-to-end login (both members) | AUTH-01 | Requires real Authelia + Pangolin topology | 01-04 Task 3 |
| Session persistence across restart | AUTH-02 | Browser-restart behavior not unit-testable | 01-04 Task 3 |
| Real Fastmail event fetch + cache | CAL-01 | Requires real app password + live calendar | 01-04 Task 2 |
| CAL-08 personal-calendar ACL spike | CAL-08 | Spike produces a human go/no-go judgement | 01-04 Task 2 |
| Pangolin SSE pass-through smoke test | D-08 | Idle-timeout behavior only observable over real public URL | 01-04 Task 3 |
| drizzle-kit push against live MariaDB | CAL-01 (schema) | Live DB apply; [BLOCKING] gate | 01-01 Task 3 |
---
## Validation Sign-Off
- [ ] All tasks have an automated verify or a Wave 0 dependency, or are listed under Manual-Only with justification
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
- [ ] Wave 0 covers all MISSING references
- [ ] No watch-mode flags
- [ ] Feedback latency < 60s
- [ ] `nyquist_compliant: true` set in frontmatter once planner maps task IDs
- [x] All tasks have an automated verify or a Wave 0 dependency, or are listed under Manual-Only with justification
- [x] Sampling continuity: no 3 consecutive code tasks without automated verify (each TDD task has a vitest verify; integration tasks have tsc/grep)
- [x] Wave 0 covers all MISSING references (created in Plan 01 Task 1)
- [x] No watch-mode flags
- [x] Feedback latency < 60s
- [x] `nyquist_compliant: true` set in frontmatter
**Approval:** pending
**Approval:** planner-mapped
@@ -0,0 +1,56 @@
# Walking Skeleton — FamilySync
**Phase:** 1
**Generated:** 2026-06-04
## Capability Proven End-to-End
A member reaches the app over the real Pangolin tunnel, authenticates through Authelia (OIDC SSO), and lands on a single screen showing their name, their auto-assigned color, and one real event read from their Fastmail calendar via the CalDAV broker — proving Browser → Hono API → MariaDB cache → Fastmail all work together in the production topology.
## Architectural Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Monorepo | pnpm workspace, `apps/api` + `apps/pwa` | Shared TypeScript, single repo; matches RESEARCH recommended structure |
| Backend framework | Hono 4.12.23 on Node 22 (@hono/node-server) | Locked in CLAUDE.md; Web-Standards-native, built-in streamSSE, RPC type sharing |
| Data layer | MariaDB 11 + Drizzle ORM 0.45.2 via mysql2 3.22.4 | Locked stack; no PostgreSQL; type-safe SQL, no binary engine (vs Prisma) |
| Schema apply | `drizzle-kit push` against live MariaDB | Greenfield Phase 1; push is the [BLOCKING] gate before verification (Drizzle types come from schema.ts, not the live DB) |
| Auth | Authelia OIDC via @hono/oidc-auth 1.8.3 (authorization-code + PKCE S256, client_secret_basic) | Authelia already deployed; backend confidential client holds refresh token (D-12), no iframe |
| Identity | `oidc_iss + oidc_sub` composite key, never email | D-10 — email is mutable in Authelia |
| Member color | Auto-assigned round-robin from a curated accessible palette, persisted on the user row | D-06 — stable across sessions, no settings UI in v1 |
| Calendar access | CalDAV via tsdav 2.2.2; per-member Fastmail app passwords | D-02/D-09 — JMAP unavailable on Fastmail; per-member app passwords eliminate cross-account ACL risk |
| Credential storage | AES-256-GCM (node:crypto), key from `APP_PASSWORD_ENCRYPTION_KEY` env, backend-only | D-04 — encrypted at rest, never exposed to frontend |
| Calendar cache | `calendar_events`: raw VEVENT blob + `dtstart_utc` (timed) / `dtstart_date` (all-day) split; ctag/sync-token polling | D-13 — all-day never coerced to DATETIME (Pitfall 3); cache-first reads |
| Background sync | node-cron 4 every 5 min, ctag change detection | RESEARCH poller pattern; sync-token with ctag fallback from day one |
| Real-time transport | SSE (`streamSSE`); WebSocket rejected | Pangolin WS upgrade known-broken (issue #1034); SSE smoke-tested in Phase 1 to de-risk Phase 4 |
| Frontend | Vite 8 + React 19; TanStack Query (server state) + Zustand (UI state) | Locked stack; React Query owns server data, Zustand UI-only |
| Deployment target | Docker Compose on Unraid, public via Pangolin/Newt tunnel (same parent domain as Authelia) | D-07 — validate real OIDC/HTTPS/cookie topology from day one |
| Directory layout | `apps/api/src/{auth,broker,db,routes}`; broker is a hard module boundary | RESEARCH structure; only `broker/` imports tsdav + Fastmail credentials |
## Stack Touched in Phase 1
- [x] Project scaffold (pnpm workspace, Hono + Vite, tsconfig, Dockerfile, Vitest) — Plan 01
- [x] Routing — `/health` (public), `/callback`, `/api/me`, `/api/events`, `/api/sse/heartbeat` — Plans 01/02/03/04
- [x] Database — real read AND write: `/health` round-trip (Plan 01) + broker event cache upsert + `/api/events` read (Plan 03) — schema applied via `drizzle-kit push` (Plan 01)
- [x] UI — React shell fetches `/health`, `/api/me` (member + color), and `/api/events` (EventProof) — Plans 01/02/04
- [x] Deployment — full stack through the real Pangolin tunnel; Authelia login + SSE smoke test verified live — Plan 04
## Out of Scope (Deferred to Later Slices)
- Calendar UI / views (day/week/month/agenda) — Phase 2
- Wife's app password onboarding flow + her credential — Phase 2 (encrypted credential table built now to support it)
- Event write-back (create/edit/delete) to Fastmail — Phase 3
- PWA manifest + service worker + guided iOS install — Phase 3
- Shared lists + live SSE co-edit sync (the SSE *transport* is only smoke-tested here) — Phase 4
- Web Push notifications (VAPID) — Phase 5
- User-pickable color picker (settings UI) — deferred, v1.x
- Single-occurrence recurring edits — never in v1
## Subsequent Slice Plan
Each later phase adds one vertical slice on top of this skeleton without altering its architectural decisions:
- Phase 2: Unified color-coded read-only calendar (day/week/month/agenda) over the confirmed broker; add wife's credential
- Phase 3: Event write-back (CRUD) to Fastmail + PWA install (manifest, service worker, guided iOS onboarding)
- Phase 4: Shared named lists with item CRUD + real-time SSE co-edit sync (uses the SSE transport proven here)
- Phase 5: VAPID Web Push for event reminders, event changes, and list changes (iOS subscription health-check)