@bongos/core 1.19.331 → 1.19.333

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/.bongos-core.json +163 -43
  2. package/docs/api/openapi.json +2 -2
  3. package/docs/api-reference.md +1 -1
  4. package/docs/copy-inventory.md +202 -202
  5. package/docs/copy-registry.json +384 -384
  6. package/modules/grading/grader-config.js +98 -0
  7. package/modules/grading/grader-model.js +150 -0
  8. package/modules/grading/grader-prompt.js +139 -0
  9. package/modules/grading/grader-score.js +706 -0
  10. package/modules/grading/grader-subagent.js +328 -0
  11. package/modules/grading/grader.js +5 -1287
  12. package/modules/hall-ui/public/builders.js +19 -1182
  13. package/modules/hall-ui/public/goals-page.js +11 -997
  14. package/modules/hall-ui/public/goals-render.js +1032 -0
  15. package/modules/hall-ui/public/goals.html +4 -0
  16. package/modules/hall-ui/public/hall-render.js +1211 -0
  17. package/modules/hall-ui/public/index.html +6 -0
  18. package/modules/lifecycle/routes/goal-route-authz.js +400 -0
  19. package/modules/lifecycle/routes/goals.js +1 -367
  20. package/modules/lifecycle/routes/task-route-helpers.js +219 -0
  21. package/modules/lifecycle/routes/task-write-routes.js +542 -0
  22. package/modules/lifecycle/routes/tasks.js +3 -696
  23. package/package-lock.json +2 -2
  24. package/package.json +1 -1
  25. package/public-landing/assets/cosmos.css +4 -0
  26. package/public-landing/projects.html +103 -23
  27. package/scripts/gds/box-infra.js +280 -0
  28. package/scripts/gds/box.js +1 -245
  29. package/scripts/gds/fitness-checks-identity.js +320 -0
  30. package/scripts/gds/fitness-checks-vocabulary.js +230 -0
  31. package/scripts/gds/fitness-checks-write-validation.js +144 -0
  32. package/scripts/gds/fitness-lib.js +82 -0
  33. package/scripts/gds/fitness.js +55 -668
  34. package/scripts/gds/provision-config.js +204 -0
  35. package/scripts/gds/provision-net.js +221 -0
  36. package/scripts/gds/provision-repo.js +849 -0
  37. package/scripts/gds/provision-units.js +262 -0
  38. package/scripts/gds/provision.js +4 -1395
  39. package/src/bongos/auth-admission.js +264 -0
  40. package/src/bongos/auth-config.js +151 -0
  41. package/src/bongos/auth-discord.js +198 -0
  42. package/src/bongos/auth-github.js +336 -0
  43. package/src/bongos/auth-idp.js +330 -0
  44. package/src/bongos/auth.js +5 -1100
  45. package/src/module-api.js +5 -1
  46. package/tests/auth_admission_gate.mjs +3 -1
  47. package/tests/est_advisory.mjs +3 -1
  48. package/tests/goal_advisory.mjs +3 -2
  49. package/tests/goal_criterion_confirm_ui.mjs +6 -0
  50. package/tests/goal_subtitle.mjs +5 -2
  51. package/tests/goals_card_view.mjs +10 -3
  52. package/tests/grader_generated_provenance.mjs +3 -1
  53. package/tests/hall_palette.mjs +3 -1
  54. package/tests/helpers.mjs +58 -25
  55. package/tests/lib_sh_resolution.mjs +3 -1
  56. package/tests/membership_kind_lockstep.mjs +3 -1
  57. package/tests/migration_allocation.mjs +5 -2
  58. package/tests/patch_gate_ordering.mjs +3 -1
  59. package/tests/projects_hub_pre_uat.mjs +53 -0
  60. package/tests/rank_tier_single_source.mjs +16 -3
  61. package/tests/reward_gate.mjs +3 -2
@@ -58,92 +58,7 @@ const BOX_INFRA_TEMPLATE = 'builder-box-cloud-init.yaml';
58
58
  // Ordered candidate infra/ dirs — first that actually holds the cloud-init
59
59
  // template wins. Pure (env + repoRoot injected) so it is unit-testable without
60
60
  // touching the real filesystem layout.
