---
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)_
