fix(canvas): boot-time matched-pair guard for ADMIN_TOKEN env vars (#53)
All checks were successful
CodeQL / Analyze (${{ matrix.language }}) (go) (pull_request) Successful in 1s
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (pull_request) Successful in 1s
CodeQL / Analyze (${{ matrix.language }}) (python) (pull_request) Successful in 0s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 5s
CI / Detect changes (pull_request) Successful in 8s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 7s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 8s
E2E API Smoke Test / detect-changes (pull_request) Successful in 8s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 7s
Harness Replays / detect-changes (pull_request) Successful in 8s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 7s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 2s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 3s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 3s
CI / Python Lint & Test (pull_request) Successful in 32s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 32s
Harness Replays / Harness Replays (pull_request) Successful in 55s
CI / Canvas (Next.js) (pull_request) Successful in 2m22s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / Platform (Go) (pull_request) Successful in 2m48s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 4m19s
All checks were successful
CodeQL / Analyze (${{ matrix.language }}) (go) (pull_request) Successful in 1s
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (pull_request) Successful in 1s
CodeQL / Analyze (${{ matrix.language }}) (python) (pull_request) Successful in 0s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 5s
CI / Detect changes (pull_request) Successful in 8s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 7s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 8s
E2E API Smoke Test / detect-changes (pull_request) Successful in 8s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 7s
Harness Replays / detect-changes (pull_request) Successful in 8s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 7s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 2s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 3s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 3s
CI / Python Lint & Test (pull_request) Successful in 32s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 32s
Harness Replays / Harness Replays (pull_request) Successful in 55s
CI / Canvas (Next.js) (pull_request) Successful in 2m22s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / Platform (Go) (pull_request) Successful in 2m48s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 4m19s
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 <noreply@anthropic.com>
This commit is contained in:
parent
c94ead1953
commit
1da8c1bb2f
@ -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;
|
||||
|
||||
116
canvas/src/lib/__tests__/admin-token-pair.test.ts
Normal file
116
canvas/src/lib/__tests__/admin-token-pair.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user