fix(canvas): boot-time matched-pair guard for ADMIN_TOKEN env vars (#53) #143

Closed
fullstack-engineer wants to merge 1 commits from fix/issue53-admin-token-pair-guard into main
2 changed files with 140 additions and 0 deletions

View File

@ -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;

View File

@ -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<typeof vi.fn>).mock.calls[0][0];
expect(msg).toContain("server=true");
expect(msg).toContain("client=false");
});
});