docs(01-04): complete integration+gate plan summary; add CAL-08 decision template
- 01-04-SUMMARY.md: code-complete; Task 1 committed (48f90ce); Tasks 2+3 are live-gate checkpoints with exact human-action steps
- CAL-08-DECISION.md: template for human to fill in after running apps/api/src/broker/spike.ts against real Fastmail credentials
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
---
|
||||
phase: 01-foundation-broker-spike
|
||||
plan: "04"
|
||||
subsystem: integration
|
||||
tags: [hono, sse, caldav, react, typescript, pwa, spike, pangolin]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- 01-02 (OIDC middleware, meRouter, upsertUser, fetchMe)
|
||||
- 01-03 (eventsRouter, startBrokerPoller, broker module)
|
||||
provides:
|
||||
- sseRouter: GET /api/sse/heartbeat (streamSSE, 10s interval) — Pangolin SSE smoke test
|
||||
- Full bootstrap: /callback → /health → /api/* (oidcAuthMiddleware) → /api/me + /api/events + /api/sse → startBrokerPoller → static
|
||||
- CAL-08 spike script: apps/api/src/broker/spike.ts — enumerate Fastmail calendar collections
|
||||
- EventProof component: renders first cached event from /api/events (broker proof, CAL-01)
|
||||
- fetchEvents() typed PWA API client
|
||||
- Landing page: member name+color + broker-proof event (or empty state)
|
||||
affects:
|
||||
- Phase 2+ (live integration confirmed; SSE transport decision for Phase 4)
|
||||
|
||||
# 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)"
|
||||
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)"
|
||||
- "EventProof: tries ical.js parse for SUMMARY field; falls back to 'Untitled event' on parse failure — resilient to malformed VEVENT blobs"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- apps/api/src/routes/sse.ts (sseRouter, GET /heartbeat using streamSSE)
|
||||
- apps/api/src/broker/spike.ts (CAL-08 spike: enumerate Fastmail calendars)
|
||||
- apps/pwa/src/components/EventProof.tsx (React Query ['events'], renders first event or empty state)
|
||||
- .planning/phases/01-foundation-broker-spike/CAL-08-DECISION.md (decision template — human fills in after spike)
|
||||
modified:
|
||||
- apps/api/src/index.ts (final bootstrap: eventsRouter + sseRouter mounted, startBrokerPoller called)
|
||||
- apps/pwa/src/api/client.ts (added fetchEvents() with typed CalendarEvent/EventsResponse)
|
||||
- apps/pwa/src/App.tsx (renders MemberBadge + EventProof on landing page)
|
||||
- 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"
|
||||
- "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"
|
||||
---
|
||||
|
||||
# Phase 01 Plan 04: Integration + Gate Slice — Summary
|
||||
|
||||
**Full Hono bootstrap wired (broker + all routes), SSE heartbeat endpoint for Pangolin smoke test, CAL-08 spike script, and EventProof landing page component. Code-complete. Live verification (CAL-08 spike + Authelia login + Pangolin SSE smoke) pending human action.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** ~3 min (code tasks)
|
||||
- **Started:** 2026-06-04T15:13:15Z
|
||||
- **Completed:** 2026-06-04 (code-complete)
|
||||
- **Tasks:** 1 of 3 complete (Tasks 2 + 3 are live-gate checkpoints)
|
||||
- **Files modified/created:** 8
|
||||
|
||||
## Accomplishments
|
||||
|
||||
**Task 1 (implemented and committed):**
|
||||
|
||||
- `apps/api/src/routes/sse.ts`: `sseRouter` with `GET /heartbeat` using `streamSSE` — emits `heartbeat` events every 10s until `stream.aborted`; mounted under `/api/sse` behind `oidcAuthMiddleware` (T-04-01)
|
||||
- `apps/api/src/index.ts`: final bootstrap — `/callback` → `/health` (pre-guard) → `app.use('/api/*', oidcAuthMiddleware())` → `/api/me` → `/api/events` → `/api/sse` → `startBrokerPoller()` → `serveStatic`
|
||||
- `apps/api/src/broker/spike.ts`: CAL-08 spike script — reads `FASTMAIL_EMAIL` + `FASTMAIL_APP_PASSWORD` from env, calls `createFastmailClient` → `fetchCalendars()`, prints each calendar's URL, displayName, ctag, syncToken
|
||||
- `apps/pwa/src/api/client.ts`: `fetchEvents()` with `CalendarEvent` and `EventsResponse` types
|
||||
- `apps/pwa/src/components/EventProof.tsx`: React Query `['events']` → `fetchEvents()` → renders first event's title (ical.js-parsed SUMMARY) + date, or "No cached events yet" empty state
|
||||
- `apps/pwa/src/App.tsx`: renders `MemberBadge` (member name + color) and `EventProof` together on the landing page
|
||||
- All 24 API unit tests green; `tsc --noEmit` clean in both `apps/api` and `apps/pwa`
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Task 1:** `48f90ce` — `feat(01-04): wire broker + routes into bootstrap, add SSE endpoint, EventProof`
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `apps/api/src/routes/sse.ts` — `sseRouter`, `GET /heartbeat` (streamSSE, 10s heartbeat, T-04-01)
|
||||
- `apps/api/src/broker/spike.ts` — CAL-08 spike: env creds → createFastmailClient → fetchCalendars → print URLs
|
||||
- `apps/api/src/index.ts` — final bootstrap wiring: all 3 API routes + poller start
|
||||
- `apps/pwa/src/components/EventProof.tsx` — broker-proof component, React Query, ical.js SUMMARY extraction
|
||||
- `apps/pwa/src/api/client.ts` — `fetchEvents()` added with typed shapes
|
||||
- `apps/pwa/src/App.tsx` — MemberBadge + EventProof on landing page
|
||||
- `apps/pwa/package.json` — ical.js@2.2.1 added
|
||||
- `.planning/phases/01-foundation-broker-spike/CAL-08-DECISION.md` — decision template (fill in after spike)
|
||||
|
||||
## Live Verification Pending (Tasks 2 + 3)
|
||||
|
||||
These are `checkpoint:human-action` tasks that require real infrastructure:
|
||||
|
||||
### Task 2: CAL-08 Spike — Confirm app password reads shared + personal calendars
|
||||
|
||||
**What to do:**
|
||||
|
||||
1. Generate a Fastmail app password for Lucas's account:
|
||||
- Fastmail Settings → Privacy & Security → App Passwords → scope: "Mail, Contacts & Calendars"
|
||||
|
||||
2. Run the spike:
|
||||
```bash
|
||||
cd apps/api
|
||||
FASTMAIL_EMAIL=lucas@fastmail.com \
|
||||
FASTMAIL_APP_PASSWORD=<app-password> \
|
||||
pnpm exec tsx src/broker/spike.ts
|
||||
```
|
||||
|
||||
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
|
||||
APP_PASSWORD_ENCRYPTION_KEY=<64-char-hex> node -e "
|
||||
import('./src/broker/crypto.js').then(({ encryptPassword }) => {
|
||||
console.log(encryptPassword('<app-password>'))
|
||||
})
|
||||
"
|
||||
# Then insert the encrypted value into member_credentials for Lucas's user row
|
||||
```
|
||||
|
||||
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;"
|
||||
```
|
||||
|
||||
6. Fill in `.planning/phases/01-foundation-broker-spike/CAL-08-DECISION.md` with:
|
||||
- `Decision: GO` or `Decision: NO-GO + fallback`
|
||||
- Discovered calendar URLs
|
||||
- Which ctag/syncToken field Fastmail actually returned
|
||||
|
||||
7. Reload the app and confirm landing page shows one real cached event.
|
||||
|
||||
**Resume signal:** Type "approved" once CAL-08-DECISION.md records GO (or NO-GO + fallback) and at least one event row is cached.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Live Deployment — Authelia login + Pangolin SSE smoke test
|
||||
|
||||
**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>
|
||||
OIDC_CLIENT_ID=familysync
|
||||
OIDC_CLIENT_SECRET=<plain-secret>
|
||||
OIDC_REDIRECT_URI=https://familysync.<domain>/callback
|
||||
OIDC_AUTH_EXTERNAL_URL=https://familysync.<domain>
|
||||
```
|
||||
|
||||
3. Expose FamilySync through Pangolin under the SAME parent domain as Authelia (same-site cookie requirement — Pitfall 1).
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
4. AUTH-01: From external network → open https://familysync.<domain> → confirm Authelia redirect → log in → see name + color + cached event.
|
||||
|
||||
5. AUTH-02: Close browser, reopen URL → confirm no re-login.
|
||||
|
||||
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.
|
||||
|
||||
**Resume signal:** Type "approved" once both members log in over the public URL, sessions persist, and SSE smoke result (PASS/FAIL) is recorded.
|
||||
|
||||
---
|
||||
|
||||
## SSE Smoke Test Result
|
||||
|
||||
**Result: PENDING** — to be filled in after Task 3.
|
||||
|
||||
If PASS: SSE confirmed for Phase 4 real-time list sync.
|
||||
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.
|
||||
- **Files modified:** apps/pwa/package.json, pnpm-lock.yaml
|
||||
- **Commit:** 48f90ce
|
||||
|
||||
## Known Stubs
|
||||
|
||||
`CAL-08-DECISION.md` is committed as a template with `Decision: [PENDING]`. The actual go/no-go decision must be filled in by the human after running the spike against real Fastmail credentials. This is the expected state for a code-complete + pending-live-verification plan.
|
||||
|
||||
## 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
|
||||
- **T-04-04 (spike credentials):** spike reads password from env, prints only calendar URLs, never echoes the password — confirmed in spike.ts
|
||||
- **T-04-05 (client_secret):** plain secret in OIDC_CLIENT_SECRET env; Authelia YAML holds hash — documented in Task 3 steps
|
||||
- **T-04-SC (tsx dev runner):** spike.ts not imported by any production module; dev-only
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- `apps/api/src/routes/sse.ts` exists and exports sseRouter: FOUND
|
||||
- `apps/api/src/broker/spike.ts` exists: FOUND
|
||||
- `apps/api/src/index.ts` contains `startBrokerPoller()`: FOUND
|
||||
- `apps/api/src/index.ts` contains `'/api/events'` and `'/api/sse'`: FOUND
|
||||
- `apps/pwa/src/components/EventProof.tsx` exists: FOUND
|
||||
- `apps/pwa/src/api/client.ts` contains `fetchEvents`: FOUND
|
||||
- Commit 48f90ce: FOUND
|
||||
- `tsc --noEmit` clean (apps/api): PASSED
|
||||
- `tsc --noEmit` clean (apps/pwa): PASSED
|
||||
- `pnpm vitest run` 24/24 tests green: PASSED
|
||||
|
||||
---
|
||||
*Phase: 01-foundation-broker-spike*
|
||||
*Completed (code): 2026-06-04 — Live verification pending*
|
||||
@@ -0,0 +1,63 @@
|
||||
# CAL-08: Personal Calendar ACL Spike — Decision Record
|
||||
|
||||
**Requirement:** CAL-08
|
||||
**Phase:** 01-foundation-broker-spike
|
||||
**Spike script:** `apps/api/src/broker/spike.ts`
|
||||
|
||||
## Status
|
||||
|
||||
**Decision: [PENDING — fill in after running the spike]**
|
||||
|
||||
---
|
||||
|
||||
## How to Run the Spike
|
||||
|
||||
```bash
|
||||
cd apps/api
|
||||
FASTMAIL_EMAIL=<lucas@fastmail.com> \
|
||||
FASTMAIL_APP_PASSWORD=<app-password-from-fastmail-settings> \
|
||||
pnpm exec tsx src/broker/spike.ts
|
||||
```
|
||||
|
||||
The script will print every calendar collection returned by Fastmail's CalDAV PROPFIND.
|
||||
|
||||
---
|
||||
|
||||
## Results
|
||||
|
||||
> Fill in after running the spike.
|
||||
|
||||
### Calendars Discovered
|
||||
|
||||
| displayName | URL | ctag returned? | syncToken returned? |
|
||||
|-------------|-----|----------------|---------------------|
|
||||
| (fill in) | (fill in) | (fill in) | (fill in) |
|
||||
|
||||
### Questions Resolved
|
||||
|
||||
| Question | Finding |
|
||||
|----------|---------|
|
||||
| Does Lucas's app password see the shared family calendar? | (fill in) |
|
||||
| Does Lucas's app password see Lucas's personal calendar? | (fill in) |
|
||||
| Which change-detection field does Fastmail use — ctag or syncToken? | (fill in) |
|
||||
|
||||
---
|
||||
|
||||
## Decision
|
||||
|
||||
**Decision: GO / NO-GO** (circle one — delete the other after filling in)
|
||||
|
||||
**Rationale:** (fill in after seeing the spike output)
|
||||
|
||||
**If NO-GO — Fallback:**
|
||||
Proceed with shared-family-only calendar in Phase 1. Personal calendar overlay moves to v1.x.
|
||||
Document the Fastmail restriction that prevented per-member personal calendar access.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- App password scope required: "Mail, Contacts & Calendars"
|
||||
- Fastmail Settings → Privacy & Security → App Passwords
|
||||
- The password is passed via env for this one-off read-only enumeration; it is NOT committed anywhere
|
||||
- Results here resolve RESEARCH Open Questions 1 + Assumptions A1/A2/A3
|
||||
Reference in New Issue
Block a user