Source: middleware/appSetup.js

/**
 * @module middleware/appSetup
 * @description Express app creation, global middleware, and static file serving.
 *
 * Extracted from `index.js` so the app instance can be imported by tests
 * or other modules without triggering side effects (DB init, listen).
 *
 * ### Exports
 * - {@link app} — The Express application instance.
 * - {@link ARTIFACTS_DIR} — Absolute path to the Playwright artifacts directory.
 * - {@link serveIndexWithNonce} — SPA fallback handler that injects the CSP nonce (SEC-002).
 *
 * @example
 * import { app, ARTIFACTS_DIR } from "./middleware/appSetup.js";
 */

import dotenv from "dotenv";
import express from "express";
import cors from "cors";
import helmet from "helmet";
import { rateLimit } from "express-rate-limit";
import crypto from "crypto";
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import { createRequire } from "module";
import { AUTH_COOKIE } from "./authenticate.js";
import { redis, isRedisAvailable } from "../utils/redisClient.js";
import { formatLogLine } from "../utils/logFormatter.js";

// Load .env before reading any env vars below (CORS_ORIGIN, etc.).
// ESM imports execute before module-level code in index.js, so the
// dotenv.config() call there runs too late for this file.
dotenv.config();

const __dirname = path.dirname(fileURLToPath(import.meta.url));

/**
 * The Express application instance.
 * @type {Object}
 */
export const app = express();

// Trust the first hop's X-Forwarded-For header (set by nginx / load balancer).
// Without this, Express uses the raw socket IP instead of the real client IP,
// making per-IP rate limiting ineffective behind a reverse proxy.
// "1" = trust exactly one proxy hop — adjust if you have multiple hops.
app.set("trust proxy", 1);

// ─── Global middleware ────────────────────────────────────────────────────────

// Security headers: X-Content-Type-Options, X-Frame-Options, Strict-Transport-Security, etc.
// SEC-002: Generate a per-request nonce and allow scripts via `'nonce-<value>'`
// instead of `'unsafe-inline'`. This keeps inline bootstrap scripts functional
// while preserving CSP's XSS protections.
app.use((req, res, next) => {
  const nonce = crypto.randomBytes(16).toString("base64");
  res.locals.cspNonce = nonce;
  next();
});

app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc:     ["'self'"],
      scriptSrc:      ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
      styleSrc:       ["'self'", "'unsafe-inline'"],   // inline styles used throughout the SPA
      imgSrc:         ["'self'", "data:", "blob:"],    // data: for canvas favicons, blob: for screenshots
      connectSrc:     ["'self'"],                      // API + SSE calls — same origin only
      fontSrc:        ["'self'", "data:"],
      frameSrc:       ["'self'"],                      // Playwright trace viewer iframes
      workerSrc:      ["'self'", "blob:"],             // Web Workers for PDF generation
      objectSrc:      ["'none'"],
      baseUri:        ["'self'"],
      formAction:     ["'self'"],
      frameAncestors: ["'none'"],                      // prevents clickjacking
      upgradeInsecureRequests: [],
    },
  },
  crossOriginEmbedderPolicy: false,   // required for Playwright trace viewer iframes
}));

// CORS — restrict origins in production, allow all in development.
// Set CORS_ORIGIN env var to the frontend URL (e.g. "https://sentri.example.com").
const corsOrigin = process.env.CORS_ORIGIN || "*";
if (corsOrigin === "*" && process.env.NODE_ENV === "production") {
  throw new Error(
    "CORS_ORIGIN must be set in production. " +
    "Set CORS_ORIGIN to your frontend URL(s) (comma-separated), e.g. CORS_ORIGIN=https://sentri.example.com"
  );
}
app.use(cors({
  origin: corsOrigin === "*" ? true : corsOrigin.split(",").map(o => o.trim()),
  credentials: true,
  // Expose the CSRF token response header so the frontend can read it via
  // fetch().  In cross-origin deployments document.cookie cannot see cookies
  // set by the backend domain, so the token is echoed in this header instead.
  exposedHeaders: ["X-CSRF-Token"],
}));

// ─── Cross-origin cookie helper ──────────────────────────────────────────────
// When the frontend and backend live on different origins (e.g. GitHub Pages +
// Render), SameSite=Strict cookies are never sent on cross-site requests.
// Detect this at startup so cookie-setting code can use SameSite=None; Secure.
const _corsOrigins = corsOrigin === "*" ? [] : corsOrigin.split(",").map(o => o.trim());

