feat(01-04): wire broker + routes into bootstrap, add SSE endpoint, EventProof

- Mount /api/events, /api/sse in index.ts behind oidcAuthMiddleware; /callback + /health before guard
- Call startBrokerPoller() on boot (5-min ctag-poll background schedule)
- Add sseRouter with GET /heartbeat (streamSSE, 10s interval) for Pangolin SSE smoke test (D-08, T-04-01)
- Add CAL-08 spike script (broker/spike.ts): createFastmailClient → fetchCalendars → print calendar URLs
- Add fetchEvents() to pwa/api/client.ts with typed CalendarEvent/EventsResponse shapes
- Add EventProof.tsx: React Query ['events'], renders first event title+date or empty-state (CAL-01 broker proof)
- Update App.tsx to render MemberBadge + EventProof on landing page
- Add ical.js@2.2.1 to PWA dependencies for VEVENT summary parsing in EventProof
- All 24 API unit tests green; tsc --noEmit clean in both apps/api and apps/pwa
This commit is contained in:
Lucas Berger
2026-06-04 11:16:10 -04:00
parent d2d8333bf8
commit 48f90ceca9
8 changed files with 313 additions and 4 deletions
+70
View File
@@ -0,0 +1,70 @@
/**
* CAL-08 spike script: enumerate Fastmail calendar collections for a single account.
*
* Purpose: Confirm that a Fastmail app password ("Mail, Contacts & Calendars" scope)
* can read BOTH the shared family calendar AND Lucas's personal calendar via
* CalDAV PROPFIND. This is the go/no-go gate for the N-credential per-member model
* described in D-02/D-05.
*
* Run:
* cd apps/api
* FASTMAIL_EMAIL=lucas@fastmail.com \
* FASTMAIL_APP_PASSWORD=<app-pw> \
* pnpm exec tsx src/broker/spike.ts
*
* Output: prints each returned calendar collection — url, displayName, ctag, syncToken.
* Expected: shared family calendar + Lucas's personal calendar both appear.
*
* Security (T-04-04):
* - App password is read from env, never logged.
* - Output prints only calendar URLs and display names, not the password.
* - This script is dev-only; NOT imported by the API or Docker image (T-04-SC).
*
* After running, record results in:
* .planning/phases/01-foundation-broker-spike/CAL-08-DECISION.md
*/
import { createFastmailClient } from './client.js'
async function main() {
const email = process.env.FASTMAIL_EMAIL
const appPassword = process.env.FASTMAIL_APP_PASSWORD
if (!email || !appPassword) {
console.error(
'Usage: FASTMAIL_EMAIL=<email> FASTMAIL_APP_PASSWORD=<pw> pnpm exec tsx src/broker/spike.ts',
)
process.exit(1)
}
console.log(`[CAL-08 spike] Connecting to Fastmail CalDAV as: ${email}`)
console.log('[CAL-08 spike] Creating tsdav client...')
const client = await createFastmailClient(email, appPassword)
console.log('[CAL-08 spike] Fetching calendars via PROPFIND...')
const calendars = await client.fetchCalendars()
console.log(`\n[CAL-08 spike] Found ${calendars.length} calendar collection(s):\n`)
for (const cal of calendars) {
console.log('---')
console.log(` url: ${cal.url}`)
console.log(` displayName: ${cal.displayName ?? '(none)'}`)
// ctag/syncToken: Fastmail may return either field (Pitfall #6)
console.log(` ctag: ${(cal as { ctag?: string }).ctag ?? '(not returned)'}`)
console.log(` syncToken: ${(cal as { syncToken?: string }).syncToken ?? '(not returned)'}`)
}
console.log('\n[CAL-08 spike] Done.')
console.log('\nNext steps:')
console.log(' 1. Confirm the shared family calendar URL appears above.')
console.log(' 2. Confirm Lucas\'s personal calendar URL appears above.')
console.log(' 3. Record both URLs and ctag/syncToken findings in:')
console.log(' .planning/phases/01-foundation-broker-spike/CAL-08-DECISION.md')
}
main().catch((err: unknown) => {
console.error('[CAL-08 spike] Fatal error:', err instanceof Error ? err.message : String(err))
process.exit(1)
})
+13 -4
View File
@@ -3,25 +3,34 @@ import { serveStatic } from '@hono/node-server/serve-static'
import { Hono } from 'hono'
import { healthRouter } from './routes/health.js'
import { meRouter } from './routes/me.js'
import { eventsRouter } from './routes/events.js'
import { sseRouter } from './routes/sse.js'
import { oidcAuthMiddleware, processOAuthCallback } from './auth/middleware.js'
import { startBrokerPoller } from './broker/poller.js'
export const app = new Hono()
// GET /health — unauthenticated, mounted BEFORE the OIDC guard (T-01-03, T-02-05)
app.route('/health', healthRouter)
// OIDC callback — must be registered BEFORE oidcAuthMiddleware so the
// authorization-code exchange is not itself intercepted by the auth check (T-02-02)
app.get('/callback', (c) => processOAuthCallback(c))
// GET /health — unauthenticated, mounted BEFORE the OIDC guard (T-01-03, T-02-05)
app.route('/health', healthRouter)
// Protect all /api/* routes with OIDC session middleware (AUTH-01, T-02-05).
// Unauthenticated requests receive a 302 redirect to Authelia's authorize endpoint.
// OIDC_AUTH_EXTERNAL_URL is MANDATORY behind Pangolin to construct the correct
// redirect_uri (Pitfall 1). Set it to https://familysync.<domain>.
app.use('/api/*', oidcAuthMiddleware())
// Protected API routes
// Protected API routes (behind oidcAuthMiddleware)
app.route('/api/me', meRouter)
app.route('/api/events', eventsRouter)
app.route('/api/sse', sseRouter)
// Start the CalDAV broker poller (5-min cron, D-13 ctag change-detection)
// Runs in the background — errors are caught and logged per-credential (T-03-04)
startBrokerPoller()
// Serve React PWA static assets from ./public (Vite build output)
app.use('/assets/*', serveStatic({ root: './public' }))
+40
View File
@@ -0,0 +1,40 @@
/**
* GET /api/sse/heartbeat — Pangolin SSE pass-through smoke test endpoint.
*
* Emits a `heartbeat` event every 10 seconds with { ts, id } payload.
* Runs until the client disconnects (stream.aborted).
*
* Mounted under /api/sse in index.ts, so it sits behind oidcAuthMiddleware (T-04-01).
* Heartbeat payload carries only timestamps — no user data or secrets (T-04-02).
*
* Smoke test procedure (D-08):
* curl -N https://familysync.<domain>/api/sse/heartbeat
* Keep open 5+ min — confirm no proxy timeout. PASS → SSE viable for Phase 4.
*
* Source: https://hono.dev/docs/helpers/streaming
* RESEARCH Pattern 5: Pangolin SSE Smoke Test
*/
import { Hono } from 'hono'
import { streamSSE } from 'hono/streaming'
export const sseRouter = new Hono()
/**
* GET /heartbeat
* Streams SSE heartbeat events every 10 seconds until client disconnects.
* Response: text/event-stream with events of type "heartbeat".
*/
sseRouter.get('/heartbeat', (c) => {
return streamSSE(c, async (stream) => {
let id = 0
while (!stream.aborted) {
await stream.writeSSE({
data: JSON.stringify({ ts: new Date().toISOString(), id }),
event: 'heartbeat',
id: String(id++),
})
await stream.sleep(10_000)
}
})
})