diff --git a/src/api/auth.ts b/src/api/auth.ts new file mode 100644 index 0000000..a5930c9 --- /dev/null +++ b/src/api/auth.ts @@ -0,0 +1,39 @@ +import express, { type Request, type Response, type NextFunction } from "express"; + +/** + * Bearer-token gate for dashboard mutating API routes. + * Set DASHBOARD_API_TOKEN in the environment; when unset, auth is disabled (dev only). + */ +export function requireDashboardAuth(req: Request, res: Response, next: NextFunction): void { + const expected = process.env.DASHBOARD_API_TOKEN?.trim(); + if (!expected) { + next(); + return; + } + const header = req.headers.authorization ?? ""; + const prefix = "Bearer "; + if (!header.startsWith(prefix)) { + res.status(401).json({ error: "dashboard authentication required" }); + return; + } + const token = header.slice(prefix.length).trim(); + if (token.length !== expected.length || !timingSafeEqual(token, expected)) { + res.status(401).json({ error: "dashboard authentication required" }); + return; + } + next(); +} + +function timingSafeEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false; + let mismatch = 0; + for (let i = 0; i < a.length; i++) { + mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i); + } + return mismatch === 0; +} + +export function installDashboardAuth(app: express.Application): void { + app.use("/api/approvals", requireDashboardAuth); + app.post("/api/approvals/:id/decide", requireDashboardAuth); +} diff --git a/src/api/server.ts b/src/api/server.ts index 81bd151..eb5f62a 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -6,6 +6,7 @@ import express from "express"; import { WebSocketServer, WebSocket } from "ws"; import { newTask, type CompanyServices, type TaskState } from "../core"; +import { installDashboardAuth } from "./auth"; const __dirname = dirname(fileURLToPath(import.meta.url)); const PUBLIC_DIR = resolve(__dirname, "../dashboard/public"); @@ -19,6 +20,7 @@ export interface HttpServerHandles { export function createHttpServer(services: CompanyServices): HttpServerHandles { const app = express(); app.use(express.json()); + installDashboardAuth(app); // Suppress the browser's default favicon request (no asset shipped). app.get("/favicon.ico", (_req, res) => { @@ -83,11 +85,14 @@ export function createHttpServer(services: CompanyServices): HttpServerHandles { const id = req.params.id; const body = req.body ?? {}; const decision = body.decision; - const by = typeof body.by === "string" ? body.by : "human"; + let by = typeof body.by === "string" ? body.by : "human"; if (decision !== "approved" && decision !== "rejected") { res.status(400).json({ error: "decision must be 'approved' or 'rejected'" }); return; } + if (process.env.DASHBOARD_API_TOKEN?.trim()) { + by = "dashboard-operator"; + } const approval = services.policy.decide(id, decision, by); if (!approval) { res.status(404).json({ error: "approval not found" });