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:
@@ -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
@@ -3,25 +3,34 @@ import { serveStatic } from '@hono/node-server/serve-static'
|
|||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import { healthRouter } from './routes/health.js'
|
import { healthRouter } from './routes/health.js'
|
||||||
import { meRouter } from './routes/me.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 { oidcAuthMiddleware, processOAuthCallback } from './auth/middleware.js'
|
||||||
|
import { startBrokerPoller } from './broker/poller.js'
|
||||||
|
|
||||||
export const app = new Hono()
|
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
|
// OIDC callback — must be registered BEFORE oidcAuthMiddleware so the
|
||||||
// authorization-code exchange is not itself intercepted by the auth check (T-02-02)
|
// authorization-code exchange is not itself intercepted by the auth check (T-02-02)
|
||||||
app.get('/callback', (c) => processOAuthCallback(c))
|
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).
|
// Protect all /api/* routes with OIDC session middleware (AUTH-01, T-02-05).
|
||||||
// Unauthenticated requests receive a 302 redirect to Authelia's authorize endpoint.
|
// Unauthenticated requests receive a 302 redirect to Authelia's authorize endpoint.
|
||||||
// OIDC_AUTH_EXTERNAL_URL is MANDATORY behind Pangolin to construct the correct
|
// OIDC_AUTH_EXTERNAL_URL is MANDATORY behind Pangolin to construct the correct
|
||||||
// redirect_uri (Pitfall 1). Set it to https://familysync.<domain>.
|
// redirect_uri (Pitfall 1). Set it to https://familysync.<domain>.
|
||||||
app.use('/api/*', oidcAuthMiddleware())
|
app.use('/api/*', oidcAuthMiddleware())
|
||||||
|
|
||||||
// Protected API routes
|
// Protected API routes (behind oidcAuthMiddleware)
|
||||||
app.route('/api/me', meRouter)
|
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)
|
// Serve React PWA static assets from ./public (Vite build output)
|
||||||
app.use('/assets/*', serveStatic({ root: './public' }))
|
app.use('/assets/*', serveStatic({ root: './public' }))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "5.101.0",
|
"@tanstack/react-query": "5.101.0",
|
||||||
|
"ical.js": "2.2.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"zustand": "5.0.14"
|
"zustand": "5.0.14"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { fetchMe, type MeUser } from './api/client'
|
import { fetchMe, type MeUser } from './api/client'
|
||||||
|
import { EventProof } from './components/EventProof'
|
||||||
|
|
||||||
interface HealthResponse {
|
interface HealthResponse {
|
||||||
ok: boolean
|
ok: boolean
|
||||||
@@ -85,6 +86,11 @@ export default function App() {
|
|||||||
<MemberBadge user={meQuery.data.user} />
|
<MemberBadge user={meQuery.data.user} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Broker proof: one cached Fastmail event (CAL-01) */}
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<EventProof />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Stack health indicator (from Plan 01) */}
|
{/* Stack health indicator (from Plan 01) */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -32,3 +32,41 @@ export async function fetchMe(): Promise<MeResponse> {
|
|||||||
|
|
||||||
return res.json() as Promise<MeResponse>
|
return res.json() as Promise<MeResponse>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<EventsResponse> {
|
||||||
|
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<EventsResponse>
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '1rem',
|
||||||
|
borderRadius: '8px',
|
||||||
|
background: '#f5f5f5',
|
||||||
|
color: '#666',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Loading calendar events...
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventsQuery.isError) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
borderRadius: '8px',
|
||||||
|
background: '#fee2e2',
|
||||||
|
color: '#991b1b',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Could not load events
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = eventsQuery.data?.events ?? []
|
||||||
|
|
||||||
|
if (events.length === 0) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
borderRadius: '8px',
|
||||||
|
background: '#fef9c3',
|
||||||
|
color: '#713f12',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No cached events yet — broker poller will run in the next 5 minutes.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = events[0]
|
||||||
|
const summary = extractSummary(first.rawVevent)
|
||||||
|
const date = formatDate(first)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
borderRadius: '8px',
|
||||||
|
background: '#f0fdf4',
|
||||||
|
border: '1px solid #bbf7d0',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 600, color: '#166534', marginBottom: '0.25rem' }}>
|
||||||
|
Broker proof — 1 event cached
|
||||||
|
</div>
|
||||||
|
<div style={{ color: '#15803d' }}>
|
||||||
|
{summary}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: '#4ade80', fontSize: '0.75rem', marginTop: '0.25rem' }}>
|
||||||
|
{date}
|
||||||
|
{events.length > 1 && ` (+${events.length - 1} more)`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Generated
+3
@@ -59,6 +59,9 @@ importers:
|
|||||||
'@tanstack/react-query':
|
'@tanstack/react-query':
|
||||||
specifier: 5.101.0
|
specifier: 5.101.0
|
||||||
version: 5.101.0(react@19.2.7)
|
version: 5.101.0(react@19.2.7)
|
||||||
|
ical.js:
|
||||||
|
specifier: 2.2.1
|
||||||
|
version: 2.2.1
|
||||||
react:
|
react:
|
||||||
specifier: ^19.0.0
|
specifier: ^19.0.0
|
||||||
version: 19.2.7
|
version: 19.2.7
|
||||||
|
|||||||
Reference in New Issue
Block a user