import { timingSafeEqual } from "node:crypto"; import { HttpError } from "@batch-cooking/error-tools"; import { ErrorCode } from "@batch-cooking/shared"; import type { NextFunction, Request, Response } from "express"; import { env } from "../config/env.js"; /** Header `services/tech-step-llm-worker` sends its shared secret on. Not `Authorization`/a bearer scheme — this isn't a user session, just one internal caller authenticating to another, same "one flat shared secret" shape as e.g. a webhook signing header. */ const INTERNAL_WORKER_SECRET_HEADER = "x-internal-worker-secret"; /** * Express middleware guarding `/internal/tech-steps/*` — the surface * `services/tech-step-llm-worker` (a process outside this monorepo, no * Prisma access of its own, see that service's own README) reads * low-confidence NLP clauses and pending `StepTechStepCorrection`s from, * and posts `TechStepTrainingSuggestion`s back to. Never reachable by an * end user's session cookie — deliberately a *different* auth mechanism * than {@link requireAuth} (`require-auth.ts`), not layered on top of it, * since the worker has no `UserProfile`/session of its own to authenticate * as. * * Fails closed: an unset `INTERNAL_WORKER_SECRET` (the default in any * environment that doesn't run the worker, see `config/env.ts`) rejects * every request rather than leaving the surface open, same posture as a * misconfigured `JWT_SECRET` would if it had a working fallback. * * @throws {HttpError} `401 NOT_AUTHENTICATED` if the header is missing, * wrong, or the server has no secret configured at all — never * distinguishes the reason, same posture as {@link requireAuth}. */ export function requireInternalWorker(req: Request, _res: Response, next: NextFunction): void { const provided = req.header(INTERNAL_WORKER_SECRET_HEADER); if (env.INTERNAL_WORKER_SECRET === undefined || provided === undefined) { next(new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated")); return; } // `timingSafeEqual` throws on mismatched buffer lengths rather than // returning `false` — checked separately first. A length mismatch alone // already means "not equal", so this loses no timing-attack protection // (an attacker learns nothing beyond what a differing length itself // already reveals, no different from `!==` on the common case where the // secret's real length isn't a secret worth protecting). const expected = Buffer.from(env.INTERNAL_WORKER_SECRET); const actual = Buffer.from(provided); if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) { next(new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated")); return; } next(); }