/**
 * `true` when CORS_ORIGIN is set to a different origin than the backend.
 * In that case cookies must use `SameSite=None; Secure` to be sent cross-site.
 *
 * Compares against the backend's own origin (PORT-based), NOT APP_URL which is
 * the frontend URL. For GitHub Pages + Render deployments, CORS_ORIGIN is the
 * GitHub Pages URL and the backend runs on Render — these are always different
 * origins, so cookies must use SameSite=None; Secure.
 * @type {boolean}
 */
export const isCrossOrigin = _corsOrigins.length > 0 && (() => {
  try {
    // Use RENDER_EXTERNAL_URL (set by Render) or build from PORT, not APP_URL
    // which is the frontend URL and would incorrectly match CORS_ORIGIN.
    const port = process.env.PORT || "3001";
    const backendOrigin = process.env.RENDER_EXTERNAL_URL
      || process.env.BACKEND_URL
      || `http://localhost:${port}`;
    return _corsOrigins.some(o => new URL(o).origin !== new URL(backendOrigin).origin);
  } catch { return false; }
})();

/**
 * Build the SameSite + Secure suffix for a Set-Cookie header.
 * Cross-origin → `SameSite=None; Secure` (required by browsers).
 * Same-origin  → `SameSite=Strict` + Secure only in production.
 * @param {boolean} [httpOnly=false] - Not used for the suffix, but kept for symmetry.
 * @returns {string}
 */
export function cookieSameSite() {
  if (isCrossOrigin) return "; SameSite=None; Secure";
  const secure = process.env.NODE_ENV === "production";
  return `; SameSite=Strict${secure ? "; Secure" : ""}`;
}

app.use(express.json({ limit: "1mb" }));

// ─── Cookie parsing ───────────────────────────────────────────────────────────
// Parse the Cookie header into req.cookies without an external dependency.
// Handles quoted values and URL-encoded characters.
app.use((req, _res, next) => {
  req.cookies = {};
  const header = req.headers.cookie;
  if (!header) return next();
  for (const part of header.split(";")) {
    const eqIdx = part.indexOf("=");
    if (eqIdx < 0) continue;
    const key = part.slice(0, eqIdx).trim();
    let val = part.slice(eqIdx + 1).trim();
    // Strip surrounding quotes
    if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1);
    try { req.cookies[key] = decodeURIComponent(val); } catch { req.cookies[key] = val; }
  }
  next();
});

// ─── CSRF double-submit cookie protection ─────────────────────────────────────
// Protects state-mutating endpoints (POST, PATCH, DELETE, PUT) against
// Cross-Site Request Forgery.
//
// Strategy: double-submit cookie pattern.
//   1. On every request the server checks for a `_csrf` cookie.
//      If missing, it creates one (a random 32-byte token) and sets it as
//      a Non-HttpOnly cookie so JavaScript can read it.
//   2. Every mutating fetch sends the same token in the `X-CSRF-Token` header.
//   3. The server compares cookie value ↔ header value. Mismatch → 403.
//
// This works because a cross-origin attacker can trigger a request with cookies
// (CORS allows credentialed requests to same-site) but cannot READ the cookie
// value to forge the matching header — that is blocked by the browser's
// Same-Origin Policy.
//
// Safe methods (GET, HEAD, OPTIONS) and the auth endpoints themselves are exempt.
// The /api/auth/login endpoint is also exempt because the user has no session yet.

const CSRF_SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
const CSRF_COOKIE_NAME  = "_csrf";
const CSRF_HEADER_NAME  = "x-csrf-token";

// These paths receive mutations but are either public (login, register) or
// use the cookie as the auth mechanism itself (logout clears it on the server).
// INF-005: Both /api/v1/ and legacy /api/ paths are exempt so CSRF doesn't
// block requests that arrive before the 308 redirect fires (e.g. form POSTs).
// Generated from a single list to avoid drift when adding new exempt paths.
const _CSRF_EXEMPT_AUTH_SUFFIXES = [
  "login", "register", "logout", "refresh",
  "forgot-password", "reset-password", "resend-verification",
  "github/callback", "google/callback",
];
const CSRF_EXEMPT_PATHS = new Set(
  _CSRF_EXEMPT_AUTH_SUFFIXES.flatMap(s => [
    `/api/v1/auth/${s}`,   // versioned (INF-005)
    `/api/auth/${s}`,      // legacy backward compat — remove after migration window
  ]),
);

