Root-level PWA files (manifest.webmanifest, sw.js, registerSW.js, workbox-*.js,
icon-*.png, apple-touch-icon.png) were falling through to the index.html
catch-all and returning HTML — breaking the manifest (syntax error) and
preventing the service worker from ever registering. serveStatic('/*') serves
any existing file and calls next() for SPA routes, so index.html stays the
fallback. Registered after /health, /api/*, /callback so those still win.
84 lines
4.0 KiB
TypeScript
84 lines
4.0 KiB
TypeScript
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.<domain>.
|
|
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}`)
|
|
})
|
|
}
|