@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
@@ -96,7 +96,19 @@
96
96
  throw { kind: '401' };
97
97
  }
98
98
  if (res.status === 403) {
99
- throw { kind: '403' };
99
+ // Carry the FLOOR the server actually enforced. requirePermission puts
100
+ // `details.floors` (permission key → catalog floor rank) on every denial
101
+ // for exactly this purpose — see its comment in src/bongos/auth.js, which
102
+ // names hardcoded prose as the failure shape ADR 0157 left behind. This
103
+ // page WAS that shape: every panel said "Archons only" while its route had
104
+ // long since moved to a metic-floored atom.
105
+ //
106
+ // One distinct floor is the panel's floor. A multi-atom gate that denies on
107
+ // keys with DIFFERENT floors has no single honest answer, so the panel names
108
+ // none rather than picking one — the same fail-quiet the old copy did not do.
109
+ const details = res.data && res.data.error && res.data.error.details;
110
+ const ranks = details && details.floors ? [...new Set(Object.values(details.floors))] : [];
111
+ throw { kind: '403', floor: ranks.length === 1 ? ranks[0] : null };
100
112
  }
101
113
  if (!res.ok) throw new Error(`${path} → ${res.status}`);
102
114
  return res.data;
@@ -152,13 +164,25 @@
152
164
  // `sealedMsg`; any other failure logs `[watch] <name> failed` and shows `errMsg`.
153
165
  // NOTE: loadMarks is the deliberate outlier — its failure state is a plain
154
166
  // "empty" message, not "unavailable", so it keeps its own try/catch.