61
- function boxInfraCandidates({ env = process.env, repoRoot = REPO_ROOT } = {}) {
62
- const out = [];
63
- const seen = new Set();
64
- const add = (d) => { if (d && !seen.has(d)) { seen.add(d); out.push(d); } };
65
- // 1. explicit override: <PREFIX>_BOX_INFRA_DIR (operator escape hatch).
66
- const override = instanceConfig.resolveEnv('BOX_INFRA_DIR', { env });
67
- if (override) add(path.resolve(override));
68
- // 2. the instance root via an EXPLICIT pointer (<PREFIX>_INSTANCE_ROOT env or a
69
- // bongos.config.json {"instanceRoot":"."}) — where a vendored instance keeps infra/.
70
- const inst = instanceConfig.resolveInstanceRootExplicit({ env });
71
- if (inst) add(path.join(inst, 'infra'));
72
- // 3. REPO_ROOT/infra — the dev single-checkout (and the ADR 0144 node_modules workaround).
73
- add(path.join(repoRoot, 'infra'));
74
- // 4. walk up out of node_modules from the core root, so a vendored
75
- // …/node_modules/@bongos/core still finds its host instance's infra/ with no pointer set.
76
- let dir = repoRoot;
77
- for (let i = 0; i < 6; i += 1) {
78
- const parent = path.dirname(dir);
79
- if (parent === dir) break;
80
- dir = parent;
81
- add(path.join(dir, 'infra'));
82
- }
83
- return out;
84
- }
85
-
86
- // The resolved infra/ dir (first candidate holding the template), or null when the
87
- // box cloud-init cannot be located anywhere — the caller MUST fail loud on null
88
- // rather than provision a box with empty user-data (ADR 0150).
89
- function resolveBoxInfraDir(opts = {}) {
90
- for (const c of boxInfraCandidates(opts)) {
91
- try { if (fs.existsSync(path.join(c, BOX_INFRA_TEMPLATE))) return c; }
92
- catch { /* unreadable candidate → try the next */ }
93
- }
94
- return null;
95
- }
96
-
97
- // task 1002757 — the instance's own apex; null when unconfigured (callers refuse).
98
- const { instanceApex } = require('./instance-apex');
99
-
100
- const CONFIG = {
101
- floor: process.env.BOX_PROVISION_MIN_RANK || 'xenos',
102
- region: process.env.BOX_REGION || 'nyc3',
103
- size: process.env.BOX_SIZE || 's-2vcpu-4gb',
104
- baseImage: process.env.BOX_BASE_IMAGE || 'ubuntu-24-04-x64',
105
- idleMinutes: numEnv('BOX_IDLE_MINUTES', 10),
106
- dormantDays: numEnv('BOX_DORMANT_DAYS', 14),
107
- // task 1002727 — how many days BEFORE the reclaim a builder gets warned.
108
- dormantWarnLeadDays: numEnv('BOX_DORMANT_WARN_LEAD_DAYS', 3),
109
- sshKeyIds: (process.env.BOX_SSH_KEY_IDS || '').split(',').map((s) => s.trim()).filter(Boolean),
110
- dnsHook: process.env.BOX_DNS_HOOK || null,
111
- tag: process.env.BOX_TAG || 'otb-builder-box',
112
- // task 1002757 settled this file's old carve-out: an operator-plane DNS zone is
113
- // still a FUNCTIONAL DEFAULT, and a founder literal aimed another operator's
114
- // fleet at the founder's zone. Derived from the instance apex, null when
115
- // unconfigured — hostnameFor() then refuses instead of minting a dead FQDN.
116
- dnsZone: process.env.BOX_DNS_ZONE || (instanceApex() ? `dev.${instanceApex()}` : null),
117
- };
118
-
119
- // Named-tunnel automation for the browser terminal (#771, ADR 0038 Part B).
120
- // OPT-IN, default OFF — an unset BOX_NAMED_TUNNEL leaves the box on the tokenless
121
- // quick tunnel (trycloudflare.com), the zero-secret #760 default. When the operator
122
- // turns it on AND supplies a Cloudflare token with Tunnel + DNS scope (the same
123
- // CLOUDFLARE_API_TOKEN box-dns uses) + the account id, every provision creates a
124
- // stable term-<login>.<terminalZone> tunnel and bakes its connector token into the
125
- // box; deprovision tears it down.
126
- const CF = {
127
- enabled: /^(1|true|yes)$/i.test(process.env.BOX_NAMED_TUNNEL || ''),
128
- apiToken: process.env.CLOUDFLARE_API_TOKEN || null,
129
- accountId: process.env.CLOUDFLARE_ACCOUNT_ID || null,
130
- // The Cloudflare ZONE the record lives in (the registrable apex) — the terminal
131
- // host is a record WITHIN it, matching box-dns-cloudflare.sh. Distinct from
132
- // terminalZone (the host suffix). Both resolve from the instance (task 1002757).
133
- zone: process.env.CLOUDFLARE_ZONE || instanceApex(),
134
- // Host suffix for term-<login>.<terminalZone>. DEFAULTS TO THE APEX, NOT dev.*:
135
- // the terminal rides a PROXIED tunnel, and free Universal SSL covers <apex> +
136
- // *.<apex> (ONE label) but not a TWO-label host like term-x.dev.<apex>, which
137
- // fails TLS at the edge (verified live, #771). An operator holding an Advanced
138
- // Certificate for *.dev.<apex> can override via BOX_TERMINAL_ZONE. (The SSH
139
- // A-record path stays on dev.* — proxied=false, so it needs no edge cert.)
140
- terminalZone: process.env.BOX_TERMINAL_ZONE || instanceApex(),
141
- };
142
-
143
- // Named-tunnel automation is live only when the operator opted in AND the
144
- // credentials are present. Missing creds while enabled is a loud config error at
145
- // provision time (see cmdProvision), not a silent fall-back, so an operator who
146
- // meant to use named tunnels learns immediately.
61
+ const { CF, CONFIG, boxInfraCandidates, dropletName, hasFlag, hostnameFor, numEnv, readCloudInit, resolveBoxInfraDir, runDnsHook, runDnsHookBlackhole, shortStamp } = require('./box-infra.js');
147
62
  function namedTunnelEnabled() { return CF.enabled; }
148
63
  function namedTunnelConfigured() { return CF.enabled && CF.apiToken && CF.accountId; }
149
64
 
@@ -194,20 +109,6 @@ async function preflightCookieStripScope() {
194
109
  return scope;
195
110
  }
196
111
 
