Skip to content

Commit 9beed2f

Browse files
karthikeyan5claude
andcommitted
fix(frontend): bake VITE_API_BASE_URL in the build too (generalize the guard)
Same class of bug as the password hash: a plain `vite build` also dropped VITE_API_BASE_URL (= API_URL), so the fixed canary could log in but then threw "VITE_API_BASE_URL is not configured." on the first backend call after login. Generalize the in-build bake (rename vite-plugin-password-hash -> vite-plugin-build-config): resolve + bake the admin/invigilator password hashes AND the backend API base URL from process.env or the repo-root .env.deploy.local, ABORT a production build (buildStart) if any REQUIRED value is missing, and verify they landed in the emitted bundle (closeBundle). VITE_EVAL_API_URL stays optional (same-origin fallback to the proctor-api eval routes). Verified: a plain build now bakes the password hashes + the proctor-api base URL; a build missing any required value aborts loudly; frontend 961/961. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e80cd73 commit 9beed2f

5 files changed

Lines changed: 294 additions & 250 deletions

File tree

frontend/src/buildConfig.test.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
2+
import { writeFileSync, rmSync, mkdtempSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import {
6+
sha256Hex,
7+
readEnvFileValue,
8+
resolveValue,
9+
bakeBuildConfig
10+
} from "../vite-plugin-build-config";
11+
12+
const ENV_KEYS = [
13+
"ADMIN_PASSWORD",
14+
"INVIGILATOR_PASSWORD",
15+
"VITE_ADMIN_PASSWORD_HASH",
16+
"VITE_INVIGILATOR_PASSWORD_HASH",
17+
"VITE_API_BASE_URL",
18+
"API_URL",
19+
"VITE_EVAL_API_URL"
20+
];
21+
22+
function clearEnv() {
23+
for (const k of ENV_KEYS) delete process.env[k];
24+
}
25+
26+
describe("build-config baking (vite-plugin-build-config)", () => {
27+
let dir: string;
28+
let envFile: string;
29+
30+
beforeEach(() => {
31+
dir = mkdtempSync(join(tmpdir(), "buildcfg-"));
32+
envFile = join(dir, ".env.deploy.local");
33+
clearEnv();
34+
});
35+
36+
afterEach(() => {
37+
rmSync(dir, { recursive: true, force: true });
38+
clearEnv();
39+
});
40+
41+
it("sha256Hex matches the known NIST vector for 'abc'", () => {
42+
expect(sha256Hex("abc")).toBe(
43+
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
44+
);
45+
});
46+
47+
it("readEnvFileValue parses KEY=value, ignores other lines and strips quotes", () => {
48+
writeFileSync(
49+
envFile,
50+
["# comment", "OTHER=nope", 'ADMIN_PASSWORD="s3cret-pw"', "API_URL=https://api.example/"].join("\n")
51+
);
52+
expect(readEnvFileValue(envFile, "ADMIN_PASSWORD")).toBe("s3cret-pw");
53+
expect(readEnvFileValue(envFile, "API_URL")).toBe("https://api.example/");
54+
expect(readEnvFileValue(envFile, "MISSING")).toBeUndefined();
55+
});
56+
57+
it("resolveValue prefers process.env over the file and tries keys in order", () => {
58+
writeFileSync(envFile, "API_URL=from-file");
59+
process.env.VITE_API_BASE_URL = "from-vite-env";
60+
expect(resolveValue(["VITE_API_BASE_URL", "API_URL"], "API_URL", envFile)).toBe("from-vite-env");
61+
delete process.env.VITE_API_BASE_URL;
62+
process.env.API_URL = "from-api-env";
63+
expect(resolveValue(["VITE_API_BASE_URL", "API_URL"], "API_URL", envFile)).toBe("from-api-env");
64+
delete process.env.API_URL;
65+
expect(resolveValue(["VITE_API_BASE_URL", "API_URL"], "API_URL", envFile)).toBe("from-file");
66+
});
67+
68+
it("bakeBuildConfig hashes passwords AND bakes the API URL (trailing slash stripped)", () => {
69+
writeFileSync(
70+
envFile,
71+
[
72+
"ADMIN_PASSWORD=admin-pw",
73+
"INVIGILATOR_PASSWORD=invig-pw",
74+
"API_URL=https://proctor-api.example.run.app/"
75+
].join("\n")
76+
);
77+
const cfg = bakeBuildConfig(envFile);
78+
expect(cfg.adminHash).toBe(sha256Hex("admin-pw"));
79+
expect(cfg.invigHash).toBe(sha256Hex("invig-pw"));
80+
expect(cfg.apiBaseUrl).toBe("https://proctor-api.example.run.app");
81+
expect(process.env.VITE_ADMIN_PASSWORD_HASH).toBe(cfg.adminHash);
82+
expect(process.env.VITE_INVIGILATOR_PASSWORD_HASH).toBe(cfg.invigHash);
83+
expect(process.env.VITE_API_BASE_URL).toBe("https://proctor-api.example.run.app");
84+
});
85+
86+
it("bakeBuildConfig returns empty values (no env set) when everything is absent", () => {
87+
const cfg = bakeBuildConfig(join(dir, "absent.env"));
88+
expect(cfg.adminHash).toBe("");
89+
expect(cfg.invigHash).toBe("");
90+
expect(cfg.apiBaseUrl).toBe("");
91+
expect(process.env.VITE_ADMIN_PASSWORD_HASH).toBeUndefined();
92+
expect(process.env.VITE_API_BASE_URL).toBeUndefined();
93+
});
94+
95+
it("explicit VITE_* overrides are not clobbered", () => {
96+
writeFileSync(envFile, ["ADMIN_PASSWORD=admin-pw", "API_URL=https://from-file"].join("\n"));
97+
process.env.VITE_ADMIN_PASSWORD_HASH = "preset-hash";
98+
process.env.VITE_API_BASE_URL = "https://preset-url";
99+
bakeBuildConfig(envFile);
100+
expect(process.env.VITE_ADMIN_PASSWORD_HASH).toBe("preset-hash");
101+
expect(process.env.VITE_API_BASE_URL).toBe("https://preset-url");
102+
});
103+
});

frontend/src/buildPasswordHash.test.ts

Lines changed: 0 additions & 84 deletions
This file was deleted.
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
// =============================================================================
2+
// Deterministic build-time config baking — wired INTO the build.
3+
//
4+
// WHY THIS EXISTS (burned more than once): the production bundle needs several
5+
// values baked at BUILD time, and a plain `vite build` / `npm run build` bakes
6+
// NONE of them unless the caller remembers to pass them. When they are missing
7+
// the app breaks in ways that only show up in prod:
8+
// * VITE_ADMIN_PASSWORD_HASH / VITE_INVIGILATOR_PASSWORD_HASH empty →
9+
// every admin/invigilator login fails (the unlock gate hashes the typed
10+
// password and compares to "").
11+
// * VITE_API_BASE_URL empty → the app throws "VITE_API_BASE_URL is not
12+
// configured." after login (it has no backend to call).
13+
//
14+
// THE FIX: resolve + bake ALL of this here, inside `vite build` itself, so it
15+
// can NEVER be skipped. Any production build:
16+
// 1. resolves the values from process.env, then the repo-root .env.deploy.local,
17+
// 2. exposes them as the VITE_* env vars Vite inlines into the bundle,
18+
// 3. ABORTS the build if a REQUIRED value can't be resolved, and
19+
// 4. ABORTS if a required value is somehow absent from the emitted bundle.
20+
// Plain passwords are read only to hash them; they never reach the output.
21+
// =============================================================================
22+
23+
import { createHash } from "node:crypto";
24+
import { readFileSync, readdirSync, statSync } from "node:fs";
25+
import { resolve } from "node:path";
26+
import type { Plugin } from "vite";
27+
28+
export function sha256Hex(input: string): string {
29+
return createHash("sha256").update(input, "utf8").digest("hex");
30+
}
31+
32+
/** Read a single KEY=value from a dotenv-style file; undefined if absent/unreadable. */
33+
export function readEnvFileValue(file: string, key: string): string | undefined {
34+
let text: string;
35+
try {
36+
text = readFileSync(file, "utf8");
37+
} catch {
38+
return undefined;
39+
}
40+
for (const line of text.split(/\r?\n/)) {
41+
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
42+
if (m && m[1] === key) {
43+
let v = m[2];
44+
if (
45+
(v.startsWith('"') && v.endsWith('"')) ||
46+
(v.startsWith("'") && v.endsWith("'"))
47+
) {
48+
v = v.slice(1, -1);
49+
}
50+
return v;
51+
}
52+
}
53+
return undefined;
54+
}
55+
56+
/** Resolve a value from process.env (first key that is set & non-empty), then the file. */
57+
export function resolveValue(envKeys: string[], fileKey: string, envFile: string): string | undefined {
58+
for (const k of envKeys) {
59+
const v = process.env[k];
60+
if (v != null && v !== "") return v;
61+
}
62+
return readEnvFileValue(envFile, fileKey);
63+
}
64+
65+
function setIfUnset(key: string, value: string): void {
66+
if (value && !process.env[key]) process.env[key] = value;
67+
}
68+
69+
export interface BuildConfig {
70+
adminHash: string;
71+
invigHash: string;
72+
apiBaseUrl: string;
73+
}
74+
75+
/**
76+
* Resolve every build-time value and expose it to Vite as the VITE_* env vars it
77+
* inlines (the same mechanism the deploy script used, but in-code so a plain
78+
* build can't skip it). Only sets a var if not already set, so an explicit
79+
* override still wins. Returns the resolved values ("" when unresolved) for the
80+
* guard plugin. The raw passwords are hashed, never exposed.
81+
*/
82+
export function bakeBuildConfig(envFile: string): BuildConfig {
83+
const adminPw = resolveValue(["ADMIN_PASSWORD"], "ADMIN_PASSWORD", envFile);
84+
const invigPw = resolveValue(["INVIGILATOR_PASSWORD"], "INVIGILATOR_PASSWORD", envFile);
85+
const adminHash = adminPw ? sha256Hex(adminPw) : "";
86+
const invigHash = invigPw ? sha256Hex(invigPw) : "";
87+
setIfUnset("VITE_ADMIN_PASSWORD_HASH", adminHash);
88+
setIfUnset("VITE_INVIGILATOR_PASSWORD_HASH", invigHash);
89+
90+
// Backend base URL: VITE_API_BASE_URL or API_URL from the environment, else
91+
// API_URL from .env.deploy.local. Strip any trailing slash (the app does too).
92+
const apiBaseUrl = (
93+
resolveValue(["VITE_API_BASE_URL", "API_URL"], "API_URL", envFile) ?? ""
94+
).replace(/\/+$/, "");
95+
setIfUnset("VITE_API_BASE_URL", apiBaseUrl);
96+
97+
// Optional: route only the admin eval calls to the separate proctor-eval
98+
// service. Unset = same-origin fallback to the proctor-api eval routes.
99+
const evalApiUrl = (
100+
resolveValue(["VITE_EVAL_API_URL"], "VITE_EVAL_API_URL", envFile) ?? ""
101+
).replace(/\/+$/, "");
102+
setIfUnset("VITE_EVAL_API_URL", evalApiUrl);
103+
104+
return { adminHash, invigHash, apiBaseUrl };
105+
}
106+
107+
function listJsFiles(dir: string): string[] {
108+
const out: string[] = [];
109+
let names: string[];
110+
try {
111+
names = readdirSync(dir);
112+
} catch {
113+
return out;
114+
}
115+
for (const name of names) {
116+
const full = resolve(dir, name);
117+
let isDir = false;
118+
try {
119+
isDir = statSync(full).isDirectory();
120+
} catch {
121+
/* unreadable entry — skip */
122+
}
123+
if (isDir) out.push(...listJsFiles(full));
124+
else if (name.endsWith(".js")) out.push(full);
125+
}
126+
return out;
127+
}
128+
129+
/**
130+
* Vite plugin: on a PRODUCTION build, fail fast if any REQUIRED build value is
131+
* missing, and after writing the bundle, assert the required values are actually
132+
* present in the emitted JS. On dev/serve it is a no-op (the :5173 dev fallback
133+
* to a plain password / same-origin API still works).
134+
*/
135+
export function buildConfigGuard(cfg: BuildConfig): Plugin {
136+
let isProd = false;
137+
let outDir = "dist";
138+
let root = process.cwd();
139+
return {
140+
name: "proctor-build-config-guard",
141+
configResolved(config) {
142+
isProd = config.command === "build" && config.mode !== "development";
143+
outDir = config.build.outDir;
144+
root = config.root;
145+
},
146+
buildStart() {
147+
if (!isProd) return;
148+
const missing: string[] = [];
149+
if (!cfg.adminHash) missing.push("ADMIN_PASSWORD (→ VITE_ADMIN_PASSWORD_HASH)");
150+
if (!cfg.invigHash) missing.push("INVIGILATOR_PASSWORD (→ VITE_INVIGILATOR_PASSWORD_HASH)");
151+
if (!cfg.apiBaseUrl) missing.push("API_URL (→ VITE_API_BASE_URL)");
152+
if (missing.length) {
153+
this.error(
154+
`[build-config] production build ABORTED: could not resolve ${missing.join(
155+
", "
156+
)} from process.env or .env.deploy.local. Shipping these empty breaks prod ` +
157+
`(empty password hash → every admin/invigilator login fails; empty API base ` +
158+
`URL → "VITE_API_BASE_URL is not configured" after login). Set the value(s) and rebuild.`
159+
);
160+
}
161+
},
162+
closeBundle() {
163+
if (!isProd) return;
164+
const dir = resolve(root, outDir);
165+
const blob = listJsFiles(dir)
166+
.map((f) => readFileSync(f, "utf8"))
167+
.join("\n");
168+
const missing: string[] = [];
169+
if (!blob.includes(cfg.adminHash)) missing.push("VITE_ADMIN_PASSWORD_HASH");
170+
if (!blob.includes(cfg.invigHash)) missing.push("VITE_INVIGILATOR_PASSWORD_HASH");
171+
if (cfg.apiBaseUrl && !blob.includes(cfg.apiBaseUrl)) missing.push("VITE_API_BASE_URL");
172+
if (missing.length) {
173+
this.error(
174+
`[build-config] built bundle in ${dir} is MISSING ${missing.join(
175+
" + "
176+
)}. Deploying it would break prod (login and/or backend calls). Build aborted.`
177+
);
178+
}
179+
}
180+
};
181+
}

0 commit comments

Comments
 (0)