import { fileURLToPath } from 'node:url'; import { realpathSync } from 'node:fs'; 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 { listsRouter, listItemsRouter } from './routes/lists.js'; import { pushRouter } from './routes/push.js'; import { adminRouter } from './routes/admin.js'; import { setupRouter } from './routes/setup.js'; import { authModeRouter } from './routes/authMode.js'; import { localAuthRouter } from './routes/localAuth.js'; import { oidcAuthMiddleware, processOAuthCallback, oidcConfigFallbackMiddleware, } from './auth/middleware.js'; import { devAuthBypass, devSessionCookieMiddleware } from './auth/devBypass.js'; import { localAuthMiddleware } from './auth/localAuthMiddleware.js'; import { verifyLocalSessionCookie } from './auth/localSession.js'; import { consumeLinkNonce } from './auth/linkNonceStore.js'; import { persistSessionCookie } from './auth/persistSessionCookie.js'; import { startBrokerPoller } from './broker/poller.js'; import { startOutboxWorker, initOutboxTrigger } from './broker/outboxWorker.js'; import { startReminderScheduler } from './broker/reminderScheduler.js'; import { assertNotDevBypassInProduction, assertLocalSessionSecretSet } from './lib/bootGuards.js'; import { getAuth } from './auth/middleware.js'; import { Jwt } from 'hono/utils/jwt'; import { linkOidcToUser, OidcLinkConflictError } from './auth/linkOidc.js'; import webpush from 'web-push'; 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). // // Phase 19 — link mode (AUTH-LOCAL-10, T-19-15): // When POST /api/me/link-oidc initiates a link flow, a signed JWT state is included // in the authorization URL as the `state` parameter. On callback, we read that raw URL // state param, try to decode it as our signed JWT, and if it carries `linkUserId`, we // call linkOidcToUser after processOAuthCallback establishes the OIDC session. // // Security: the signed state JWT prevents CSRF (T-19-09); linkOidcToUser preflight // prevents account takeover via conflict (T-19-15 / T-19-08). // // Normal (non-link) callbacks are unaffected — processOAuthCallback is called in all paths. app.get('/callback', async (c) => { // Attempt to extract linkUserId from the signed state param BEFORE processOAuthCallback // consumes it. The state param may be our signed JWT (link mode) or a random string (normal). let linkUserId: number | null = null; let linkNonce: string | null = null; const rawState = c.req.query('state'); if (rawState) { const secret = process.env.LOCAL_SESSION_SECRET; if (secret) { try { const payload = await Jwt.verify(rawState, secret, 'HS256'); if (typeof payload.linkUserId === 'number') { linkUserId = payload.linkUserId; linkNonce = typeof payload.nonce === 'string' ? payload.nonce : null; } } catch { // Not our signed link state — normal OIDC callback, proceed normally. } } } // Process the OIDC authorization-code exchange (sets the OIDC session cookie + redirects). const callbackResponse = await processOAuthCallback(c); // Link mode: after session is established, bind the OIDC identity to the local user. if (linkUserId !== null) { try { // IN-04: enforce SINGLE USE of the link nonce. The signed state JWT is otherwise // replayable for its full 10-minute signature lifetime; consuming the nonce here means // a captured state can be used at most once. A replay (already-consumed), unknown, or // expired nonce is rejected before any binding occurs. if (!linkNonce || !consumeLinkNonce(linkNonce)) { console.warn('[callback] OIDC-link rejected: link nonce missing, replayed, or expired.'); return c.redirect('/?error=oidc-link-conflict'); } // BL-03: cross-check that the local session completing this callback is the SAME // user the link flow was initiated for. The signed `state` JWT proves the state was // minted by POST /api/me/link-oidc, but NOT that the person finishing the OIDC login // is that user. Without this check, an attacker who gets a victim to complete an OIDC // login while replaying a still-valid (10-min) captured link state would bind the // ATTACKER's OIDC identity onto the VICTIM's account (account takeover). Require the // initiating local session to still be present and to match linkUserId. const sessionUserId = await verifyLocalSessionCookie(c); if (sessionUserId !== linkUserId) { console.warn( '[callback] OIDC-link rejected: local session does not match link state (possible replay).', ); return c.redirect('/?error=oidc-link-conflict'); } const auth = await getAuth(c); if (auth) { const iss = (auth.iss as string | undefined) ?? ''; const sub = auth.sub ?? ''; // BL-03: never bind on a blank/partial identity. linkOidcToUser writes oidc_iss/ // oidc_sub AND deletes the user's local_credentials — binding empty iss/sub would // both corrupt identity and lock the user out of BOTH auth methods. Reject instead. if (!iss || !sub) { console.warn('[callback] OIDC-link rejected: empty iss/sub from getAuth.'); return c.redirect('/?error=oidc-link-conflict'); } await linkOidcToUser(linkUserId, iss, sub); // On success: user is now OIDC-only; normal redirect via callbackResponse proceeds. } } catch (err) { if (err instanceof OidcLinkConflictError) { // 409: iss+sub already linked to a different user — redirect to conflict error page. // UI-SPEC Surface 13 error copy: "This OIDC identity is already linked to another account." return c.redirect('/?error=oidc-link-conflict'); } // Unexpected error during link binding — log and continue with normal redirect. console.error( '[callback] linkOidcToUser unexpected error:', err instanceof Error ? err.message : String(err), ); } } return callbackResponse; }); // GET /health — unauthenticated, mounted BEFORE the OIDC guard (T-01-03, T-02-05) app.route('/health', healthRouter); // /api/setup/* — pre-auth wizard surface; mounted BEFORE the /api/* middleware chain // so the wizard is never caught by devAuthBypass or oidcAuthMiddleware (Pitfall 1 / T-12-09). // Mirrors the /health pre-auth pattern. isSetupLocked() in each handler provides the // 423 lock after setup is complete (SETUP-04 / D-10). app.route('/api/setup', setupRouter); // Phase 19 — pre-auth auth routes: GET /api/auth/mode and POST /api/auth/local/login, /logout. // Mounted BEFORE devAuthBypass so they are reachable without a session (D-01 / AUTH-LOCAL-05). app.route('/api/auth', authModeRouter); app.route('/api/auth', localAuthRouter); // 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()); // Phase 19 Option C (AUTH-LOCAL-16, D-14/D-15): issue a real local-session cookie for DEV_USER // under bypass so the PWA login gate sees a valid session and skips /login. Pure no-op outside // bypass mode (production guard is FIRST check — T-19-24; see auth/devBypass.ts). // Mount AFTER devAuthBypass() so DEV_USER is already in context; BEFORE localAuthMiddleware. app.use('/api/*', devSessionCookieMiddleware()); // Phase 19 — local-session middleware: sets c.get('user') from 'local-session' JWT cookie. // No-op passthrough when no cookie is present — the OIDC guard fires for unauthenticated. // Runs AFTER devAuthBypass (which may set c.get('user') first) and BEFORE the OIDC guard. // The OIDC guard below is wrapped to skip when c.get('user') is already set (Pitfall 1 guard). app.use('/api/*', localAuthMiddleware()); // Protect all /api/* routes with OIDC session middleware (AUTH-01, T-02-05). // Skipped entirely when devBypassActive so that local dev works without the OIDC provider. // In production devBypassActive is always false — OIDC is unconditionally mounted. // Unauthenticated requests receive a 302 redirect to the OIDC 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) { // Phase 12 / D-02 / D-03: env-OR-app_config fallback for non-secret OIDC config. // Reads OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_AUTH_EXTERNAL_URL from app_config when // absent from process.env — so a fresh unconfigured instance does not crash at boot // and a wizard-configured instance reads the DB values before a container restart. // A2 CONFIRMED: oidcAuthMiddleware() reads process.env per-request (call time), so // injecting into process.env here is safe and effective (see auth/middleware.ts A2 note). app.use('/api/*', oidcConfigFallbackMiddleware); // Phase 19 / D-03: OIDC guard wrapped to skip when c.get('user') is already set. // A valid local-session (or dev-bypass) user must NOT be 302-redirected to the OIDC // provider — the skip-when-set wrapper is the coexistence seam (D-03 / RESEARCH Pitfall 1). // // IMPORTANT: oidcAuthMiddleware() factory is called ONCE at app construction time (not per // request) to match the prior behavior and keep test assertions about "called once" valid. // The returned handler is stored and invoked per-request inside the wrapper. const oidcHandler = oidcAuthMiddleware(); app.use('/api/*', async (c, next) => { if (c.get('user')) { await next(); return; } // oidcHandler's parameter is typed as the generic Hono Context; our wrapper's c is the // same runtime Context narrowed to '/api/*' — the structural mismatch is type-only. // eslint-disable-next-line @typescript-eslint/no-unsafe-argument await oidcHandler(c, next); }); // Re-issues the session-scoped oidc-auth cookie as persistent so PWA sessions survive close (AUTH-02). app.use('/api/*', persistSessionCookie()); } // 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 the OIDC provider. After login, the provider 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); app.route('/api/lists', listsRouter); app.route('/api/list-items', listItemsRouter); app.route('/api/push', pushRouter); app.route('/api/admin', adminRouter); // WR-04: background worker startup (cron schedules) moved into the isMainModule() // guard below. Calling them at top level registered real node-cron schedules whenever // ./index.js was imported — the route tests import `app` from here, so the poller/outbox // drain fired during the test run, touched the mocked DB/CalDAV layers nondeterministically, // and left open handles that blocked clean process exit. They now start only when the // module is the process entrypoint. // 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' })); /** * True only when this module is the process entrypoint (run directly), not when it * is imported (e.g. by route tests that import `app`). * * WR-05: the previous basename-tail comparison * import.meta.url.endsWith(process.argv[1].replace(/^.*\//, '')) * was fragile — a symlinked entrypoint or a differently-located file sharing the same * basename could make it mis-fire (start the server during an unrelated import, or fail * to start it in production). Compare fully-resolved real paths instead. realpathSync * resolves symlinks on argv[1]; fileURLToPath turns the module URL into a real path. */ function isMainModule(): boolean { if (!process.argv[1]) return false; try { // eslint-disable-next-line security/detect-non-literal-fs-filename -- process.argv[1] is the Node runtime entry path, not user input return fileURLToPath(import.meta.url) === realpathSync(process.argv[1]); } catch { return false; } } // Only start the HTTP server AND background workers when this module is run directly // (not imported in tests). WR-04: gating the cron schedules here keeps them out of the // test process. if (isMainModule()) { // D-08: Production safety guard — must be FIRST, before VAPID config, workers, or serve(). assertNotDevBypassInProduction(); // D-05 / T-19-03: Refuse to start if LOCAL_SESSION_SECRET is missing/short in non-bypass mode. assertLocalSessionSecretSet(); // Configure VAPID credentials for web-push before starting background workers. // VAPID_SUBJECT must be a mailto: or https: URL identifying the operator. // The private key is NEVER served to clients; it signs push requests server-side only. const vapidSubject = process.env.VAPID_SUBJECT ?? ''; const vapidPublicKey = process.env.VAPID_PUBLIC_KEY ?? ''; const vapidPrivateKey = process.env.VAPID_PRIVATE_KEY ?? ''; if (vapidSubject && vapidPublicKey && vapidPrivateKey) { try { webpush.setVapidDetails(vapidSubject, vapidPublicKey, vapidPrivateKey); } catch (err) { console.warn( '[startup] setVapidDetails failed — push notifications will not work:', err instanceof Error ? err.message : String(err), ); } } else { console.warn( '[startup] VAPID env vars not set — push notifications will fail. Set VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY.', ); } // 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(); initOutboxTrigger(); // subscribe drain signal listener (D-01) // Start the 1-min reminder scan for shared timed events starting in ~15 min (NOTIF-01). // VAPID must be configured (above) before this starts or push sends will fail. startReminderScheduler(); serve({ fetch: app.fetch, port: 3000 }, (info) => { console.log(`FamilySync API running on http://localhost:${info.port}`); }); }