export function csrfMiddleware(req, res, next) {
  // Step 1: Ensure the CSRF cookie exists on every response.
  // If the client doesn't have one yet, generate and set it.
  // Non-HttpOnly so frontend JS can read it. SameSite=Strict for defence-in-depth.
  let csrfToken = req.cookies[CSRF_COOKIE_NAME];
  if (!csrfToken) {
    csrfToken = crypto.randomBytes(32).toString("base64url");
    // Session cookie (no Max-Age / Expires) — lives until the browser is closed.
    // This avoids a subtle bug where the CSRF cookie expires after a fixed TTL
    // while the JWT is refreshed indefinitely by the frontend. With a session
    // cookie the CSRF token can never expire before the auth session does.
    // The CSRF token is not a secret — it's Non-HttpOnly by design so JS can
    // read it for the double-submit header. A long-lived cookie is safe here.
    // Use Express's res.append() (available since Express 4.x) instead of
    // Node's res.appendHeader() (added in Node 18.3.0) to avoid breaking
    // deployments on older Node versions.  res.append() correctly appends
    // to existing Set-Cookie headers without overwriting them.
    res.append("Set-Cookie",
      `${CSRF_COOKIE_NAME}=${csrfToken}; Path=/${cookieSameSite()}`
    );
    req.cookies[CSRF_COOKIE_NAME] = csrfToken; // make it available for this request
  }

  // Cross-origin deployments (e.g. GitHub Pages + Render): the _csrf cookie is
  // set on the backend's domain, so `document.cookie` on the frontend origin
  // cannot read it.  Expose the token in a custom response header that the
  // frontend can read via `fetch()` response headers.  The header is listed in
  // Access-Control-Expose-Headers so it survives CORS.
  if (isCrossOrigin) {
    res.setHeader("X-CSRF-Token", csrfToken);
  }

  // Step 2: Validate the header on mutating requests.
  if (CSRF_SAFE_METHODS.has(req.method)) return next();
  if (CSRF_EXEMPT_PATHS.has(req.path)) return next();
  // CSRF protection is only needed for cookie-based auth.  If the request
  // has no auth cookie at all, it must be using a non-cookie strategy
  // (Bearer token, trigger token, query param) which is immune to CSRF
  // because the browser cannot attach those credentials to a cross-origin
  // request.  This replaces the old manual regex carve-out for /trigger
  // and automatically covers any future non-cookie auth strategies added
  // to middleware/authenticate.js.
  if (!req.cookies?.[AUTH_COOKIE]) return next();

  const headerToken = req.headers[CSRF_HEADER_NAME];
  if (!headerToken || headerToken !== csrfToken) {
    return res.status(403).json({ error: "CSRF token missing or invalid. Please refresh the page." });
  }
  next();
}

app.use(csrfMiddleware);

// ─── Global API rate limiting (INF-002: Redis-backed when available) ──────────
// Applies to ALL /api/* routes. Separate tighter buckets are defined below for
// expensive operations (crawl, test run, AI generation) that consume significant
// server or third-party AI API resources.
//
// When REDIS_URL is set, rate-limit-redis shares counters across all instances
// so limits are enforced globally (not per-process).  When Redis is not
// available, the default in-memory store is used (single-instance only).

// Lazy-load rate-limit-redis only when Redis is configured.
// We check `redis !== null` (client created) rather than `isRedisAvailable()`
// (client connected) because the ioredis `connect` event fires asynchronously
// AFTER all synchronous module-level code runs, so `isRedisAvailable()` would
// always return `false` at import time. The RedisStore itself handles
// connection retries gracefully — commands are queued until the client connects.
//
// IMPORTANT: Each rate limiter MUST have its own RedisStore instance with a
// unique prefix. Sharing a single store across multiple limiters corrupts
// counters because rate-limit-redis uses the prefix to namespace keys.
const _require = createRequire(import.meta.url);
let _RedisStoreClass = null;
if (redis) {
  try {
    const mod = _require("rate-limit-redis");
    // rate-limit-redis v4 uses `export default class RedisStore`. When loaded
    // via CJS require(), the module object is `{ default: RedisStore }`, so
    // mod.RedisStore is undefined. Handle both named and default export shapes.
    _RedisStoreClass = mod.RedisStore || mod.default || null;
    if (!_RedisStoreClass) {
      console.warn(formatLogLine("warn", null, "[rate-limit] rate-limit-redis loaded but RedisStore class not found — using in-memory store."));
    } else {
      console.log(formatLogLine("info", null, "[rate-limit] Using Redis-backed store for rate limiting"));
    }
  } catch {
    console.warn(formatLogLine("warn", null, "[rate-limit] rate-limit-redis not installed — using in-memory store. Run `npm install rate-limit-redis` for shared rate limiting."));
  }
}