197
- function numEnv(name, dflt) {
198
- const v = process.env[name];
199
- if (v == null || v === '') return dflt;
200
- const n = Number(v);
201
- return Number.isFinite(n) && n > 0 ? n : dflt;
202
- }
203
-
204
- function hasFlag(args, name) { return args.includes(name); }
205
-
206
- // Lazy + memoized DB + lib load so `--help` and arg errors don't require Postgres
207
- // (this CLI runs on a non-droplet machine for dry-run planning of pure logic too):
208
- // nothing is require()d until the first makeDo/makeCf/IIFE call, and repeat calls
209
- // reuse the first resolution instead of re-running the six requires.
210
- let _deps;
211
112
  function loadDeps() {
212
113
  if (_deps) return _deps;
213
114
  // eslint-disable-next-line global-require
@@ -274,151 +175,6 @@ async function resolveBuilder(pool, ref) {
274
175
  }
275
176
 
276
177
  // A short, DNS-safe-ish suffix from the current time (CLI context — Date is fine).
277
- function shortStamp() {
278
- return new Date().toISOString().replace(/[-:T.Z]/g, '').slice(2, 14);
279
- }
280
-
281
- function dropletName(login) { return `otb-box-${login}`; }
282
- // Refuses on an unresolved zone (task 1002757) — `box-x.undefined` would mint a
283
- // DNS record and a box that can never be reached.
284
- function hostnameFor(login) {
285
- if (!CONFIG.dnsZone) throw new Error('box: no DNS zone configured — set BOX_DNS_ZONE, or give this instance a domains.publicOrigin in config/branding.json (task 1002757).');
286
- return `box-${login}.${CONFIG.dnsZone}`;
287
- }
288
-
289
- // Run the optional DNS hook so the per-builder hostname tracks the new IP; with no
290
- // hook configured, print the reminder — the stable-hostname CUTOVER is #599's, #598
291
- // only records IP + hostname and offers the hook so an operator can wire DNS without
292
- // editing this file. → { ok, error?, skipped? }. task 1180: callers MUST act on
293
- // ok:false (box_events row + flag the box), never continue silently — that swallow
294
- // left @kingaonline's A record pinned at a dead IP while provision reported success.
295
- // The hook retries transient Cloudflare blips, so ok:false is a real, exhausted
296
- // failure. `hook` is injectable so the contract is unit-testable (#1034).
297
- function runDnsHook(hostname, ip, hook = CONFIG.dnsHook) {
298
- if (!ip) return { ok: true, skipped: true };
299
- if (!hook) {
300
- console.log(` ↳ DNS: point ${hostname} → ${ip} (no BOX_DNS_HOOK set; #599 owns the cutover)`);
301
- return { ok: true, skipped: true };
302
- }
303
- try {
304
- execFileSync('bash', ['-c', hook], {
305
- cwd: REPO_ROOT, stdio: 'pipe',
306
- env: { ...process.env, BOX_HOSTNAME: hostname, BOX_IP: ip },
307
- });
308
- console.log(` ↳ DNS hook updated ${hostname} → ${ip}`);
309
- return { ok: true };
310
- } catch (e) {
311
- const msg = (e.stderr && e.stderr.toString().trim()) || e.message;
312
- console.log(` ⚠ DNS hook failed: ${msg} (point ${hostname} → ${ip} manually)`);
313
- return { ok: false, error: msg };
314
- }
315
- }
316
-
317
- // Blackhole sentinel for a torn-down box's A record (idea 309). 192.0.2.1 is in
318
- // TEST-NET-1 (RFC 5737) — reserved for documentation and GUARANTEED never to be a
319
- // routable host, so no recycled DigitalOcean IP can ever land here. A perfect
320
- // dead end to park a record at.
321
- const DNS_BLACKHOLE_IP = '192.0.2.1';
322
-
323
- // Park the per-builder A record at the blackhole sentinel on deprovision (idea 309,
324
- // refining task 1180). 1180 DELETED the record so it couldn't dangle at a recycled
325
- // DO IP, but delete-then-recreate across a reap→rebuild left an NXDOMAIN window that
326
- // resolvers negative-cache for the zone's SOA neg-TTL (~30 min, not builder-editable)
327
- // — a client querying then kept getting ENOTFOUND long after the box was back.
328
- // REPOINTING to a non-routable sentinel keeps the name always-resolving (nothing to
329
- // negative-cache) while preserving 1180's guarantee that a torn-down box's name can
330
- // never dial a stranger's recycled IP; the next provision/wake PUTs the live IP
331
- // in-place at the 60s TTL, no gap. Best-effort like the prior delete. Reuses
332
- // runDnsHook's UPSERT path; `hook` injectable for tests. → { ok, error?, skipped? }
333
- function runDnsHookBlackhole(hostname, hook = CONFIG.dnsHook) {
334
- if (!hook || !hostname) return { ok: true, skipped: true };
335
- return runDnsHook(hostname, DNS_BLACKHOLE_IP, hook);
336
- }
337
-
338
- // Read the cloud-init template and substitute the repo URL from BOX_REPO_URL
339
- // (kept out of the repo file so no GitHub identity is hardcoded — V3.R12 rule).
340
- // An unset URL leaves the placeholder; the cloud-init then logs guidance rather
341
- // than cloning, and #600 wires the scoped credential.
342
- // Read + render the cloud-init. `authorizedKeysBlock` is the otb-managed block
343
- // for the builder being provisioned (#685) — base64'd here and baked in so the
344
- // FIRST SSH login works before the box has a GDS token. An empty/absent block
345
- // renders an empty managed block (a keyless box; the operator is warned upstream).
346
- function readCloudInit({ authorizedKeysBlock = '', boxEnv = '', gdsSession = '', infraDir = resolveBoxInfraDir() } = {}) {
347
- // Fail loud, never provision a BARE box (ADR 0150). No infra/ located → there is
348
- // no cloud-init to render, so THROW here rather than the old silent `return null`
349
- // that let cmdProvision ship empty user-data (a box that boots as bare Ubuntu).
350
- if (!infraDir) {
351
- throw new Error(
352
- `box cloud-init not found: no infra/${BOX_INFRA_TEMPLATE} in any candidate location `
353
- + `(set <PREFIX>_BOX_INFRA_DIR or <PREFIX>_INSTANCE_ROOT, or ship infra/). A box must `
354
- + `never be provisioned without user-data — it would boot bare (ADR 0150).`,
355
- );
356
- }
357
- const p = path.join(infraDir, BOX_INFRA_TEMPLATE);
358
- // gzip+base64 a repo file into the cloud-init write_files (`encoding: gz+b64`).
359
- // EVERY inlined infra file goes through this: cloud-init gunzips it back to the
360
- // exact bytes on the box, so it is byte-identical to plain b64 but ~2-3x smaller,
361
- // which keeps the rendered user-data well under DO's 64 KiB cap (#903; the source
362
- // -fetch growth in task 1314 had pushed the all-b64-but-source-fetch render to
363
- // 67 KB — gz+b64-ing the rest brings it back to ~51 KB, task 1321). Only the
364
- // dynamic runtime values (authorized_keys, box.env, gds-session) stay plain b64
365
- // below — they are minted per-provision, not read from infra/. A missing inlined
366
- // file is COLLECTED and fails loud after the render (ADR 0150) — a half-wired box
367
- // (that component silently never boots) is a bug, not something to warn past.
368
- const missingInfra = [];
369
- const fileGzB64 = (name) => {
370
- try {
371
- return zlib.gzipSync(fs.readFileSync(path.join(infraDir, name))).toString('base64');
372
- } catch {
373
- missingInfra.push(name);
374
- return '';
375
- }
376
- };
377
- const raw = fs.readFileSync(p, 'utf8');
378
- // Inject the rank-gated source-fetch (#600) + authorized-keys-fetch (#685)
379
- // scripts as gz+b64 (a single YAML-safe token — no indentation/escaping
380
- // pitfalls; cloud-init gunzips encoding: gz+b64 back to the exact file), plus
381
- // the baked authorized_keys block. ONE canonical copy of each lives in infra/.
382
- const rendered = raw
383
- .replace(/__REPO_URL__/g, process.env.BOX_REPO_URL || '')
384
- .replace(/__BOX_SOURCE_FETCH_B64__/g, fileGzB64('box-source-fetch.sh'))
385
- .replace(/__BOX_AUTHKEYS_FETCH_B64__/g, fileGzB64('box-authorized-keys-fetch.sh'))
386
- .replace(/__BUILDER_AUTHORIZED_KEYS_B64__/g, Buffer.from(authorizedKeysBlock || '', 'utf8').toString('base64'))
387
- // /etc/otb/box.env — empty by default (quick tunnel); the named-tunnel
388
- // connector token + stable hostname when BOX_NAMED_TUNNEL is on (#771).
389
- .replace(/__BOX_ENV_B64__/g, Buffer.from(boxEnv || '', 'utf8').toString('base64'))
390
- // /root/.config/otb/gds-session.json — the builder's GDS session, minted at
391
- // provision so box-source-fetch (#600) can authenticate on FIRST boot and
392
- // clone the rank-scoped repo. Without it the box never clones (the #818
393
- // chicken-and-egg: the session normally comes from /builder-setup, which
394
- // lives in the un-cloned repo).
395
- .replace(/__GDS_SESSION_B64__/g, Buffer.from(gdsSession || '', 'utf8').toString('base64'))
396
- // #774: the browser-terminal units, inlined so they start at FIRST BOOT
397
- // (independent of the repo clone). ONE canonical copy lives in infra/.
398
- .replace(/__BOX_TERMINAL_SERVICE_B64__/g, fileGzB64('box-terminal.service'))
399
- // #917: credential-drift watcher pair — restarts ttyd when the credential
400
- // file lands/changes after the service started (the wake/first-boot race).
401
- .replace(/__BOX_TERMINAL_CREDSYNC_SERVICE_B64__/g, fileGzB64('box-terminal-credsync.service'))
402
- .replace(/__BOX_TERMINAL_CREDENTIAL_PATH_B64__/g, fileGzB64('box-terminal-credential.path'))
403
- .replace(/__CLOUDFLARED_SERVICE_B64__/g, fileGzB64('cloudflared.service'))
404
- .replace(/__CLOUDFLARED_RUN_B64__/g, fileGzB64('cloudflared-run.sh'))
405
- // #893: the `close-box` teardown command, present at first boot so a bare
406
- // browser-only box has it before any repo clone (box-source-fetch refreshes
407
- // it on existing boxes). ONE canonical copy lives in infra/close-box.sh.
408
- .replace(/__CLOSE_BOX_B64__/g, fileGzB64('close-box.sh'))
409
- // task 986: the browser-terminal auto-launcher — runs `claude` on first
410
- // connect so the builder skips the "type claude" step. box-source-fetch
411
- // refreshes it on existing boxes. ONE canonical copy in infra/otb-box-launch.sh.
412
- .replace(/__OTB_BOX_LAUNCH_B64__/g, fileGzB64('otb-box-launch.sh'));
413
- if (missingInfra.length) {
414
- throw new Error(
415
- `box cloud-init is incomplete under ${infraDir}: missing ${missingInfra.join(', ')} `
416
- + `— a box would boot half-wired (ADR 0150). Fix the instance infra/ before provisioning.`,
417
- );
418
- }
419
- return rendered;
420
- }
421
-
422
178
  // ---- provision -------------------------------------------------------------
423
179
 
424
180
  async function cmdProvision(pool, boxes, boxOnboard, ref, { apply }) {
@@ -0,0 +1,320 @@
1
+ // scripts/gds/fitness-checks-identity.js — the checks that keep the portable core
2
+ // free of any one host's identity: kernel prompts (BV1.R76), the neutral config
3
+ // packs (BV1.R102) and the design contract.
4
+ //
5
+ // Carved out of scripts/gds/fitness.js by task 1003206 (goal 1000079, the R03
6
+ // de-monolith plan) to bring that file under the 1,500-line ratchet. A STRUCTURAL
7
+ // move: the lines below are fitness.js's originals unchanged, and fitness.js
8
+ // imports every name straight back, so the CHECKS registry and the module's
9
+ // public surface (which the tests read heavily) are untouched.
10
+
11
+ 'use strict';
12
+
13
+ const fs = require('node:fs');
14
+ const path = require('node:path');
15
+ const { CORE_ROOT, MODULE_ROOTS, REPO_ROOT, rel } = require('./fitness-lib.js');
16
+
17
+ // ============================================================================
18
+ // Check 13 — portable code carries no hardcoded host-brand identity (BV1.R76 + task 1705)
19
+ // ============================================================================
20
+ //
21
+ // The portable core (src/bongos/**) and every feature MODULE (modules/**) must read
22
+ // the instance's product/world name + domains from branding() / window.__BRANDING__,
23
+ // never a baked-in "Off the Boats" / "Amazonprimea" / apex domain (ADR 0062 §3/§5;
24
+ // the branding contract). R76 shipped this as an 8-FILE allowlist over the kernel
25
+ // LLM-prompt headers; task 1705 WIDENS it to a directory-wide scan, so ANY new brand
26
+ // leak in portable code reds the build — not just the nine files someone remembered
27
+ // to list. (The 2026-07-02 separation audit found five leaks the old allowlist missed:
28
+ // the rank/graduation broadcasts, the auth error-page title, the apex-origin default,
29
+ // and ~124 served-UI strings.) Comments are stripped first (block /* */, HTML
30
+ // <!-- -->, and JS // line comments via a `://`-safe strip so a URL is never
31
+ // truncated) because the de-brand rationale legitimately names the old literals in
32
+ // prose. Instance-injected placeholders ({{worldName}} etc.) carry no literal, so
33
+ // they pass untouched.
34
+ //
35
+ // EXCLUDED — the small instance-1 / ops allowlist (task 1705):
36
+ // - modules/game/** — the INSTANCE'S OWN PRODUCT. A product's identity is
37
+ // instance-specific by definition (a vanilla instance ships a different product,
38
+ // or none); it is not portable-platform code the branding pack can neutralize.
39
+ // A documented carve-out, not an oversight.
40
+ // - generated / build-output dirs (no authored source).
41
+ const PROMPT_IDENTITY_LITERALS = [
42
+ /off the boats/i,
43
+ /amazonprimea/i,
44
+ ];
45
+ // Scan roots (recursive) + kernel-prompt CLIs that live outside them.
46
+ // scripts/gds joined at task 1002427: the builder CLI was UNSCANNED, which is how
47
+ // paste-token.js kept a hardcoded 'https://amazonprimea.com' long after the task
48
+ // 1002201 sweep replaced that literal everywhere else. The straggler surfaced as a
49
+ // 401 that locked a builder out of re-authenticating — a valid token verified
50
+ // against the wrong host — and nothing in CI could see it (idea 1000564).
51
+ // Widened scripts/gds → ALL of scripts/ at task 1002756: migrate.sh's hardcoded
52
+ // founder DB default (task 1002755) survived every gate because the detector never
53
+ // saw the file — a gate that cannot see the file class the bug lives in is worse
54
+ // than no gate, because it is trusted.
55
+ const IDENTITY_SCAN_ROOTS = ['src/bongos', 'modules', 'scripts'];
56
+ const IDENTITY_SCAN_EXTRA_FILES = [];
57
+ // PERMANENT, COUNTED carve-outs for the scripts/gds root — {file: allowed hits}.
58
+ //
59
+ // Widening the root (task 1002427) surfaced 23 files. Task 1002428 swept them:
60
+ // every one whose literal was a real runtime bug is FIXED (a wrong instance URL
61
+ // printed to a builder, an `amazonprimea-*` User-Agent that should come from
62
+ // branding().userAgent(), a hardcoded product name in user-facing copy). What is
63
+ // left below is NOT a backlog — each entry is a literal that is CORRECT as
64
+ // written, so this list is permanent rather than a ratchet-to-zero.
65
+ //
66
+ // The COUNT is still enforced, which is the point: an entry may not grow. A file
67
+ // here that gains a NEW hardcoded host fails the build exactly like an unlisted
68
+ // one, and any file NOT listed must have zero hits. Getting better never fails.
69
+ //
70
+ // Why each is legitimate:
71
+ // * box.js — REAL Cloudflare/DNS zone names for the operator plane
72
+ // (`dev.<host>`, the terminal zone). These are infrastructure identifiers,
73
+ // not an instance URL; routing them through branding would be wrong. (Its
74
+ // two genuine API-base defaults WERE fixed in 1002428.)
75
+ // * publish-manifest.js — the literal FILENAMES of two real committed docs
76
+ // (docs/recipes/amazonprimea-decouple-*.md). Renaming the strings without
77
+ // renaming the files would break the publish manifest.
78
+ // * backfill-sessions.js / version-close.js — one-time operator runbook text
79
+ // naming the actual prod DB + a historical local directory. Describing what
80
+ // an operator must literally type is the correct content.
81
+ // * seed-*.js — the hits are PROSE INSIDE seeded task descriptions: historical
82
+ // records of what those tasks said when they were filed. Rewriting them would
83
+ // falsify the record; deleting the files would destroy it. They are inert
84
+ // (idempotent on source_ref) and referenced only by generated manifests.
85
+ // * the four *.sh entries — surfaced when `.sh` joined the extension set (task
86
+ // 1002567). Each is a HOST-PINNED OPS script, not portable code: they name the
87
+ // live box's actual Postgres database/role and its actual /etc secrets dir, and
88
+ // every one of those literals is already only the DEFAULT behind an env override
89
+ // (GDS_BACKUP_DB, PROD_DB/STAGING_DB, PG_SOCKET). Same rationale as box.js's real
90
+ // DNS zones — an infrastructure identifier, not an instance URL, and the systemd
91
+ // units on that box invoke these by path. Re-pointing an instance's backups is
92
+ // deployment work, not a branding read. Their COUNT is still frozen here, so a
93
+ // NEW literal in any of them fails exactly like one in an unlisted file.
94
+ // * fitness-checks-identity.js / package-core.js — THE DETECTORS THEMSELVES: Check 13's regexes
95
+ // and package-core's PRIOR_INSTANCE_FLOOR name the brand because hunting it is
96
+ // their job. * seed-v3-tasks.js — the seeded-task-prose rationale above. All
97
+ // three passed pre-1002756 only by the old stripper's accidents (phantom
98
+ // blocks + regex-blindness); now they are seen and counted.
99
+ const IDENTITY_SCAN_ALLOWED = {
100
+ // task 1003206 moved Check 13's own regexes out of fitness.js into THIS file
101
+ // when the monolith was split, so the detector's self-carve-out moved with them.
102
+ 'scripts/gds/fitness-checks-identity.js': 2,
103
+ 'scripts/gds/package-core.js': 1,
104
+ 'scripts/gds/seed-v3-tasks.js': 14,
105
+ // audit-replay.sh + db-backup-{nightly,offsite}.sh were carved out here as
106
+ // "host-pinned ops"; task 1002757 RETIRED that justification for them — each
107
+ // now resolves its database from the branding pack (the migrate.sh pattern) and
108
+ // fails loudly rather than defaulting, so their counts ratcheted to zero and the
109
+ // entries are gone. seed-staging.sh keeps its carve-out on the OPPOSITE
110
+ // rationale: its literal is a fail-CLOSED safety interlock (the script wipes
111
+ // STAGING_DB and refuses unless the name matches exactly), so on any other
112
+ // instance it declines to run instead of destroying the wrong database.
113
+ 'scripts/gds/seed-staging.sh': 4,
114
+ 'scripts/gds/backfill-sessions.js': 2,
115
+ 'scripts/gds/box.js': 3,
116
+ 'scripts/gds/publish-manifest.js': 2,
117
+ 'scripts/gds/seed-bongos-coreB-tranche1-tasks.js': 1,
118
+ 'scripts/gds/seed-bongos-coreB-tranche2-tasks.js': 1,
119
+ 'scripts/gds/seed-bongos-goal-hierarchy-tasks.js': 1,
120
+ 'scripts/gds/seed-bongos-tasks.js': 1,
121
+ 'scripts/gds/seed-gds-v4-tasks.js': 1,
122
+ 'scripts/gds/seed-medusa-tasks.js': 7,
123
+ 'scripts/gds/seed-module-upstreaming.js': 1,
124
+ 'scripts/gds/seed-productization-tasks.js': 6,
125
+ 'scripts/gds/seed-provisioning-tasks.js': 2,
126
+ 'scripts/gds/version-close.js': 1,
127
+ };
128
+ // `.sh` joined at task 1002567: the extension set was `.js/.html/.css`, so NO shell
129
+ // script had ever been scanned — which is how scripts/gds/lib.sh kept a hardcoded
130
+ // founder host as its API-base default (and a hardcoded ~/.config/otb token path)
131
+ // through the entire task 1002201 sweep, leaving all five bash smokes dead on any
132
+ // instance with a different configDir. The guard could not see the file class the
133
+ // bug lived in.
134
+ const IDENTITY_SCAN_EXTS = new Set(['.js', '.html', '.css', '.sh']);
135
+ // Path-prefix carve-outs (see the header note). Kept SMALL and documented.
136
+ const IDENTITY_SCAN_EXCLUDE = ['modules/game/'];
137
+ // Directories with no authored source to gate.
138
+ const IDENTITY_SCAN_SKIP_DIRS = new Set(['node_modules', '.git', 'generated', 'iterations', 'references']);
139
+
140
+ // Comment stripping lives in identity-strip.js (task 1002756: extracted + replaced
141
+ // the two-regex JS strip, which a `/*` inside a `//` comment could OPEN — it
142
+ // silently hid ~330 provision.js lines from this gate). Re-exported for the tests.
143
+ const { stripCommentsForIdentity } = require('./identity-strip');
144
+
145
+ // Pure: scan one file's comment-stripped source for a hardcoded host-brand literal.
146
+ // Returns violation strings. Exported for the "actually catches a re-hardcode" test.
147
+ function scanPromptIdentity(relFile, text, ext = path.extname(relFile)) {
148
+ const violations = [];
149
+ stripCommentsForIdentity(text, ext).split('\n').forEach((raw, i) => {
150
+ if (!raw.trim()) return;
151
+ for (const re of PROMPT_IDENTITY_LITERALS) {
152
+ const m = raw.match(re);
153
+ if (m) {
154
+ violations.push(`${relFile}:${i + 1} hardcodes the host-brand identity "${m[0]}" — read it from branding()/window.__BRANDING__ (identity/domains/currency), or use a {{worldName}}-style token, so a non-OTB instance renders ITS OWN brand (ADR 0062 §3/§5; BV1.R76 / task 1705).`);
155
+ break; // one hit per line is enough
156
+ }
157
+ }
158
+ });
159
+ return violations;
160
+ }
161
+
162
+ // Recursively collect scannable files under one root, honoring the skip-dir + carve-out lists.
163
+ function collectIdentityFiles(absRoot, relRoot) {
164
+ const out = [];
165
+ let entries;
166
+ try { entries = fs.readdirSync(absRoot, { withFileTypes: true }); }
167
+ catch { return out; }
168
+ for (const e of entries) {
169
+ const rel = relRoot ? `${relRoot}/${e.name}` : e.name;
170
+ if (e.isDirectory()) {
171
+ if (IDENTITY_SCAN_SKIP_DIRS.has(e.name)) continue;
172
+ if (IDENTITY_SCAN_EXCLUDE.some((p) => `${rel}/`.startsWith(p))) continue;
173
+ out.push(...collectIdentityFiles(path.join(absRoot, e.name), rel));
174
+ } else if (IDENTITY_SCAN_EXTS.has(path.extname(e.name))
175
+ && !IDENTITY_SCAN_EXCLUDE.some((p) => rel.startsWith(p))) {
176
+ out.push(rel);
177
+ }
178
+ }
179
+ return out;
180
+ }
181
+
182
+ function checkKernelPromptsNoHardcodedIdentity() {
183
+ const violations = [];
184
+ let scanned = 0;
185
+ // Collect {abs, rel}: src/bongos is CORE; modules ship from BOTH roots (ADR 0108 §Consequences #4).
186
+ // Dedup by repo-relative path so a shared checkout never scans a file twice, and read each file
187
+ // from the root it was actually found under (a module may live in the instance repo post-split).
188
+ const seen = new Set();
189
+ const entries = [];
190
+ const addFrom = (rootAbs, sub) => {
191
+ for (const relFromRoot of collectIdentityFiles(path.join(rootAbs, sub), sub)) {
192
+ if (seen.has(relFromRoot)) continue;
193
+ seen.add(relFromRoot);
194
+ entries.push({ abs: path.join(rootAbs, relFromRoot), rel: relFromRoot });
195
+ }
196
+ };
197
+ for (const root of IDENTITY_SCAN_ROOTS) {
198
+ for (const rootAbs of (root === 'modules' ? MODULE_ROOTS : [CORE_ROOT])) addFrom(rootAbs, root);
199
+ }
200
+ for (const extra of IDENTITY_SCAN_EXTRA_FILES) {
201
+ if (!seen.has(extra)) { seen.add(extra); entries.push({ abs: path.join(CORE_ROOT, extra), rel: extra }); }
202
+ }
203
+ let ratcheted = 0;
204
+ for (const { abs, rel: relFile } of entries) {
205
+ let text;
206
+ try { text = fs.readFileSync(abs, 'utf8'); }
207
+ catch { continue; } // a file moved/carved away is not a failure of this gate
208
+ scanned += 1;
209
+ const found = scanPromptIdentity(relFile, text);
210
+ // Permanently allowed at a COUNT (task 1002428): the listed literals are
211
+ // correct as written, but an entry may not GROW — a new hardcode in one of
212
+ // these files fails exactly like one in an unlisted file.
213
+ const allowed = IDENTITY_SCAN_ALLOWED[relFile];
214
+ if (allowed !== undefined) {
215
+ if (found.length > allowed) {
216
+ violations.push(
217
+ `${relFile} now hardcodes the host-brand identity ${found.length} time(s), above its allowed ${allowed} — ` +
218
+ `the carve-out covers the EXISTING literals (see IDENTITY_SCAN_ALLOWED), not new ones. ` +
219
+ `Read the host from branding()/window.__BRANDING__ instead (task 1002428).`
220
+ );
221
+ } else {
222
+ ratcheted += 1;
223
+ }
224
+ continue;
225
+ }
226
+ violations.push(...found);
227
+ }
228
+ return {
229
+ name: 'portable code carries no hardcoded host identity (BV1.R76 / task 1705)',
230
+ ok: violations.length === 0,
231
+ hardFail: violations.length > 0,
232
+ violations,
233
+ warnings: [],
234
+ note: `${scanned} portable source file(s) scanned (.js/.html/.css/.sh under src/bongos/** + modules/** + scripts/** (whole root, task 1002756), minus the instance-1 product + generated dirs) — none may hardcode the host brand; identity comes from branding()/window.__BRANDING__ (ADR 0062 §3/§5). ${ratcheted} scripts/gds file(s) hold documented permanent carve-outs (real DNS zones, real doc filenames, operator runbook text, historical seeded prose) capped at a count they may not exceed — task 1002428.`,
235
+ };
236
+ }
237
+
238
+ // ============================================================================
239
+ // Check 15b — core-shipped NEUTRAL config packs carry no host identity (BV1.R102)
240
+ // ============================================================================
241
+ //
242
+ // Sibling of Check 15 for the CONFIG class. IDENTITY_SCAN_EXTS is js/html/css, so
243
+ // the scan above never opens a .json — yet config/*.neutral.json is precisely what
244
+ // a vanilla instance boots from (branding R76, governance R96). A host name leaking
245
+ // into a neutral pack ships one instance's brand to EVERY fresh instance, and until
246
+ // this check nothing in CI could see it.
247
+ //
248
+ // Only the NEUTRAL packs are scanned, deliberately: an instance pack (config/
249
+ // branding.json, config/government.json) is host content that legitimately names
250
+ // its host, and is boundary-excluded from the published core anyway.
251
+ function checkNeutralConfigsNoHostIdentity() {
252
+ const violations = [];
253
+ const dir = path.join(CORE_ROOT, 'config');
254
+ let names = [];
255
+ try { names = fs.readdirSync(dir).filter((n) => n.endsWith('.neutral.json')).sort(); }
256
+ catch { /* no config/ dir in this root — nothing to gate */ }
257
+ for (const name of names) {
258
+ const relFile = `config/${name}`;
259
+ let text;
260
+ try { text = fs.readFileSync(path.join(dir, name), 'utf8'); }
261
+ catch { continue; } // a pack that moved away is not a failure of this gate
262
+ violations.push(...scanPromptIdentity(relFile, text, '.json'));
263
+ }
264
+ return {
265
+ name: 'neutral config packs carry no host identity (BV1.R102)',
266
+ ok: violations.length === 0,
267
+ hardFail: violations.length > 0,
268
+ violations,
269
+ warnings: [],
270
+ note: `${names.length} core-shipped config/*.neutral.json pack(s) scanned — a vanilla instance boots from these alone, so none may name a host brand. Instance packs (config/<name>.json) are host content and are deliberately NOT scanned.`,
271
+ };
272
+ }
273
+
274
+ // ---- design contract (ADR 0081 / task 1377) --------------------------------
275
+
276
+ function checkDesignContract() {
277
+ const { spawnSync } = require('node:child_process');
278
+ const validatorPath = path.join(REPO_ROOT, 'scripts', 'gds', 'validate-design.js');
279
+ const adaptersDir = path.join(REPO_ROOT, 'src', 'ui', 'adapters');
280
+ const hasAdapters = (() => {
281
+ try { return fs.readdirSync(adaptersDir).some((e) => fs.statSync(path.join(adaptersDir, e)).isDirectory()); }
282
+ catch { return false; }
283
+ })();
284
+
285
+ const result = spawnSync(process.execPath, [validatorPath, '--json'], { encoding: 'utf8' });
286
+ let report = { ok: true, errors: [], warnings: [] };
287
+ try { report = JSON.parse(result.stdout || '{}'); } catch { /* fall through with defaults */ }
288
+ if (result.status !== 0 && !report.errors.length) {
289
+ report.errors.push(result.stderr ? result.stderr.trim() : 'validate-design.js exited non-zero');
290
+ report.ok = false;
291
+ }
292
+
293
+ return {
294
+ name: 'design contract valid (docs/design-contract.md / ADR 0081)',
295
+ ok: report.ok,
296
+ hardFail: !report.ok && hasAdapters,
297
+ violations: report.errors || [],
298
+ warnings: report.warnings || [],
299
+ note: hasAdapters
300
+ ? 'Hard-fail: adapters present — contract must be valid. Run: node scripts/gds/validate-design.js'
301
+ : 'Warn-only until first adapter lands (task 1378 / task 1379). Run: node scripts/gds/validate-design.js',
302
+ };
303
+ }
304
+
305
+
306
+ module.exports = {
307
+ IDENTITY_SCAN_ALLOWED,
308
+ IDENTITY_SCAN_EXCLUDE,
309
+ IDENTITY_SCAN_EXTRA_FILES,
310
+ IDENTITY_SCAN_EXTS,
311
+ IDENTITY_SCAN_ROOTS,
312
+ IDENTITY_SCAN_SKIP_DIRS,
313
+ PROMPT_IDENTITY_LITERALS,
314
+ checkDesignContract,
315
+ checkKernelPromptsNoHardcodedIdentity,
316
+ checkNeutralConfigsNoHostIdentity,
317
+ collectIdentityFiles,
318
+ scanPromptIdentity,
319
+ stripCommentsForIdentity,
320
+ };