diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 8e133b5..cee1eb3 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -31,7 +31,12 @@ Decimal phases appear between their surrounding integers in numeric order. 3. Each member is assigned a stable, distinct display color that does not change between sessions 4. The broker successfully fetches and caches at least one event from the shared Fastmail calendar via CalDAV PROPFIND/REPORT 5. The personal-calendar ACL spike produces a documented go/no-go decision: either the broker token sees the wife's personal calendar after Fastmail share+accept, or the fallback strategy (shared-family-only or per-member app password) is chosen and recorded -**Plans**: TBD +**Plans**: 4 plans +Plans: +- [ ] 01-01-PLAN.md — Walking skeleton: monorepo scaffold + Docker/MariaDB + Drizzle schema (push) + /health end-to-end slice + Vitest Wave 0 harness +- [ ] 01-02-PLAN.md — Authelia OIDC slice: stable identity (iss+sub) + auto-assigned member color + /api/me + authenticated PWA shell (AUTH-01/02/03) +- [ ] 01-03-PLAN.md — CalDAV broker slice: AES-256-GCM credential encryption + tsdav broker + ical.js sync (all-day DATE) + ctag poller + /api/events (CAL-01) +- [ ] 01-04-PLAN.md — Integration + gate: wire poller/routes, event-proof landing page, CAL-08 spike + go/no-go doc, live Pangolin deploy + SSE smoke test ### Phase 2: Calendar Display **Goal**: Both members can see a unified, color-coded calendar aggregating all accessible Fastmail calendars across day, week, month, and agenda views — read-only, no write-back yet diff --git a/.planning/phases/01-foundation-broker-spike/01-01-PLAN.md b/.planning/phases/01-foundation-broker-spike/01-01-PLAN.md new file mode 100644 index 0000000..b46dc02 --- /dev/null +++ b/.planning/phases/01-foundation-broker-spike/01-01-PLAN.md @@ -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" +--- + + +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. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.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 + + + +## 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`. + + + + + + Task 1: Scaffold monorepo, Docker Compose stack, and Vitest harness + 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 + + - .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) + + + 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. + + + 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 + + + - `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") + + pnpm install succeeds in apps/api; vitest discovers and runs the Wave 0 test files; docker-compose.yml validates with mariadb healthcheck. + + + + Task 2: Drizzle schema + DB client + /health slice (end-to-end skeleton) + 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 + + - .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) + + + - 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 + + + 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. + + + cd apps/api && pnpm vitest run tests/health.test.ts --reporter=verbose && pnpm exec tsc --noEmit + + + - `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 + + tsc clean; health test green; App.tsx wired to /health; schema exports all four tables with all-day DATE separation. + + + + Task 3: [BLOCKING] Bring up stack and run drizzle-kit push + Docker Compose stack (MariaDB + Hono API) and the Drizzle schema in src/db/schema.ts. + + 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= 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 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,...}`. + + Type "approved" once drizzle-kit push exits 0, SHOW TABLES lists all four tables, and /health returns ok:true — or describe the failure. + + + + + +## 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 | + + + +- `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 }` + + + +- 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 + + + +Create `.planning/phases/01-foundation-broker-spike/01-01-SUMMARY.md` when done. + diff --git a/.planning/phases/01-foundation-broker-spike/01-02-PLAN.md b/.planning/phases/01-foundation-broker-spike/01-02-PLAN.md new file mode 100644 index 0000000..35bc03a --- /dev/null +++ b/.planning/phases/01-foundation-broker-spike/01-02-PLAN.md @@ -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" +--- + + +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. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.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 + + + +## 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`. + + + + + + Task 1: User upsert + stable color assignment (AUTH-03) + apps/api/src/auth/user.ts, apps/api/tests/auth/user.test.ts + + - .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) + + + - 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 + + + 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. + + + cd apps/api && pnpm vitest run tests/auth/user.test.ts --reporter=verbose + + + - `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 + + user.test.ts green; color assignment deterministic and stable; identity keyed on iss+sub. + + + + Task 2: Authelia OIDC middleware + /api/me + authenticated PWA shell (AUTH-01/02) + 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 + + - .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) + + + 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./callback), OIDC_AUTH_EXTERNAL_URL (https://familysync. — 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). + + + 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 + + + - `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 + + 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. + + + + + +## 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) | + + + +- `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 + + + +- 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 + + + +Create `.planning/phases/01-foundation-broker-spike/01-02-SUMMARY.md` when done. Include the Authelia client registration YAML for the operator. + diff --git a/.planning/phases/01-foundation-broker-spike/01-03-PLAN.md b/.planning/phases/01-foundation-broker-spike/01-03-PLAN.md new file mode 100644 index 0000000..f92bfe8 --- /dev/null +++ b/.planning/phases/01-foundation-broker-spike/01-03-PLAN.md @@ -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" +--- + + +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. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.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 + + + +## 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). + + + + + + Task 1: AES-256-GCM app-password encryption (CAL-01 security) + apps/api/src/broker/crypto.ts, apps/api/tests/broker/crypto.test.ts, .env.example + + - .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) + + + - 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) + + + 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. + + + cd apps/api && pnpm vitest run tests/broker/crypto.test.ts --reporter=verbose + + + - `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 + + crypto.test.ts green; encrypt/decrypt lossless; IV unique; tamper throws. + + + + Task 2: Broker client + sync (ical.js parse, all-day DATE) + events route (CAL-01) + 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 + + - .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) + + + - 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) + + + 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. + + + cd apps/api && pnpm vitest run tests/broker/sync.test.ts --reporter=verbose && pnpm exec tsc --noEmit + + + - `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 + + sync.test.ts green; all-day DATE handling correct; events route reads cache only; tsc clean. + + + + Task 3: node-cron poller with ctag change detection (CAL-01) + apps/api/src/broker/poller.ts, apps/api/tests/broker/poller.test.ts + + - .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) + + + - 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) + + + 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. + + + cd apps/api && pnpm vitest run tests/broker/poller.test.ts --reporter=verbose + + + - `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 + + poller.test.ts green; ctag skip logic correct; credentials decrypted not logged. + + + + + +## 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] | + + + +- `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) + + + +- 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 + + + +Create `.planning/phases/01-foundation-broker-spike/01-03-SUMMARY.md` when done. + diff --git a/.planning/phases/01-foundation-broker-spike/01-04-PLAN.md b/.planning/phases/01-foundation-broker-spike/01-04-PLAN.md new file mode 100644 index 0000000..99cdbe5 --- /dev/null +++ b/.planning/phases/01-foundation-broker-spike/01-04-PLAN.md @@ -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" +--- + + +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. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.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 + + + +## 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). + + + + + + Task 1: Wire broker + routes into bootstrap, add SSE smoke endpoint, render event proof + 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 + + - .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) + + + 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 `` together — the single broker-proof landing screen (Claude's Discretion landing page). + + + 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 + + + - `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 + + tsc clean; full route mount + poller boot; SSE heartbeat endpoint; landing page shows member + cached event proof. + + + + Task 2: [BLOCKING] CAL-08 spike — confirm app password reads shared + personal calendars, record go/no-go + 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. + + 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= FASTMAIL_EMAIL= FASTMAIL_APP_PASSWORD= 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 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. + + 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. + + + + Task 3: [BLOCKING] Live deployment — Authelia login (both members) + Pangolin SSE smoke test + 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. + + 1. Register the FamilySync OIDC client in Authelia using the YAML captured in 01-02-SUMMARY (client_id familysync, redirect_uris https://familysync./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 ` 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. → 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./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). + + 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. + + + + + +## 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 | + + + +- `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 + + + +- 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 + + + +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. + diff --git a/.planning/phases/01-foundation-broker-spike/01-VALIDATION.md b/.planning/phases/01-foundation-broker-spike/01-VALIDATION.md index ee5dfca..770e84f 100644 --- a/.planning/phases/01-foundation-broker-spike/01-VALIDATION.md +++ b/.planning/phases/01-foundation-broker-spike/01-VALIDATION.md @@ -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 diff --git a/.planning/phases/01-foundation-broker-spike/SKELETON.md b/.planning/phases/01-foundation-broker-spike/SKELETON.md new file mode 100644 index 0000000..8ac2b39 --- /dev/null +++ b/.planning/phases/01-foundation-broker-spike/SKELETON.md @@ -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)