/** Create a RedisStore with a unique prefix, or return {} for in-memory fallback. */
function _makeRedisStore(prefix) {
  if (!_RedisStoreClass) return {};
  return {
    store: new _RedisStoreClass({
      sendCommand: (...args) => redis.call(...args),
      prefix,
    }),
  };
}

/**
 * General API rate limiter — 300 requests per 15 minutes per IP.
 * Applied to all /api/* routes as a DoS / abuse baseline.
 */
const generalApiLimiter = rateLimit({
  windowMs:         15 * 60 * 1000,   // 15 minutes
  max:              300,               // 300 requests per window per IP
  standardHeaders:  "draft-7",         // Retry-After, X-RateLimit-* headers
  legacyHeaders:    false,
  skip:             (req) => {
    // Never block preflight.
    if (req.method === "OPTIONS") return true;
    // DIF-015 (PR #115): the recorder forwards every canvas pointer/keyboard
    // event through POST /record/:sessionId/input. Mouse moves are throttled
    // to ~30fps client-side but a single recording session still produces
    // hundreds of requests in seconds, which exhausts the 300/15min global
    // budget and breaks the subsequent /stop call. The /input route is cheap
    // (one async CDP send), already gated by requireRole("qa_lead") + workspace
    // scope, and has a dedicated allowlist of valid event types — exempting it
    // here is safe and necessary for the recorder to function at all.
    if (req.method === "POST" && /\/record\/[^/]+\/input$/.test(req.path)) return true;
    return false;
  },
  ..._makeRedisStore("sentri:rl:general:"),
  handler: (_req, res) => {
    res.status(429).json({
      error: "Too many requests. Please slow down and try again shortly.",
    });
  },
});

/**
 * Expensive operations limiter — 20 requests per hour per IP.
 * Applied to: POST /api/projects/:id/crawl, POST /api/projects/:id/run,
 *             POST /api/tests/:testId/run
 * These endpoints launch a browser instance and consume AI API quota.
 */
export const expensiveOpLimiter = rateLimit({
  windowMs:         60 * 60 * 1000,   // 1 hour
  max:              20,               // 20 crawl/run triggers per hour per IP
  standardHeaders:  "draft-7",
  legacyHeaders:    false,
  ..._makeRedisStore("sentri:rl:expensive:"),
  handler: (_req, res) => {
    res.status(429).json({
      error: "Rate limit reached for test runs. You can trigger up to 20 runs per hour. Please wait before starting another.",
    });
  },
});

/**
 * AI generation limiter — 30 requests per hour per IP.
 * Applied to: POST /api/projects/:id/tests/generate
 * These endpoints make direct AI API calls (Claude / GPT / Gemini).
 */
export const aiGenerationLimiter = rateLimit({
  windowMs:         60 * 60 * 1000,   // 1 hour
  max:              30,               // 30 AI generation calls per hour per IP
  standardHeaders:  "draft-7",
  legacyHeaders:    false,
  ..._makeRedisStore("sentri:rl:ai:"),
  handler: (_req, res) => {
    res.status(429).json({
      error: "Rate limit reached for AI generation. You can trigger up to 30 AI requests per hour. Please wait before generating more tests.",
    });
  },
});

// Apply the general limiter to all /api/* routes (covers both /api/v1/* and
// legacy /api/* redirect paths — INF-005).
// The tighter per-operation limiters are applied at the route level in
// routes/runs.js and routes/tests.js via the exported limiters above.
app.use("/api", generalApiLimiter);

// ─── Artifact signing helpers ─────────────────────────────────────────────────
// Screenshots, videos, and Playwright traces are served as static files.
// <img>, <video>, and <a download> tags cannot send Authorization headers, so
// we use short-lived HMAC-signed query-param tokens instead.
//
// Token format:  ?token=<hmac-sha256(artifactPath + exp, ARTIFACT_SECRET)>&exp=<unix-ms>
// Default TTL:   1 hour (ARTIFACT_TOKEN_TTL_MS env var to override)
//
// ARTIFACT_SECRET must be set in production.  In development a random per-
// process secret is derived so artifacts still work without configuration.
// Generate a production value with:
//   node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"

