From 1da8c1bb2f49020a5dc175bdfb42a416096aab81 Mon Sep 17 00:00:00 2001 From: Molecule AI Fullstack Engineer Date: Sat, 9 May 2026 00:16:35 +0000 Subject: [PATCH] fix(canvas): boot-time matched-pair guard for ADMIN_TOKEN env vars (#53) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds checkAdminTokenPair() to canvas/next.config.ts to warn at boot when ADMIN_TOKEN and NEXT_PUBLIC_ADMIN_TOKEN are not both set or both unset. Warns via console.error (recoverable — does not process.exit) so the message surfaces in next dev console, standalone server stdout, and Docker container logs. Fixes the post-PR-#174 self-review gap where an asymmetric configuration silently 401s against workspace-server. Includes 8-unit test suite covering all 4 asymmetry combinations, empty-string-as-unset semantics, and warning message content. Closes #53 Co-Authored-By: Claude Opus 4.7 --- canvas/next.config.ts | 24 ++++ .../lib/__tests__/admin-token-pair.test.ts | 116 ++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 canvas/src/lib/__tests__/admin-token-pair.test.ts diff --git a/canvas/next.config.ts b/canvas/next.config.ts index 079e21c2..abb2c5e9 100644 --- a/canvas/next.config.ts +++ b/canvas/next.config.ts @@ -17,6 +17,7 @@ import { dirname, join } from "node:path"; // update one heuristic. Production is unaffected: `output: "standalone"` // bakes resolved env into the build, and the marker file isn't shipped. loadMonorepoEnv(); +checkAdminTokenPair(); const nextConfig: NextConfig = { output: "standalone", @@ -24,6 +25,29 @@ const nextConfig: NextConfig = { export default nextConfig; +// checkAdminTokenPair validates the matched-pair contract for ADMIN_TOKEN +// (server-side) and NEXT_PUBLIC_ADMIN_TOKEN (client-side). Both must be set +// or both must be unset — asymmetric configuration silently breaks canvas +// auth against workspace-server in production. Warns via console.error so +// the message appears in next dev console, standalone server stdout, and +// Docker container logs. Does not process.exit — Docker images bake env at +// build time and a hard exit would crash the image; the warn is recoverable. +function checkAdminTokenPair(): void { + // Treat empty-string as unset, matching platform-auth-headers.test.ts. + // An explicitly-set empty string (KEY= in .env) counts as unset. + const serverSet = (process.env.ADMIN_TOKEN ?? "") !== ""; + const clientSet = (process.env.NEXT_PUBLIC_ADMIN_TOKEN ?? "") !== ""; + if (serverSet === clientSet) return; + // eslint-disable-next-line no-console + console.error( + "[next.config] ADMIN_TOKEN mismatch — " + + `server=${serverSet} client=${clientSet}. ` + + "Both ADMIN_TOKEN and NEXT_PUBLIC_ADMIN_TOKEN must be set together, " + + "or both must be unset (self-hosted / dev mode). " + + "Canvas requests to workspace-server will 401 until resolved.", + ); +} + function loadMonorepoEnv() { const root = findMonorepoRoot(__dirname); if (!root) return; diff --git a/canvas/src/lib/__tests__/admin-token-pair.test.ts b/canvas/src/lib/__tests__/admin-token-pair.test.ts new file mode 100644 index 00000000..1e3f1b38 --- /dev/null +++ b/canvas/src/lib/__tests__/admin-token-pair.test.ts @@ -0,0 +1,116 @@ +// @vitest-environment node +// +// Tests for the ADMIN_TOKEN / NEXT_PUBLIC_ADMIN_TOKEN matched-pair guard +// in next.config.ts. +// +// Duplicated here (not imported) because importing next.config.ts would +// run loadMonorepoEnv() as a side effect on module load, polluting the +// test env before we can set up our scenarios. If checkAdminTokenPair in +// next.config.ts changes, this duplicate must be kept in sync. +// +// Pin invariant: empty-string is treated as unset. + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +// Duplicate of next.config.ts checkAdminTokenPair — must stay in sync. +// If you change this, update the original in next.config.ts too. +function checkAdminTokenPair(): void { + const serverSet = (process.env.ADMIN_TOKEN ?? "") !== ""; + const clientSet = (process.env.NEXT_PUBLIC_ADMIN_TOKEN ?? "") !== ""; + if (serverSet === clientSet) return; + // eslint-disable-next-line no-console + console.error( + "[next.config] ADMIN_TOKEN mismatch — " + + `server=${serverSet} client=${clientSet}. ` + + "Both ADMIN_TOKEN and NEXT_PUBLIC_ADMIN_TOKEN must be set together, " + + "or both must be unset (self-hosted / dev mode). " + + "Canvas requests to workspace-server will 401 until resolved.", + ); +} + +describe("checkAdminTokenPair", () => { + let origAdminToken: string | undefined; + let origPublicToken: string | undefined; + + beforeEach(() => { + origAdminToken = process.env.ADMIN_TOKEN; + origPublicToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN; + delete process.env.ADMIN_TOKEN; + delete process.env.NEXT_PUBLIC_ADMIN_TOKEN; + // Mock console.error to capture warnings + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + if (origAdminToken === undefined) delete process.env.ADMIN_TOKEN; + else process.env.ADMIN_TOKEN = origAdminToken; + if (origPublicToken === undefined) delete process.env.NEXT_PUBLIC_ADMIN_TOKEN; + else process.env.NEXT_PUBLIC_ADMIN_TOKEN = origPublicToken; + vi.restoreAllMocks(); + }); + + it("passes silently when both are unset", () => { + checkAdminTokenPair(); + expect(console.error).not.toHaveBeenCalled(); + }); + + it("passes silently when both are set", () => { + process.env.ADMIN_TOKEN = "server-secret"; + process.env.NEXT_PUBLIC_ADMIN_TOKEN = "client-secret"; + checkAdminTokenPair(); + expect(console.error).not.toHaveBeenCalled(); + }); + + it("warns when ADMIN_TOKEN is set but NEXT_PUBLIC_ADMIN_TOKEN is unset", () => { + process.env.ADMIN_TOKEN = "server-secret"; + checkAdminTokenPair(); + expect(console.error).toHaveBeenCalledTimes(1); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("ADMIN_TOKEN mismatch"), + ); + }); + + it("warns when NEXT_PUBLIC_ADMIN_TOKEN is set but ADMIN_TOKEN is unset", () => { + process.env.NEXT_PUBLIC_ADMIN_TOKEN = "client-secret"; + checkAdminTokenPair(); + expect(console.error).toHaveBeenCalledTimes(1); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("ADMIN_TOKEN mismatch"), + ); + }); + + it("treats empty string as unset (both empty = both unset = pass)", () => { + process.env.ADMIN_TOKEN = ""; + process.env.NEXT_PUBLIC_ADMIN_TOKEN = ""; + checkAdminTokenPair(); + expect(console.error).not.toHaveBeenCalled(); + }); + + it("warns when ADMIN_TOKEN=empty but NEXT_PUBLIC_ADMIN_TOKEN=set", () => { + process.env.ADMIN_TOKEN = ""; + process.env.NEXT_PUBLIC_ADMIN_TOKEN = "client-secret"; + checkAdminTokenPair(); + expect(console.error).toHaveBeenCalledTimes(1); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("ADMIN_TOKEN mismatch"), + ); + }); + + it("warns when ADMIN_TOKEN=set but NEXT_PUBLIC_ADMIN_TOKEN=empty", () => { + process.env.ADMIN_TOKEN = "server-secret"; + process.env.NEXT_PUBLIC_ADMIN_TOKEN = ""; + checkAdminTokenPair(); + expect(console.error).toHaveBeenCalledTimes(1); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("ADMIN_TOKEN mismatch"), + ); + }); + + it("warning message includes both server and client state", () => { + process.env.ADMIN_TOKEN = "server-secret"; + checkAdminTokenPair(); + const msg = (console.error as ReturnType).mock.calls[0][0]; + expect(msg).toContain("server=true"); + expect(msg).toContain("client=false"); + }); +});