diff --git a/apps/api/src/broker/spike.ts b/apps/api/src/broker/spike.ts new file mode 100644 index 0000000..ca75abe --- /dev/null +++ b/apps/api/src/broker/spike.ts @@ -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= \ + * 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= FASTMAIL_APP_PASSWORD= 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) +}) diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index bdf528d..2998863 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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.. 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' })) diff --git a/apps/api/src/routes/sse.ts b/apps/api/src/routes/sse.ts new file mode 100644 index 0000000..2b79968 --- /dev/null +++ b/apps/api/src/routes/sse.ts @@ -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./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) + } + }) +}) diff --git a/apps/pwa/package.json b/apps/pwa/package.json index 6c3cfd4..fda7356 100644 --- a/apps/pwa/package.json +++ b/apps/pwa/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@tanstack/react-query": "5.101.0", + "ical.js": "2.2.1", "react": "^19.0.0", "react-dom": "^19.0.0", "zustand": "5.0.14" diff --git a/apps/pwa/src/App.tsx b/apps/pwa/src/App.tsx index 61326d4..e13855b 100644 --- a/apps/pwa/src/App.tsx +++ b/apps/pwa/src/App.tsx @@ -1,5 +1,6 @@ import { useQuery } from '@tanstack/react-query' import { fetchMe, type MeUser } from './api/client' +import { EventProof } from './components/EventProof' interface HealthResponse { ok: boolean @@ -85,6 +86,11 @@ export default function App() { )} + {/* Broker proof: one cached Fastmail event (CAL-01) */} +
+ +
+ {/* Stack health indicator (from Plan 01) */}
{ return res.json() as Promise } + +/** + * A single cached calendar event from the broker's MariaDB cache. + * Mirrors the calendarEvents table schema from apps/api/src/db/schema.ts. + */ +export interface CalendarEvent { + id: number + calendarId: number + uid: string + etag: string | null + rawVevent: string + dtstartUtc: string | null + dtstartDate: string | null + allDay: boolean + updatedAt: string | null +} + +export interface EventsResponse { + events: CalendarEvent[] +} + +/** + * Fetches all cached calendar events from the broker's MariaDB cache. + * No live Fastmail call — the 5-min poller keeps the cache fresh. + * + * Used by EventProof to display the first cached event as broker proof (CAL-01). + */ +export async function fetchEvents(): Promise { + const res = await fetch('/api/events', { + credentials: 'include', + }) + + if (!res.ok) { + throw new Error(`GET /api/events failed: ${res.status}`) + } + + return res.json() as Promise +} diff --git a/apps/pwa/src/components/EventProof.tsx b/apps/pwa/src/components/EventProof.tsx new file mode 100644 index 0000000..ba4891c --- /dev/null +++ b/apps/pwa/src/components/EventProof.tsx @@ -0,0 +1,142 @@ +/** + * EventProof — renders one cached event from /api/events as broker proof. + * + * This is the Phase 1 landing page's broker-proof component: it shows a + * single cached event title and date, confirming the CalDAV broker has + * successfully fetched and cached at least one real Fastmail event (CAL-01). + * + * Data flow: + * React Query ['events'] → fetchEvents() → GET /api/events + * → renders first event's summary (parsed from rawVevent) and date + * + * Empty state: "No cached events yet" — shown when the poller hasn't run yet. + * This is expected on first boot before any Fastmail credentials are loaded. + */ + +import { useQuery } from '@tanstack/react-query' +import { fetchEvents, type CalendarEvent } from '../api/client' +import ICAL from 'ical.js' + +/** + * Extracts the event summary (title) from a raw VCALENDAR/VEVENT string. + * Falls back to 'Untitled event' if parsing fails or SUMMARY is absent. + */ +function extractSummary(rawVevent: string): string { + try { + const parsed = ICAL.parse(rawVevent) + const comp = new ICAL.Component(parsed) + const vevent = comp.getFirstSubcomponent('vevent') + if (!vevent) return 'Untitled event' + return (vevent.getFirstPropertyValue('summary') as string | null) ?? 'Untitled event' + } catch { + return 'Untitled event' + } +} + +/** + * Formats the event date for display. + * Uses dtstartDate (for all-day) or dtstartUtc (for timed events). + */ +function formatDate(event: CalendarEvent): string { + if (event.allDay && event.dtstartDate) { + return event.dtstartDate + } + if (event.dtstartUtc) { + try { + return new Date(event.dtstartUtc).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }) + } catch { + return event.dtstartUtc + } + } + return 'Date unknown' +} + +export function EventProof() { + const eventsQuery = useQuery({ + queryKey: ['events'], + queryFn: fetchEvents, + retry: false, + staleTime: 5 * 60 * 1000, // 5 min — matches broker poll interval + }) + + if (eventsQuery.isLoading) { + return ( +
+ Loading calendar events... +
+ ) + } + + if (eventsQuery.isError) { + return ( +
+ Could not load events +
+ ) + } + + const events = eventsQuery.data?.events ?? [] + + if (events.length === 0) { + return ( +
+ No cached events yet — broker poller will run in the next 5 minutes. +
+ ) + } + + const first = events[0] + const summary = extractSummary(first.rawVevent) + const date = formatDate(first) + + return ( +
+
+ Broker proof — 1 event cached +
+
+ {summary} +
+
+ {date} + {events.length > 1 && ` (+${events.length - 1} more)`} +
+
+ ) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 61f20c1..bc58ea9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: '@tanstack/react-query': specifier: 5.101.0 version: 5.101.0(react@19.2.7) + ical.js: + specifier: 2.2.1 + version: 2.2.1 react: specifier: ^19.0.0 version: 19.2.7