const ARTIFACT_SECRET = process.env.ARTIFACT_SECRET ||
  (() => {
    if (process.env.NODE_ENV === "production") {
      throw new Error(
        "ARTIFACT_SECRET must be set in production. " +
        "Generate one with: node -e \"console.log(require('crypto').randomBytes(48).toString('hex'))\""
      );
    }
    // Development fallback: stable per-process random — fine for local use.
    return crypto.randomBytes(48).toString("hex");
  })();

const ARTIFACT_TOKEN_TTL_MS = parseInt(process.env.ARTIFACT_TOKEN_TTL_MS ?? "", 10) || 60 * 60 * 1000; // 1 hour

/**
 * Generate a short-lived HMAC-signed token for an artifact path.
 *
 * @param {string} artifactPath - The URL path, e.g. `/artifacts/screenshots/foo.png`
 * @returns {string} The full artifact URL with `?token=…&exp=…` appended.
 */
export function signArtifactUrl(artifactPath) {
  const exp = Date.now() + ARTIFACT_TOKEN_TTL_MS;
  const mac = crypto
    .createHmac("sha256", ARTIFACT_SECRET)
    .update(`${artifactPath}:${exp}`)
    .digest("base64url");
  return `${artifactPath}?token=${mac}&exp=${exp}`;
}

/**
 * Deep-clone a run object and sign all artifact paths so the frontend receives
 * fresh, non-expired URLs.  Call this at **read time** (API responses, SSE
 * events) — never persist signed URLs to the database.
 *
 * Handles: `run.tracePath`, `run.videoPath`, `run.videoSegments[]`,
 *          `run.results[].screenshotPath`, `run.results[].videoPath`.
 *
 * @param {Object} run - The run object from the database.
 * @returns {Object} A shallow clone with all artifact paths signed.
 */
export function signRunArtifacts(run) {
  if (!run) return run;
  const signed = { ...run };

  if (signed.tracePath) signed.tracePath = signArtifactUrl(signed.tracePath);
  if (signed.videoPath) signed.videoPath = signArtifactUrl(signed.videoPath);
  if (Array.isArray(signed.videoSegments)) {
    signed.videoSegments = signed.videoSegments.map(s => signArtifactUrl(s));
  }
  if (Array.isArray(signed.results)) {
    signed.results = signed.results.map(r => {
      const sr = { ...r };
      if (sr.screenshotPath) sr.screenshotPath = signArtifactUrl(sr.screenshotPath);
      if (sr.videoPath) sr.videoPath = signArtifactUrl(sr.videoPath);
      // DIF-001: sign baseline + diff paths on the final-screenshot visual diff
      if (sr.visualDiff) {
        sr.visualDiff = { ...sr.visualDiff };
        if (sr.visualDiff.baselinePath) sr.visualDiff.baselinePath = signArtifactUrl(sr.visualDiff.baselinePath);
        if (sr.visualDiff.diffPath) sr.visualDiff.diffPath = signArtifactUrl(sr.visualDiff.diffPath);
      }
      // DIF-001: per-step visual diffs live on stepCaptures[].visualDiff
      if (Array.isArray(sr.stepCaptures)) {
        sr.stepCaptures = sr.stepCaptures.map(sc => {
          const s = { ...sc };
          if (s.screenshotPath) s.screenshotPath = signArtifactUrl(s.screenshotPath);
          if (s.visualDiff) {
            s.visualDiff = { ...s.visualDiff };
            if (s.visualDiff.baselinePath) s.visualDiff.baselinePath = signArtifactUrl(s.visualDiff.baselinePath);
            if (s.visualDiff.diffPath) s.visualDiff.diffPath = signArtifactUrl(s.visualDiff.diffPath);
          }
          return s;
        });
      }
      return sr;
    });
  }
  return signed;
}

/**
 * Validate an incoming artifact request's `?token=` and `?exp=` query params.
 * Returns `true` when the token is valid and not expired; `false` otherwise.
 *
 * @param {string} artifactPath - The URL path without query string.
 * @param {string|undefined} token
 * @param {string|undefined} exp
 * @returns {boolean}
 */