167
+ // sealedText — name the floor the DENIAL reported, never one written here.
168
+ // Each `sealedMsg` below is the SUBJECT clause only ("The roster is not visible
169
+ // at your rank."); the rank sentence in front of it is composed from the 403.
170
+ // That is what keeps this page from telling a builder a permission model the
171
+ // server stopped implementing, which is the whole of task 1003449 — and why the
172
+ // floor is not merely corrected to 'metic' here: a hardcoded 'metic' is the same
173
+ // defect one ADR later. Label via rankLabel so an instance that renames its
174
+ // ranks reads its own words (ADR 0157).
175
+ function sealedText(floor, subject) {
176
+ return floor ? `${rankLabel(floor)} and up. ${subject}` : subject;
177
+ }
178
+
155
179
  async function section(target, fetcher, render, { sealedMsg, errMsg, name }) {
156
180
  if (!target) return;
157
181
  try {
158
182
  render(await fetcher(), target);
159
183
  } catch (err) {
160
184
  if (err && err.kind === '401') return;
161
- if (err && err.kind === '403') { renderUnavailable(target, sealedMsg); return; }
185
+ if (err && err.kind === '403') { renderUnavailable(target, sealedText(err.floor, sealedMsg)); return; }
162
186
  console.error(`[watch] ${name} failed`, err);
163
187
  renderUnavailable(target, errMsg);
164
188
  }
@@ -499,7 +523,7 @@
499
523
 
500
524
  function loadGate() {
501
525
  return section($('#gate-body'), () => fetchJson(`/access-requests?status=${encodeURIComponent(gateView)}`), renderGate, {
502
- sealedMsg: 'Archons only. Access requests are not visible at your rank.',
526
+ sealedMsg: 'Access requests are not visible at your rank.',
503
527
  errMsg: 'Could not load access requests.',
504
528
  name: 'gate',
505
529
  });
@@ -554,7 +578,7 @@
554
578
 
555
579
  function loadReports() {
556
580
  return section($('#reports-body'), () => fetchJson('/security/reports'), renderReports, {
557
- sealedMsg: 'Archons only. Security reports are not visible at your rank.',
581
+ sealedMsg: 'Security reports are not visible at your rank.',
558
582
  errMsg: 'Could not load the security reports.',
559
583
  name: 'reports',
560
584
  });
@@ -621,7 +645,7 @@
621
645
 
622
646
  function loadEngagementDocs() {
623
647
  return section($('#engagement-docs-body'), () => fetchJson('/security/docs'), renderEngagementDocs, {
624
- sealedMsg: 'Archons only. Engagement reports are not visible at your rank.',
648
+ sealedMsg: 'Engagement reports are not visible at your rank.',
625
649
  errMsg: 'Could not load the engagement reports.',
626
650
  name: 'engagement docs',
627
651
  });
@@ -667,7 +691,7 @@
667
691
 
668
692
  function loadOverrides() {
669
693
  return section($('#overrides-body'), () => fetchJson('/override-requests?status=open'), renderOverrides, {
670
- sealedMsg: 'Archons only. The override queue is not visible at your rank.',
694
+ sealedMsg: 'The override queue is not visible at your rank.',
671
695
  errMsg: 'Could not load the override queue.',
672
696
  name: 'overrides',
673
697
  });
@@ -706,7 +730,7 @@
706
730
 
707
731
  function loadAudit() {
708
732
  return section($('#audit-body'), () => fetchJson('/audit-log?limit=50'), renderAudit, {
709
- sealedMsg: 'Archons only. The audit log is not visible at your rank.',
733
+ sealedMsg: 'The audit log is not visible at your rank.',
710
734
  errMsg: 'Could not load the audit log.',
711
735
  name: 'audit',
712
736
  });
@@ -826,7 +850,7 @@
826
850
 
827
851
  function loadRoster() {
828
852
  return section($('#roster-body'), () => fetchJson('/builders/roster'), renderRoster, {
829
- sealedMsg: 'Archons only. The roster is not visible at your rank.',
853
+ sealedMsg: 'The roster is not visible at your rank.',
830
854
  errMsg: 'Could not load the roster.',
831
855
  name: 'roster',
832
856
  });
@@ -861,7 +885,7 @@
861
885
 
862
886
  function loadBoxCosts() {
863
887
  return section($('#box-costs-body'), () => fetchJson('/boxes/cost-ledger?limit=100'), renderBoxCosts, {
864
- sealedMsg: 'Archons only. The cost ledger is not visible at your rank.',
888
+ sealedMsg: 'The cost ledger is not visible at your rank.',
865
889
  errMsg: 'Could not load the dev-box cost ledger.',
866
890
  name: 'box costs',
867
891
  });
@@ -908,7 +932,7 @@
908
932
  if (data.window_days) target.insertAdjacentHTML('beforeend', foot(`Window: last ${escapeHtml(data.window_days)} days.`));
909
933
  } catch (err) {
910
934
  if (err && err.kind === '401') return;
911
- if (err && err.kind === '403') { renderUnavailable(target, 'Archons only. Review data is not visible at your rank.'); return; }
935
+ if (err && err.kind === '403') { renderUnavailable(target, sealedText(err.floor, 'Review data is not visible at your rank.')); return; }
912
936
  // 404 / not-yet-built / any other failure → graceful empty state.
913
937
  console.error('[watch] marks-by-builder failed', err);
914
938
  target.innerHTML = emptyStateHtml('No reviews recorded in this window.');
@@ -964,7 +988,7 @@
964
988
 
965
989
  function loadMasonMarks() {
966
990
  return section($('#mason-marks-stats'), () => fetchJson('/public/grades?days=7'), renderMasonMarks, {
967
- sealedMsg: 'Archons only. Review quality data is not visible at your rank.',
991
+ sealedMsg: 'Review quality data is not visible at your rank.',
968
992
  errMsg: 'Could not load review quality data.',
969
993
  name: 'public grades',
970
994
  });
@@ -999,7 +1023,7 @@
999
1023
 
1000
1024
  function loadSurveyor() {
1001
1025
  return section($('#surveyor-stats'), () => fetchJson('/public/estimation-drift'), renderSurveyor, {
1002
- sealedMsg: 'Archons only. Estimation data is not visible at your rank.',
1026
+ sealedMsg: 'Estimation data is not visible at your rank.',
1003
1027
  errMsg: 'Could not load estimation data.',
1004
1028
  name: 'estimation drift',
1005
1029
  });
@@ -1177,7 +1201,7 @@
1177
1201
 
1178
1202
  function loadLedger() {
1179
1203
  return section($('#ledger-stats'), () => fetchJson('/public/repo-health?days=30'), renderLedger, {
1180
- sealedMsg: 'Archons only. Repo health data is not visible at your rank.',
1204
+ sealedMsg: 'Repo health data is not visible at your rank.',
1181
1205
  errMsg: 'Could not load repo health data.',
1182
1206
  name: 'repo health',
1183
1207
  });
@@ -1227,7 +1251,7 @@
1227
1251
 
1228
1252
  function loadSessions() {
1229
1253
  return section($('#sessions-stats'), () => fetchJson('/sessions/search?order=recent&limit=5'), renderSessions, {
1230
- sealedMsg: 'Archons only. Session data is not visible at your rank.',
1254
+ sealedMsg: 'Session data is not visible at your rank.',
1231
1255
  errMsg: 'Could not load the session tally.',
1232
1256
  name: 'sessions tally',
1233
1257
  });
@@ -63,6 +63,25 @@ function registerSeams() {
63
63
  }
64
64
  }
65
65
 
66
+ // The author-facing shape of a ratification window this request just opened —
67
+ // spelled once because all three open sites (capture, develop, resubmit) return
68
+ // the same thing to the same readers (task 1003654).
69
+ //
70
+ // `closes_at` is legitimately NULL: it is the no-clock constitution, not a
71
+ // missing value (modules/government/db.js — a NULL deadline never expires).
72
+ // That is exactly why `pass_rule` travels WITH it. A caller holding only the
73
+ // timestamp cannot tell a clockless sitting from a broken one, so it prints the
74
+ // raw null — which is what the R157 proof walk caught spark.js doing ("open
75
+ // until null"). With the rule in hand a renderer can say HOW the sitting closes
76
+ // instead, the way the Board Room's own clockLine() already does.
77
+ function boardWindowPayload(item) {
78
+ return {
79
+ item_id: item.id,
80
+ closes_at: item.closes_at ?? null,
81
+ pass_rule: (item.constitution && item.constitution.pass_rule) || null,
82
+ };
83
+ }
84
+
66
85
  module.exports = function buildInboxRouter() {
67
86
  registerSeams();
68
87
  const router = express.Router();
@@ -227,7 +246,7 @@ module.exports = function buildInboxRouter() {
227
246
  completenessScore: row.completeness_score,
228
247
  });
229
248
  if (w && w.opened && w.item) {
230
- idea.board_window = { item_id: w.item.id, closes_at: w.item.closes_at };
249
+ idea.board_window = boardWindowPayload(w.item);
231
250
  } else if (w && w.reason === 'below_bar') {
232
251
  // R09 (goal 1000069, criterion C2): a sub-bar idea returns to
233
252
  // its author WITH A REASON THEY CAN ACT ON — the weakest section
@@ -673,7 +692,7 @@ module.exports = function buildInboxRouter() {
673
692
  try {
674
693
  if (government && government.openBoardWindowForIdea) {
675
694
  const w = await government.openBoardWindowForIdea({ ideaId: id, completenessScore: row.completeness_score });
676
- if (w && w.opened && w.item) board_window = { item_id: w.item.id, closes_at: w.item.closes_at };
695
+ if (w && w.opened && w.item) board_window = boardWindowPayload(w.item);
677
696
  else if (w && w.reason === 'below_bar') {
678
697
  const flag = weakestSectionPayload(req.body.field_values, templateKey ? { templateKey } : undefined);
679
698
  if (flag) out.rejection = flag; // still short — the NEW weakest section, fresh feedback
@@ -957,7 +976,7 @@ module.exports = function buildInboxRouter() {
957
976
  const government = api.resolveOptional && api.resolveOptional('government');
958
977
  if (government && government.openBoardWindowForIdea) {
959
978
  const w = await government.openBoardWindowForIdea({ ideaId: id, completenessScore: row.completeness_score });
960
- if (w && w.opened && w.item) board_window = { item_id: w.item.id, closes_at: w.item.closes_at };
979
+ if (w && w.opened && w.item) board_window = boardWindowPayload(w.item);
961
980
  else if (w && w.reason === 'below_bar') {
962
981
  // The same honest feedback the resubmit path gives: which section is
963
982
  // thinnest, computed from what was just written. Derived, never stored.
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.653",
3
+ "version": "1.19.655",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.653",
9
+ "version": "1.19.655",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.653",
3
+ "version": "1.19.655",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -312,7 +312,7 @@ async function cmdProvision(pool, boxes, boxOnboard, ref, { apply }) {
312
312
  // session-file fallback (box-source-fetch.sh, out-of-repo on the control-plane
313
313
  // — see the cross-repo blocker filed alongside this fix). Baking it explicitly
314
314
  // closes that gap for every future box regardless of which script forgets it.
315
- const apiOrigin = process.env.GDS_PUBLIC_ORIGIN || instanceConfig.defaultApiBase();
315
+ const apiOrigin = instanceConfig.resolveEnv('PUBLIC_ORIGIN') || instanceConfig.defaultApiBase();
316
316
  let boxEnv = buildBoxEnv({ apiOrigin });
317
317
  let terminalHostname = null;
318
318
  let terminalCredential = null;
@@ -380,7 +380,7 @@ async function cmdProvision(pool, boxes, boxOnboard, ref, { apply }) {
380
380
  // operator's own CLI session happens to be signed into right now — the two can
381
381
  // differ (an operator provisioning boxes across instances) and the box must
382
382
  // still call home to itself, not to the operator's other login.
383
- api_base: process.env.GDS_PUBLIC_ORIGIN || instanceConfig.defaultApiBase(),
383
+ api_base: instanceConfig.resolveEnv('PUBLIC_ORIGIN') || instanceConfig.defaultApiBase(),
384
384
  }, null, 2);
385
385
 
386
386
  const doApi = makeDo(apply);
@@ -1204,7 +1204,7 @@ async function cmdStatus(pool, boxes) {
1204
1204
 
1205
1205
  let APPLY = false;
1206
1206
  function actorTag() {
1207
- const who = process.env.GDS_ACTOR || process.env.USER || 'cli';
1207
+ const who = instanceConfig.resolveEnv('ACTOR') || process.env.USER || 'cli';
1208
1208
  return `cli:${who}`;
1209
1209
  }
1210
1210
  function fail(msg) { console.error(`✖ ${msg}`); return { ok: false, error: msg }; }
@@ -121,7 +121,7 @@ function buildRebrandRules(from = {}, to = {}) {
121
121
  add(`.config/${fc}`, `.config/${tc}`);
122
122
  add(`/etc/${fc}`, `/etc/${tc}`);
123
123
  add(`[${fc}]`, `[${tc}]`);
124
- // The instance env-var prefix (e.g. OTB_ → CLOUDBONGOS_), bounded by its underscore.
124
+ // The instance env-var prefix (e.g. OTB_ → BONGOS_), bounded by its underscore.
125
125
  add(`${effEnvPrefix(from)}_`, `${effEnvPrefix(to)}_`);
126
126
  // task 1002672: the bare User-Agent brand token (e.g. `amazonprimea-gds-cli/1.0`
127
127
  // in a hook's outbound fetch). It carries no TLD, so none of the HOST rules above
@@ -73,7 +73,7 @@ const path = require('node:path');
73
73
  const readline = require('node:readline');
74
74
  const { apiCall, arg, hasFlag } = require('./cli-lib');
75
75
  const { DEFAULT_HIERARCHY } = require('../../src/bongos/hierarchy-config');
76
- const { resolveCoreRoot } = require('../../src/instance-config'); // ADR 0108 §1: where the core package lives
76
+ const { resolveCoreRoot, FALLBACK_ENV_PREFIX } = require('../../src/instance-config'); // ADR 0108 §1: where the core package lives
77
77
  const { materializeClaude } = require('./claude-materialize'); // ADR 0108 §3 (W3): copy .claude/ into the instance
78
78
  const { detectStack, formatStackSummary } = require('./adopt-detect'); // ADR 0121 §Decision 4 (task 2043): inspect an existing repo before layering
79
79
  const { resolveBacklogTasks, DEFAULT_MAX: BACKLOG_MAX } = require('./adopt-import'); // ADR 0121 §Decision 3 (task 2044): import an existing backlog
@@ -250,7 +250,7 @@ function buildBrandingConfig(spec) {
250
250
  repo: { owner: spec.repo.owner, name: spec.repo.name },
251
251
  firstAdmin: spec.firstAdmin,
252
252
  currency: { label: (spec.currency && spec.currency.label) || 'credits', symbol: (spec.currency && spec.currency.symbol) || '' },
253
- envPrefix: spec.envPrefix || 'CLOUDBONGOS',
253
+ envPrefix: spec.envPrefix || FALLBACK_ENV_PREFIX,
254
254
  // Every instance gets its OWN session/config dir (task 1002626). Without
255
255
  // this, instance-config.js falls back to the shared 'cloudbongos' dir, so
256
256
  // every un-branded instance on a machine reads/writes the SAME
@@ -35,12 +35,21 @@
35
35
  // node scripts/gds/main-audit.js --strict # also block on unresolved authors
36
36
  // node scripts/gds/main-audit.js --db # resolve ranks from Postgres instead of the API
37
37
  // node scripts/gds/main-audit.js --require-roster # CI: an empty roster is a loud DID-NOT-RUN, not a vacuous green
38
+ //
39
+ // EXIT CONTRACT — never `process.exit()` here, use `cliExit(code)` + `return`
40
+ // (task 1003772). Every exit in this file lands after the roster request, and a
41
+ // hard exit mid-teardown aborts node natively on Windows (UV_HANDLE_CLOSING),
42
+ // printing the verdict and THEN dying at 127. That is worse here than in any
43
+ // other CLI: --verify-head IS the deploy gate, so the destroyed code makes a
44
+ // clean audit look like a refusal and a real blocker indistinguishable from a
45
+ // crash. Guarded by the static check in tests/cli_exit_no_abort.mjs.
46
+ // Codes: 0 = clean · 1 = blocker found · 2 = DID NOT RUN (never a pass).
38
47
 
39
48
  'use strict';
40
49
 
41
50
  const { execSync } = require('node:child_process');
42
51
  const ppc = require(require('node:path').join(__dirname, '..', '..', 'src', 'bongos', 'permission-path-check.js'));
43
- const { cliClient, API_BASE } = require('./cli-lib');
52
+ const { cliClient, cliExit, API_BASE } = require('./cli-lib');
44
53
 
45
54
  // ---------- pure analyzer (tested in tests/main_audit.mjs) ----------
46
55
 
@@ -319,14 +328,16 @@ async function main() {
319
328
  + `${Number.isFinite(depth) ? `${depth} commit(s)` : 'unreadable'} deep. `
320
329
  + 'Pass --range <A..B> or set OTB_AUDIT_RANGE. This is a check that did not execute, not a clean audit.'
321
330
  );
322
- process.exit(2);
331
+ cliExit(2);
332
+ return;
323
333
  }
324
334
  }
325
335
 
326
336
  const parsed = parseCommits(range);
327
337
  if (parsed.error) {
328
338
  console.error(`main-audit: ${parsed.error}`);
329
- process.exit(2);
339
+ cliExit(2);
340
+ return;
330
341
  }
331
342
  const rankMap = await fetchRankMap({ useDb });
332
343
  if (Object.keys(rankMap).length === 0) {
@@ -336,7 +347,8 @@ async function main() {
336
347
  // clean audit (the same false-green shape parseCommits guards against).
337
348
  if (process.argv.includes('--require-roster')) {
338
349
  console.error('main-audit: DID NOT RUN — the roster is unavailable (offline / no session / public leaderboard unreachable). An empty rank map can never produce a blocker, so exiting green would be a vacuous pass. Fix connectivity or drop --require-roster for a best-effort local read.');
339
- process.exit(2);
350
+ cliExit(2);
351
+ return;
340
352
  }
341
353
  console.error('main-audit: WARNING — could not load the GDS roster (offline / insufficient rank / no DB). Authors will be unresolved; run as a Metic+ builder or with --db (on the droplet) for a definitive audit.');
342
354
  }
@@ -346,7 +358,8 @@ async function main() {
346
358
  console.log(`\nMain audit (SEC #863) — range ${range}, ${parsed.commits.length} commit(s)`);
347
359
  if (result.findings.length === 0) {
348
360
  console.log(' ✓ clean — no sub-Metic edits to permission-sensitive paths.\n');
349
- process.exit(0);
361
+ cliExit(0);
362
+ return;
350
363
  }
351
364
  for (const f of result.findings) {
352
365
  const tag = f.severity === 'blocker' ? '\x1b[31m[BLOCKER]\x1b[0m' : '\x1b[33m[review]\x1b[0m';
@@ -364,16 +377,17 @@ async function main() {
364
377
  // verify-head is the deploy gate: any blocker (and, unless allowed, any
365
378
  // unresolved author) must stop the deploy.
366
379
  if (verifyHead) {
367
- process.exit(result.hardFail ? 1 : 0);
380
+ cliExit(result.hardFail ? 1 : 0);
381
+ return;
368
382
  }
369
383
  // report mode: non-zero on a real blocker so CI can fail.
370
- process.exit(result.hardFail ? 1 : 0);
384
+ cliExit(result.hardFail ? 1 : 0);
371
385
  }
372
386
 
373
387
  if (require.main === module) {
374
388
  main().catch((e) => {
375
389
  console.error(`main-audit: ${e.message}`);
376
- process.exit(2);
390
+ cliExit(2);
377
391
  });
378
392
  }
379
393
 
@@ -93,8 +93,9 @@ function readJson(p) {
93
93
  }
94
94
 
95
95
  function envPrefix() {
96
- try { return require('../../src/branding').branding().envPrefix || 'CLOUDBONGOS'; }
97
- catch { return 'CLOUDBONGOS'; }
96
+ const { FALLBACK_ENV_PREFIX } = require('../../src/instance-config');
97
+ try { return require('../../src/branding').branding().envPrefix || FALLBACK_ENV_PREFIX; }
98
+ catch { return FALLBACK_ENV_PREFIX; }
98
99
  }
99
100
 
100
101
  // ---------------------------------------------------------------------------
@@ -59,12 +59,15 @@ function validateClientId(id) {
59
59
 
60
60
  // The env-var prefix the provisioned instance resolves (the neutral pack's envPrefix,
61
61
  // mirroring provision.js provisionedEnvPrefix — the creds MUST be written under this
62
- // prefix or src/branding + auth never read them, task 1972). Fail-soft to CLOUDBONGOS.
62
+ // prefix or src/branding + auth never read them, task 1972). Fail-soft to the
63
+ // core's own FALLBACK_ENV_PREFIX so this cannot drift from the pack again — it
64
+ // was a hardcoded 'CLOUDBONGOS' until task 1003703 made BONGOS canonical.
63
65
  function provisionedEnvPrefix() {
66
+ const { FALLBACK_ENV_PREFIX } = require('../../src/instance-config');
64
67
  try {
65
68
  const pack = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, PROVISIONED_BRANDING_FILE), 'utf8'));
66
- return pack.envPrefix || 'CLOUDBONGOS';
67
- } catch { return 'CLOUDBONGOS'; }
69
+ return pack.envPrefix || FALLBACK_ENV_PREFIX;
70
+ } catch { return FALLBACK_ENV_PREFIX; }
68
71
  }
69
72
 
70
73
  function webEnvPath(slug, { etcBase = ETC_BASE } = {}) {
@@ -152,10 +152,11 @@ const ENV_PREFIX_PACK = 'config/branding.neutral.json';
152
152
  // was a latent bug: it never matched the neutral pack's CLOUDBONGOS envPrefix, so
153
153
  // the OAuth creds were silently unreadable — task 1972.)
154
154
  function provisionedEnvPrefix() {
155
+ const { FALLBACK_ENV_PREFIX } = require('../../src/instance-config');
155
156
  try {
156
157
  const pack = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, ENV_PREFIX_PACK), 'utf8'));
157
- return pack.envPrefix || 'CLOUDBONGOS';
158
- } catch { return 'CLOUDBONGOS'; }
158
+ return pack.envPrefix || FALLBACK_ENV_PREFIX;
159
+ } catch { return FALLBACK_ENV_PREFIX; }
159
160
  }
160
161
 
161
162
  // The per-instance secrets file body (the standup runbook's /etc/<inst>/web.env).
@@ -7,7 +7,7 @@
7
7
  // Run: node scripts/gds/seed-bongos-coreB-tranche1-tasks.js
8
8
  const os = require('os');
9
9
  const TOKEN = require(os.homedir() + '/.config/otb/gds-session.json').token;
10
- const BASE = process.env.GDS_BASE || 'https://amazonprimea.com';
10
+ const BASE = require('../../src/instance-config').resolveEnv('BASE') || 'https://amazonprimea.com';
11
11
  const H = { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' };
12
12
 
13
13
  // ref → task spec. depends_on_refs are local R## refs resolved after create.
@@ -9,7 +9,7 @@
9
9
  // Run: node scripts/gds/seed-bongos-coreB-tranche2-tasks.js
10
10
  const os = require('os');
11
11
  const TOKEN = require(os.homedir() + '/.config/otb/gds-session.json').token;
12
- const BASE = process.env.GDS_BASE || 'https://amazonprimea.com';
12
+ const BASE = require('../../src/instance-config').resolveEnv('BASE') || 'https://amazonprimea.com';
13
13
  const H = { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' };
14
14
 
15
15
  // ref → task spec. deps are local R## refs resolved after create. Clean-slicing
@@ -7,7 +7,7 @@
7
7
  // Run: node scripts/gds/seed-provisioning-tasks.js
8
8
  const os = require('os');
9
9
  const TOKEN = require(os.homedir() + '/.config/otb/gds-session.json').token;
10
- const BASE = process.env.GDS_BASE || 'https://amazonprimea.com';
10
+ const BASE = require('../../src/instance-config').resolveEnv('BASE') || 'https://amazonprimea.com';
11
11
  const H = { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' };
12
12
  const GOAL = 26; // Cloud Bongos productization and self-host
13
13
 
@@ -157,6 +157,31 @@ function templateSkeleton() {
157
157
  return lines.join('\n');
158
158
  }
159
159
 
160
+ // How the freshly-opened ratification window is described to its author.
161
+ //
162
+ // A NULL `closes_at` is the no-clock constitution, not a missing deadline —
163
+ // several pass rules run a sitting with no clock at all, and the window simply
164
+ // never expires. Printing the field raw turned that into "open until null" for
165
+ // every author on a clockless instance (task 1003654, caught five times by the
166
+ // R157 proof walk). So the rule decides the sentence, and the wording tracks
167
+ // the Board Room's own clockLine() (modules/hall-ui/public/board-room.js) —
168
+ // one vocabulary for one fact, whichever surface the author reads it on.
169
+ //
170
+ // ALL FOUR of config.js's PASS_RULES get a branch, `consent` included: it is
171
+ // not clock-safe (CLOCK_SAFE_PASS_RULES is majority + unanimous only), so a
172
+ // consent constitution is one of the likeliest to arrive here with no deadline
173
+ // at all. The default arm is for an unreadable rule, not for a known one.
174
+ function boardWindowLine(w) {
175
+ if (w.closes_at) return ` A board ratification window is open until ${w.closes_at}.`;
176
+ switch (w.pass_rule) {
177
+ case 'first_ratifier': return ' A board ratification window is open — no clock; it closes on the first vote.';
178
+ case 'consent': return ' A board ratification window is open — no clock; it stands unless someone objects.';
179
+ case 'majority': return ' A board ratification window is open — no clock; it waits for a majority.';
180
+ case 'unanimous': return ' A board ratification window is open — no clock; it waits for a unanimous yes.';
181
+ default: return ' A board ratification window is open — no clock; it stays open until the board decides.';
182
+ }
183
+ }
184
+
160
185
  // What the developer is told once it lands. The completeness score is the number
161
186
  // the R147 credit split and the board bar both read, so it is the headline.
162
187
  function renderDeveloped(data) {
@@ -168,7 +193,7 @@ function renderDeveloped(data) {
168
193
  ? ' You had the thought and you developed it.'
169
194
  : ` The thought stays credited to builder ${author_id}; you are recorded as its developer (${developer_id}).`);
170
195
  if (board_window) {
171
- lines.push(` A board ratification window is open until ${board_window.closes_at}.`);
196
+ lines.push(boardWindowLine(board_window));
172
197
  } else if (idea.rejection) {
173
198
  lines.push(` Not yet at the bar — thinnest section: ${idea.rejection.section_key}.`);
174
199
  if (idea.rejection.reason_md) lines.push(` ${idea.rejection.reason_md}`);
@@ -20,12 +20,13 @@
20
20
  // Exit codes: 0 ok · 1 network/api error · 2 bad invocation · 3 not found.
21
21
 
22
22
  const { API_BASE, cliClient, assertInstanceMatch } = require('./cli-lib');
23
+ const { resolveEnv } = require('../../src/instance-config');
23
24
 
24
- // The active internal version; override with GDS_STATUS_DEFAULT_VERSION (or pass
25
+ // The active internal version; override with <PREFIX>_STATUS_DEFAULT_VERSION (or pass
25
26
  // --version). Bumped GDS-V4 → BONGOS-V1 (task 1709, ADR 0103): GDS-V4 was
26
27
  // consolidated into BONGOS-V1 (the Cloud Bongos platform version) and frozen, so
27
28
  // the bare `/status` card should default to the live version, not a sealed one.
28
- const DEFAULT_VERSION = process.env.GDS_STATUS_DEFAULT_VERSION || 'BONGOS-V1';
29
+ const DEFAULT_VERSION = resolveEnv('STATUS_DEFAULT_VERSION') || 'BONGOS-V1';
29
30
 
30
31
  // Compact, status-first ordering for the "remaining: …" summaries.
31
32
  const STATUS_ORDER = ['shipped', 'confirmed', 'completed', 'claimed', 'ready', 'blocked', 'backlog'];
@@ -358,7 +358,7 @@ function resolveInstanceDb({ instanceDir }, run = spawnSync) {
358
358
  const r = run(process.execPath, ['-e', "process.stdout.write(require(process.argv[1]).instanceDbName() || '')", poolPath], {
359
359
  cwd: instanceDir,
360
360
  encoding: 'utf8',
361
- env: { ...process.env, CLOUDBONGOS_INSTANCE_ROOT: instanceDir },
361
+ env: { ...process.env, BONGOS_INSTANCE_ROOT: instanceDir },
362
362
  });
363
363
  if (!r || r.error || r.status !== 0 || typeof r.stdout !== 'string') return { db: null, ran: false };
364
364
  return { db: String(r.stdout).trim() || null, ran: true };
@@ -377,7 +377,7 @@ function resolveInstanceDb({ instanceDir }, run = spawnSync) {
377
377
  // (fitness `gen-api-docs --check` + the api_client test backstop it on the next ship).
378
378
  // Runs AFTER npm install so the generators come from the NEW core.
379
379
  function regenerateApiArtifacts({ instanceDir, coreRoot }, run = spawnSync, fsImpl = fs) {
380
- const env = { ...process.env, CLOUDBONGOS_INSTANCE_ROOT: instanceDir };
380
+ const env = { ...process.env, BONGOS_INSTANCE_ROOT: instanceDir };
381
381
  const ran = [];
382
382
  for (const gen of ['gen-api-docs.js', 'gen-api-client.js']) {
383
383
  const script = path.join(coreRoot, 'scripts', 'gds', gen);
@@ -416,7 +416,7 @@ function regenerateApiArtifacts({ instanceDir, coreRoot }, run = spawnSync, fsIm
416
416
  // an upgrade is precisely what destroys them (task 1002441).
417
417
  const NAV_WHOLE_FILE_GENERATORS = ['gen-session-index.js'];
418
418
  function regenerateNavDocs({ instanceDir, coreRoot, generators = NAV_WHOLE_FILE_GENERATORS }, run = spawnSync, fsImpl = fs) {
419
- const env = { ...process.env, CLOUDBONGOS_INSTANCE_ROOT: instanceDir };
419
+ const env = { ...process.env, BONGOS_INSTANCE_ROOT: instanceDir };
420
420
  const ran = [];
421
421
  const failed = [];
422
422
  for (const gen of generators) {
@@ -66,7 +66,7 @@ const MODULES_ON = String(arg('--modules-on') || '').split(',').map((s) => s.tri
66
66
  // and it is the explicit way to preview the signed-out fallback (task 1003315).
67
67
  const FIXTURE_ME = process.argv.includes('--fixture-me');
68
68
  const BRAND_FILE = arg('--brand') || 'config/branding.neutral.json';
69
- process.env.GDS_BRANDING_FILE = path.resolve(ROOT, BRAND_FILE);
69
+ process.env.BONGOS_BRANDING_FILE = path.resolve(ROOT, BRAND_FILE);
70
70
 
71
71
  /* eslint-disable import/no-dynamic-require */
72
72
  // express comes from this tree's node_modules when installed, else node's
@@ -40,6 +40,7 @@ const { pairStore } = require('../app-pair');
40
40
  const { publicOrigin, parseCookie, validateOrRespond } = require('./_helpers');
41
41
  const { accountExistenceReadRateLimit } = require('../middleware/rate-limit');
42
42
  const { branding } = require('../../branding');
43
+ const { resolveEnv } = require('../../instance-config');
43
44
  const seams = require('../../module-seams');
44
45
  // task 1003208: structured logging — was console.*. A core file logs through
45
46
  // the kernel logger directly; only MODULES go via api.logger (the doorway).
@@ -373,14 +374,14 @@ function postLoginLanding(brand) {
373
374
  // the request's x-forwarded-host (spoofable; F2 Hacker finding). Configured via
374
375
  // env, falling back to the configured canonical public origin, so an injected host
375
376
  // cannot rewrite the redirect and Discord's URI registration is not the SOLE backstop.
376
- const DISCORD_REDIRECT_ORIGIN = process.env.GDS_PUBLIC_ORIGIN || configuredOAuthOrigin();
377
+ const DISCORD_REDIRECT_ORIGIN = resolveEnv('PUBLIC_ORIGIN') || configuredOAuthOrigin();
377
378
  const DISCORD_REDIRECT_URI = `${DISCORD_REDIRECT_ORIGIN}/api/gds/auth/discord/callback`;
378
379
 
379
380
  // The GitHub web-OAuth redirect_uri origin (#734). PINNED to the canonical
380
381
  // public origin (same env knob as Discord), never the request host — GitHub
381
382
  // matches redirect_uri against the app's single registered callback host, so a
382
383
  // sign-in started on a builders.* subdomain must still use the apex.
383
- const WEB_REDIRECT_ORIGIN = process.env.GDS_PUBLIC_ORIGIN || configuredOAuthOrigin();
384
+ const WEB_REDIRECT_ORIGIN = resolveEnv('PUBLIC_ORIGIN') || configuredOAuthOrigin();
384
385
 
385
386
  // The ship-time hub push (task 1002803, ADR 0171 amendment R01) lives here
386
387
  // because this file already owns the OTHER trigger: the federated sign-in flows
@@ -22,19 +22,23 @@ const path = require('node:path');
22
22
  const { execFile } = require('node:child_process');
23
23
  const auth = require('../auth');
24
24
  const { branding } = require('../../branding');
25
+ const { resolveEnv } = require('../../instance-config');
25
26
  const { asyncHandler } = require('./_helpers');
26
27
  // task 1003208: structured logging — was console.*. A core file logs through
27
28
  // the kernel logger directly; only MODULES go via api.logger (the doorway).
28
29
  const log = require('../logger').logger.child({ src: 'backup' });
29
30
 
30
31
  const REPO_ROOT = path.resolve(__dirname, '..', '..', '..');
31
- const BACKUP_DIR = process.env.GDS_BACKUP_DIR || '/var/backups/gds';
32
+ // These four knobs are set in the droplet's systemd unit, so they resolve through
33
+ // resolveEnv() — <PREFIX>_BACKUP_* first, then the legacy spellings including the
34
+ // GDS_BACKUP_* the deployed unit still exports (task 1003703).
35
+ const BACKUP_DIR = resolveEnv('BACKUP_DIR') || '/var/backups/gds';
32
36
  // The DB name comes from the branding pack (branding.db.database), never a
33
37
  // hardcoded host brand (ADR 0062 §3) — a non-OTB instance dumps its own DB.
34
- const DB_NAME = process.env.GDS_BACKUP_DB
38
+ const DB_NAME = resolveEnv('BACKUP_DB')
35
39
  || (() => { try { return branding().db && branding().db.database; } catch { return null; } })()
36
40
  || 'gds';
37
- const BACKUP_SCRIPT = process.env.GDS_BACKUP_SCRIPT
41
+ const BACKUP_SCRIPT = resolveEnv('BACKUP_SCRIPT')
38
42
  || path.join(REPO_ROOT, 'scripts', 'gds', 'db-backup-nightly.sh');
39
43
 
40
44
  // In-memory guard: true while a trigger-initiated dump is running.
@@ -42,7 +46,7 @@ let triggerRunning = false;
42
46
 
43
47
  // A dump older than this counts as STALE. The nightly timer fires daily, so 36h
44
48
  // allows one missed run plus slack before we call it broken.
45
- const STALE_AFTER_HOURS = Number(process.env.GDS_BACKUP_STALE_HOURS) || 36;
49
+ const STALE_AFTER_HOURS = Number(resolveEnv('BACKUP_STALE_HOURS')) || 36;
46
50
 
47
51
  // parseDumpTimestamp — the dump filename stamp `YYYY-MM-DDTHHMMSSZ` is ALMOST
48
52
  // ISO-8601 but omits the time separators, so Date.parse rejects it. Re-insert
@@ -21,6 +21,7 @@ const fs = require('node:fs/promises');
21
21
  const auth = require('../auth');
22
22
  const db = require('../db');
23
23
  const { validateOrRespond } = require('./_helpers');
24
+ const { resolveEnv } = require('../../instance-config');
24
25
 
25
26
  // Slug shape for security_docs (task 991): lowercase kebab, 1–128 chars. Used to
26
27
  // validate POST /security/docs and to safely interpolate :slug into the GET.
@@ -36,7 +37,7 @@ const SECURITY_DOC_CONTENT_MAX = 2_000_000;
36
37
  // staging can point at a different file; the recommended prod path is
37
38
  // /etc/amazonprimea/security-adr.md (chmod 600, owned by the deploy user).
38
39
  function adrPath() {
39
- return (process.env.GDS_SECURITY_ADR_PATH || '').trim() || null;
40
+ return (resolveEnv('SECURITY_ADR_PATH') || '').trim() || null;
40
41
  }
41
42
 
42
43
  // Read the full ADR off disk. Returns { adr_md, source, fetched_at } on