style(13-03): apply Prettier formatting across repo

Mechanical reformat — no logic changes. 398 files changed, 19125
insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc
(singleQuote:true, semi:true, tabWidth:2, trailingComma:all,
printWidth:100). Isolated per D-13-08 for reviewability.
This commit is contained in:
Lucas Berger
2026-06-11 20:35:18 -04:00
parent 4bc0445173
commit 982438dc10
398 changed files with 19050 additions and 16382 deletions
@@ -36,44 +36,44 @@ autonomous: false
requirements: [CAL-01]
user_setup:
- service: mariadb
why: "Local stack DB; provisioned via Docker Compose (no external account)"
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)"
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)"
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)"
- '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"
- 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"
- 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"
- 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"
---
@@ -103,6 +103,7 @@ Output: Running Docker stack, applied DB schema, a green `/health` slice, and th
</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`.
@@ -138,6 +139,7 @@ New env vars: `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, `DB_NAME`, `DB_ROO
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>
@@ -177,6 +179,7 @@ New env vars: `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, `DB_NAME`, `DB_ROO
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>
@@ -211,21 +214,23 @@ New env vars: `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, `DB_NAME`, `DB_ROO
</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 |
| 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 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>
@@ -238,12 +243,13 @@ New env vars: `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, `DB_NAME`, `DB_ROO
</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>
</success_criteria>
<output>
Create `.planning/phases/01-foundation-broker-spike/01-01-SUMMARY.md` when done.
@@ -148,6 +148,7 @@ None — plan executed exactly as specified. One minor pnpm API difference (allo
### Auto-fixed Issues
**1. [Rule 3 - Blocking] pnpm 11 allowBuilds syntax**
- **Found during:** Task 1 (pnpm install)
- **Issue:** `pnpm install` failed with `ERR_PNPM_IGNORED_BUILDS: esbuild@*`. pnpm 11 uses `allowBuilds` map (not `onlyBuiltDependencies` list used in older versions)
- **Fix:** Set `allowBuilds.esbuild: true` in pnpm-workspace.yaml
@@ -156,6 +157,7 @@ None — plan executed exactly as specified. One minor pnpm API difference (allo
- **Committed in:** `3f59156` (Task 1 commit)
**2. [Rule 1 - Bug] vi.mock hoisting in health test**
- **Found during:** Task 2 (writing TDD RED test)
- **Issue:** Placing `vi.mock()` inside `describe()` blocks caused Vitest hoisting warnings; tests used `resetModules` approach which conflicted with hoisting behavior
- **Fix:** Moved `vi.mock` to module top level; used `vi.mocked().mockRejectedValueOnce()` for per-test override
@@ -164,6 +166,7 @@ None — plan executed exactly as specified. One minor pnpm API difference (allo
- **Committed in:** `96cda58` (Task 2 feat commit)
**3. [Checkpoint clearing - Blocking] Docker image build broken for pnpm workspace**
- **Found during:** Task 3 (orchestrator bringing up the stack to clear the checkpoint)
- **Issue:** The original `apps/api/Dockerfile` built from a `./apps/api` context and could not work in a pnpm workspace:
1. `COPY package.json pnpm-lock.yaml* ./` + `pnpm install --frozen-lockfile` failed (`ERR_PNPM_NO_LOCKFILE`) — the lockfile lives at the repo root, not in `apps/api/`.
@@ -200,6 +203,7 @@ None. The `serveStatic` warning for `./public` in tests is expected (no built PW
## Threat Surface Scan
No new threat surface beyond what was planned in the threat model:
- T-01-01: `.env` excluded from git via `.gitignore`
- T-01-02: `familysync` user (not root) in docker-compose.yml ✓
- T-01-03: `/health` unauthenticated, returns only `{ok, db}`
@@ -221,5 +225,6 @@ No new threat surface beyond what was planned in the threat model:
- Commits 3f59156, f31711a, 96cda58: FOUND
---
*Phase: 01-foundation-broker-spike*
*Completed: 2026-06-04 (Tasks 1-2; Task 3 at checkpoint)*
_Phase: 01-foundation-broker-spike_
_Completed: 2026-06-04 (Tasks 1-2; Task 3 at checkpoint)_
@@ -3,7 +3,7 @@ phase: 01-foundation-broker-spike
plan: 02
type: execute
wave: 2
depends_on: ["01-01"]
depends_on: ['01-01']
files_modified:
- apps/api/src/auth/middleware.ts
- apps/api/src/auth/user.ts
@@ -19,33 +19,33 @@ 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"
- '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"]
- 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"
- 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"
- 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>
@@ -73,6 +73,7 @@ Output: Working Authelia OIDC login, stable identity + color, /api/me, authentic
</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`.
@@ -105,6 +106,7 @@ New env vars: `OIDC_AUTH_SECRET`, `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_
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>
@@ -136,6 +138,7 @@ New env vars: `OIDC_AUTH_SECRET`, `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_
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>
@@ -155,24 +158,26 @@ New env vars: `OIDC_AUTH_SECRET`, `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_
</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 |
| 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 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>
@@ -185,11 +190,12 @@ New env vars: `OIDC_AUTH_SECRET`, `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_
</verification>
<success_criteria>
- AUTH-01: unauthenticated /api/* redirects to Authelia; login lands authenticated (verified live in Plan 04)
- 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>
</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.
@@ -69,7 +69,7 @@ completed: "2026-06-04"
- **Task 1 (TDD):** Replaced 5 `it.todo` stubs in `tests/auth/user.test.ts` with 6 real tests covering: palette[0] first user, palette[1] second distinct user, idempotent re-upsert (no duplicate insert), identity keyed on iss+sub not email, full row shape. All GREEN.
- **Task 2:** Wired full OIDC vertical slice:
- `src/auth/middleware.ts` re-exports oidcAuthMiddleware/processOAuthCallback/getAuth from @hono/oidc-auth
- `src/index.ts` updated: /health (public) → /callback → /api/* guarded by oidcAuthMiddleware → /api/me mounted
- `src/index.ts` updated: /health (public) → /callback → /api/\* guarded by oidcAuthMiddleware → /api/me mounted
- `src/routes/me.ts` calls getAuth → upsertUser(iss, sub, email) → returns {user: {id, displayName, color}}
- `apps/pwa/src/api/client.ts` typed fetchMe() with credentials: 'include'
- `apps/pwa/src/App.tsx` renders MemberBadge (name + color swatch circle) via useQuery(['me'], fetchMe)
@@ -85,7 +85,7 @@ completed: "2026-06-04"
- `apps/api/src/auth/user.ts``upsertUser` + `COLOR_PALETTE` (6 hex hues)
- `apps/api/src/auth/middleware.ts``oidcAuthMiddleware`, `processOAuthCallback`, `getAuth` re-exports with env var documentation
- `apps/api/src/routes/me.ts``GET /` handler: getAuth → upsertUser → `{user: {id, displayName, color}}`
- `apps/api/src/index.ts` — updated mount order: /health → /callback → oidcAuthMiddleware on /api/* → /api/me
- `apps/api/src/index.ts` — updated mount order: /health → /callback → oidcAuthMiddleware on /api/\* → /api/me
- `apps/pwa/src/api/client.ts``fetchMe()` with typed response shape
- `apps/pwa/src/App.tsx``MemberBadge` component with `ColorSwatch`; useQuery(['me'], fetchMe); retains /health indicator
- `apps/api/tests/auth/user.test.ts` — 6 passing tests (was 5 it.todo stubs)
@@ -127,13 +127,13 @@ identity_providers:
**`.env` values to set before first run:**
| Variable | Value |
|----------|-------|
| `OIDC_AUTH_SECRET` | 32+ char random string (e.g. `openssl rand -base64 32`) |
| `OIDC_ISSUER` | Authelia base URL, e.g. `https://auth.yourdomain.com` |
| `OIDC_CLIENT_ID` | `familysync` |
| `OIDC_CLIENT_SECRET` | Plain text secret (same value used with `authelia crypto hash`) |
| `OIDC_REDIRECT_URI` | `https://familysync.yourdomain.com/callback` |
| Variable | Value |
| ------------------------ | ------------------------------------------------------------------------------- |
| `OIDC_AUTH_SECRET` | 32+ char random string (e.g. `openssl rand -base64 32`) |
| `OIDC_ISSUER` | Authelia base URL, e.g. `https://auth.yourdomain.com` |
| `OIDC_CLIENT_ID` | `familysync` |
| `OIDC_CLIENT_SECRET` | Plain text secret (same value used with `authelia crypto hash`) |
| `OIDC_REDIRECT_URI` | `https://familysync.yourdomain.com/callback` |
| `OIDC_AUTH_EXTERNAL_URL` | `https://familysync.yourdomain.com`**mandatory** behind Pangolin (Pitfall 1) |
**Note:** `OIDC_AUTH_EXTERNAL_URL` is not optional behind Pangolin. Without it, `@hono/oidc-auth` constructs the redirect_uri from the internal container `Host` header, which won't match the registered URI in Authelia — login fails with "invalid redirect_uri".
@@ -154,7 +154,7 @@ All surfaces are within the planned threat model (Plan 02 STRIDE register):
- **T-02-02 (CSRF):** processOAuthCallback uses PKCE (state + code_verifier); Authelia configured with require_pkce: true, S256
- **T-02-03 (cookie tampering):** @hono/oidc-auth signs cookie with OIDC_AUTH_SECRET; httpOnly + Secure + SameSite enforced by library
- **T-02-04 (refresh token / client_secret):** backend-only (D-12); getAuth → upsertUser → returns {id, displayName, color} only — no token or credential data in /api/me response
- **T-02-05 (/api/* without auth):** oidcAuthMiddleware on /api/*; /health public-before-guard
- **T-02-05 (/api/\* without auth):** oidcAuthMiddleware on /api/\*; /health public-before-guard
- **T-02-06 (identity confusion):** upsertUser keyed exclusively on oidcIss + oidcSub; no email lookup anywhere in auth path
No new threat surface introduced beyond plan.
@@ -170,5 +170,6 @@ No new threat surface introduced beyond plan.
- 6 auth/user tests pass: PASSED
---
*Phase: 01-foundation-broker-spike*
*Completed: 2026-06-04*
_Phase: 01-foundation-broker-spike_
_Completed: 2026-06-04_
@@ -3,7 +3,7 @@ phase: 01-foundation-broker-spike
plan: 03
type: execute
wave: 2
depends_on: ["01-01"]
depends_on: ['01-01']
files_modified:
- apps/api/src/broker/crypto.ts
- apps/api/src/broker/client.ts
@@ -19,40 +19,40 @@ 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"
- '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)"
- '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"]
- 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"
- 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"
- 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>
@@ -80,6 +80,7 @@ Output: crypto helper, broker client/sync/poller, /api/events router, all unit-t
</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`.
@@ -113,6 +114,7 @@ New env vars: `APP_PASSWORD_ENCRYPTION_KEY` (64-char hex = 32 bytes).
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>
@@ -149,6 +151,7 @@ New env vars: `APP_PASSWORD_ENCRYPTION_KEY` (64-char hex = 32 bytes).
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>
@@ -183,6 +186,7 @@ New env vars: `APP_PASSWORD_ENCRYPTION_KEY` (64-char hex = 32 bytes).
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>
@@ -199,24 +203,26 @@ New env vars: `APP_PASSWORD_ENCRYPTION_KEY` (64-char hex = 32 bytes).
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| 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 |
| 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 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>
@@ -227,12 +233,13 @@ New env vars: `APP_PASSWORD_ENCRYPTION_KEY` (64-char hex = 32 bytes).
</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>
</success_criteria>
<output>
Create `.planning/phases/01-foundation-broker-spike/01-03-SUMMARY.md` when done.
@@ -1,6 +1,6 @@
---
phase: 01-foundation-broker-spike
plan: "03"
plan: '03'
subsystem: api
tags: [caldav, ical.js, tsdav, node-cron, aes-256-gcm, drizzle, mariadb, vitest]
@@ -46,22 +46,22 @@ key-files:
- .env.example (APP_PASSWORD_ENCRYPTION_KEY with generator comment)
key-decisions:
- "Store dtstartDate as JS Date at T00:00:00Z (not raw string): Drizzle date column expects a Date or null; ical.js toString().slice(0,10) gives the YYYY-MM-DD, appending T00:00:00Z avoids TZ ambiguity"
- "Export runPoll separately from startBrokerPoller: lets tests invoke one poll cycle synchronously with vi.mock injected deps, avoiding real cron schedule in tests"
- "ctag skip condition: null ctag means first sync (no row stored) → must always sync; only skip when both sides have a non-null matching ctag"
- 'Store dtstartDate as JS Date at T00:00:00Z (not raw string): Drizzle date column expects a Date or null; ical.js toString().slice(0,10) gives the YYYY-MM-DD, appending T00:00:00Z avoids TZ ambiguity'
- 'Export runPoll separately from startBrokerPoller: lets tests invoke one poll cycle synchronously with vi.mock injected deps, avoiding real cron schedule in tests'
- 'ctag skip condition: null ctag means first sync (no row stored) → must always sync; only skip when both sides have a non-null matching ctag'
- "Per-credential try/catch in runPoll: one corrupted or expired credential must not block other members' calendars from syncing"
- "events route imports db but never tsdav or crypto: enforces broker hard boundary (T-03-02)"
- 'events route imports db but never tsdav or crypto: enforces broker hard boundary (T-03-02)'
patterns-established:
- "Pattern: broker boundary — tsdav and credentials are imported exclusively under apps/api/src/broker/; routes never touch Fastmail I/O"
- "Pattern: D-13 dtstart split — use ical.js ICAL.Time.isDate to route all-day vs timed into separate nullable columns"
- "Pattern: ctag null-defence — always use davCal.ctag ?? davCal.syncToken ?? null; Fastmail may return either field"
- 'Pattern: broker boundary — tsdav and credentials are imported exclusively under apps/api/src/broker/; routes never touch Fastmail I/O'
- 'Pattern: D-13 dtstart split — use ical.js ICAL.Time.isDate to route all-day vs timed into separate nullable columns'
- 'Pattern: ctag null-defence — always use davCal.ctag ?? davCal.syncToken ?? null; Fastmail may return either field'
requirements-completed: [CAL-01]
# Metrics
duration: ~multi-session
completed: "2026-06-04"
completed: '2026-06-04'
---
# Phase 01 Plan 03: CalDAV Broker Slice — Summary
@@ -100,7 +100,7 @@ Each task committed with TDD RED → GREEN cycle:
- `apps/api/src/broker/crypto.ts` — encryptPassword / decryptPassword using node:crypto aes-256-gcm; 96-bit IV; JSON payload {iv, authTag, ciphertext} as hex
- `apps/api/src/broker/client.ts` — createFastmailClient(email, appPassword) → tsdav DAVClient; FastmailClient type alias
- `apps/api/src/broker/sync.ts` — syncCalendar: upserts calendars row, fetches REPORT objects, ical.js parses VEVENTs, upserts calendarEvents with D-13 split; onDuplicateKeyUpdate on calendarId+uid
- `apps/api/src/broker/poller.ts` — startBrokerPoller (node-cron */5 * * * *) + runPoll (exported for tests); per-credential try/catch
- `apps/api/src/broker/poller.ts` — startBrokerPoller (node-cron _/5 _ \* \* \*) + runPoll (exported for tests); per-credential try/catch
- `apps/api/src/routes/events.ts` — eventsRouter GET / reads from db.select().from(calendarEvents); no tsdav import
- `apps/api/tests/broker/crypto.test.ts` — roundtrip, IV uniqueness, tamper-throws
- `apps/api/tests/broker/sync.test.ts` — timed dtstart_utc, all-day dtstart_date, same-UID idempotency
@@ -125,6 +125,7 @@ None — the draft poller.ts written by the interrupted agent passed all tests o
## User Setup Required
Add to `.env`:
```
APP_PASSWORD_ENCRYPTION_KEY=<64-char hex> # node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
```
@@ -139,6 +140,7 @@ No external service configuration required for this plan. Live Fastmail integrat
## Threat Surface Scan
No new surface beyond the plan's threat model:
- T-03-01: AES-256-GCM with 96-bit IV + auth tag — implemented in crypto.ts
- T-03-02: /api/events reads cache only, no tsdav import in routes/events.ts
- T-03-03: GCM auth tag verified on decrypt; tampered ciphertext throws (test asserts this)
@@ -157,5 +159,6 @@ No new surface beyond the plan's threat model:
## Self-Check: PASSED
---
*Phase: 01-foundation-broker-spike*
*Completed: 2026-06-04*
_Phase: 01-foundation-broker-spike_
_Completed: 2026-06-04_
@@ -3,7 +3,7 @@ phase: 01-foundation-broker-spike
plan: 04
type: execute
wave: 3
depends_on: ["01-02", "01-03"]
depends_on: ['01-02', '01-03']
files_modified:
- apps/api/src/routes/sse.ts
- apps/api/src/index.ts
@@ -17,35 +17,35 @@ 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"
- '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)"
- '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"
- 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"
- 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"
- 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>
@@ -75,6 +75,7 @@ Output: fully wired app, landing page with member + event proof, CAL-08 decision
</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`.
@@ -103,6 +104,7 @@ Modified: `apps/api/src/index.ts` (mount events/sse routers, startBrokerPoller o
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>
@@ -149,24 +151,26 @@ Modified: `apps/api/src/index.ts` (mount events/sse routers, startBrokerPoller o
</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 |
| 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 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>
@@ -179,13 +183,14 @@ Modified: `apps/api/src/index.ts` (mount events/sse routers, startBrokerPoller o
</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>
</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.
@@ -1,6 +1,6 @@
---
phase: 01-foundation-broker-spike
plan: "04"
plan: '04'
subsystem: integration
tags: [hono, sse, caldav, react, typescript, pwa, spike, pangolin]
@@ -21,12 +21,12 @@ affects:
# Tech tracking
tech-stack:
added:
- "hono/streaming (streamSSE) — SSE streaming helper, already a peer dep of hono"
- "ical.js@2.2.1 added to apps/pwa (already in apps/api; needed for EventProof summary parsing)"
- 'hono/streaming (streamSSE) — SSE streaming helper, already a peer dep of hono'
- 'ical.js@2.2.1 added to apps/pwa (already in apps/api; needed for EventProof summary parsing)'
patterns:
- "SSE auth: sseRouter mounted under /api/sse, behind oidcAuthMiddleware — no unauthenticated SSE access (T-04-01)"
- "Bootstrap order: /callback → /health (pre-guard) → app.use(/api/*, oidcAuthMiddleware) → /api/me → /api/events → /api/sse → startBrokerPoller → serveStatic"
- "Spike pattern: env-var credentials (FASTMAIL_EMAIL, FASTMAIL_APP_PASSWORD) → createFastmailClient → fetchCalendars → print URLs; never log password (T-04-04)"
- 'SSE auth: sseRouter mounted under /api/sse, behind oidcAuthMiddleware — no unauthenticated SSE access (T-04-01)'
- 'Bootstrap order: /callback → /health (pre-guard) → app.use(/api/*, oidcAuthMiddleware) → /api/me → /api/events → /api/sse → startBrokerPoller → serveStatic'
- 'Spike pattern: env-var credentials (FASTMAIL_EMAIL, FASTMAIL_APP_PASSWORD) → createFastmailClient → fetchCalendars → print URLs; never log password (T-04-04)'
- "EventProof: tries ical.js parse for SUMMARY field; falls back to 'Untitled event' on parse failure — resilient to malformed VEVENT blobs"
key-files:
@@ -42,13 +42,13 @@ key-files:
- apps/pwa/package.json (added ical.js@2.2.1 dependency)
key-decisions:
- "ical.js added to PWA for VEVENT summary parsing in EventProof: EventProof parses rawVevent to extract the SUMMARY field for a human-readable event title; ical.js is already approved and used in the API — the same package at the same version"
- "spike.ts uses tsx (dev-only runner) via pnpm exec: tsx is accepted per T-04-SC; spike is not imported by the API or Docker image"
- 'ical.js added to PWA for VEVENT summary parsing in EventProof: EventProof parses rawVevent to extract the SUMMARY field for a human-readable event title; ical.js is already approved and used in the API — the same package at the same version'
- 'spike.ts uses tsx (dev-only runner) via pnpm exec: tsx is accepted per T-04-SC; spike is not imported by the API or Docker image'
- "CAL-08-DECISION.md committed as a template now, human-filled after running spike: the file must contain 'Decision:' to satisfy the artifact spec; template pre-populates the structure"
# Metrics
duration: ~3min (code tasks only; live verification pending)
completed: "2026-06-04"
completed: '2026-06-04'
---
# Phase 01 Plan 04: Integration + Gate Slice — Summary
@@ -102,6 +102,7 @@ These are `checkpoint:human-action` tasks that require real infrastructure:
- Fastmail Settings → Privacy & Security → App Passwords → scope: "Mail, Contacts & Calendars"
2. Run the spike:
```bash
cd apps/api
FASTMAIL_EMAIL=lucas@fastmail.com \
@@ -112,6 +113,7 @@ These are `checkpoint:human-action` tasks that require real infrastructure:
3. Confirm in output: (a) shared family calendar collection URL appears; (b) Lucas's personal calendar URL appears. Record both.
4. To insert the credential into the DB for the broker to use (one-off node script):
```bash
# First ensure APP_PASSWORD_ENCRYPTION_KEY is set in .env
cd apps/api
@@ -124,6 +126,7 @@ These are `checkpoint:human-action` tasks that require real infrastructure:
```
5. Start the stack and verify at least one event lands in `calendar_events`:
```bash
docker compose exec mariadb mariadb -ufamilysync -p<pw> familysync \
-e "SELECT id, uid, all_day FROM calendar_events LIMIT 5;"
@@ -145,12 +148,15 @@ These are `checkpoint:human-action` tasks that require real infrastructure:
**What to do:**
1. Register FamilySync OIDC client in Authelia (see 01-02-SUMMARY.md for full YAML):
```bash
authelia crypto hash --sha512 <your-plain-client-secret>
```
Add the client block to Authelia's `configuration.yml` and reload.
2. Set env vars in `.env`:
```
OIDC_AUTH_SECRET=<openssl rand -base64 32>
OIDC_ISSUER=https://auth.<domain>
@@ -161,6 +167,7 @@ These are `checkpoint:human-action` tasks that require real infrastructure:
```
3. Expose FamilySync through Pangolin under the SAME parent domain as Authelia (same-site cookie requirement — Pitfall 1).
```bash
docker compose up -d
```
@@ -172,9 +179,11 @@ These are `checkpoint:human-action` tasks that require real infrastructure:
6. Repeat for second member (wife) → confirm distinct color (AUTH-03).
7. SSE smoke test (D-08) — run from external network with a valid session cookie:
```bash
curl -N -b "session=<cookie-value>" https://familysync.<domain>/api/sse/heartbeat
```
Keep open 5+ minutes. Record: **PASS** (events keep arriving) or **FAIL** (stream cut by proxy).
8. Record SSE smoke result in SUMMARY (update this file) for Phase 4 transport decision.
@@ -193,6 +202,7 @@ If FAIL: investigate Pangolin idle-timeout config; note for Phase 4.
## Deviations from Plan
**[Rule 2 - Missing dependency] ical.js added to PWA for EventProof summary parsing**
- **Found during:** Task 1 (EventProof.tsx implementation)
- **Issue:** EventProof.tsx parses rawVevent strings using ical.js to extract human-readable SUMMARY. ical.js was only in apps/api; EventProof runs in the browser.
- **Fix:** Added ical.js@2.2.1 to apps/pwa/package.json. Same package, same version, already approved in the legitimacy audit.
@@ -206,6 +216,7 @@ If FAIL: investigate Pangolin idle-timeout config; note for Phase 4.
## Threat Surface Scan
All surfaces within Plan 04 threat model:
- **T-04-01 (SSE auth):** sseRouter mounted under `/api/sse` behind `oidcAuthMiddleware` — confirmed
- **T-04-02 (SSE payload):** heartbeat carries only `{ ts, id }` — no user data or secrets
- **T-04-03 (same-site cookies):** operator must expose FamilySync under same parent domain as Authelia — documented in Task 3 steps
@@ -227,5 +238,6 @@ All surfaces within Plan 04 threat model:
- `pnpm vitest run` 24/24 tests green: PASSED
---
*Phase: 01-foundation-broker-spike*
*Completed (code): 2026-06-04 — Live verification pending*
_Phase: 01-foundation-broker-spike_
_Completed (code): 2026-06-04 — Live verification pending_
@@ -16,6 +16,7 @@ Stand up the FamilySync stack (Docker Compose + MariaDB, deployed through the re
## Implementation Decisions
### Personal-Calendar Access & Spike (CAL-08 gate)
- **D-01:** The wife's personal calendar lives on **Fastmail** (confirmed by user). It is a Fastmail-hosted calendar collection, so the broker can reach it via CalDAV. This removes the iCloud "unreachable" risk entirely.
- **D-02:** Broker access model = **per-member app passwords**, NOT single-token cross-account share+accept. Each member generates their own Fastmail app password; the broker holds N credentials and reads each account directly. This eliminates the unconfirmed cross-account CalDAV ACL discovery risk — the original highest project risk. The CAL-08 spike therefore simplifies to: confirm an app password reads its own account's shared + personal calendars via PROPFIND/REPORT.
- **D-03:** Phase 1 proceeds with **only the primary user's (Lucas) app password**. Success criterion #4 (read+cache a real event) is proven against Lucas's personal + the shared family calendar. The wife's app password is added in Phase 2 — Phase 1 is NOT blocked on coordinating with her. CAL-08 is structurally proven (N-credential broker) without her credential present.
@@ -23,13 +24,16 @@ Stand up the FamilySync stack (Docker Compose + MariaDB, deployed through the re
- **D-05:** Go/no-go record: since access is per-member app passwords and her calendar is on Fastmail, the expected outcome is GO. The documented fallback (only relevant if a Fastmail app password unexpectedly cannot read a personal calendar) is shared-family-only for v1, moving CAL-08 to v1.x.
### Member Color Assignment (AUTH-03)
- **D-06:** Each member's color is **auto-assigned from a curated palette on first login** and persisted on the user row (keyed by `oidc_iss + oidc_sub`). Stable across sessions, no settings UI in Phase 1, works for both current members and any future member. Not user-pickable in v1 (a settings color-picker is a deferred idea).
### Infrastructure & Deployment Scope
- **D-07:** Phase 1 **deploys through the real Pangolin tunnel + Authelia**, not local-only. OIDC redirect URIs, HTTPS, and session cookies are validated in the real topology from day one to avoid a "worked locally, broke in prod" OIDC failure. Ensure FamilySync and Authelia share the same parent domain so Authelia session cookies are same-site (Pitfall 17).
- **D-08:** Phase 1 **folds in the Pangolin SSE pass-through smoke test** (a trivial long-lived SSE endpoint confirmed over the public URL). De-risks Phase 4 transport choice early (issue #1034). A failure here changes the Phase 4 real-time transport decision; it does NOT block Phase 1's auth/broker success criteria.
### Locked Upstream (carried forward — do NOT re-litigate)
- **D-09:** CalDAV-only via `tsdav`; broker auth = Fastmail **app password**, never JMAP/API token. Principal URL form `https://caldav.fastmail.com/dav/principals/user/{email}/` (Pitfall 1).
- **D-10:** Identity = `oidc_iss + oidc_sub` composite key, never email (AUTH-03).
- **D-11:** **Skip the Authelia `groups` claim** — two equal members; authentication not authorization (Pitfall 16).
@@ -37,29 +41,34 @@ Stand up the FamilySync stack (Docker Compose + MariaDB, deployed through the re
- **D-13:** Calendar cache: store raw VEVENT blob + `dtstart_utc`; all-day events as `DATE` / `{date, allDay}` struct, never coerced to DATETIME/UTC (Pitfall 3). Write-through cache invalidation; only cache server-returned objects (Pitfall 14). Use sync-token (WebDAV-Sync) with ctag-poll fallback from day one (Pitfall 4).
### Claude's Discretion
- **Phase 1 landing page:** a thin authenticated shell that ALSO displays the one cached event as broker proof (not a bare health page, not a real calendar UI). Confirms end-to-end auth + broker in one screen.
- **Color palette:** a small set of visually-distinct, accessible hues assigned round-robin by join order. Exact values are Claude's choice.
- **Broker internals:** sync-token vs ctag detection, poll interval (research suggests conservative 5-min / 60s acceptable for v1), Drizzle schema specifics, OIDC middleware wiring, encryption helper implementation.
- **Stack libraries/versions:** per locked research stack (Hono + Drizzle/mysql2 + tsdav + ical.js + rrule + @hono/oidc-auth).
</decisions>
</decisions>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
### Phase definition & requirements
- `.planning/ROADMAP.md` § "Phase 1: Foundation + Broker Spike" — goal + 5 success criteria (the scope anchor)
- `.planning/REQUIREMENTS.md` — AUTH-01, AUTH-02, AUTH-03, CAL-01, CAL-08 (full requirement text + traceability)
- `.planning/PROJECT.md` — constraints, key decisions, household context
### Research (read before planning — flagged NEEDS research-phase by SUMMARY)
- `.planning/research/SUMMARY.md` — cross-cutting findings; Phase 1 section + confidence assessment
- `.planning/research/PITFALLS.md` — Phase-1-relevant pitfalls: #1 (CalDAV-only), #3 (all-day DATE), #4 (ETag/sync-token), #7 (personal-cal sharing), #14 (cache double-write), #16 (Authelia groups), #17 (Authelia silent renewal/cookies), #18 (Pangolin WS/SSE)
- `.planning/research/STACK.md` — locked library versions + import paths
- `.planning/research/ARCHITECTURE.md` — broker-cache pattern, component layout, MariaDB schema guidance
### External docs (authoritative)
- Fastmail CalDAV principal URL + app passwords (see SUMMARY/PITFALLS Sources)
- Authelia OIDC client config — PKCE S256, `client_secret_basic`, response_type `code`, grant types `authorization_code`+`refresh_token` (see CLAUDE.md "Authelia OIDC Integration")
- Pangolin WebSocket/SSE issue #1034 (referenced in PITFALLS #18)
@@ -67,15 +76,19 @@ Stand up the FamilySync stack (Docker Compose + MariaDB, deployed through the re
</canonical_refs>
<code_context>
## Existing Code Insights
### Reusable Assets
- None — greenfield repo. Only `.planning/`, generated `CLAUDE.md`, and an empty `README` exist. No source tree yet.
### Established Patterns
- None established yet. Phase 1 sets the foundational patterns (Docker Compose layout, Drizzle schema/migrations, broker module boundary, OIDC session middleware) that later phases inherit.
### Integration Points
- Authelia (already deployed, both members have accounts) — register FamilySync as an OIDC confidential client; no Authelia deploy/provisioning.
- Pangolin/Newt tunnel (already running) — FamilySync gets a public hostname under the same parent domain as Authelia.
- MariaDB + Redis available in the Unraid stack; no PostgreSQL.
@@ -86,7 +99,7 @@ Stand up the FamilySync stack (Docker Compose + MariaDB, deployed through the re
## Specific Ideas
- The broker module must be a hard boundary: all Fastmail I/O isolated in `broker/`; nothing else imports Fastmail credentials or tsdav directly (architecture note).
- "No per-member credential juggling" is a core-value phrase, but the user explicitly accepted per-member app passwords as the access model — the juggling avoided is calendar-credential *login* friction for members (they still log in only via Authelia SSO), not broker-side secrets.
- "No per-member credential juggling" is a core-value phrase, but the user explicitly accepted per-member app passwords as the access model — the juggling avoided is calendar-credential _login_ friction for members (they still log in only via Authelia SSO), not broker-side secrets.
</specifics>
@@ -98,11 +111,12 @@ Stand up the FamilySync stack (Docker Compose + MariaDB, deployed through the re
- **Wife's app password onboarding flow** — the encrypted-credential DB table is built in Phase 1 to support it, but the actual onboarding UX/endpoint is Phase 2 work.
### Reviewed Todos (not folded)
- "Kick off FamilySync with /gsd:new-project" — stale project-kickoff todo, already completed during initialization; not relevant to Phase 1 scope.
</deferred>
---
*Phase: 1-Foundation + Broker Spike*
*Context gathered: 2026-06-04*
_Phase: 1-Foundation + Broker Spike_
_Context gathered: 2026-06-04_
@@ -11,52 +11,56 @@
## Area Selection
| Option | Selected |
|--------|----------|
| Personal-cal spike & fallback | ✓ |
| Member color assignment | ✓ |
| Phase 1 landing scope | (skipped → Claude's discretion) |
| Infra validation scope | ✓ |
| Option | Selected |
| ----------------------------- | ------------------------------- |
| Personal-cal spike & fallback | ✓ |
| Member color assignment | ✓ |
| Phase 1 landing scope | (skipped → Claude's discretion) |
| Infra validation scope | ✓ |
---
## Personal-cal spike & fallback
### Where the wife's personal calendar lives
| Option | Description | Selected |
|--------|-------------|----------|
| Fastmail | Fastmail-hosted collection; broker can reach it | ✓ |
| iCloud / Apple Calendar | Broker cannot read; CAL-08 degrades | |
| Not sure yet | Confirm before spike | |
| Both / mixed | Define overlay target | |
| Option | Description | Selected |
| ----------------------- | ----------------------------------------------- | -------- |
| Fastmail | Fastmail-hosted collection; broker can reach it | ✓ |
| iCloud / Apple Calendar | Broker cannot read; CAL-08 degrades | |
| Not sure yet | Confirm before spike | |
| Both / mixed | Define overlay target | |
**User's choice:** Fastmail
**Notes:** Removes the iCloud "unreachable" risk; CAL-08 is technically achievable.
### Broker access model
| Option | Description | Selected |
|--------|-------------|----------|
| Single broker token + share | One token; share+accept per member | |
| Per-member app passwords | Each member's own app password; broker holds N creds | ✓ |
| Prove single-token, keep per-member ready | Hybrid | |
| Option | Description | Selected |
| ----------------------------------------- | ---------------------------------------------------- | -------- |
| Single broker token + share | One token; share+accept per member | |
| Per-member app passwords | Each member's own app password; broker holds N creds | ✓ |
| Prove single-token, keep per-member ready | Hybrid | |
**User's choice:** Per-member app passwords
**Notes:** Eliminates the unconfirmed cross-account CalDAV ACL discovery risk (the project's original highest risk). Spike simplifies to confirming an app password reads its own account.
### Spike credential sequencing
| Option | Description | Selected |
|--------|-------------|----------|
| Just mine now | Prove broker with Lucas's app password; wife's added Phase 2 | ✓ |
| Both before Phase 1 done | Get wife's password too; fully close CAL-08 now | |
| Option | Description | Selected |
| ------------------------ | ------------------------------------------------------------ | -------- |
| Just mine now | Prove broker with Lucas's app password; wife's added Phase 2 | ✓ |
| Both before Phase 1 done | Get wife's password too; fully close CAL-08 now | |
**User's choice:** Just mine now
**Notes:** Phase 1 not blocked on coordinating with wife. CAL-08 structurally proven via N-credential broker.
### Credential storage
| Option | Description | Selected |
|--------|-------------|----------|
| DB table, encrypted at rest | Keyed by oidc_sub, key from env; supports later onboarding | ✓ |
| Env vars / secrets file | Simple, but redeploy to add/rotate | |
| Option | Description | Selected |
| --------------------------- | ---------------------------------------------------------- | -------- |
| DB table, encrypted at rest | Keyed by oidc_sub, key from env; supports later onboarding | ✓ |
| Env vars / secrets file | Simple, but redeploy to add/rotate | |
**User's choice:** DB table, encrypted at rest
**Notes:** Supports adding wife later with no redeploy; backend-only, never exposed to frontend.
@@ -65,11 +69,11 @@
## Member color assignment
| Option | Description | Selected |
|--------|-------------|----------|
| Auto-assign from palette | Assign on first login, persist on user row | ✓ |
| User-pickable in settings | Default + settings color-picker | |
| Hardcoded per account | Fixed color in config per oidc_sub | |
| Option | Description | Selected |
| ------------------------- | ------------------------------------------ | -------- |
| Auto-assign from palette | Assign on first login, persist on user row | ✓ |
| User-pickable in settings | Default + settings color-picker | |
| Hardcoded per account | Fixed color in config per oidc_sub | |
**User's choice:** Auto-assign from palette
**Notes:** No settings UI in Phase 1; stable across sessions; works for future members. Pickable color deferred.
@@ -79,19 +83,21 @@
## Infra validation scope
### Deployment scope
| Option | Description | Selected |
|--------|-------------|----------|
| Deploy through Pangolin | Real tunnel + Authelia; validate OIDC topology day one | ✓ |
| Local docker-compose only | Localhost; defer tunnel validation | |
| Option | Description | Selected |
| ------------------------- | ------------------------------------------------------ | -------- |
| Deploy through Pangolin | Real tunnel + Authelia; validate OIDC topology day one | |
| Local docker-compose only | Localhost; defer tunnel validation | |
**User's choice:** Deploy through Pangolin
**Notes:** Avoids "worked locally, broke in prod" OIDC failure; same-parent-domain cookies (Pitfall 17).
### Pangolin SSE smoke test
| Option | Description | Selected |
|--------|-------------|----------|
| Fold it in now | Trivial SSE endpoint over public URL; de-risk Phase 4 | ✓ |
| Defer to Phase 4 | Keep Phase 1 strict to its 5 criteria | |
| Option | Description | Selected |
| ---------------- | ----------------------------------------------------- | -------- |
| Fold it in now | Trivial SSE endpoint over public URL; de-risk Phase 4 | ✓ |
| Defer to Phase 4 | Keep Phase 1 strict to its 5 criteria | |
**User's choice:** Fold it in now
**Notes:** Cheap while stack+tunnel are up; a failure changes Phase 4 transport but does not block Phase 1.
@@ -2,8 +2,8 @@
status: partial
phase: 01-foundation-broker-spike
source: [01-VERIFICATION.md]
started: "2026-06-04"
updated: "2026-06-04"
started: '2026-06-04'
updated: '2026-06-04'
---
## Current Test
@@ -13,19 +13,23 @@ updated: "2026-06-04"
## Tests
### 1. AUTH-01 — Live Authelia OIDC login over the public Pangolin URL
expected: From an external network, opening `https://familysync.<domain>` redirects to Authelia; after logging in as Lucas, the app shell loads showing his name, his assigned color, and one real cached Fastmail event. No Fastmail credentials are entered in the app.
result: [pending]
setup: Register the `familysync` OIDC client in Authelia (`require_pkce: true`, `pkce_challenge_method: S256`, `token_endpoint_auth_method: client_secret_basic`, redirect `https://familysync.<domain>/callback`, scopes openid/profile/email). Set OIDC env vars in `.env` — note `OIDC_AUTH_EXTERNAL_URL` is mandatory behind Pangolin. Deploy via `docker compose up -d`.
### 2. AUTH-02 — Session persists across browser restart
expected: After authenticating, fully close and reopen the browser, revisit the URL → no re-login prompt; the shell loads directly.
result: [pending]
### 3. AUTH-03 — Second member gets a distinct color
expected: The wife logs in via Authelia on her device and is assigned a stable color distinct from Lucas's; it does not change on subsequent logins.
result: [pending]
### 4. SSE-over-Pangolin smoke test (de-risks Phase 4)
expected: With a valid session cookie, `curl -N -H "Cookie: oidc-auth=<value>" https://familysync.<domain>/api/sse/heartbeat` streams a `heartbeat` event roughly every 10s and stays open for 5+ minutes without Pangolin cutting the stream. PASS = continuous heartbeats; FAIL = stream cut early (investigate Pangolin idle-timeout; note as Phase 4 constraint, ref issue #1034).
result: PASS (2026-06-08) — GET /api/sse/heartbeat over familysync-dev.bergerhouse.net (Pangolin→Newt→api) with a valid session cookie held open ~6 min (01:37:53Z→01:43:54Z), 35 heartbeat events id 0→34 at ~10s cadence; response bytes grew 71→2535 (incremental delivery → Pangolin buffering OFF); no early cut. Phase 4 entry gate (D-14 / issue #1034) CLEARED. Caveat: proves no idle-timeout/buffering over ~6 min, not the absence of a max total connection-duration cap — residual risk covered by Phase 4 design (D-10/D-11/D-12 in 04-CONTEXT.md).
File diff suppressed because it is too large Load Diff
@@ -15,13 +15,13 @@ created: 2026-06-04
## Test Infrastructure
| Property | Value |
|----------|-------|
| **Framework** | vitest (Vite-native, shared backend + frontend per CLAUDE.md) |
| **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 |
| Property | Value |
| ---------------------- | --------------------------------------------------------------------------------- |
| **Framework** | vitest (Vite-native, shared backend + frontend per CLAUDE.md) |
| **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 |
---
@@ -38,16 +38,16 @@ created: 2026-06-04
> Mapped to final task IDs. Each phase success criterion maps to at least one automated or manual verification.
| 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 |
| 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 legend: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
_Status legend: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky_
---
@@ -66,14 +66,14 @@ created: 2026-06-04
## Manual-Only Verifications
| 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 |
| 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 |
---
@@ -5,18 +5,18 @@ status: human_needed
score: 10/13 must-haves verified (3 human-pending)
overrides_applied: 0
human_verification:
- test: "AUTH-01: Open https://familysync.<domain> from an external network, confirm redirect to Authelia authorize endpoint, log in, land on shell with name + color — no Fastmail credential prompt"
expected: "Browser redirects to Authelia, login succeeds, PWA shell renders member name and color swatch (MemberBadge component)"
why_human: "Requires live Authelia + Pangolin infrastructure not available in this environment; oidcAuthMiddleware only validates at runtime against a real OIDC issuer"
- test: "AUTH-02: Close browser completely after step above, reopen the public URL, confirm no re-login required"
expected: "Session cookie persists; /api/me still returns 200 without re-authenticating; access-token refresh rotation has kept the session alive"
why_human: "Session persistence is enforced by @hono/oidc-auth refresh-token rotation at runtime; cannot verify without real Authelia token endpoint"
- test: "AUTH-03 (cross-member): Log in as the second member (wife) over the same public URL, confirm she receives a visually distinct color from Lucas"
- test: 'AUTH-01: Open https://familysync.<domain> from an external network, confirm redirect to Authelia authorize endpoint, log in, land on shell with name + color — no Fastmail credential prompt'
expected: 'Browser redirects to Authelia, login succeeds, PWA shell renders member name and color swatch (MemberBadge component)'
why_human: 'Requires live Authelia + Pangolin infrastructure not available in this environment; oidcAuthMiddleware only validates at runtime against a real OIDC issuer'
- test: 'AUTH-02: Close browser completely after step above, reopen the public URL, confirm no re-login required'
expected: 'Session cookie persists; /api/me still returns 200 without re-authenticating; access-token refresh rotation has kept the session alive'
why_human: 'Session persistence is enforced by @hono/oidc-auth refresh-token rotation at runtime; cannot verify without real Authelia token endpoint'
- test: 'AUTH-03 (cross-member): Log in as the second member (wife) over the same public URL, confirm she receives a visually distinct color from Lucas'
expected: "palette[1] (#E8734A warm coral) assigned; both members' MemberBadge components show different colors; identity row for second member present in users table"
why_human: "Requires a second live Authelia account and real browser session; unit tests verify color assignment logic but not end-to-end identity creation for both members"
why_human: 'Requires a second live Authelia account and real browser session; unit tests verify color assignment logic but not end-to-end identity creation for both members'
- test: "SSE smoke test (D-08): From external network with a valid session cookie, run 'curl -N https://familysync.<domain>/api/sse/heartbeat' for 5+ minutes, confirm heartbeat events keep arriving"
expected: "Server-sent events arrive every 10 seconds without proxy timeout; stream stays alive through the Pangolin/Newt tunnel for Phase 4 transport decision"
why_human: "Pangolin idle-timeout behavior is network-infrastructure-dependent; only observable over the real tunnel"
expected: 'Server-sent events arrive every 10 seconds without proxy timeout; stream stays alive through the Pangolin/Newt tunnel for Phase 4 transport decision'
why_human: 'Pangolin idle-timeout behavior is network-infrastructure-dependent; only observable over the real tunnel'
---
# Phase 01: Foundation + Broker Spike — Verification Report
@@ -32,21 +32,21 @@ human_verification:
### Observable Truths
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | Docker Compose stack starts MariaDB healthy and API serving | VERIFIED | docker-compose.yml has mariadb:11 with healthcheck; api depends_on service_healthy; 01-01-SUMMARY confirms `/health` returned `{"ok":true,"db":"up"}` live |
| 2 | GET /health returns 200 with real DB round-trip | VERIFIED | `apps/api/src/routes/health.ts` executes `db.execute(sql'SELECT 1')` before returning `{ok:true,db:"up"}`; health.test.ts 2/2 green |
| 3 | Drizzle schema pushed to live MariaDB (4 tables) | VERIFIED | 01-01-SUMMARY records drizzle-kit push clearing Task 3 checkpoint; all 4 tables listed in SHOW TABLES output |
| 4 | React PWA fetches /health and renders result | VERIFIED | `apps/pwa/src/App.tsx` uses `useQuery({queryKey:['health'],queryFn:fetchHealth})` and renders "stack: up/down" |
| 5 | OIDC middleware guards /api/* and redirects unauthenticated requests (AUTH-01 code path) | VERIFIED | `apps/api/src/index.ts` line 24: `app.use('/api/*', oidcAuthMiddleware())`; /callback registered before guard; /health before guard |
| 6 | upsertUser keyed on oidc_iss + oidc_sub with stable color assignment (AUTH-03) | VERIFIED | `apps/api/src/auth/user.ts` SELECT by `and(eq(users.oidcIss,...),eq(users.oidcSub,...))`, never email; COLOR_PALETTE 6 entries; user.test.ts 6/6 green |
| 7 | GET /api/me returns authenticated member identity + color | VERIFIED | `apps/api/src/routes/me.ts` calls getAuth → upsertUser → returns `{user:{id,displayName,color}}`; mounted behind oidcAuthMiddleware |
| 8 | AES-256-GCM app-password encryption: lossless roundtrip, unique IVs, tamper detection | VERIFIED | `apps/api/src/broker/crypto.ts` uses node:crypto aes-256-gcm, 96-bit random IV; crypto.test.ts 5/5 green (roundtrip, IV uniqueness, tamper-throws x2, payload shape) |
| 9 | CalDAV broker reads Fastmail calendars and caches VEVENTs with D-13 all-day DATE handling | VERIFIED | CAL-08-DECISION.md records live REPORT against Fastmail fetching 503 real events; sync.test.ts 6/6 green confirming timed→dtstart_utc, all-day→dtstart_date, onDuplicateKeyUpdate idempotency |
| 10 | Broker poller skips DB writes when ctag unchanged | VERIFIED | `apps/api/src/broker/poller.ts` lines 59-61: skip when `currentCtag !== null && currentCtag === knownCtag`; poller.test.ts 5/5 green |
| 11 | CAL-08 go/no-go decision recorded | VERIFIED | `CAL-08-DECISION.md` present, committed (0b074cd), contains "Decision: GO" — per-member app-password model proven live; 503 events cached; cross-account ACL concern resolved as moot |
| 12 | Both members authenticate live through Authelia over Pangolin (AUTH-01/02) | HUMAN-PENDING | Code wired (oidcAuthMiddleware, processOAuthCallback, OIDC env vars); live verification requires real Authelia + Pangolin infrastructure |
| 13 | Both members have distinct stable colors confirmed in real browser (AUTH-03 cross-member) | HUMAN-PENDING | upsertUser unit-tested for color assignment; live cross-member test requires two real OIDC sessions |
| # | Truth | Status | Evidence |
| --- | ----------------------------------------------------------------------------------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | Docker Compose stack starts MariaDB healthy and API serving | VERIFIED | docker-compose.yml has mariadb:11 with healthcheck; api depends_on service_healthy; 01-01-SUMMARY confirms `/health` returned `{"ok":true,"db":"up"}` live |
| 2 | GET /health returns 200 with real DB round-trip | VERIFIED | `apps/api/src/routes/health.ts` executes `db.execute(sql'SELECT 1')` before returning `{ok:true,db:"up"}`; health.test.ts 2/2 green |
| 3 | Drizzle schema pushed to live MariaDB (4 tables) | VERIFIED | 01-01-SUMMARY records drizzle-kit push clearing Task 3 checkpoint; all 4 tables listed in SHOW TABLES output |
| 4 | React PWA fetches /health and renders result | VERIFIED | `apps/pwa/src/App.tsx` uses `useQuery({queryKey:['health'],queryFn:fetchHealth})` and renders "stack: up/down" |
| 5 | OIDC middleware guards /api/\* and redirects unauthenticated requests (AUTH-01 code path) | VERIFIED | `apps/api/src/index.ts` line 24: `app.use('/api/*', oidcAuthMiddleware())`; /callback registered before guard; /health before guard |
| 6 | upsertUser keyed on oidc_iss + oidc_sub with stable color assignment (AUTH-03) | VERIFIED | `apps/api/src/auth/user.ts` SELECT by `and(eq(users.oidcIss,...),eq(users.oidcSub,...))`, never email; COLOR_PALETTE 6 entries; user.test.ts 6/6 green |
| 7 | GET /api/me returns authenticated member identity + color | VERIFIED | `apps/api/src/routes/me.ts` calls getAuth → upsertUser → returns `{user:{id,displayName,color}}`; mounted behind oidcAuthMiddleware |
| 8 | AES-256-GCM app-password encryption: lossless roundtrip, unique IVs, tamper detection | VERIFIED | `apps/api/src/broker/crypto.ts` uses node:crypto aes-256-gcm, 96-bit random IV; crypto.test.ts 5/5 green (roundtrip, IV uniqueness, tamper-throws x2, payload shape) |
| 9 | CalDAV broker reads Fastmail calendars and caches VEVENTs with D-13 all-day DATE handling | VERIFIED | CAL-08-DECISION.md records live REPORT against Fastmail fetching 503 real events; sync.test.ts 6/6 green confirming timed→dtstart_utc, all-day→dtstart_date, onDuplicateKeyUpdate idempotency |
| 10 | Broker poller skips DB writes when ctag unchanged | VERIFIED | `apps/api/src/broker/poller.ts` lines 59-61: skip when `currentCtag !== null && currentCtag === knownCtag`; poller.test.ts 5/5 green |
| 11 | CAL-08 go/no-go decision recorded | VERIFIED | `CAL-08-DECISION.md` present, committed (0b074cd), contains "Decision: GO" — per-member app-password model proven live; 503 events cached; cross-account ACL concern resolved as moot |
| 12 | Both members authenticate live through Authelia over Pangolin (AUTH-01/02) | HUMAN-PENDING | Code wired (oidcAuthMiddleware, processOAuthCallback, OIDC env vars); live verification requires real Authelia + Pangolin infrastructure |
| 13 | Both members have distinct stable colors confirmed in real browser (AUTH-03 cross-member) | HUMAN-PENDING | upsertUser unit-tested for color assignment; live cross-member test requires two real OIDC sessions |
**Score:** 11/13 truths verified (2 human-pending, counted as HUMAN-PENDING not FAILED; see requirements section for SSE smoke test)
@@ -54,30 +54,30 @@ human_verification:
### Required Artifacts
| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `apps/api/src/db/schema.ts` | 4 mysqlTable defs, D-13 dtstart split, D-10 iss+sub unique key | VERIFIED | All 4 tables; unique('uniq_oidc_identity').on(oidcIss,oidcSub); separate dtstart_utc (timestamp) + dtstart_date (date) + allDay boolean |
| `apps/api/src/db/client.ts` | drizzle(mysql2 pool) db singleton export | VERIFIED | Exports `db = drizzle({client:pool,schema,mode:'default'})`; connectionLimit 10 |
| `apps/api/src/routes/health.ts` | GET / with real DB round-trip | VERIFIED | SELECT 1 via db.execute; returns 200 or 503; unauthenticated |
| `docker-compose.yml` | api + mariadb:11 (healthcheck) + redis | VERIFIED | mariadb healthcheck using healthcheck.sh --connect; api depends_on service_healthy; redis present |
| `apps/api/src/auth/user.ts` | upsertUser + COLOR_PALETTE | VERIFIED | Exports both; palette 6 entries; SELECT-first idempotent pattern |
| `apps/api/src/auth/middleware.ts` | oidcAuthMiddleware re-export | VERIFIED | Re-exports oidcAuthMiddleware, processOAuthCallback, getAuth from @hono/oidc-auth |
| `apps/api/src/routes/me.ts` | GET /api/me → {user:{id,displayName,color}} | VERIFIED | getAuth → upsertUser → returns user shape; exports meRouter |
| `apps/api/src/broker/crypto.ts` | encryptPassword/decryptPassword (AES-256-GCM) | VERIFIED | node:crypto aes-256-gcm, 96-bit IV, JSON {iv,authTag,ciphertext} payload |
| `apps/api/src/broker/client.ts` | createFastmailClient → tsdav DAVClient | VERIFIED | caldav.fastmail.com, Basic auth, caldav account type |
| `apps/api/src/broker/sync.ts` | syncCalendar: REPORT → ical.js → upsert | VERIFIED | ical.js ICAL.Time.isDate routing, onDuplicateKeyUpdate on calendarId+uid |
| `apps/api/src/broker/poller.ts` | startBrokerPoller (node-cron */5) + ctag detection | VERIFIED | schedule('*/5 * * * *'); ctag skip logic; per-credential try/catch; exports runPoll for tests |
| `apps/api/src/routes/events.ts` | GET /api/events reads DB cache only | VERIFIED | db.select().from(calendarEvents); no tsdav import; exports eventsRouter |
| `apps/api/src/routes/sse.ts` | GET /api/sse/heartbeat (streamSSE) | VERIFIED | streamSSE every 10s; exports sseRouter; mounted behind oidcAuthMiddleware |
| `apps/api/src/broker/spike.ts` | CAL-08 spike script | VERIFIED | createFastmailClient → fetchCalendars → print URLs; never logs password |
| `.planning/phases/01-foundation-broker-spike/CAL-08-DECISION.md` | Decision: GO/NO-GO recorded | VERIFIED | "Decision: GO"; 503 events cached; ctag/syncToken findings recorded |
| `apps/pwa/src/App.tsx` | Renders MemberBadge + EventProof | VERIFIED | useQuery(['me']) → MemberBadge; EventProof component rendered |
| `apps/pwa/src/components/EventProof.tsx` | Fetches /api/events, renders first event | VERIFIED | useQuery(['events'],fetchEvents); renders event SUMMARY + date or empty state |
| `apps/api/tests/auth/user.test.ts` | 6 passing tests | VERIFIED | 6/6 green (palette[0], palette[1], idempotent, iss+sub identity, full row shape, COLOR_PALETTE validity) |
| `apps/api/tests/broker/crypto.test.ts` | 5 passing tests | VERIFIED | 5/5 green |
| `apps/api/tests/broker/sync.test.ts` | 6 passing tests | VERIFIED | 6/6 green |
| `apps/api/tests/broker/poller.test.ts` | 5 passing tests | VERIFIED | 5/5 green |
| `apps/api/tests/health.test.ts` | 2 passing tests | VERIFIED | 2/2 green |
| Artifact | Expected | Status | Details |
| ---------------------------------------------------------------- | -------------------------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `apps/api/src/db/schema.ts` | 4 mysqlTable defs, D-13 dtstart split, D-10 iss+sub unique key | VERIFIED | All 4 tables; unique('uniq_oidc_identity').on(oidcIss,oidcSub); separate dtstart_utc (timestamp) + dtstart_date (date) + allDay boolean |
| `apps/api/src/db/client.ts` | drizzle(mysql2 pool) db singleton export | VERIFIED | Exports `db = drizzle({client:pool,schema,mode:'default'})`; connectionLimit 10 |
| `apps/api/src/routes/health.ts` | GET / with real DB round-trip | VERIFIED | SELECT 1 via db.execute; returns 200 or 503; unauthenticated |
| `docker-compose.yml` | api + mariadb:11 (healthcheck) + redis | VERIFIED | mariadb healthcheck using healthcheck.sh --connect; api depends_on service_healthy; redis present |
| `apps/api/src/auth/user.ts` | upsertUser + COLOR_PALETTE | VERIFIED | Exports both; palette 6 entries; SELECT-first idempotent pattern |
| `apps/api/src/auth/middleware.ts` | oidcAuthMiddleware re-export | VERIFIED | Re-exports oidcAuthMiddleware, processOAuthCallback, getAuth from @hono/oidc-auth |
| `apps/api/src/routes/me.ts` | GET /api/me → {user:{id,displayName,color}} | VERIFIED | getAuth → upsertUser → returns user shape; exports meRouter |
| `apps/api/src/broker/crypto.ts` | encryptPassword/decryptPassword (AES-256-GCM) | VERIFIED | node:crypto aes-256-gcm, 96-bit IV, JSON {iv,authTag,ciphertext} payload |
| `apps/api/src/broker/client.ts` | createFastmailClient → tsdav DAVClient | VERIFIED | caldav.fastmail.com, Basic auth, caldav account type |
| `apps/api/src/broker/sync.ts` | syncCalendar: REPORT → ical.js → upsert | VERIFIED | ical.js ICAL.Time.isDate routing, onDuplicateKeyUpdate on calendarId+uid |
| `apps/api/src/broker/poller.ts` | startBrokerPoller (node-cron \*/5) + ctag detection | VERIFIED | schedule('_/5 _ \* \* \*'); ctag skip logic; per-credential try/catch; exports runPoll for tests |
| `apps/api/src/routes/events.ts` | GET /api/events reads DB cache only | VERIFIED | db.select().from(calendarEvents); no tsdav import; exports eventsRouter |
| `apps/api/src/routes/sse.ts` | GET /api/sse/heartbeat (streamSSE) | VERIFIED | streamSSE every 10s; exports sseRouter; mounted behind oidcAuthMiddleware |
| `apps/api/src/broker/spike.ts` | CAL-08 spike script | VERIFIED | createFastmailClient → fetchCalendars → print URLs; never logs password |
| `.planning/phases/01-foundation-broker-spike/CAL-08-DECISION.md` | Decision: GO/NO-GO recorded | VERIFIED | "Decision: GO"; 503 events cached; ctag/syncToken findings recorded |
| `apps/pwa/src/App.tsx` | Renders MemberBadge + EventProof | VERIFIED | useQuery(['me']) → MemberBadge; EventProof component rendered |
| `apps/pwa/src/components/EventProof.tsx` | Fetches /api/events, renders first event | VERIFIED | useQuery(['events'],fetchEvents); renders event SUMMARY + date or empty state |
| `apps/api/tests/auth/user.test.ts` | 6 passing tests | VERIFIED | 6/6 green (palette[0], palette[1], idempotent, iss+sub identity, full row shape, COLOR_PALETTE validity) |
| `apps/api/tests/broker/crypto.test.ts` | 5 passing tests | VERIFIED | 5/5 green |
| `apps/api/tests/broker/sync.test.ts` | 6 passing tests | VERIFIED | 6/6 green |
| `apps/api/tests/broker/poller.test.ts` | 5 passing tests | VERIFIED | 5/5 green |
| `apps/api/tests/health.test.ts` | 2 passing tests | VERIFIED | 2/2 green |
**Artifact total: 22/22 present and substantive.**
@@ -85,19 +85,19 @@ human_verification:
### Key Link Verification
| From | To | Via | Status | Details |
|------|-----|-----|--------|---------|
| `apps/api/src/routes/health.ts` | `apps/api/src/db/client.ts` | `db.execute()` | WIRED | `import { db } from '../db/client.js'`; execute called in route handler |
| `apps/pwa/src/App.tsx` | `/health` | `fetch('/health')` in fetchHealth | WIRED | `fetchHealth` calls `fetch('/health')` inside useQuery |
| `apps/api/src/routes/me.ts` | `apps/api/src/auth/user.ts` | `upsertUser(iss, sub, email)` | WIRED | `import { upsertUser }` + called in handler body |
| `apps/api/src/index.ts` | `@hono/oidc-auth` | `oidcAuthMiddleware` on `/api/*` | WIRED | `app.use('/api/*', oidcAuthMiddleware())` line 24 |
| `apps/pwa/src/App.tsx` | `/api/me` | React Query via fetchMe | WIRED | `import { fetchMe }` + `useQuery({queryKey:['me'],queryFn:fetchMe})` |
| `apps/api/src/broker/poller.ts` | `apps/api/src/broker/crypto.ts` | `decryptPassword` | WIRED | `import { decryptPassword }` + called before createFastmailClient |
| `apps/api/src/broker/sync.ts` | `apps/api/src/db/client.ts` | calendarEvents upsert | WIRED | `import { db }` + `db.insert(calendarEvents)...onDuplicateKeyUpdate()` |
| `apps/api/src/routes/events.ts` | `apps/api/src/db/client.ts` | cache read | WIRED | `import { db }` + `db.select().from(calendarEvents)` |
| `apps/api/src/index.ts` | `apps/api/src/broker/poller.ts` | `startBrokerPoller()` on boot | WIRED | `import { startBrokerPoller }` + called unconditionally at module level |
| `apps/api/src/index.ts` | `apps/api/src/routes/events.ts` | `app.route('/api/events', eventsRouter)` | WIRED | Line 28 in index.ts |
| `apps/pwa/src/components/EventProof.tsx` | `/api/events` | React Query via fetchEvents | WIRED | `import { fetchEvents }` + `useQuery({queryKey:['events'],queryFn:fetchEvents})` |
| From | To | Via | Status | Details |
| ---------------------------------------- | ------------------------------- | ---------------------------------------- | ------ | -------------------------------------------------------------------------------- |
| `apps/api/src/routes/health.ts` | `apps/api/src/db/client.ts` | `db.execute()` | WIRED | `import { db } from '../db/client.js'`; execute called in route handler |
| `apps/pwa/src/App.tsx` | `/health` | `fetch('/health')` in fetchHealth | WIRED | `fetchHealth` calls `fetch('/health')` inside useQuery |
| `apps/api/src/routes/me.ts` | `apps/api/src/auth/user.ts` | `upsertUser(iss, sub, email)` | WIRED | `import { upsertUser }` + called in handler body |
| `apps/api/src/index.ts` | `@hono/oidc-auth` | `oidcAuthMiddleware` on `/api/*` | WIRED | `app.use('/api/*', oidcAuthMiddleware())` line 24 |
| `apps/pwa/src/App.tsx` | `/api/me` | React Query via fetchMe | WIRED | `import { fetchMe }` + `useQuery({queryKey:['me'],queryFn:fetchMe})` |
| `apps/api/src/broker/poller.ts` | `apps/api/src/broker/crypto.ts` | `decryptPassword` | WIRED | `import { decryptPassword }` + called before createFastmailClient |
| `apps/api/src/broker/sync.ts` | `apps/api/src/db/client.ts` | calendarEvents upsert | WIRED | `import { db }` + `db.insert(calendarEvents)...onDuplicateKeyUpdate()` |
| `apps/api/src/routes/events.ts` | `apps/api/src/db/client.ts` | cache read | WIRED | `import { db }` + `db.select().from(calendarEvents)` |
| `apps/api/src/index.ts` | `apps/api/src/broker/poller.ts` | `startBrokerPoller()` on boot | WIRED | `import { startBrokerPoller }` + called unconditionally at module level |
| `apps/api/src/index.ts` | `apps/api/src/routes/events.ts` | `app.route('/api/events', eventsRouter)` | WIRED | Line 28 in index.ts |
| `apps/pwa/src/components/EventProof.tsx` | `/api/events` | React Query via fetchEvents | WIRED | `import { fetchEvents }` + `useQuery({queryKey:['events'],queryFn:fetchEvents})` |
**All 11 key links WIRED.**
@@ -105,23 +105,23 @@ human_verification:
### Data-Flow Trace (Level 4)
| Artifact | Data Variable | Source | Produces Real Data | Status |
|----------|---------------|--------|-------------------|--------|
| `apps/pwa/src/App.tsx` (MemberBadge) | `meQuery.data.user` | `/api/me` → upsertUser → MariaDB `users` table | Yes — upsertUser does SELECT then INSERT; backed by real DB | FLOWING |
| `apps/pwa/src/App.tsx` (health indicator) | `healthQuery.data` | `/health` → db.execute SELECT 1 | Yes — real DB round-trip | FLOWING |
| `apps/pwa/src/components/EventProof.tsx` | `events[0]` | `/api/events` → db.select().from(calendarEvents) | Yes — live Fastmail REPORT cached 503 events into MariaDB (CAL-08-DECISION.md); dev DB populated | FLOWING |
| Artifact | Data Variable | Source | Produces Real Data | Status |
| ----------------------------------------- | ------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------ | ------- |
| `apps/pwa/src/App.tsx` (MemberBadge) | `meQuery.data.user` | `/api/me` → upsertUser → MariaDB `users` table | Yes — upsertUser does SELECT then INSERT; backed by real DB | FLOWING |
| `apps/pwa/src/App.tsx` (health indicator) | `healthQuery.data` | `/health` → db.execute SELECT 1 | Yes — real DB round-trip | FLOWING |
| `apps/pwa/src/components/EventProof.tsx` | `events[0]` | `/api/events` → db.select().from(calendarEvents) | Yes — live Fastmail REPORT cached 503 events into MariaDB (CAL-08-DECISION.md); dev DB populated | FLOWING |
---
### Behavioral Spot-Checks
| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| All 24 unit tests pass | `cd apps/api && pnpm vitest run` | 24/24 passed, 5 test files | PASS |
| TypeScript compiles clean (api) | `cd apps/api && pnpm exec tsc --noEmit` | Exit 0, no errors | PASS |
| tsdav imported only under broker module | `grep -rn "import.*tsdav" apps/api/src/` | `broker/sync.ts`, `broker/client.ts` only | PASS |
| events route imports no tsdav or broker client | `grep -rn "createFastmailClient\|tsdav" apps/api/src/routes/` | No matches | PASS |
| oidcAuthMiddleware mounted on /api/* | `grep -n "oidcAuthMiddleware" apps/api/src/index.ts` | Line 24: `app.use('/api/*', oidcAuthMiddleware())` | PASS |
| Behavior | Command | Result | Status |
| ---------------------------------------------- | ------------------------------------------------------------- | -------------------------------------------------- | ------ |
| All 24 unit tests pass | `cd apps/api && pnpm vitest run` | 24/24 passed, 5 test files | PASS |
| TypeScript compiles clean (api) | `cd apps/api && pnpm exec tsc --noEmit` | Exit 0, no errors | PASS |
| tsdav imported only under broker module | `grep -rn "import.*tsdav" apps/api/src/` | `broker/sync.ts`, `broker/client.ts` only | PASS |
| events route imports no tsdav or broker client | `grep -rn "createFastmailClient\|tsdav" apps/api/src/routes/` | No matches | PASS |
| oidcAuthMiddleware mounted on /api/\* | `grep -n "oidcAuthMiddleware" apps/api/src/index.ts` | Line 24: `app.use('/api/*', oidcAuthMiddleware())` | PASS |
---
@@ -133,21 +133,21 @@ No `scripts/*/tests/probe-*.sh` files declared or found. Task 3 of Plan 01 and T
### Requirements Coverage
| Requirement | Source Plan | Description | Status | Evidence |
|-------------|-------------|-------------|--------|----------|
| AUTH-01 | 01-02 | User can log in through Authelia (OIDC SSO) | HUMAN-PENDING | Code wired: oidcAuthMiddleware on /api/*, processOAuthCallback on /callback; live Authelia test pending |
| AUTH-02 | 01-02 | User stays logged in across sessions | HUMAN-PENDING | @hono/oidc-auth refresh-token rotation documented in middleware.ts; live session persistence test pending |
| AUTH-03 | 01-02 | Stable iss+sub identity + consistent per-member color | PARTIALLY VERIFIED | Unit-tested fully (6 tests); live cross-member color check pending human verification |
| CAL-01 | 01-01, 01-03 | Broker reads shared Fastmail calendar via CalDAV, caches locally (ctag polling) | VERIFIED | CAL-08-DECISION.md: 503 real events cached live via REPORT; poller ctag-detection unit tested |
| CAL-08 | 01-04 | Personal calendar spike go/no-go | VERIFIED | CAL-08-DECISION.md: Decision GO; per-member app-password model proven; no fallback needed |
| Requirement | Source Plan | Description | Status | Evidence |
| ----------- | ------------ | ------------------------------------------------------------------------------- | ------------------ | --------------------------------------------------------------------------------------------------------- |
| AUTH-01 | 01-02 | User can log in through Authelia (OIDC SSO) | HUMAN-PENDING | Code wired: oidcAuthMiddleware on /api/\*, processOAuthCallback on /callback; live Authelia test pending |
| AUTH-02 | 01-02 | User stays logged in across sessions | HUMAN-PENDING | @hono/oidc-auth refresh-token rotation documented in middleware.ts; live session persistence test pending |
| AUTH-03 | 01-02 | Stable iss+sub identity + consistent per-member color | PARTIALLY VERIFIED | Unit-tested fully (6 tests); live cross-member color check pending human verification |
| CAL-01 | 01-01, 01-03 | Broker reads shared Fastmail calendar via CalDAV, caches locally (ctag polling) | VERIFIED | CAL-08-DECISION.md: 503 real events cached live via REPORT; poller ctag-detection unit tested |
| CAL-08 | 01-04 | Personal calendar spike go/no-go | VERIFIED | CAL-08-DECISION.md: Decision GO; per-member app-password model proven; no fallback needed |
---
### Anti-Patterns Found
| File | Pattern | Severity | Impact |
|------|---------|----------|--------|
| None found | — | — | — |
| File | Pattern | Severity | Impact |
| ---------- | ------- | -------- | ------ |
| None found | — | — | — |
No TBD/FIXME/XXX markers in source files. No `return null` or `return []` stubs in non-test production code. No hardcoded empty values flowing to rendering. No console.log of credentials or encryption key in broker code.
@@ -30,19 +30,19 @@ displayName, ctag, syncToken). It never logs the password.
### Calendars Discovered
| displayName | URL | ctag returned? | syncToken returned? |
|-------------|-----|----------------|---------------------|
| Calendar | `https://caldav.fastmail.com/dav/calendars/user/me@lucasberger.ca/2180A37A-806E-11EB-872C-AE53E9CB9923/` | yes (`1615249618-218118`) | yes (`data:,1615249618-218118`) |
| displayName | URL | ctag returned? | syncToken returned? |
| ------------ | -------------------------------------------------------------------------------------------------------- | ------------------------- | ------------------------------- |
| Calendar | `https://caldav.fastmail.com/dav/calendars/user/me@lucasberger.ca/2180A37A-806E-11EB-872C-AE53E9CB9923/` | yes (`1615249618-218118`) | yes (`data:,1615249618-218118`) |
| USA Holidays | `https://caldav.fastmail.com/dav/calendars/user/me@lucasberger.ca/2da291bc-7962-4a0e-94bc-7bb135c90d6e/` | yes (`1635037263-211516`) | yes (`data:,1635037263-211516`) |
### Questions Resolved
| Question | Finding |
|----------|---------|
| Does one Fastmail app password see all of that account's calendars? | **Yes** — a single app password (scope "Mail, Contacts & Calendars") enumerated every collection owned by the account via PROPFIND. |
| Question | Finding |
| ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Does one Fastmail app password see all of that account's calendars? | **Yes** — a single app password (scope "Mail, Contacts & Calendars") enumerated every collection owned by the account via PROPFIND. |
| Does the broker need cross-account ACL sharing to read a member's personal calendar? | **No** — under the locked design (D-09) each member supplies their own app password; the broker stores one encrypted credential per member and aggregates. Cross-account ACL sharing (the original CAL-08 risk) is not on the critical path. |
| Which change-detection field does Fastmail use — ctag or syncToken? | **Both** are returned. The poller uses ctag as the primary change signal with syncToken available as a fallback (matches the SKELETON poller design). |
| Can the broker actually fetch + cache real events (CAL-01)? | **Yes** — a live REPORT against the `Calendar` collection fetched and cached **503 events** into `calendar_events`, with correct D-13 handling (timed events → `dtstart_utc` set, `dtstart_date` null, `all_day` false). |
| Which change-detection field does Fastmail use — ctag or syncToken? | **Both** are returned. The poller uses ctag as the primary change signal with syncToken available as a fallback (matches the SKELETON poller design). |
| Can the broker actually fetch + cache real events (CAL-01)? | **Yes** — a live REPORT against the `Calendar` collection fetched and cached **503 events** into `calendar_events`, with correct D-13 handling (timed events → `dtstart_utc` set, `dtstart_date` null, `all_day` false). |
---
@@ -53,7 +53,7 @@ displayName, ctag, syncToken). It never logs the password.
**Rationale:** The personal-calendar read path is proven end-to-end against live Fastmail. A
single app password reaches every calendar owned by its account, and the broker successfully
fetched and cached real events (CAL-01). The original CAL-08 worry — whether the broker token
could see *another member's* personal calendar via Fastmail cross-account share+accept — is
could see _another member's_ personal calendar via Fastmail cross-account share+accept — is
moot: the project already locked the **per-member app-password** model (D-09), where each
member contributes their own credential. That model is validated here. The wife's personal
calendar is reached the same way (her own app password), onboarded in Phase 2.
@@ -9,23 +9,23 @@ A member reaches the app over the real Pangolin tunnel, authenticates through Au
## 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 |
| 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
@@ -41,7 +41,7 @@ A member reaches the app over the real Pangolin tunnel, authenticates through Au
- 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
- 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