diff --git a/.env.example b/.env.example index ad58fbb..37c0da9 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,9 @@ # CORPOS_MODE=local does not skip the bearer gate. # CORPOS_MODE=shared # DASHBOARD_API_TOKEN= +# Identity bound to that shared token (not client-supplied on decide/appeal): +# DASHBOARD_OPERATOR_ID=operator@dashboard +# DASHBOARD_TENANT_ID=default # ── Data ───────────────────────────────────────────────────────────── # Local libsql / SQLite file used by the firm store (default: data/company.db). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a1418f7..a9881eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,8 +18,8 @@ jobs: matrix: node-version: [22, 24] steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: ${{ matrix.node-version }} cache: npm @@ -37,8 +37,8 @@ jobs: timeout-minutes: 10 needs: verify steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 cache: npm diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 4973d86..8afae54 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -31,17 +31,17 @@ jobs: matrix: language: [javascript-typescript] steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Initialize CodeQL - uses: github/codeql-action/init@4187e74d05793876e9989daffde9c3e66b4acd07 # v3 + uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: languages: ${{ matrix.language }} queries: +security-and-quality - name: Autobuild - uses: github/codeql-action/autobuild@4187e74d05793876e9989daffde9c3e66b4acd07 # v3 + uses: github/codeql-action/autobuild@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@4187e74d05793876e9989daffde9c3e66b4acd07 # v3 + uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 83a62bb..6448ca5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Check Fly token id: fly diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index b8dddf6..69e2283 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -29,7 +29,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up QEMU uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4 diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 3cf9f46..6eeab29 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -19,18 +19,18 @@ jobs: security-events: write steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: ossf/scorecard-action@05b42c624433fc40578a4040d5cf5e36ddca8cde # v2.4.2 + - uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4 with: results_file: results.sarif results_format: sarif publish_results: true - if: always() - uses: github/codeql-action/upload-sarif@4187e74d05793876e9989daffde9c3e66b4acd07 # v3 + uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: sarif_file: results.sarif category: scorecard diff --git a/CONTEXT.md b/CONTEXT.md index 5321178..dc20c41 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -15,6 +15,8 @@ Commands: `npm run dev`, `npm test`, `npm run scenario`, `npm run audit:verify`, `HttpLLMProvider` when live; `/api/health.mode` reports `live` only then). - Shared mode (`CORPOS_MODE=shared` + `DASHBOARD_API_TOKEN`) gates approve/kill on the API via Bearer; console sends `VITE_DASHBOARD_API_TOKEN` when configured. + Decide/appeal bind `decidedBy` and tenant to `DASHBOARD_OPERATOR_ID` / + `DASHBOARD_TENANT_ID` (not the request body). Site id: `corpos`. Program: `corpos-autonomous-company-r3`. G1–G6 firm governance coded; TTL scheduler; console Bearer; orchestrator-driven day; live SSE. diff --git a/README.md b/README.md index f3d72e5..e8f7745 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ Architecture decisions live in [`docs/adr/README.md`](docs/adr/README.md). ## Security -Reference architecture — not production-hardened. `DASHBOARD_API_TOKEN` is required for approve/kill mutations by default; `CORPOS_MODE=local` does not skip the bearer gate. Set `CORPOS_ALLOW_UNAUTHENTICATED=true` only for local simulation. See [SECURITY.md](SECURITY.md). +Reference architecture — not production-hardened. `DASHBOARD_API_TOKEN` is required for approve/kill mutations by default; `CORPOS_MODE=local` does not skip the bearer gate. Decide/appeal bind the decider and tenant to `DASHBOARD_OPERATOR_ID` / `DASHBOARD_TENANT_ID`, not the request body. Set `CORPOS_ALLOW_UNAUTHENTICATED=true` only for local simulation. See [SECURITY.md](SECURITY.md). ## Community diff --git a/SECURITY.md b/SECURITY.md index 4f5c3ca..ca3240a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -12,6 +12,10 @@ Treat it as a design artifact you can run locally. - Exception HITL with scheduled TTL fail-closed; kill switch; department capital caps. - Shared-mode console sends Bearer (`VITE_DASHBOARD_API_TOKEN` / `DASHBOARD_API_TOKEN`). - Dashboard mutations require `DASHBOARD_API_TOKEN` by default; `CORPOS_MODE !== "shared"` does not ungated them. +- Approve/appeal bind `decidedBy` and tenant matching to `DASHBOARD_OPERATOR_ID` / + `DASHBOARD_TENANT_ID` (defaults `operator@dashboard` / `default`). Client-supplied + `by` / `tenantId` on those routes are ignored. This is still a shared static token, + not per-user OIDC. - G3 quorum, G6 appeal, and enforcement `strict`/`audit` modes. - Hash-chained audit receipts (`npm run audit:verify`). - Company-day demos do not auto-approve exceptions unless a caller passes @@ -30,7 +34,9 @@ Treat it as a design artifact you can run locally. When `CORPOS_ALLOW_UNAUTHENTICATED` is unset, `DASHBOARD_API_TOKEN` is required as a **Bearer** token on API approve and kill mutations. `CORPOS_MODE=local` (or any value other than an explicit opt-in) does not skip the gate. The ops console sends -`VITE_DASHBOARD_API_TOKEN` when configured. This is a +`VITE_DASHBOARD_API_TOKEN` when configured. The server derives the decider and tenant +from `DASHBOARD_OPERATOR_ID` / `DASHBOARD_TENANT_ID`, not from the request body, and +rejects decide/appeal when the exception tenant does not match. This is a static token compare for demos, not an OAuth flow. ## Dependency / supply-chain hygiene @@ -43,7 +49,7 @@ static token compare for demos, not an OAuth flow. `./scripts/harness/verify.sh` fails if `npm approve-scripts --allow-scripts-pending` reports gaps. - Verify does **not** wipe `node_modules` by default (avoids concurrent `ENOTEMPTY` races). CI sets `CORPOS_VERIFY_CLEAN=1` for a full reinstall. Never fetch npm via `npx` inside verify. -- `@hono/node-server` is pinned to `2.0.11` via root dependency + `overrides` (covers API +- `@hono/node-server` is pinned to `2.0.12` via root dependency + `overrides` (covers API `serveStatic` and `@modelcontextprotocol/sdk`). - Review Dependabot PRs before merge; re-run `npm approve-scripts --allow-scripts-pending` after lockfile changes that introduce new install scripts. diff --git a/apps/api/package.json b/apps/api/package.json index c6615db..394a6c7 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -20,9 +20,9 @@ }, "dependencies": { "@corpos/core": "0.2.0", - "@hono/node-server": "^2.0.11", + "@hono/node-server": "^2.0.12", "drizzle-orm": "^0.45.2", - "hono": "^4.12.32" + "hono": "^4.13.7" }, "devDependencies": { "tsx": "^4.23.0", diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 0dbb086..ed90bb4 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -28,6 +28,24 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +export const DEFAULT_DASHBOARD_OPERATOR_ID = "operator@dashboard"; +export const DEFAULT_DASHBOARD_TENANT_ID = "default"; + +export type DashboardIdentity = { + operatorId: string; + tenantId: string; +}; + +/** + * Identity bound to the shared dashboard bearer (or unauthenticated opt-in). + * Never read client-supplied `by` / `tenantId` from the request body. + */ +export function dashboardIdentity(): DashboardIdentity { + const operatorId = process.env.DASHBOARD_OPERATOR_ID?.trim() || DEFAULT_DASHBOARD_OPERATOR_ID; + const tenantId = process.env.DASHBOARD_TENANT_ID?.trim() || DEFAULT_DASHBOARD_TENANT_ID; + return { operatorId, tenantId }; +} + /** Dashboard bearer gate — exported for adversarial behavioral probes. */ export function requireAuth(c: { req: { header: (n: string) => string | undefined } }): boolean { // Ungated simulation only with an explicit opt-in (FO-017). CORPOS_MODE !== "shared" must not imply allow. @@ -38,6 +56,24 @@ export function requireAuth(c: { req: { header: (n: string) => string | undefine return header === `Bearer ${expected}`; } +type ExceptionRow = (typeof exceptions)["$inferSelect"]; + +type BoundException = + | { status: 200; ex: ExceptionRow } + | { status: 403; error: string } + | { status: 404; error: string }; + +async function bindExceptionTenant( + company: Company, + exceptionId: string, + tenantId: string, +): Promise { + const ex = (await company.db.select().from(exceptions).where(eq(exceptions.id, exceptionId)))[0]; + if (!ex) return { status: 404, error: "not found" }; + if (ex.tenantId !== tenantId) return { status: 403, error: "cross-tenant approval denied" }; + return { status: 200, ex }; +} + function loadAibom(): unknown { const candidates = [ path.resolve(process.cwd(), "docs/aibom.json"), @@ -98,32 +134,45 @@ export function buildApp(company: Company, mode: "simulation" | "live" = "simula app.post("/api/exceptions/:id/decide", async (c) => { if (!requireAuth(c)) return c.json({ error: "dashboard authentication required" }, 401); await expireExceptionTtl(company); + const identity = dashboardIdentity(); const body = await c.req.json<{ decision: "approved" | "rejected"; - by?: string; dissentReason?: string; }>(); + if (body.decision !== "approved" && body.decision !== "rejected") { + return c.json({ error: "decision must be approved|rejected" }, 400); + } + const bound = await bindExceptionTenant(company, c.req.param("id"), identity.tenantId); + if (bound.status !== 200) return c.json({ error: bound.error }, bound.status); const out = await decideException( company, - c.req.param("id"), + bound.ex.id, body.decision, - body.by ?? "operator", + identity.operatorId, body.dissentReason, ); - return c.json({ ok: true, ...out }); + return c.json({ + ok: true, + decidedBy: identity.operatorId, + tenantId: identity.tenantId, + ...out, + }); }); app.post("/api/exceptions/:id/appeal", async (c) => { if (!requireAuth(c)) return c.json({ error: "dashboard authentication required" }, 401); - const body = await c.req.json<{ by?: string; reason?: string }>(); + const identity = dashboardIdentity(); + const body = await c.req.json<{ reason?: string }>(); + const bound = await bindExceptionTenant(company, c.req.param("id"), identity.tenantId); + if (bound.status !== 200) return c.json({ error: bound.error }, bound.status); const out = await appealException( company, - c.req.param("id"), - body.by ?? "operator", + bound.ex.id, + identity.operatorId, body.reason ?? "appeal", ); if (!out.ok) return c.json(out, 400); - return c.json(out); + return c.json({ ...out, decidedBy: identity.operatorId, tenantId: identity.tenantId }); }); app.post("/api/kill", async (c) => { diff --git a/apps/console/src/main.tsx b/apps/console/src/main.tsx index ddb72d3..cf49d82 100644 --- a/apps/console/src/main.tsx +++ b/apps/console/src/main.tsx @@ -159,7 +159,6 @@ function App() { headers: authHeaders({ "content-type": "application/json" }), body: JSON.stringify({ decision, - by: "operator@console", dissentReason: decision === "rejected" ? dissent || "rejected by operator" : undefined, }), }); diff --git a/docs/adr/0013-console-hitl-kill-resume.md b/docs/adr/0013-console-hitl-kill-resume.md index e17fb80..11e4f45 100644 --- a/docs/adr/0013-console-hitl-kill-resume.md +++ b/docs/adr/0013-console-hitl-kill-resume.md @@ -5,5 +5,9 @@ Accepted (corpos-autonomous-company-r3) ## Decision Console sends `Authorization: Bearer` from `VITE_DASHBOARD_API_TOKEN` on decide/kill. +The API ignores client-supplied `by` / `tenantId` on decide/appeal and binds +`decidedBy` plus tenant matching to `DASHBOARD_OPERATOR_ID` / `DASHBOARD_TENANT_ID` +(defaults `operator@dashboard` / `default`). Cross-tenant attempts return 403. +This remains a shared static token, not per-user OIDC. `expireTtl` runs on API boot, ~30s interval, and before company-day/decide. MCP elicitation for privileged tools resolves via the durable exception queue. diff --git a/package-lock.json b/package-lock.json index f38c90e..2fec212 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ ], "dependencies": { "@corpos/core": "0.2.0", - "@hono/node-server": "2.0.11", + "@hono/node-server": "2.0.12", "drizzle-orm": "^0.45.2" }, "devDependencies": { @@ -23,7 +23,7 @@ "eslint": "^10.6.0", "eslint-config-prettier": "^10.1.8", "gifenc": "^1.0.3", - "globals": "^17.7.0", + "globals": "^17.8.0", "playwright": "^1.52.0", "pngjs": "^7.0.0", "prettier": "^3.9.3", @@ -41,9 +41,9 @@ "version": "0.2.0", "dependencies": { "@corpos/core": "0.2.0", - "@hono/node-server": "^2.0.11", + "@hono/node-server": "^2.0.12", "drizzle-orm": "^0.45.2", - "hono": "^4.12.32" + "hono": "^4.13.7" }, "devDependencies": { "tsx": "^4.23.0", @@ -1543,9 +1543,9 @@ } }, "node_modules/@hono/node-server": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.0.11.tgz", - "integrity": "sha512-bjD221KPLoJTWUwso1J6fGKiTXEUFedG/s0visavY4zakFPkeGURMRNly+FhBHs7T8Dz4qHaZIMX9ZoJHSJtKA==", + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.0.12.tgz", + "integrity": "sha512-eWpQYr67tqJLeaSUl0Q+TquuYfUdTibpOJlUMV2FfUP7+KqCC5TufnwnlXL6mobZBJbGAYRd7ZvEBDCbLInjhg==", "license": "MIT", "engines": { "node": ">=20" @@ -4120,9 +4120,9 @@ } }, "node_modules/globals": { - "version": "17.7.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz", - "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==", + "version": "17.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.12.0.tgz", + "integrity": "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==", "dev": true, "license": "MIT", "engines": { @@ -4179,9 +4179,9 @@ } }, "node_modules/hono": { - "version": "4.12.32", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.32.tgz", - "integrity": "sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==", + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.7.tgz", + "integrity": "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==", "license": "MIT", "engines": { "node": ">=16.9.0" diff --git a/package.json b/package.json index d2177a3..410bac7 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ }, "dependencies": { "@corpos/core": "0.2.0", - "@hono/node-server": "2.0.11", + "@hono/node-server": "2.0.12", "drizzle-orm": "^0.45.2" }, "devDependencies": { @@ -48,7 +48,7 @@ "eslint": "^10.6.0", "eslint-config-prettier": "^10.1.8", "gifenc": "^1.0.3", - "globals": "^17.7.0", + "globals": "^17.8.0", "playwright": "^1.52.0", "pngjs": "^7.0.0", "prettier": "^3.9.3", @@ -64,9 +64,9 @@ "fsevents@2.3.2": true }, "overrides": { - "@hono/node-server": "2.0.11", + "@hono/node-server": "2.0.12", "@modelcontextprotocol/sdk": { - "@hono/node-server": "2.0.11" + "@hono/node-server": "2.0.12" } } } diff --git a/test/dashboard-identity.test.ts b/test/dashboard-identity.test.ts new file mode 100644 index 0000000..9381399 --- /dev/null +++ b/test/dashboard-identity.test.ts @@ -0,0 +1,155 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { buildApp } from "@corpos/api"; +import { createCompany, exceptions } from "@corpos/core"; +import { eq } from "drizzle-orm"; + +const AUTH = { + "content-type": "application/json", + authorization: "Bearer secret", +}; + +const ENV_KEYS = [ + "DASHBOARD_API_TOKEN", + "DASHBOARD_OPERATOR_ID", + "DASHBOARD_TENANT_ID", + "CORPOS_ALLOW_UNAUTHENTICATED", + "CORPOS_MODE", +] as const; + +const saved: Partial> = {}; + +function stashEnv(): void { + for (const key of ENV_KEYS) { + saved[key] = process.env[key]; + } +} + +function restoreEnv(): void { + for (const key of ENV_KEYS) { + const value = saved[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } +} + +async function openException( + company: Awaited>, + tenantId: string, +): Promise { + const invoked = await company.gateway.invoke( + "comms.send_email", + { to: "x@y.z", body: "hi" }, + { + agentId: "agent_support", + taskId: "t", + contractId: "c", + tenantId, + originatingAuthority: "alice@corpos.local", + }, + ); + const id = invoked.decision.approvalId; + if (!id) throw new Error("expected HITL exception"); + return id; +} + +describe("dashboard identity binding", () => { + afterEach(() => { + restoreEnv(); + }); + + it("derives decidedBy from the token-bound operator, not the request body", async () => { + stashEnv(); + delete process.env.CORPOS_ALLOW_UNAUTHENTICATED; + process.env.DASHBOARD_API_TOKEN = "secret"; + process.env.DASHBOARD_OPERATOR_ID = "governor@corpos.local"; + process.env.DASHBOARD_TENANT_ID = "default"; + + const company = await createCompany({ dbPath: ":memory:" }); + const app = buildApp(company, "simulation"); + const exId = await openException(company, "default"); + + const res = await app.request(`/api/exceptions/${exId}/decide`, { + method: "POST", + headers: AUTH, + body: JSON.stringify({ + decision: "rejected", + by: "attacker@evil.example", + tenantId: "other-tenant", + dissentReason: "nope", + }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { decidedBy?: string; tenantId?: string }; + expect(body.decidedBy).toBe("governor@corpos.local"); + expect(body.tenantId).toBe("default"); + + const row = (await company.db.select().from(exceptions).where(eq(exceptions.id, exId)))[0]; + expect(row?.decidedBy).toBe("governor@corpos.local"); + expect(row?.state).toBe("rejected"); + company.close(); + }); + + it("rejects cross-tenant decide and appeal", async () => { + stashEnv(); + delete process.env.CORPOS_ALLOW_UNAUTHENTICATED; + process.env.DASHBOARD_API_TOKEN = "secret"; + process.env.DASHBOARD_OPERATOR_ID = "operator@dashboard"; + process.env.DASHBOARD_TENANT_ID = "default"; + + const company = await createCompany({ dbPath: ":memory:" }); + const app = buildApp(company, "simulation"); + const foreignId = await openException(company, "other-tenant"); + + const decide = await app.request(`/api/exceptions/${foreignId}/decide`, { + method: "POST", + headers: AUTH, + body: JSON.stringify({ decision: "approved", by: "default-operator" }), + }); + expect(decide.status).toBe(403); + const decideBody = (await decide.json()) as { error?: string }; + expect(decideBody.error).toBe("cross-tenant approval denied"); + + const foreign = ( + await company.db.select().from(exceptions).where(eq(exceptions.id, foreignId)) + )[0]; + expect(foreign?.state).toBe("pending"); + + await company.db + .update(exceptions) + .set({ state: "rejected", decidedBy: "ttl", dissentReason: "TTL expired" }) + .where(eq(exceptions.id, foreignId)); + + const appeal = await app.request(`/api/exceptions/${foreignId}/appeal`, { + method: "POST", + headers: AUTH, + body: JSON.stringify({ by: "default-operator", reason: "please" }), + }); + expect(appeal.status).toBe(403); + + const missing = await app.request("/api/exceptions/ex_missing/decide", { + method: "POST", + headers: AUTH, + body: JSON.stringify({ decision: "approved" }), + }); + expect(missing.status).toBe(404); + company.close(); + }); + + it("still requires bearer when identity env is set", async () => { + stashEnv(); + delete process.env.CORPOS_ALLOW_UNAUTHENTICATED; + process.env.DASHBOARD_API_TOKEN = "secret"; + process.env.DASHBOARD_OPERATOR_ID = "governor@corpos.local"; + + const company = await createCompany({ dbPath: ":memory:" }); + const app = buildApp(company, "simulation"); + const exId = await openException(company, "default"); + const res = await app.request(`/api/exceptions/${exId}/decide`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ decision: "approved" }), + }); + expect(res.status).toBe(401); + company.close(); + }); +});