@bongos/core 1.19.653 → 1.19.655

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 (48) hide show
  1. package/.bongos-core.json +58 -53
  2. package/.env.local.example +3 -3
  3. package/config/branding.neutral.json +1 -1
  4. package/docker-compose.yml +1 -1
  5. package/docs/branding-contract.md +1 -1
  6. package/docs/copy-inventory.md +26 -26
  7. package/docs/copy-registry.json +32 -32
  8. package/docs/module-api-changelog.md +4 -0
  9. package/docs/recipes/self-host.md +3 -3
  10. package/docs/recipes/standalone-live-docs.md +1 -1
  11. package/docs/recipes/upgrading-the-core.md +1 -1
  12. package/modules/dev-box/routes/box.js +4 -2
  13. package/modules/hall-ui/public/watch.js +38 -14
  14. package/modules/ideas/routes/inbox.js +22 -3
  15. package/package-lock.json +2 -2
  16. package/package.json +1 -1
  17. package/scripts/gds/box.js +3 -3
  18. package/scripts/gds/claude-materialize.js +1 -1
  19. package/scripts/gds/init.js +2 -2
  20. package/scripts/gds/main-audit.js +22 -8
  21. package/scripts/gds/module.js +3 -2
  22. package/scripts/gds/oauth-secret.js +6 -3
  23. package/scripts/gds/provision-units.js +3 -2
  24. package/scripts/gds/seed-bongos-coreB-tranche1-tasks.js +1 -1
  25. package/scripts/gds/seed-bongos-coreB-tranche2-tasks.js +1 -1
  26. package/scripts/gds/seed-provisioning-tasks.js +1 -1
  27. package/scripts/gds/spark.js +26 -1
  28. package/scripts/gds/status.js +3 -2
  29. package/scripts/gds/upgrade.js +3 -3
  30. package/scripts/hall-preview/server.js +1 -1
  31. package/src/bongos/routes/auth.js +3 -2
  32. package/src/bongos/routes/backup.js +8 -4
  33. package/src/bongos/routes/security.js +2 -1
  34. package/src/bongos/serve-internal.js +2 -2
  35. package/src/branding.js +24 -7
  36. package/src/instance-config.js +74 -13
  37. package/src/module-api.js +1 -1
  38. package/src/modules.js +15 -7
  39. package/tests/claude_materialize.mjs +1 -1
  40. package/tests/cli_exit_no_abort.mjs +109 -2
  41. package/tests/cloud_bongos_pack.mjs +1 -1
  42. package/tests/idea_develop_spark.mjs +43 -0
  43. package/tests/idea_resubmit.mjs +7 -3
  44. package/tests/init.mjs +1 -1
  45. package/tests/instance_config.mjs +98 -2
  46. package/tests/upgrade.mjs +3 -3
  47. package/tests/watch_applications_queue.mjs +13 -1
  48. package/tests/watch_sealed_floor.mjs +241 -0
@@ -51,7 +51,7 @@ const { stealthGate } = require('./stealth-gate');
51
51
  const { platformVisibilityGate } = require('./platform-visibility-gate');
52
52
  const { clientModules, isModuleEnabled } = require('../modules');
53
53
  const { hallWidgetScripts, moduleWebSurfaces } = require('../module-loader/loader');
54
- const { resolveCoreRoot, resolveDocsRoot, resolveInstanceRoot } = require('../instance-config');
54
+ const { resolveCoreRoot, resolveDocsRoot, resolveInstanceRoot, resolveEnv } = require('../instance-config');
55
55
  const { API_PREFIX, LEGACY_API_PREFIXES, API_VERSION, VERSIONED_API_PREFIX, ALL_API_PREFIXES } = require('./api-prefix');
56
56
 
57
57
  // ADR 0108 §1: every use of ROOT below (infra/ installers, modules/, docs/) is