function isValidArtifactToken(artifactPath, token, exp) {
  if (!token || !exp) return false;
  const expMs = parseInt(exp, 10);
  if (isNaN(expMs) || Date.now() > expMs) return false;
  const expected = crypto
    .createHmac("sha256", ARTIFACT_SECRET)
    .update(`${artifactPath}:${expMs}`)
    .digest("base64url");
  // Constant-time comparison to prevent timing attacks.
  try {
    return crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expected));
  } catch {
    // Buffers are different lengths — definitively invalid.
    return false;
  }
}

// ─── Serve Playwright artifacts ───────────────────────────────────────────────
// Protected by HMAC-signed ?token= query params generated by signArtifactUrl().
// <img>, <video>, and <a download> tags cannot send Authorization headers, so
// the signed URL pattern is the correct approach for browser-native media tags.

/**
 * Absolute path to the Playwright artifacts directory (screenshots, videos, traces).
 * @type {string}
 */
export const ARTIFACTS_DIR = path.join(__dirname, "..", "..", "artifacts");

app.use("/artifacts", (req, res, next) => {
  const artifactPath = "/artifacts" + req.path;
  const { token, exp } = req.query;

  if (!isValidArtifactToken(artifactPath, token, exp)) {
    return res.status(401).json({ error: "Invalid or expired artifact token." });
  }
  next();
}, express.static(ARTIFACTS_DIR, {
  setHeaders(res, fp) {
    if (fp.endsWith(".webm")) res.setHeader("Content-Type", "video/webm");
    if (fp.endsWith(".zip"))  res.setHeader("Content-Type", "application/zip");
    if (fp.endsWith(".png"))  res.setHeader("Content-Type", "image/png");
    res.setHeader("Accept-Ranges", "bytes");
    // Prevent browsers from caching artifact URLs — they contain expiring tokens.
    res.setHeader("Cache-Control", "private, no-store");
  },
}));

// ─── SEC-002: Serve index.html with nonce placeholder replaced ───────────────
// In production the Vite-built SPA is served as static files. The build output
// contains `nonce="__CSP_NONCE__"` placeholders on all `<script>` tags (injected
// by the `cspNoncePlugin` in `vite.config.js`). This middleware replaces the
// placeholder with the real per-request nonce so the scripts pass CSP validation.
//
// The replacement is done on-the-fly for `index.html` only — it is a small file
// and the string replace is negligible. All other static assets are served as-is.

/** @type {string|null|undefined} Cached index.html template with `__CSP_NONCE__` placeholders. */
let _indexHtmlTemplate = undefined;

/**
 * Read and cache the built `index.html` from the frontend dist directory.
 * Returns `null` when the file does not exist (e.g. dev mode where Vite serves).
 *
 * Uses `undefined` as the "not yet loaded" sentinel so that a failed read
 * (empty string) is not permanently cached — the file may appear later if
 * the build completes after the server starts.
 *
 * @returns {string|null}
 */
function getIndexHtmlTemplate() {
  if (typeof _indexHtmlTemplate === "string" && _indexHtmlTemplate.length > 0) {
    return _indexHtmlTemplate;
  }
  // SPA_INDEX_PATH allows Docker / custom deployments to point at the built
  // index.html when the frontend dist is not a sibling of the backend source
  // tree (e.g. multi-container Docker where frontend is a separate image).
  const distIndex = process.env.SPA_INDEX_PATH
    || path.join(__dirname, "..", "..", "..", "frontend", "dist", "index.html");
  try {
    _indexHtmlTemplate = fs.readFileSync(distIndex, "utf-8");
  } catch {
    _indexHtmlTemplate = undefined;
  }
  return _indexHtmlTemplate || null;
}

/**
 * Middleware that serves `index.html` with `__CSP_NONCE__` replaced by the
 * per-request nonce from `res.locals.cspNonce`.
 *
 * Must be mounted **after** all API routes and static file middleware so it
 * only catches SPA navigation requests (HTML pages, not API calls or assets).
 *
 * @param {Object} req
 * @param {Object} res
 */
export function serveIndexWithNonce(req, res) {
  const template = getIndexHtmlTemplate();
  if (!template) {
    return res.status(404).send("Frontend build not found.");
  }
  const nonce = res.locals.cspNonce || "";
  const html = template.replaceAll("__CSP_NONCE__", nonce);
  res.setHeader("Content-Type", "text/html; charset=utf-8");
  res.setHeader("Cache-Control", "no-cache");
  res.send(html);
}