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