test(01): verification (human_needed, 11/13) + persist Gate 2 human-UAT

Code verified against codebase, 24 tests pass, CAL-01 + CAL-08 proven live.
AUTH-01/02/03 live login + SSE-over-Pangolin smoke test require operator infra —
tracked as 01-HUMAN-UAT.md (status: partial).
This commit is contained in:
Lucas Berger
2026-06-04 11:44:52 -04:00
parent fe8d5c8e4b
commit 5bed2228b7
2 changed files with 244 additions and 0 deletions
@@ -0,0 +1,41 @@
---
status: partial
phase: 01-foundation-broker-spike
source: [01-VERIFICATION.md]
started: "2026-06-04"
updated: "2026-06-04"
---
## Current Test
[awaiting human testing — requires live Authelia + Pangolin infrastructure]
## 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: [pending]
## Summary
total: 4
passed: 0
issues: 0
pending: 4
skipped: 0
blocked: 0
## Gaps
@@ -0,0 +1,203 @@
---
phase: 01-foundation-broker-spike
verified: 2026-06-04T11:45:00Z
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"
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"
- 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"
---
# Phase 01: Foundation + Broker Spike — Verification Report
**Phase Goal:** The app stack is running, both members can authenticate through Authelia OIDC, and the CalDAV broker can read Fastmail calendars — with a confirmed go/no-go decision on personal-calendar cross-account sharing.
**Verified:** 2026-06-04T11:45:00Z
**Status:** human_needed
**Re-verification:** No — initial verification
---
## Goal Achievement
### 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 |
**Score:** 11/13 truths verified (2 human-pending, counted as HUMAN-PENDING not FAILED; see requirements section for SSE smoke test)
---
### 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 total: 22/22 present and substantive.**
---
### 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})` |
**All 11 key links WIRED.**
---
### 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 |
---
### 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 |
---
### Probe Execution
No `scripts/*/tests/probe-*.sh` files declared or found. Task 3 of Plan 01 and Tasks 2+3 of Plan 04 are `checkpoint:human-action` gates that require live infrastructure. These are routed to Human Verification.
---
### 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 |
---
### Anti-Patterns 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.
The one notable "pending" marker is in `CAL-08-DECISION.md` history — the initial template had `Decision: [PENDING]` but was overwritten by commit `0b074cd` with the live GO decision. Current state is fully filled.
---
### Human Verification Required
#### 1. AUTH-01 — Authelia OIDC Login (Member 1: Lucas)
**Test:** From an external network (not the Docker host), open `https://familysync.<domain>`. Confirm the browser is redirected to Authelia's authorize endpoint. Log in with Lucas's Authelia credentials. Confirm landing on the PWA shell showing MemberBadge (name + color swatch).
**Expected:** Successful redirect to Authelia, login completes, PWA renders `MemberBadge` with Lucas's display name and assigned hex color (`#4A90D9` if first user). No separate FamilySync login, no Fastmail credential prompt.
**Why human:** oidcAuthMiddleware contacts the real OIDC issuer at runtime; OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_REDIRECT_URI, OIDC_AUTH_EXTERNAL_URL must all be set and Authelia must have the client registered (YAML in 01-02-SUMMARY.md).
---
#### 2. AUTH-02 — Session Persistence Across Browser Restart
**Test:** After AUTH-01 passes, close the browser completely (not just the tab). Reopen `https://familysync.<domain>`. Confirm no Authelia login prompt appears — the PWA loads directly to the authenticated shell.
**Expected:** Session cookie survives browser restart; @hono/oidc-auth refresh-token rotation silently renews the access token; `/api/me` returns 200 without re-authentication.
**Why human:** Session cookie persistence and refresh-token rotation require live interaction with Authelia's token endpoint over time.
---
#### 3. AUTH-03 — Cross-Member Distinct Color (Member 2: Wife)
**Test:** Log in as the second member (wife) at `https://familysync.<domain>`. Confirm she lands on the PWA shell with a MemberBadge showing a **different** color from Lucas.
**Expected:** palette[1] (`#E8734A` warm coral) assigned to wife's account; both members' shells display visually distinct color swatches; wife's `users` row is present in MariaDB with a different `color` value.
**Why human:** Requires a second live Authelia account and a real second browser session to trigger upsertUser for the second member.
---
#### 4. SSE Smoke Test — Pangolin Pass-Through (D-08)
**Test:** From an external network with a valid authenticated session cookie, run: `curl -N -b "session=<cookie>" https://familysync.<domain>/api/sse/heartbeat`. Keep the connection open for 5+ minutes.
**Expected:** Heartbeat events (`{"ts":"...","id":0}`, `{"ts":"...","id":1}`, ...) arrive every 10 seconds without the stream being cut. Result is PASS (SSE viable for Phase 4 real-time list sync) or FAIL (Pangolin idle-timeout needs configuration).
**Why human:** Pangolin/Newt proxy idle-timeout behavior is network-infrastructure-dependent and can only be observed over the real tunnel. The sseRouter code is verified; tunnel compatibility is the open question.
---
## Gaps Summary
No gaps (no must-haves are FAILED or MISSING). All code artifacts are present, substantive, and wired. All 24 unit tests pass. TypeScript is clean. The CAL-08 GO decision is recorded from a live run.
The 4 human verification items are classified as HUMAN-PENDING (code implemented, live operator confirmation required), not as failures. This is the expected terminal state for a phase whose Plans 01-04 included `checkpoint:human-action` tasks requiring real Authelia + Pangolin infrastructure that is not available in the dev environment.
The 01-04 SUMMARY explicitly notes "Code-complete. Live verification (CAL-08 spike + Authelia login + Pangolin SSE smoke) pending human action." CAL-08 was subsequently cleared (commit `0b074cd`). AUTH-01/02/03 live tests and SSE smoke remain pending.
---
_Verified: 2026-06-04T11:45:00Z_
_Verifier: Claude (gsd-verifier)_