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>
125 lines
5.0 KiB
TypeScript
125 lines
5.0 KiB
TypeScript
import type { NextConfig } from "next";
|
||
import { existsSync, readFileSync } from "node:fs";
|
||
import { dirname, join } from "node:path";
|
||
|
||
// Load NEXT_PUBLIC_* vars from the monorepo root .env so a fresh
|
||
// `pnpm dev` works without a per-developer canvas/.env.local. Next.js
|
||
// only auto-loads .env from the project root by default — but our
|
||
// canonical config (NEXT_PUBLIC_PLATFORM_URL, NEXT_PUBLIC_WS_URL,
|
||
// MOLECULE_ENV, etc.) lives at the monorepo root, gitignored, shared
|
||
// by the Go platform binary. Without this, the canvas falls back to
|
||
// `window.location` (`ws://localhost:3000/ws`) and the WS pill stays
|
||
// "Reconnecting" forever because Next.js dev doesn't serve /ws.
|
||
//
|
||
// Mirrors workspace-server/cmd/server/dotenv.go's monorepo-rooted .env
|
||
// loader. Both processes look for the SAME marker (`workspace-server/
|
||
// go.mod`) so a developer renaming or relocating the repo only has to
|
||
// 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",
|
||
};
|
||
|
||
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;
|
||
const envPath = join(root, ".env");
|
||
if (!existsSync(envPath)) return;
|
||
const body = readFileSync(envPath, "utf8");
|
||
let loaded = 0;
|
||
let skipped = 0;
|
||
for (const line of body.split(/\r?\n/)) {
|
||
const kv = parseLine(line);
|
||
if (!kv) continue;
|
||
const [k, v] = kv;
|
||
// Existing env wins. NOTE: an explicitly-set empty string
|
||
// (`KEY=` exported from a parent shell, where Node represents it
|
||
// as `""` not `undefined`) counts as "set" — we keep the empty
|
||
// value rather than backfilling from the file. Matches Go's
|
||
// os.LookupEnv check in workspace-server/cmd/server/dotenv.go so
|
||
// both processes treat the same input identically. Operators who
|
||
// want the file value to win must `unset KEY` in the launching
|
||
// shell.
|
||
if (process.env[k] !== undefined) {
|
||
skipped++;
|
||
continue;
|
||
}
|
||
process.env[k] = v;
|
||
loaded++;
|
||
}
|
||
// eslint-disable-next-line no-console
|
||
console.log(
|
||
`[next.config] loaded ${loaded} vars from ${envPath} (${skipped} already set in env)`,
|
||
);
|
||
}
|
||
|
||
function findMonorepoRoot(start: string): string | null {
|
||
let dir = start;
|
||
for (let i = 0; i < 6; i++) {
|
||
if (existsSync(join(dir, "workspace-server", "go.mod"))) return dir;
|
||
const parent = dirname(dir);
|
||
if (parent === dir) break;
|
||
dir = parent;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Mirror of workspace-server/cmd/server/dotenv.go's parseDotEnvLine
|
||
// — same rules so the two loaders agree on every line in the shared
|
||
// .env. If you change one parser, change the other.
|
||
function parseLine(raw: string): [string, string] | null {
|
||
let line = raw.replace(/^/, "").trim();
|
||
if (line === "" || line.startsWith("#")) return null;
|
||
// `export ` prefix uses a literal space — `export\tFOO=bar` with a
|
||
// tab is intentionally rejected, matching the Go mirror in
|
||
// workspace-server/cmd/server/dotenv.go. Shells emit the prefix
|
||
// with a space; tabs would only appear in hand-mangled files.
|
||
if (line.startsWith("export ")) line = line.slice("export ".length).trimStart();
|
||
const eq = line.indexOf("=");
|
||
if (eq <= 0) return null;
|
||
const k = line.slice(0, eq).trim();
|
||
let v = line.slice(eq + 1).replace(/^[ \t]+/, "");
|
||
if (v.length >= 2 && (v[0] === '"' || v[0] === "'")) {
|
||
const quote = v[0];
|
||
const end = v.indexOf(quote, 1);
|
||
if (end >= 0) return [k, v.slice(1, end)];
|
||
// unterminated — fall through to bare-value handling
|
||
}
|
||
for (let i = 0; i < v.length; i++) {
|
||
if (v[i] !== "#") continue;
|
||
if (i === 0 || v[i - 1] === " " || v[i - 1] === "\t") {
|
||
v = v.slice(0, i);
|
||
break;
|
||
}
|
||
}
|
||
return [k, v.trim()];
|
||
}
|