@@ -640,7 +640,7 @@ function mountInternalSurfaces(app) {
640
640
  // Pinned public origin for the served installer scripts (never a request header —
641
641
  // a spoofed Host can't redirect a `curl | sh`). The fallback comes from the
642
642
  // branding pack, not a hardcoded host brand (ADR 0062 §3).
643
- const BONGOS_PUBLIC_ORIGIN = process.env.GDS_PUBLIC_ORIGIN || branding().domains.publicOrigin;
643
+ const BONGOS_PUBLIC_ORIGIN = resolveEnv('PUBLIC_ORIGIN') || branding().domains.publicOrigin;
644
644
  const readBongosInstallScript = (name) => {
645
645
  try {
646
646
  return fs.readFileSync(path.join(ROOT, 'infra', name), 'utf8').split('__GDS_API_BASE__').join(BONGOS_PUBLIC_ORIGIN);
package/src/branding.js CHANGED
@@ -18,7 +18,7 @@
18
18
 
19
19
  const fs = require('node:fs');
20
20
  const path = require('node:path');
21
- const { resolveCoreRoot, resolveInstanceRoot } = require('./instance-config');
21
+ const { resolveCoreRoot, resolveInstanceRoot, resolveEnv, FALLBACK_ENV_PREFIX } = require('./instance-config');
22
22
 
23
23
  // ADR 0108 §1: the neutral starter ships WITH the core package; the instance
24
24
  // pack is host content. Both resolvers return the repo root today, so this is
@@ -106,9 +106,15 @@ function resolveBranding({ neutral = {}, instance = {}, env = {} } = {}) {
106
106
  // neutral/instance inputs — otherwise the deepFreeze below would also freeze
107
107
  // the caller's input objects (deepMerge shallow-copies only the top level).
108
108
  const resolved = structuredClone(deepMerge(neutral, instance));
109
- const prefix = resolved.envPrefix || 'CLOUDBONGOS';
109
+ const prefix = resolved.envPrefix || FALLBACK_ENV_PREFIX;
110
110
  for (const [suffix, p, coerce] of ENV_OVERRIDES) {
111
- const v = env[`${prefix}_${suffix}`];
111
+ // resolveEnv, not a bare env[] read: these overrides are set OUTSIDE the repo
112
+ // (/etc/cloudbongos/web.env, systemd units, the provisioning runner), so when
113
+ // task 1003703 moved the canonical prefix from CLOUDBONGOS_ to BONGOS_ a
114
+ // direct read would have silently stopped honoring every already-deployed
115
+ // CLOUDBONGOS_PUBLIC_ORIGIN — an origin regression with no error, found only
116
+ // by a broken OAuth callback. The legacy spellings warn once each instead.
117
+ const v = resolveEnv(suffix, { env, prefix });
112
118
  if (v === undefined || v === '') continue;
113
119
  // Most overrides are string-valued (origins, cookie domain) and set verbatim. A
114
120
  // 'bool'-tagged override (auth.idp) coerces the env string to a real boolean —
@@ -139,15 +145,26 @@ function readJson(p) {
139
145
  // OTB on the apex (server.js, the committed config/branding.json) and a vanilla
140
146
  // Cloud Bongos process pointed at its own pack (config/branding.cloudbongos.json)
141
147
  // — differentiated only by env, with no divergent clone to maintain. The loader
142
- // runs before the resolved envPrefix is known, so this is a FIXED env name (like
143
- // auth.js's GDS_PUBLIC_ORIGIN), not a <PREFIX>_ one. A relative path resolves
144
- // against the repo root.
148
+ // runs before the resolved envPrefix is known, so this is a FIXED env name, not
149
+ // a <PREFIX>_ one which is exactly why it cannot go through resolveEnv() and
150
+ // needs the two spellings listed by hand here. A relative path resolves against
151
+ // the repo root.
152
+ //
153
+ // BONGOS_BRANDING_FILE is canonical since task 1003703; GDS_BRANDING_FILE is
154
+ // still read (one deprecation warning per process) because it is set outside the
155
+ // repo — the hall-preview server, CI jobs and side-by-side dev processes all
156
+ // export it. Task 1003706 retires the old spelling.
145
157
  function loadBranding({ neutralPath = NEUTRAL_PATH, instancePath, env = process.env } = {}) {
146
158
  const neutral = readJson(neutralPath);
147
159
  if (!neutral) throw new Error(`branding: neutral starter not found at ${neutralPath}`);
148
160
  let ip = instancePath;
149
161
  if (ip === undefined) {
150
- const override = env.GDS_BRANDING_FILE;
162
+ // prefix pinned to FALLBACK_ENV_PREFIX, not envPrefix(): this read happens
163
+ // BEFORE the pack that would name the prefix can be loaded, so the canonical
164
+ // spelling has to be a fixed one. Same pin, same reason, as
165
+ // resolveInstanceRootExplicit(). The legacy prefixes still resolve, which is
166
+ // what keeps every already-deployed GDS_BRANDING_FILE working.
167
+ const override = resolveEnv('BRANDING_FILE', { env, prefix: FALLBACK_ENV_PREFIX });
151
168
  ip = override ? path.resolve(resolveInstanceRoot(), override) : INSTANCE_PATH;
152
169
  }
153
170
  const instance = readJson(ip) || {};
@@ -21,7 +21,11 @@ const path = require('node:path');
21
21
 
22
22
  // Last-resort default if branding can't be read at all (vanilla, never OTB).
23
23
  const FALLBACK_DIR = 'cloudbongos';
24
- const FALLBACK_ENV_PREFIX = 'CLOUDBONGOS';
24
+ // BONGOS is the CANONICAL env prefix (task 1003703 / criterion C3 of goal
25
+ // 1000073). It was CLOUDBONGOS until then; that spelling moved into
26
+ // LEGACY_ENV_PREFIXES below rather than being dropped, so a vanilla instance
27
+ // already exporting CLOUDBONGOS_* keeps resolving.
28
+ const FALLBACK_ENV_PREFIX = 'BONGOS';
25
29
 
26
30
  // Historical hard-coded config dir — always also checked on READ so a session /
27
31
  // ledger / key written before R61 (or by an instance that later changed its
@@ -30,8 +34,24 @@ const LEGACY_DIRS = ['otb'];
30
34
 
31
35
  // Legacy env prefixes tried (in order) AFTER the configured one. These cover the
32
36
  // three prefixes that coexisted pre-R61 (OTB_ operational, GDS_ API/auth, PMS_
33
- // the original name). Keeping them means prod's existing env is honored verbatim.
34
- const LEGACY_ENV_PREFIXES = ['OTB', 'GDS', 'PMS'];
37
+ // the original name) plus CLOUDBONGOS_, which was the canonical default until
38
+ // task 1003703 made it BONGOS_. Keeping them means prod's existing env is
39
+ // honored verbatim.
40
+ //
41
+ // WHY THESE MUST OUTLIVE THIS TASK: these names are set OUTSIDE the repo —
42
+ // /etc/cloudbongos/web.env on the droplet, systemd units, CI secrets, shipped
43
+ // Dev Box binaries, live dev boxes. Deleting a spelling here is an outage the
44
+ // next time one of those restarts. Task 1003706 (C3d) retires them deliberately,
45
+ // after evidence — and asserts their ABSENCE, which is why the fallback needs a
46
+ // test that it FIRES rather than one that merely tolerates it.
47
+ const LEGACY_ENV_PREFIXES = ['CLOUDBONGOS', 'OTB', 'GDS', 'PMS'];
48
+
49
+ // The core release in which the legacy prefixes above stop being read. Named in
50
+ // every deprecation warning so an operator learns the deadline from the warning
51
+ // itself rather than from a changelog they will not read. This lands in 1.19.x,
52
+ // so 1.21 leaves a full minor of overlap; task 1003706 is the task that both
53
+ // removes the fallback and moves this constant's meaning to "already gone".
54
+ const LEGACY_ENV_SUNSET_RELEASE = '1.21';
35
55
 
36
56
  // Lazy require (ADR 0108 §1 made this a two-way edge: branding.js now requires
37
57
  // resolveCoreRoot()/resolveInstanceRoot() from this module, so a top-level
@@ -135,21 +155,62 @@ function readConfigFileSync(name, { encoding = 'utf8', b = safeBrand() } = {}) {
135
155
  }
136
156
 
137
157
  // Resolve an env var by SUFFIX across the configured prefix + legacy prefixes.
138
- // resolveEnv('API_BASE') tries <PREFIX>_API_BASE (CLOUDBONGOS_ on vanilla, OTB_
139
- // on the OTB instance), then OTB_/GDS_/PMS_API_BASE. Returns undefined if none
140
- // is set to a non-empty value. `extraLegacy` prepends suffix-specific aliases
141
- // (rare — e.g. a var that had an idiosyncratic legacy name).
142
- function resolveEnv(suffix, { env = process.env, prefix = envPrefix(), legacy = LEGACY_ENV_PREFIXES } = {}) {
143
- const seen = new Set();
144
- for (const pfx of [prefix, ...legacy]) {
158
+ // resolveEnv('API_BASE') tries <PREFIX>_API_BASE (BONGOS_ on vanilla, OTB_ on
159
+ // the OTB instance), then CLOUDBONGOS_/OTB_/GDS_/PMS_API_BASE. Returns undefined
160
+ // if none is set to a non-empty value.
161
+ //
162
+ // THE CANONICAL SPELLING ALWAYS WINS, including over a legacy one set to a
163
+ // different value — an operator mid-migration who sets the new name expects it
164
+ // to take effect, and silently preferring the old one would make the rename
165
+ // untestable. Each legacy name that is READ (won or shadowed) emits ONE warning
166
+ // per process naming the old spelling, the new one, and the release the old
167
+ // stops working in; see warnLegacyEnv below. `warn` is injectable so a test can
168
+ // assert the warning fires rather than only that the value resolves.
169
+ function resolveEnv(suffix, { env = process.env, prefix = envPrefix(), legacy = LEGACY_ENV_PREFIXES, warn = warnLegacyEnv } = {}) {
170
+ const canonical = `${prefix}_${suffix}`;
171
+ const canonicalValue = prefix ? env[canonical] : undefined;
172
+ const canonicalSet = canonicalValue !== undefined && canonicalValue !== '';
173
+ // Every legacy spelling is visited even when the canonical one already won —
174
+ // a spelling that is merely SHADOWED is still set on some box, and it will
175
+ // stop being read at the sunset, so it earns its one warning too. Warning only
176
+ // on the winner would leave the "both set" operator with no notice at all.
177
+ const seen = new Set([prefix].filter(Boolean));
178
+ let winner = canonicalSet ? canonicalValue : undefined;
179
+ for (const pfx of legacy) {
145
180
  if (!pfx || seen.has(pfx)) continue;
146
181
  seen.add(pfx);
147
- const v = env[`${pfx}_${suffix}`];
148
- if (v !== undefined && v !== '') return v;
182
+ const name = `${pfx}_${suffix}`;
183
+ const v = env[name];
184
+ if (v === undefined || v === '') continue;
185
+ warn({ legacyName: name, canonicalName: canonical, shadowed: canonicalSet, differs: canonicalSet && canonicalValue !== v });
186
+ if (winner === undefined) winner = v;
149
187
  }
150
- return undefined;
188
+ return winner;
151
189
  }
152
190
 
191
+ // One warning per legacy env NAME per process. A silent fallback is how an old
192
+ // spelling survives forever — but a warning per READ would drown a CLI that
193
+ // resolves the same knob in a loop, so the emitted set is the dedupe key.
194
+ const _legacyEnvWarned = new Set();
195
+
196
+ function warnLegacyEnv({ legacyName, canonicalName, shadowed, differs, log = console } = {}) {
197
+ if (_legacyEnvWarned.has(legacyName)) return false;
198
+ _legacyEnvWarned.add(legacyName);
199
+ const fate = differs
200
+ ? `${canonicalName} is ALSO set to a different value and WINS`
201
+ : shadowed
202
+ ? `${canonicalName} is also set and wins`
203
+ : `rename it to ${canonicalName}`;
204
+ log.warn(`[bongos] deprecated env ${legacyName} — ${fate}. The legacy spelling stops being read in core ${LEGACY_ENV_SUNSET_RELEASE}.`);
205
+ return true;
206
+ }
207
+
208
+ // NOT exported, deliberately: warnLegacyEnv, LEGACY_ENV_SUNSET_RELEASE and the
209
+ // ledger are internals. resolveEnv's injectable `warn` is the whole test seam a
210
+ // caller needs, and a test of the DEFAULT path captures console.warn — which
211
+ // exercises the real wiring rather than a hand-called helper. Exporting them
212
+ // would also add three untraceable entries to the knip dead-code ratchet.
213
+
153
214
  // resolveCoreRoot() / resolveInstanceRoot() — the ADR 0108 §1 configurable-root
154
215
  // resolvers, extending this module's established zero-risk pattern from
155
216
  // ~/.config to the in-repo roots. Two OPPOSITE directions, both load-bearing:
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.653'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.655'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
package/src/modules.js CHANGED
@@ -27,7 +27,7 @@ const path = require('node:path');
27
27
  // resolver below spans built-in + discovered modules (ADR 0083 / BV1.R41). Empty
28
28
  // until a module is moved into modules/ — so this is a no-op on the current tree.
29
29
  const loader = require('./module-loader/loader');
30
- const { resolveCoreRoot, resolveInstanceRoot } = require('./instance-config');
30
+ const { resolveCoreRoot, resolveInstanceRoot, resolveEnv, FALLBACK_ENV_PREFIX } = require('./instance-config');
31
31
  const { responsibilitiesFor } = require('./role-responsibilities');
32
32
 
33
33
  // ADR 0108 §1: the neutral starter ships WITH the core package; the instance
@@ -121,7 +121,7 @@ function coerceBool(v) {
121
121
  // Pure resolver — all inputs injected, so tests drive it with zero I/O.
122
122
  // Returns a frozen { <moduleKey>: boolean } covering EVERY registry key, plus a
123
123
  // non-enumerable resolution that throws on an unknown module key (typo guard).
124
- function resolveModules({ neutral = {}, instance = {}, env = {}, envPrefix = 'CLOUDBONGOS', registry = MODULE_REGISTRY } = {}) {
124
+ function resolveModules({ neutral = {}, instance = {}, env = {}, envPrefix = FALLBACK_ENV_PREFIX, registry = MODULE_REGISTRY } = {}) {
125
125
  // The known-key set + defaults come from the passed registry: callers inject
126
126
  // the built-in MODULE_REGISTRY (the default, what the pure unit tests use) or
127
127
  // the effective built-in+discovered registry (what loadModules passes). This
@@ -157,8 +157,12 @@ function resolveModules({ neutral = {}, instance = {}, env = {}, envPrefix = 'CL
157
157
  // Env overrides: <PREFIX>_MODULE_<KEY> where KEY is upper-snake of the module
158
158
  // name (dev-box -> DEV_BOX). Highest precedence.
159
159
  for (const key of known) {
160
- const envName = `${envPrefix}_MODULE_${key.toUpperCase().replace(/-/g, '_')}`;
161
- const raw = env[envName];
160
+ const suffix = `MODULE_${key.toUpperCase().replace(/-/g, '_')}`;
161
+ const envName = `${envPrefix}_${suffix}`;
162
+ // resolveEnv, not env[envName]: a provisioned instance sets these per box
163
+ // (provision-units.js writes them into the systemd unit), so the pre-rename
164
+ // spelling must keep switching the same module off. Warns once per legacy name.
165
+ const raw = resolveEnv(suffix, { env, prefix: envPrefix });
162
166
  if (raw !== undefined && raw !== '') {
163
167
  const b = coerceBool(raw);
164
168
  if (b === undefined) throw new Error(`modules: ${envName}="${raw}" is not a boolean (use 1/0, on/off, true/false)`);
@@ -203,14 +207,18 @@ function loadModules({ neutralPath = NEUTRAL_PATH, instancePath, env = process.e
203
207
  const neutral = readJson(neutralPath) || {};
204
208
  let ip = instancePath;
205
209
  if (ip === undefined) {
206
- const override = env.GDS_MODULES_FILE;
210
+ // Canonical spelling pinned to FALLBACK_ENV_PREFIX for the same reason as
211
+ // branding's selector — read before the pack that names the prefix. The
212
+ // legacy prefixes still resolve (one warning each), so an already-deployed
213
+ // GDS_MODULES_FILE / GDS_BRANDING_FILE keeps selecting the same pack.
214
+ const override = resolveEnv('MODULES_FILE', { env, prefix: FALLBACK_ENV_PREFIX });
207
215
  if (override) ip = path.resolve(resolveInstanceRoot(), override);
208
- else if (env.GDS_BRANDING_FILE) ip = NEUTRAL_PATH;
216
+ else if (resolveEnv('BRANDING_FILE', { env, prefix: FALLBACK_ENV_PREFIX })) ip = NEUTRAL_PATH;
209
217
  else ip = INSTANCE_PATH;
210
218
  }
211
219
  const instance = readJson(ip) || {};
212
220
  // Reuse the branding env prefix so an instance has ONE prefix, not two.
213
- let envPrefix = 'CLOUDBONGOS';
221
+ let envPrefix = FALLBACK_ENV_PREFIX;
214
222
  try { envPrefix = require('./branding').branding().envPrefix || envPrefix; } catch { /* fail-to-default */ }
215
223
  // Resolve against the EFFECTIVE registry (built-in + discovered) so a config
216
224
  // flag for a discovered module is honored. No-op until a module exists.
@@ -466,7 +466,7 @@ t('materializeClaude: copied hooks carry NO founder origin/config-dir; a disable
466
466
  const dir = mkdtempSync(join(tmpdir(), 'rb-e2e-'));
467
467
  // Target instance: demo-branded, art-pipeline OFF (so its otb-* skills must be dropped).
468
468
  mkdirSync(join(dir, 'config'), { recursive: true });
469
- writeFileSync(join(dir, 'config', 'branding.json'), JSON.stringify({ domains: { publicOrigin: 'https://demo.cloudbongos.com', buildersOrigin: 'https://demo.cloudbongos.com', statusOrigin: 'https://demo.cloudbongos.com', oauthOrigin: 'https://demo.cloudbongos.com' }, configDir: 'cloudbongos', envPrefix: 'CLOUDBONGOS' }));
469
+ writeFileSync(join(dir, 'config', 'branding.json'), JSON.stringify({ domains: { publicOrigin: 'https://demo.cloudbongos.com', buildersOrigin: 'https://demo.cloudbongos.com', statusOrigin: 'https://demo.cloudbongos.com', oauthOrigin: 'https://demo.cloudbongos.com' }, configDir: 'cloudbongos', envPrefix: 'BONGOS' }));
470
470
  writeFileSync(join(dir, 'config', 'modules.json'), JSON.stringify({ modules: { 'art-pipeline': false } }));
471
471
  const res = m.materializeClaude({ coreRoot: ROOT, instanceDir: dir, dryRun: false });
472
472
  // Founder core → demo: a branding delta exists and rules are built. The NEUTRAL
@@ -1,6 +1,13 @@
1
1
  // tests/cli_exit_no_abort.mjs — the GDS CLIs must not abort natively on exit
2
2
  // (task 1002734; the pre-push twin of this is tests/pre_push_hook.mjs, task 1002725).
3
3
  //
4
+ // Guards api.js AND main-audit.js (task 1003772). main-audit --verify-head IS the
5
+ // deploy gate (infra/deploy.sh calls it so a poisoned main never reaches
6
+ // production), so there the destroyed exit code is not cosmetic: the abort fires
7
+ // AFTER the verdict prints, and a caller reading the code cannot tell a clean
8
+ // audit from a real blocker. It reproduced 1/1 on Windows + Node 24 before the fix
9
+ // and went 5/5 clean after it.
10
+ //
4
11
  // THE BUG. On Windows + Node 24, calling process.exit() after the CLI has made a
5
12
  // request aborts the process before it can exit cleanly:
6
13
  //
@@ -26,13 +33,24 @@ import { test } from 'node:test';
26
33
  import { spawnSync, spawn } from 'node:child_process';
27
34
  import fs from 'node:fs';
28
35
  import path from 'node:path';
36
+ import { createRequire } from 'node:module';
29
37
  import { fileURLToPath } from 'node:url';
30
38
 
31
39
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
32
40
  const API = path.join(ROOT, 'scripts', 'gds', 'api.js');
41
+ const MAIN_AUDIT = path.join(ROOT, 'scripts', 'gds', 'main-audit.js');
33
42
  const CLI_LIB = path.join(ROOT, 'scripts', 'gds', 'cli-lib.js');
34
43
  const DEAD_BASE = 'http://127.0.0.1:9'; // discard port — connection refused, instantly
35
44
 
45
+ // Point a CLI at a base URL. DERIVED, not hardcoded: instance-config resolves
46
+ // <PREFIX>_API_BASE first and only then the legacy GDS_/OTB_/PMS_ spellings, so a
47
+ // literal here would (a) add to the legacy-GDS ratchet the rename is retiring and
48
+ // (b) silently stop steering the CLI on any instance whose brand pack sets a
49
+ // different prefix — the test would then hit the REAL API instead of the stub.
50
+ const { envPrefix } = createRequire(import.meta.url)(path.join(ROOT, 'src', 'instance-config.js'));
51
+ const API_BASE_ENV = `${envPrefix()}_API_BASE`;
52
+ const atBase = (url) => ({ [API_BASE_ENV]: url });
53
+
36
54
  // The native abort prints this and nothing else can be relied on; it is NOT a
37
55
  // JS-level error, so it never appears as a caught exception.
38
56
  const ABORT_RE = /Assertion failed|UV_HANDLE_CLOSING/i;
@@ -41,7 +59,7 @@ function runApi(args, env = {}) {
41
59
  return spawnSync(process.execPath, [API, ...args], {
42
60
  cwd: ROOT,
43
61
  encoding: 'utf8',
44
- env: { ...process.env, GDS_API_BASE: DEAD_BASE, ...env },
62
+ env: { ...process.env, ...atBase(DEAD_BASE), ...env },
45
63
  });
46
64
  }
47
65
 
@@ -104,7 +122,7 @@ test('api.js exits 0 after a SUCCESSFUL request — the path that aborted in the
104
122
  const { proc, port } = await startStub();
105
123
  try {
106
124
  for (let i = 0; i < 3; i++) {
107
- const r = runApi(['GET', '/api/gds/me', '--quiet'], { GDS_API_BASE: `http://127.0.0.1:${port}` });
125
+ const r = runApi(['GET', '/api/gds/me', '--quiet'], atBase(`http://127.0.0.1:${port}`));
108
126
  assert.doesNotMatch(r.stderr || '', ABORT_RE, `run ${i + 1} aborted natively after a 200:\n${r.stderr}`);
109
127
  assert.equal(r.status, 0, `a successful request must exit 0, got ${r.status} (127 = the abort); stderr: ${r.stderr}`);
110
128
  assert.match(r.stdout, /"ok"/, 'the JSON body must still reach stdout');
@@ -133,3 +151,92 @@ test('api.js routes its post-request exits through cliExit, not process.exit', (
133
151
  );
134
152
  assert.match(afterRequest, /cliExit\(/, 'the post-request paths must set the exit code via cliExit');
135
153
  });
154
+
155
+ // ---- main-audit.js — the deploy gate (task 1003772) ------------------------
156
+ // Ranges are always HEAD..HEAD: it resolves on every checkout including a shallow
157
+ // CI clone, audits zero commits, and still runs the WHOLE pipeline — fetchRankMap
158
+ // is called before the verdict, so the exit is a post-request exit either way.
159
+ //
160
+ // THE STATIC CHECK IS THE REAL NET, and this was measured, not assumed: with the
161
+ // hard exit deliberately put back, the static test failed while all four
162
+ // behavioural tests below still PASSED. The same run against a real range
163
+ // (`--range origin/main~15..origin/main`, 31 commits, a live roster call) aborted
164
+ // at exit 127. So the abort needs the full stack to line up — enough commits and a
165
+ // real request — which a network-free test cannot stage, exactly as the api.js
166
+ // note above says. The behavioural tests are still worth their runtime: they pin
167
+ // the exit CODES (0 / 2) that callers branch on, and their timeouts catch the
168
+ // documented conversion trap where a leaked timer turns the crash into a hang.
169
+
170
+ function runAudit(args, env = {}) {
171
+ return spawnSync(process.execPath, [MAIN_AUDIT, ...args], {
172
+ cwd: ROOT,
173
+ encoding: 'utf8',
174
+ timeout: 60_000, // dropping a hard exit trades a crash for a HANG if a timer leaks
175
+ env: { ...process.env, ...atBase(DEAD_BASE), ...env },
176
+ });
177
+ }
178
+
179
+ test('main-audit.js routes every exit through cliExit, not process.exit', () => {
180
+ const src = fs.readFileSync(MAIN_AUDIT, 'utf8');
181
+ const stripped = src.replace(/\/\/[^\n]*/g, '');
182
+ // Unlike api.js, main-audit has no pre-request exits worth keeping separate —
183
+ // all seven sites were converted, so the guard is simply "none left".
184
+ assert.ok(
185
+ !/process\.exit\s*\(/.test(stripped),
186
+ 'no process.exit() may remain in main-audit.js — that is what aborts node on Windows, '
187
+ + 'and this script IS the deploy gate whose exit code the abort destroys'
188
+ );
189
+ assert.match(src, /cliExit\(/, 'the exit paths must set the exit code via cliExit');
190
+ });
191
+
192
+ test('main-audit.js exits cleanly after a FAILED roster request', () => {
193
+ const r = runAudit(['--range', 'HEAD..HEAD']);
194
+ assert.notEqual(r.signal, 'SIGTERM', 'timed out — a leaked timer turned the crash into a hang');
195
+ assert.doesNotMatch(r.stderr || '', ABORT_RE, `native abort on the audit path:\n${r.stderr}`);
196
+ assert.equal(r.status, 0, `a clean audit must exit 0, not ${r.status} (127 = the abort)`);
197
+ assert.match(r.stdout, /✓ clean/, 'the verdict must still reach stdout');
198
+ });
199
+
200
+ test('main-audit.js --verify-head exits 0 on a clean audit — the deploy gate reads this code', () => {
201
+ // The gate path specifically: infra/deploy.sh treats non-zero as "do not deploy",
202
+ // so an abort here blocks a deploy that nothing is actually wrong with.
203
+ for (let i = 0; i < 3; i++) {
204
+ const r = runAudit(['--verify-head', '--range', 'HEAD..HEAD']);
205
+ assert.notEqual(r.signal, 'SIGTERM', `run ${i + 1} timed out — leaked handle`);
206
+ assert.doesNotMatch(r.stderr || '', ABORT_RE, `run ${i + 1} aborted natively:\n${r.stderr}`);
207
+ assert.equal(r.status, 0, `run ${i + 1} exited ${r.status}; stderr: ${r.stderr}`);
208
+ }
209
+ });
210
+
211
+ test('main-audit.js --require-roster still exits 2 when the roster is unreachable', () => {
212
+ // The post-request DID-NOT-RUN path. Converting it to cliExit must not turn a
213
+ // refusal into a vacuous green — exit 2 is what stops CI calling this a pass.
214
+ const r = runAudit(['--require-roster', '--range', 'HEAD..HEAD']);
215
+ assert.notEqual(r.signal, 'SIGTERM', 'timed out — leaked handle');
216
+ assert.doesNotMatch(r.stderr || '', ABORT_RE);
217
+ assert.equal(r.status, 2, `an unavailable roster must exit 2, got ${r.status}`);
218
+ assert.match(r.stderr, /DID NOT RUN/);
219
+ });
220
+
221
+ test('main-audit.js reports an unresolvable range as exit 2 (pre-request path)', () => {
222
+ const r = runAudit(['--range', 'bogusref..HEAD']);
223
+ assert.doesNotMatch(r.stderr || '', ABORT_RE);
224
+ assert.equal(r.status, 2, 'an unresolvable range is exit 2, and must not be mistaken for clean');
225
+ assert.match(r.stderr, /DID NOT RUN/);
226
+ });
227
+
228
+ test('main-audit.js exits 0 after a SUCCESSFUL roster request — the teardown that aborted in the wild', async () => {
229
+ // A refused connection tears down far less machinery than a 200. The wild repro
230
+ // was a successful roster call, so this is the case that actually matters.
231
+ const { proc, port } = await startStub();
232
+ try {
233
+ for (let i = 0; i < 3; i++) {
234
+ const r = runAudit(['--range', 'HEAD..HEAD'], atBase(`http://127.0.0.1:${port}`));
235
+ assert.notEqual(r.signal, 'SIGTERM', `run ${i + 1} timed out — leaked handle`);
236
+ assert.doesNotMatch(r.stderr || '', ABORT_RE, `run ${i + 1} aborted natively after a 200:\n${r.stderr}`);
237
+ assert.equal(r.status, 0, `run ${i + 1} exited ${r.status} (127 = the abort); stderr: ${r.stderr}`);
238
+ }
239
+ } finally {
240
+ proc.kill();
241
+ }
242
+ });
@@ -28,7 +28,7 @@ test('R74: a vanilla boot resolves to the Cloud Bongos identity', () => {
28
28
  assert.equal(b.identity.productName, 'Cloud Bongos');
29
29
  assert.equal(b.identity.worldName, 'Cloud Bongos');
30
30
  assert.equal(b.currency.label, 'credits'); // not "drachmae"
31
- assert.equal(b.envPrefix, 'CLOUDBONGOS');
31
+ assert.equal(b.envPrefix, 'BONGOS'); // canonical since task 1003703; CLOUDBONGOS_ still resolves as a legacy prefix
32
32
  });
33
33
 
34
34
  test('R74: the vanilla pack carries the Cloud Bongos LOOK (chrome palette, Manrope, bongo favicon)', () => {
@@ -382,6 +382,49 @@ await test('the CLI names both parties, and never prints "undefined"', () => {
382
382
  assert.doesNotMatch(out, /undefined|null/);
383
383
  });
384
384
 
385
+ // task 1003654 — the R157 proof walk read "A board ratification window is open
386
+ // until null" five times on cloudbongos.com. A NULL closes_at is the no-clock
387
+ // constitution, NOT a broken window, so the copy has to name how the sitting
388
+ // closes. One case per pass rule, plus the unknown-rule fallback, because the
389
+ // whole failure was one branch that assumed a deadline always exists.
390
+ for (const [rule, expected] of [
391
+ ['first_ratifier', /no clock; it closes on the first vote/],
392
+ ['consent', /no clock; it stands unless someone objects/],
393
+ ['majority', /no clock; it waits for a majority/],
394
+ ['unanimous', /no clock; it waits for a unanimous yes/],
395
+ [null, /no clock; it stays open until the board decides/],
396
+ ]) {
397
+ await test(`a clockless ${rule || 'unknown-rule'} window says how it closes, never "until null"`, () => {
398
+ const out = cli.renderDeveloped({
399
+ idea: { id: '77', completeness_score: 88 },
400
+ author_id: '9', developer_id: '42', self_developed: false,
401
+ board_window: { item_id: '3', closes_at: null, pass_rule: rule },
402
+ });
403
+ assert.match(out, /ratification window is open/);
404
+ assert.match(out, expected);
405
+ assert.doesNotMatch(out, /until null|undefined/);
406
+ });
407
+ }
408
+
409
+ // Held equal to government's own rule list, the way board.js's FULL_IDEA_PASS_BAR
410
+ // is held equal to economy's — the CLI cannot require() a sibling module, so the
411
+ // test is where the two lists meet. Without this, adding a fifth pass rule
412
+ // silently drops its authors onto the unreadable-rule fallback.
413
+ await test('EVERY pass rule government declares gets its own sentence, not the fallback', () => {
414
+ const { PASS_RULES } = require('../modules/government/config.js');
415
+ const fallback = cli.renderDeveloped({
416
+ idea: { id: '77', completeness_score: 88 }, author_id: '9', developer_id: '42', self_developed: false,
417
+ board_window: { item_id: '3', closes_at: null, pass_rule: '__no_such_rule__' },
418
+ });
419
+ for (const rule of PASS_RULES) {
420
+ const out = cli.renderDeveloped({
421
+ idea: { id: '77', completeness_score: 88 }, author_id: '9', developer_id: '42', self_developed: false,
422
+ board_window: { item_id: '3', closes_at: null, pass_rule: rule },
423
+ });
424
+ assert.notEqual(out, fallback, `pass rule '${rule}' falls through to the unreadable-rule fallback`);
425
+ }
426
+ });
427
+
385
428
  await test('self-development reads as one person, not a handoff to yourself', () => {
386
429
  const out = cli.renderDeveloped({ idea: { id: '1', completeness_score: 70 }, author_id: '9', developer_id: '9', self_developed: true });
387
430
  assert.match(out, /You had the thought and you developed it/);
@@ -25,7 +25,7 @@ const api = require('../src/module-api.js');
25
25
 
26
26
  // ---- the government port stub (registered BEFORE the routes load) ----------
27
27
  let rejectionAnswer = null; // what latestRejectionForIdea returns
28
- let windowAnswer = { opened: true, item: { id: '31', closes_at: null } };
28
+ let windowAnswer = { opened: true, item: { id: '31', closes_at: null, constitution: { pass_rule: 'first_ratifier' } } };
29
29
  const portCalls = [];
30
30
  if (!api.hasProvider('government')) {
31
31
  api.registerProvider('government', {
@@ -84,7 +84,7 @@ const fullIdea = (over = {}) => ({
84
84
  let passed = 0, failed = 0;
85
85
  async function test(name, fn) {
86
86
  queries = []; portCalls.length = 0;
87
- rejectionAnswer = null; windowAnswer = { opened: true, item: { id: '31', closes_at: null } };
87
+ rejectionAnswer = null; windowAnswer = { opened: true, item: { id: '31', closes_at: null, constitution: { pass_rule: 'first_ratifier' } } };
88
88
  try { await fn(); passed++; console.log(` ok ${name}`); }
89
89
  catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
90
90
  }
@@ -113,7 +113,11 @@ await test('a board objection outranks the mechanical flag, and an ANSWERED sect
113
113
  assert.equal(r.payload.flagged_section, 'purpose', 'the human objection wins over the weakest-section flag');
114
114
  assert.equal(r.payload.flagged_section_changed, true);
115
115
  assert.equal(r.payload.rejection_source, 'board');
116
- assert.deepEqual(r.payload.board_window, { item_id: '31', closes_at: null }, 'a new window opened');
116
+ // The pass rule rides along with the deadline (task 1003654): a clockless
117
+ // sitting reports closes_at null, and without the rule a caller cannot tell
118
+ // that apart from a broken window — which is how "open until null" reached
119
+ // authors in the first place.
120
+ assert.deepEqual(r.payload.board_window, { item_id: '31', closes_at: null, pass_rule: 'first_ratifier' }, 'a new window opened, and it says how it closes');
117
121
  });
118
122
 
119
123
  await test('reworking everything EXCEPT the flagged section reads changed:false', async () => {
package/tests/init.mjs CHANGED
@@ -51,7 +51,7 @@ test('buildBrandingConfig: lean overrides only (no theme block)', () => {
51
51
  assert.equal(b.repo.owner, 'lars589');
52
52
  assert.equal(b.firstAdmin, 'lars589');
53
53
  assert.equal(b.currency.label, 'credits');
54
- assert.equal(b.envPrefix, 'CLOUDBONGOS');
54
+ assert.equal(b.envPrefix, 'BONGOS'); // canonical since task 1003703
55
55
  assert.equal(b.copy.tagline, 'Building software is laying down a beat.');
56
56
  assert.equal(b.theme, undefined); // lean — neutral fills the look
57
57
  assert.equal(b.domains.buildersOrigin, 'http://localhost:3000');