Files
sencho/backend/src/app.ts
T
Anso ccad5c925b feat(nodes): hide hub-only views when active node is remote (#1007)
* feat(nodes): hide hub-only views when active node is remote

Fleet, Schedules, Audit, Logs, and Auto-Update operate on hub-owned state
(node registry, fleet schedules, centralized audit, fleet-wide log
aggregation, fleet-wide update preview). When the active node is remote,
proxying those surfaces would show that remote's own disconnected state
instead of the hub's. Hide them from the nav strip and force-redirect to
Home if one was open during the node switch.

Backend hubOnlyGuard middleware sits between nodeContextMiddleware and the
remote proxy and rejects /api/scheduled-tasks, /api/audit-log, and
/api/notification-routes with 403 + HUB_ONLY_ENDPOINT when nodeId resolves
to a remote, closing the script-bypass path the UI gating cannot reach.

Settings sub-sections were already gated via the hiddenOnRemote registry;
this extends the same model to top-level views.

* docs(nodes): note hub-only visibility on Fleet, Schedules, Audit, Logs, Auto-Update

Each of the five hub-only feature pages now points readers to the
canonical "What top-level views show when a remote node is active"
section in multi-node.mdx, so users landing directly on a feature page
understand why the nav item disappears when they switch to a remote node.
2026-05-08 22:54:58 -04:00

131 lines
5.5 KiB
TypeScript

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 (17 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
* 11. auditLog (at /api) -- registered in index.ts
* 12. enforceApiTokenScope (at /api) -- registered in index.ts
* 13. hubOnlyGuard (at /api) -- middleware/hubOnlyGuard.ts, registered in index.ts
* 14. createRemoteProxyMiddleware -- proxy/remoteNodeProxy.ts, registered in index.ts
* 15. routes -- registered in index.ts from routes/*
* 16. static serving + SPA fallback -- registered in index.ts
* 17. errorHandler -- registered in index.ts
*
* Steps 10 to 13 and 15 must run after the public auth routers (meta, auth,
* mfa, sso) are registered so those routes stay reachable without a session
* cookie. index.ts mounts those public routers before step 10 to preserve
* that invariant.
*/
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;
}