diff --git a/deploy.sh b/deploy.sh index 9ea2f6b..5dc4b50 100755 --- a/deploy.sh +++ b/deploy.sh @@ -35,11 +35,12 @@ python3 scripts/sync_nav.py # (api/src/service-catalog.ts) is the authority (it is what checkout charges). # The site build context is ./site only and cannot read ../api, so we generate # site/src/lib/service-catalog.generated.ts here on the host before the docker -# build. This guarantees displayed prices == charged prices. +# build. This guarantees displayed prices == charged prices. (Python because the +# prod box has python3 but not node; matches scripts/sync_nav.py.) echo "" echo "=== Generating site service catalog from API source ===" -node scripts/gen-service-catalog.mjs -node scripts/check-service-catalog-drift.mjs +python3 scripts/gen-service-catalog.py +python3 scripts/check-service-catalog-drift.py # Render the Alertmanager config from its template. Alertmanager does NOT expand # ${ENV} placeholders in its YAML, so the raw template (with ${TELEGRAM_BOT_TOKEN} diff --git a/scripts/check-service-catalog-drift.mjs b/scripts/check-service-catalog-drift.mjs deleted file mode 100644 index e9ceb99..0000000 --- a/scripts/check-service-catalog-drift.mjs +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env node -/** - * Drift guard: the site's generated catalog MUST match the API source. - * - * Run in CI / pre-deploy. Regenerates the catalog into memory from - * api/src/service-catalog.ts and compares it to the committed - * site/src/lib/service-catalog.generated.ts. Fails (exit 1) on any difference, - * so a price edited in the API but not regenerated, or the generated file - * hand-edited, is caught before it reaches customers. - * - * Usage: node scripts/check-service-catalog-drift.mjs - */ -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { dirname, resolve } from "node:path"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const ROOT = resolve(__dirname, ".."); -const GEN = resolve(ROOT, "site/src/lib/service-catalog.generated.ts"); - -// Regenerate to a temp string by invoking the generator's parse logic inline. -const SRC = resolve(ROOT, "api/src/service-catalog.ts"); -const tsSource = readFileSync(SRC, "utf8"); -const m = tsSource.match(/export const COMPLIANCE_SERVICES[^=]*=\s*(\{[\s\S]*?\n\});/); -if (!m) { console.error("drift-check: cannot parse API catalog"); process.exit(1); } -const apiCatalog = Function(`"use strict"; return (${m[1]});`)(); - -// Parse the committed generated file's SERVICE_META. -const genSource = readFileSync(GEN, "utf8"); -const gm = genSource.match(/export const SERVICE_META[^=]*=\s*(\{[\s\S]*?\n\});/); -if (!gm) { console.error("drift-check: cannot parse generated SERVICE_META"); process.exit(1); } -const gen = Function(`"use strict"; return (${gm[1]});`)(); - -let problems = []; -for (const slug of Object.keys(apiCatalog)) { - const a = apiCatalog[slug]; - const g = gen[slug]; - if (!g) { problems.push(`${slug}: missing from generated file`); continue; } - if (a.price_cents !== g.price_cents) problems.push(`${slug}: price API=${a.price_cents} generated=${g.price_cents}`); - if (a.name !== g.name) problems.push(`${slug}: name mismatch`); - if ((a.gov_fee_label || undefined) !== (g.gov_fee_label || undefined)) problems.push(`${slug}: gov_fee_label mismatch`); -} -for (const slug of Object.keys(gen)) { - if (!apiCatalog[slug]) problems.push(`${slug}: in generated file but not in API`); -} - -if (problems.length) { - console.error("SERVICE CATALOG DRIFT DETECTED (run: node scripts/gen-service-catalog.mjs):"); - for (const p of problems) console.error(" - " + p); - process.exit(1); -} -console.log(`drift-check: OK -- ${Object.keys(apiCatalog).length} services, API and generated catalog match.`); diff --git a/scripts/check-service-catalog-drift.py b/scripts/check-service-catalog-drift.py new file mode 100644 index 0000000..ec9e5ec --- /dev/null +++ b/scripts/check-service-catalog-drift.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Drift guard: the site's generated catalog MUST match the API source. + +Run in deploy.sh before the site build. Re-parses the API catalog and compares +it to the committed site/src/lib/service-catalog.generated.ts. Exits 1 on any +difference, so a price edited in the API but not regenerated (or a hand-edited +generated file) is caught before it reaches customers. + +Usage: python3 scripts/check-service-catalog-drift.py +""" +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "scripts")) +import importlib.util + +spec = importlib.util.spec_from_file_location("gen_catalog", ROOT / "scripts/gen-service-catalog.py") +gen = importlib.util.module_from_spec(spec) +spec.loader.exec_module(gen) + +GEN = ROOT / "site/src/lib/service-catalog.generated.ts" + + +def parse_generated(ts: str) -> dict: + m = re.search(r"export const SERVICE_META[^=]*=\s*\{(.*)\n\};", ts, re.S) + if not m: + raise SystemExit("drift-check: cannot parse generated SERVICE_META") + body = m.group(1) + out = {} + for em in re.finditer(r'"([a-z0-9\-]+)":\s*\{(.*?)\},', body): + slug = em.group(1) + inner = em.group(2) + name_m = re.search(r'name:\s*"((?:[^"\\]|\\.)*)"', inner) + price_m = re.search(r"price_cents:\s*(\d+)", inner) + gov_m = re.search(r'gov_fee_label:\s*"((?:[^"\\]|\\.)*)"', inner) + entry = {"name": gen._unescape(name_m.group(1)), "price_cents": int(price_m.group(1))} + if gov_m: + entry["gov_fee_label"] = gen._unescape(gov_m.group(1)) + out[slug] = entry + return out + + +def main() -> int: + api = gen.parse_catalog(gen.SRC.read_text()) + have = parse_generated(GEN.read_text()) + problems = [] + for slug, a in api.items(): + g = have.get(slug) + if not g: + problems.append(f"{slug}: missing from generated file") + continue + if a["price_cents"] != g["price_cents"]: + problems.append(f"{slug}: price API={a['price_cents']} generated={g['price_cents']}") + if a["name"] != g["name"]: + problems.append(f"{slug}: name mismatch") + if a.get("gov_fee_label") != g.get("gov_fee_label"): + problems.append(f"{slug}: gov_fee_label mismatch") + for slug in have: + if slug not in api: + problems.append(f"{slug}: in generated file but not in API") + if problems: + print("SERVICE CATALOG DRIFT DETECTED (run: python3 scripts/gen-service-catalog.py):", file=sys.stderr) + for p in problems: + print(" - " + p, file=sys.stderr) + return 1 + print(f"drift-check: OK -- {len(api)} services, API and generated catalog match.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/gen-service-catalog.mjs b/scripts/gen-service-catalog.mjs deleted file mode 100644 index c8bacf3..0000000 --- a/scripts/gen-service-catalog.mjs +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env node -/** - * Generate the site's read-only service catalog from the API's single source. - * - * Source : api/src/service-catalog.ts (COMPLIANCE_SERVICES -- the authority) - * Output : site/src/lib/service-catalog.generated.ts (SERVICE_META for display) - * - * Run automatically before the site build (site `prebuild` script). Never edit - * the generated file by hand. To change a price or name, edit the API catalog. - * - * We import the API module directly (it is plain TS with a literal object), via - * a tiny transpile-free parse: the catalog is a static object literal, so we - * evaluate it in a sandboxed module context. - */ -import { readFileSync, writeFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { dirname, resolve } from "node:path"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const ROOT = resolve(__dirname, ".."); -const SRC = resolve(ROOT, "api/src/service-catalog.ts"); -const OUT = resolve(ROOT, "site/src/lib/service-catalog.generated.ts"); - -const tsSource = readFileSync(SRC, "utf8"); - -// Extract the object literal assigned to COMPLIANCE_SERVICES. -const m = tsSource.match(/export const COMPLIANCE_SERVICES[^=]*=\s*(\{[\s\S]*?\n\});/); -if (!m) { - console.error("[gen-service-catalog] could not locate COMPLIANCE_SERVICES object literal in", SRC); - process.exit(1); -} -// The object literal is valid JS (string keys, number/bool/string values, trailing commas). -// Evaluate it safely in an isolated function scope (no access to anything). -let catalog; -try { - catalog = Function(`"use strict"; return (${m[1]});`)(); -} catch (e) { - console.error("[gen-service-catalog] failed to evaluate catalog literal:", e); - process.exit(1); -} - -const slugs = Object.keys(catalog).sort(); -const lines = slugs.map((slug) => { - const s = catalog[slug]; - const parts = [`name: ${JSON.stringify(s.name)}`, `price_cents: ${s.price_cents}`]; - if (s.gov_fee_label) parts.push(`gov_fee_label: ${JSON.stringify(s.gov_fee_label)}`); - return ` ${JSON.stringify(slug)}: { ${parts.join(", ")} },`; -}); - -const out = `/** - * AUTO-GENERATED -- DO NOT EDIT. - * - * Source of truth: api/src/service-catalog.ts (COMPLIANCE_SERVICES). - * Regenerate with: node scripts/gen-service-catalog.mjs (runs on site prebuild). - * - * Display-only subset (name, price_cents, gov_fee_label) of the API catalog, - * so the public site can never drift from what the API actually charges. - */ - -export interface ServiceMeta { - name: string; - price_cents: number; - gov_fee_label?: string; -} - -export const SERVICE_META: Record = { -${lines.join("\n")} -}; -`; - -writeFileSync(OUT, out); -console.log(`[gen-service-catalog] wrote ${slugs.length} services -> ${OUT}`); diff --git a/scripts/gen-service-catalog.py b/scripts/gen-service-catalog.py new file mode 100644 index 0000000..9726ffe --- /dev/null +++ b/scripts/gen-service-catalog.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Generate the site's read-only service catalog from the API's single source. + +Source : api/src/service-catalog.ts (COMPLIANCE_SERVICES -- the authority) +Output : site/src/lib/service-catalog.generated.ts (SERVICE_META for display) + +Run host-side before the site build (deploy.sh / site prebuild). The prod box +has python3 but not node, so this is Python (matches scripts/sync_nav.py). Never +edit the generated file by hand: to change a price/name, edit the API catalog. + +The catalog is a static TS object literal (string keys, number/bool/string +values, trailing commas, // comments). We parse it into Python dicts. +""" +import json +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +SRC = ROOT / "api/src/service-catalog.ts" +OUT = ROOT / "site/src/lib/service-catalog.generated.ts" + + +def _unescape(s: str) -> str: + """Unescape only TS string escapes we expect (\\" and \\\\); the source is + already UTF-8, so we must NOT unicode_escape-decode (that mangles e.g. -> / section sign).""" + return s.replace('\\"', '"').replace("\\\\", "\\") + + +def parse_catalog(ts: str) -> dict: + """Parse COMPLIANCE_SERVICES entries: name, price_cents, gov_fee_label.""" + m = re.search(r"export const COMPLIANCE_SERVICES[^=]*=\s*\{(.*)\n\};", ts, re.S) + if not m: + raise SystemExit("[gen-service-catalog] could not locate COMPLIANCE_SERVICES literal") + body = m.group(1) + out = {} + # Each entry: "slug": { ... }, with the inner object possibly multi-line. + for em in re.finditer(r'"([a-z0-9\-]+)":\s*\{(.*?)\}', body, re.S): + slug = em.group(1) + inner = em.group(2) + name_m = re.search(r'name:\s*"((?:[^"\\]|\\.)*)"', inner) + price_m = re.search(r"price_cents:\s*(\d+)", inner) + gov_m = re.search(r'gov_fee_label:\s*"((?:[^"\\]|\\.)*)"', inner) + if not name_m or not price_m: + continue + entry = { + "name": _unescape(name_m.group(1)), + "price_cents": int(price_m.group(1)), + } + if gov_m: + entry["gov_fee_label"] = _unescape(gov_m.group(1)) + out[slug] = entry + return out + + +def render(catalog: dict) -> str: + lines = [] + for slug in sorted(catalog): + s = catalog[slug] + parts = [f"name: {json.dumps(s["name"], ensure_ascii=False)}", f"price_cents: {s['price_cents']}"] + if s.get("gov_fee_label"): + parts.append(f"gov_fee_label: {json.dumps(s["gov_fee_label"], ensure_ascii=False)}") + lines.append(f" {json.dumps(slug, ensure_ascii=False)}: {{ {', '.join(parts)} }},") + return ( + "/**\n" + " * AUTO-GENERATED -- DO NOT EDIT.\n" + " *\n" + " * Source of truth: api/src/service-catalog.ts (COMPLIANCE_SERVICES).\n" + " * Regenerate with: python3 scripts/gen-service-catalog.py (runs on deploy).\n" + " *\n" + " * Display-only subset (name, price_cents, gov_fee_label) of the API catalog,\n" + " * so the public site can never drift from what the API actually charges.\n" + " */\n\n" + "export interface ServiceMeta {\n" + " name: string;\n" + " price_cents: number;\n" + " gov_fee_label?: string;\n" + "}\n\n" + "export const SERVICE_META: Record = {\n" + + "\n".join(lines) + + "\n};\n" + ) + + +if __name__ == "__main__": + catalog = parse_catalog(SRC.read_text()) + OUT.write_text(render(catalog)) + print(f"[gen-service-catalog] wrote {len(catalog)} services -> {OUT}")