mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-27 18:57:09 +00:00
refactor(backend): extract authMiddleware and introduce createApp factory (phase 2) (#732)
Phase 2 of the index.ts refactor. Pulls the auth middleware and session cookie issuers into their own module, and introduces the app.ts factory that owns the first nine steps of the canonical middleware pipeline. New modules: - middleware/auth.ts: authMiddleware, issueSessionCookie, issueMfaPendingCookie, clearMfaPendingCookie - app.ts: createApp() factory installing trust proxy, helmet, cors, compression, cookieParser, rate limiters, conditionalJsonParser, and nodeContextMiddleware. A header comment documents all 16 canonical middleware steps and where each currently lives. Changes: - middleware/authGate.ts: createAuthGate factory removed; authGate now imports authMiddleware directly (the factory existed only to avoid a circular import while authMiddleware lived in index.ts). - services/DatabaseService.ts: added API_TOKEN_SCOPE_TO_ROLE map so the auth middleware no longer inlines a stringly-typed record. - index.ts drops ~260 lines; auth routes, authGate, auditLog, apiTokenScope, remaining routes, static serving, and the error handler continue to be registered there until their respective phases. - vitest.config.ts bumps testTimeout to 30s and hookTimeout to 45s so fork-pool workers have enough headroom to ts-node-transform the growing module graph under CPU contention (64 workers each import the full Express stack in beforeAll). Code review fixes: use getErrorMessage() util in the auth catch block instead of inline cast; promote the scope-to-role map to a typed module-level constant.
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
import express, { type Request, type Response } from 'express';
|
||||
import cors from 'cors';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import compression from 'compression';
|
||||
import helmet from 'helmet';
|
||||
import { globalApiLimiter, pollingLimiter } from './middleware/rateLimiters';
|
||||
import { conditionalJsonParser } from './middleware/jsonParser';
|
||||
import { nodeContextMiddleware } from './middleware/nodeContext';
|
||||
import './types/express';
|
||||
|
||||
/**
|
||||
* Build an Express app with the full middleware pipeline installed.
|
||||
*
|
||||
* Canonical middleware order (16 steps). Do not reorder without re-running the
|
||||
* regression checklist in `docs/internal/architecture/middleware-order.md`.
|
||||
*
|
||||
* 1. trust proxy
|
||||
* 2. helmet
|
||||
* 3. cors
|
||||
* 4. compression
|
||||
* 5. cookieParser
|
||||
* 6. globalApiLimiter (at /api)
|
||||
* 7. pollingLimiter (at /api)
|
||||
* 8. conditionalJsonParser
|
||||
* 9. nodeContextMiddleware
|
||||
* 10. authGate (at /api) -- registered in index.ts (before Phase 4 finishes)
|
||||
* 11. auditLog (at /api) -- registered in index.ts
|
||||
* 12. enforceApiTokenScope (at /api) -- registered in index.ts
|
||||
* 13. remote HTTP proxy -- Phase 3
|
||||
* 14. routes -- registered in index.ts (moves to routes/* in Phase 4)
|
||||
* 15. static serving + SPA fallback -- registered in index.ts (moves here in Phase 5)
|
||||
* 16. errorHandler -- registered in index.ts
|
||||
*
|
||||
* Steps 10-12 and 14 must run after the auth routes are registered so those
|
||||
* routes can remain public (login, setup, MFA, SSO). Once all routes live in
|
||||
* `routes/*.ts` routers and are mounted here in `createApp()`, every step
|
||||
* collapses into this factory.
|
||||
*/
|
||||
export function createApp(): express.Express {
|
||||
const app = express();
|
||||
|
||||
// 1. Trust the first reverse proxy (nginx, Traefik, etc.) for correct
|
||||
// req.protocol, req.ip, and secure cookie detection behind a proxy.
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
// 2. Security headers.
|
||||
// crossOriginEmbedderPolicy: disabled because Monaco editor workers lack COEP headers.
|
||||
// hsts: disabled. HSTS must only be set over HTTPS; enabling over HTTP
|
||||
// permanently breaks browser access for 1 year.
|
||||
// contentSecurityPolicy.upgradeInsecureRequests: explicitly null. Helmet 8
|
||||
// merges custom directives with its defaults, which include this directive.
|
||||
// It tells browsers to silently upgrade every HTTP sub-resource fetch to
|
||||
// HTTPS; on a plain-HTTP self-hosted deployment this causes every JS/CSS
|
||||
// asset to fail with ERR_SSL_PROTOCOL_ERROR, producing a blank page.
|
||||
// Setting null is the Helmet 8 API to remove a default directive.
|
||||
app.use(helmet({
|
||||
crossOriginEmbedderPolicy: false,
|
||||
// COOP is only meaningful over HTTPS. Over HTTP the browser logs a warning
|
||||
// and ignores it, creating noise in the console with no security benefit.
|
||||
crossOriginOpenerPolicy: false,
|
||||
// Origin-Agent-Cluster is only meaningful over HTTPS. Over plain HTTP the
|
||||
// browser logs a warning and ignores it. Disabling removes console noise.
|
||||
originAgentCluster: false,
|
||||
hsts: false,
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
baseUri: ["'self'"],
|
||||
fontSrc: ["'self'", 'https:', 'data:'],
|
||||
formAction: ["'self'"],
|
||||
frameAncestors: ["'self'"],
|
||||
// img-src: 'https:' is required for App Store template icons hosted on
|
||||
// external registries (e.g. raw.githubusercontent.com).
|
||||
imgSrc: ["'self'", 'data:', 'https:'],
|
||||
objectSrc: ["'none'"],
|
||||
scriptSrc: ["'self'"],
|
||||
scriptSrcAttr: ["'none'"],
|
||||
styleSrc: ["'self'", 'https:', "'unsafe-inline'"],
|
||||
// connect-src: explicit 'self' covers same-origin fetch/XHR/WebSocket.
|
||||
// ws: and wss: are included for WebSocket connections in any scheme context.
|
||||
connectSrc: ["'self'", 'ws:', 'wss:'],
|
||||
// worker-src: Monaco editor creates Web Workers via blob: URLs for
|
||||
// language services (syntax highlighting, intellisense). Without blob:
|
||||
// they silently fail.
|
||||
workerSrc: ["'self'", 'blob:'],
|
||||
upgradeInsecureRequests: null,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// 3. CORS: production restricts to FRONTEND_URL; dev mirrors the request
|
||||
// origin so Vite's dev server works.
|
||||
const corsOrigin = process.env.NODE_ENV === 'production'
|
||||
? (process.env.FRONTEND_URL || false)
|
||||
: true;
|
||||
app.use(cors({
|
||||
origin: corsOrigin,
|
||||
credentials: true,
|
||||
}));
|
||||
|
||||
// 4. Compression. SSE streams (Content-Type: text/event-stream) MUST NOT be
|
||||
// compressed because compression buffers output and would delay event delivery
|
||||
// until a flush, breaking live log and status streams.
|
||||
app.use(compression({
|
||||
filter: (req: Request, res: Response) => {
|
||||
const ct = res.getHeader('Content-Type');
|
||||
if (typeof ct === 'string' && ct.includes('text/event-stream')) {
|
||||
return false;
|
||||
}
|
||||
return compression.filter(req, res);
|
||||
},
|
||||
}));
|
||||
|
||||
// 5. Cookie parser must run before the rate limiters so the hybrid key
|
||||
// generator can read req.cookies for per-user rate limit bucketing.
|
||||
app.use(cookieParser());
|
||||
|
||||
// 6-7. Tiered rate limiting (see middleware/rateLimiters.ts for the model).
|
||||
app.use('/api/', globalApiLimiter);
|
||||
app.use('/api/', pollingLimiter);
|
||||
|
||||
// 8. Parse JSON on local requests; preserve the raw stream for remote proxy.
|
||||
app.use(conditionalJsonParser);
|
||||
|
||||
// 9. Resolve req.nodeId and short-circuit requests to deleted nodes.
|
||||
app.use(nodeContextMiddleware);
|
||||
|
||||
return app;
|
||||
}
|
||||
Reference in New Issue
Block a user