import { serve } from '@hono/node-server' 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 { devAuthBypass } from './auth/devBypass.js' import { startBrokerPoller } from './broker/poller.js' import { startOutboxWorker } from './broker/outboxWorker.js' export const app = new Hono() // Compute once at startup: bypass is active only in non-production with explicit opt-in. // In production NODE_ENV='production' → devBypassActive=false → OIDC is always mounted. const devBypassActive = process.env.NODE_ENV !== 'production' && process.env.DEV_AUTH_BYPASS === 'true' if (devBypassActive) { console.warn( '⚠ DEV_AUTH_BYPASS active — OIDC guard DISABLED. Never use in production.', ) } // 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) // Dev-auth bypass — no-op passthrough unless DEV_AUTH_BYPASS=true AND NODE_ENV!='production'. // When active, injects DEV_USER into the Hono context and the OIDC guard is NOT mounted. // Must be mounted BEFORE oidcAuthMiddleware (T-02-01 mitigation; see auth/devBypass.ts). app.use('/api/*', devAuthBypass()) // Protect all /api/* routes with OIDC session middleware (AUTH-01, T-02-05). // Skipped entirely when devBypassActive so that local dev works without Authelia. // In production devBypassActive is always false — OIDC is unconditionally mounted. // 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.. if (!devBypassActive) { app.use('/api/*', oidcAuthMiddleware()) } // Protected API routes (behind oidcAuthMiddleware) // GET /api/login — login entry point for the PWA. // Flow (production): unauthenticated top-level nav hits the OIDC guard above, // which 302-redirects to Authelia. After login, Authelia POSTs to /callback, // the middleware sets a `continue` cookie pointing back to /api/login, and the // browser follows it here — now authenticated. The handler then redirects to / // so the SPA boots with a valid session. Under DEV_AUTH_BYPASS the guard is not // mounted, so /api/login reaches this handler directly and still redirects to /. app.get('/api/login', (c) => c.redirect('/')) 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() // Drain the D-05 outbox every 15s: dispatches pending CalDAV writes to Fastmail startOutboxWorker() // Serve React PWA static assets from ./public (Vite build output). // MUST serve the whole ./public tree, not just /assets/* — root-level PWA files // (manifest.webmanifest, sw.js, registerSW.js, workbox-*.js, icon-*.png, // apple-touch-icon.png) live at the root. serveStatic calls next() when a file // is not found, so SPA routes fall through to the index.html catch-all below. // (Registered AFTER /health, /api/*, and /callback, so those win.) app.use('/*', serveStatic({ root: './public' })) app.get('*', serveStatic({ path: './public/index.html' })) // Only start the HTTP server when this module is run directly (not imported in tests) if (process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/^.*\//, ''))) { serve({ fetch: app.fetch, port: 3000 }, (info) => { console.log(`FamilySync API running on http://localhost:${info.port}`) }) }