@bongos/core 1.19.654 → 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 (42) hide show
  1. package/.bongos-core.json +52 -47
  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 +2 -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/package-lock.json +2 -2
  15. package/package.json +1 -1
  16. package/scripts/gds/box.js +3 -3
  17. package/scripts/gds/claude-materialize.js +1 -1
  18. package/scripts/gds/init.js +2 -2
  19. package/scripts/gds/module.js +3 -2
  20. package/scripts/gds/oauth-secret.js +6 -3
  21. package/scripts/gds/provision-units.js +3 -2
  22. package/scripts/gds/seed-bongos-coreB-tranche1-tasks.js +1 -1
  23. package/scripts/gds/seed-bongos-coreB-tranche2-tasks.js +1 -1
  24. package/scripts/gds/seed-provisioning-tasks.js +1 -1
  25. package/scripts/gds/status.js +3 -2
  26. package/scripts/gds/upgrade.js +3 -3
  27. package/scripts/hall-preview/server.js +1 -1
  28. package/src/bongos/routes/auth.js +3 -2
  29. package/src/bongos/routes/backup.js +8 -4
  30. package/src/bongos/routes/security.js +2 -1
  31. package/src/bongos/serve-internal.js +2 -2
  32. package/src/branding.js +24 -7
  33. package/src/instance-config.js +74 -13
  34. package/src/module-api.js +1 -1
  35. package/src/modules.js +15 -7
  36. package/tests/claude_materialize.mjs +1 -1
  37. package/tests/cloud_bongos_pack.mjs +1 -1
  38. package/tests/init.mjs +1 -1
  39. package/tests/instance_config.mjs +98 -2
  40. package/tests/upgrade.mjs +3 -3
  41. package/tests/watch_applications_queue.mjs +13 -1
  42. package/tests/watch_sealed_floor.mjs +241 -0
@@ -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.654'; // 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
@@ -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)', () => {
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');
@@ -8,7 +8,7 @@ import ic from '../src/instance-config.js';
8
8
 
9
9
  const {
10
10
  configDirName, envPrefix, configHome, configReadDirs, configReadPaths,
11
- resolveEnv, FALLBACK_DIR, FALLBACK_ENV_PREFIX,
11
+ resolveEnv, FALLBACK_DIR, FALLBACK_ENV_PREFIX, LEGACY_ENV_PREFIXES,
12
12
  resolveInstanceRootExplicit, resolveDocsRoot, resolveCoreRoot,
13
13
  } = ic;
14
14
 
@@ -22,7 +22,10 @@ test('configDirName: from branding, else the vanilla fallback (never OTB)', () =
22
22
  test('envPrefix: from branding, else the vanilla fallback', () => {
23
23
  assert.equal(envPrefix({ envPrefix: 'OTB' }), 'OTB');
24
24
  assert.equal(envPrefix({}), FALLBACK_ENV_PREFIX);
25
- assert.equal(FALLBACK_ENV_PREFIX, 'CLOUDBONGOS');
25
+ // BONGOS since task 1003703 (criterion C3a). CLOUDBONGOS did not disappear —
26
+ // it moved into LEGACY_ENV_PREFIXES, which the fallback tests below pin.
27
+ assert.equal(FALLBACK_ENV_PREFIX, 'BONGOS');
28
+ assert.ok(LEGACY_ENV_PREFIXES.includes('CLOUDBONGOS'), 'the previous canonical prefix must stay readable');
26
29
  });
27
30
 
28
31
  test('configHome: ~/.config/<dir>', () => {
@@ -61,6 +64,99 @@ test('resolveEnv: legacy PMS prefix still honored (original-name back-compat)',
61
64
  assert.equal(resolveEnv('GITHUB_CLIENT_ID', { prefix: 'CLOUDBONGOS', env: { PMS_GITHUB_CLIENT_ID: 'old' } }), 'old');
62
65
  });
63
66
 
67
+ // ---------------------------------------------------------------------------
68
+ // The legacy-prefix DEPRECATION WARNING (task 1003703 / criterion C3a).
69
+ //
70
+ // These assert the fallback FIRES, not merely that the new spelling works. A
71
+ // happy-path-only test would still pass with the whole legacy path deleted,
72
+ // which is exactly the regression task 1003706 (C3d) exists to catch when it
73
+ // removes the fallback on purpose — at which point these tests must be inverted,
74
+ // not quietly dropped.
75
+ // ---------------------------------------------------------------------------
76
+
77
+ // Collects warn() payloads instead of printing, so a case can assert on shape.
78
+ function capture() {
79
+ const calls = [];
80
+ return { calls, warn: (payload) => { calls.push(payload); return true; } };
81
+ }
82
+
83
+ // Runs fn with console.warn captured, so a case can assert on the REAL default
84
+ // warn path rather than a hand-called helper. resolveEnv's `warn` option is the
85
+ // only seam the module exports; the renderer and its warn-once ledger are
86
+ // internals, reached here exactly the way production reaches them.
87
+ function captureConsole(fn) {
88
+ const lines = [];
89
+ const real = console.warn;
90
+ console.warn = (m) => lines.push(String(m));
91
+ try { fn(); } finally { console.warn = real; }
92
+ return lines;
93
+ }
94
+
95
+ test('resolveEnv: ONLY the old spelling set — resolves, and warns naming old, new and the sunset', () => {
96
+ const { calls, warn } = capture();
97
+ const v = resolveEnv('API_BASE', { prefix: 'BONGOS', env: { GDS_API_BASE: 'legacy' }, warn });
98
+ assert.equal(v, 'legacy', 'the legacy spelling must still resolve — boxes still export it');
99
+ assert.equal(calls.length, 1, 'exactly one warning for the one legacy name that was read');
100
+ assert.deepEqual(calls[0], { legacyName: 'GDS_API_BASE', canonicalName: 'BONGOS_API_BASE', shadowed: false, differs: false });
101
+
102
+ // …and the DEFAULT renderer puts all three facts an operator needs on the line.
103
+ // A suffix used by no other case, so the process-global warn-once ledger cannot
104
+ // have already spent this name.
105
+ const lines = captureConsole(() => {
106
+ resolveEnv('C3A_RENDER_PROBE', { prefix: 'BONGOS', env: { CLOUDBONGOS_C3A_RENDER_PROBE: 'legacy' } });
107
+ });
108
+ assert.equal(lines.length, 1);
109
+ assert.match(lines[0], /CLOUDBONGOS_C3A_RENDER_PROBE/, 'names the old spelling');
110
+ assert.match(lines[0], /BONGOS_C3A_RENDER_PROBE/, 'names the new spelling');
111
+ assert.match(lines[0], /stops being read in core 1\.21/, 'names the release it stops working in');
112
+ });
113
+
114
+ test('resolveEnv: ONLY the new spelling set — resolves, and is SILENT', () => {
115
+ const { calls, warn } = capture();
116
+ assert.equal(resolveEnv('API_BASE', { prefix: 'BONGOS', env: { BONGOS_API_BASE: 'new' }, warn }), 'new');
117
+ assert.deepEqual(calls, [], 'the canonical spelling must never warn');
118
+ });
119
+
120
+ test('resolveEnv: BOTH set and disagreeing — the new one wins, and the warning says so', () => {
121
+ const { calls, warn } = capture();
122
+ const v = resolveEnv('API_BASE', { prefix: 'BONGOS', env: { BONGOS_API_BASE: 'new', GDS_API_BASE: 'legacy' }, warn });
123
+ assert.equal(v, 'new', 'an operator who sets the new name expects it to take effect');
124
+ assert.equal(calls.length, 1, 'a SHADOWED legacy name still earns its one warning — it is set on some box');
125
+ assert.equal(calls[0].shadowed, true);
126
+ assert.equal(calls[0].differs, true);
127
+ const lines = captureConsole(() => {
128
+ resolveEnv('C3A_DISAGREE_PROBE', { prefix: 'BONGOS', env: { BONGOS_C3A_DISAGREE_PROBE: 'new', CLOUDBONGOS_C3A_DISAGREE_PROBE: 'legacy' } });
129
+ });
130
+ assert.match(lines[0], /ALSO set to a different value and WINS/, 'the disagreement is spelled out, not implied');
131
+ });
132
+
133
+ test('resolveEnv: both set to the SAME value — warns, but does not claim a disagreement', () => {
134
+ const { calls, warn } = capture();
135
+ resolveEnv('API_BASE', { prefix: 'BONGOS', env: { BONGOS_API_BASE: 'same', GDS_API_BASE: 'same' }, warn });
136
+ assert.equal(calls[0].shadowed, true);
137
+ assert.equal(calls[0].differs, false);
138
+ });
139
+
140
+ test('resolveEnv: every legacy spelling set gets its OWN warning, and the first one wins', () => {
141
+ const { calls, warn } = capture();
142
+ const v = resolveEnv('API_BASE', { prefix: 'BONGOS', env: { CLOUDBONGOS_API_BASE: 'a', OTB_API_BASE: 'b', PMS_API_BASE: 'c' }, warn });
143
+ assert.equal(v, 'a', 'LEGACY_ENV_PREFIXES order decides: CLOUDBONGOS before OTB before PMS');
144
+ assert.deepEqual(calls.map((c) => c.legacyName), ['CLOUDBONGOS_API_BASE', 'OTB_API_BASE', 'PMS_API_BASE']);
145
+ });
146
+
147
+ test('the deprecation warning is once per NAME per process, not once per read', () => {
148
+ const env = { CLOUDBONGOS_C3A_ONCE_PROBE: 'x', CLOUDBONGOS_C3A_ONCE_OTHER: 'y' };
149
+ const lines = captureConsole(() => {
150
+ resolveEnv('C3A_ONCE_PROBE', { prefix: 'BONGOS', env });
151
+ resolveEnv('C3A_ONCE_PROBE', { prefix: 'BONGOS', env }); // same name again
152
+ resolveEnv('C3A_ONCE_PROBE', { prefix: 'BONGOS', env });
153
+ resolveEnv('C3A_ONCE_OTHER', { prefix: 'BONGOS', env }); // a DIFFERENT name
154
+ });
155
+ assert.equal(lines.length, 2, 'three reads of one name warn once; a second name warns again');
156
+ assert.match(lines[0], /CLOUDBONGOS_C3A_ONCE_PROBE/);
157
+ assert.match(lines[1], /CLOUDBONGOS_C3A_ONCE_OTHER/);
158
+ });
159
+
64
160
  // task 2051: docs+client are per-instance artifacts written to the instance root when
65
161
  // it is EXPLICITLY pointed at (a consumer running `bongos upgrade`), else the coreRoot.
66
162
  test('resolveInstanceRootExplicit: env pointer (branded or fallback prefix), else null', () => {
package/tests/upgrade.mjs CHANGED
@@ -211,7 +211,7 @@ t('regenerateApiArtifacts: spawns docs then client from the new core, targeting
211
211
  assert.equal(c.cmd, process.execPath, 'runs node');
212
212
  assert.equal(c.opts.cwd, '/inst', 'cwd is the instance');
213
213
  // the env points the generators OUTPUT at the instance repo, not node_modules
214
- assert.equal(c.opts.env.CLOUDBONGOS_INSTANCE_ROOT, '/inst');
214
+ assert.equal(c.opts.env.BONGOS_INSTANCE_ROOT, '/inst');
215
215
  }
216
216
  });
217
217
 
@@ -805,7 +805,7 @@ t('regenerateNavDocs: default set is the whole-file nav docs (session-index only
805
805
  assert.deepEqual(u.NAV_WHOLE_FILE_GENERATORS, ['gen-session-index.js']);
806
806
  });
807
807
 
808
- t('regenerateNavDocs: spawns each generator from the CORE against the INSTANCE root (cwd + CLOUDBONGOS_INSTANCE_ROOT), ok when all succeed', () => {
808
+ t('regenerateNavDocs: spawns each generator from the CORE against the INSTANCE root (cwd + BONGOS_INSTANCE_ROOT), ok when all succeed', () => {
809
809
  const calls = [];
810
810
  const run = (cmd, args, opts) => { calls.push({ cmd, arg0: args[0], cwd: opts && opts.cwd, env: opts && opts.env }); return { status: 0 }; };
811
811
  const fsImpl = { existsSync: () => true };
@@ -815,7 +815,7 @@ t('regenerateNavDocs: spawns each generator from the CORE against the INSTANCE r
815
815
  assert.deepEqual(res.failed, []);
816
816
  assert.equal(calls.length, 1);
817
817
  assert.equal(calls[0].cwd, '/inst', 'runs with cwd = the instance dir');
818
- assert.equal(calls[0].env.CLOUDBONGOS_INSTANCE_ROOT, '/inst', 'sets the instance-root env so the generator writes into the instance');
818
+ assert.equal(calls[0].env.BONGOS_INSTANCE_ROOT, '/inst', 'sets the instance-root env so the generator writes into the instance');
819
819
  assert.equal(calls[0].arg0, join('/core', 'scripts', 'gds', 'gen-session-index.js'), 'spawns the generator from the CORE package');
820
820
  });
821
821
 
@@ -368,8 +368,20 @@ await test('an empty view still offers the invite form and the switch out of it'
368
368
  });
369
369
 
370
370
  await test('a 403 seals the section rather than blanking the page', async () => {
371
+ // This used to assert the literal "Archons only". Task 1003449 removed that
372
+ // sentence from the page: the floor is read off the denial's `details.floors`
373
+ // now, because ADR 0157 moved most of this surface to metic and a page must
374
+ // not name a floor its gate does not enforce. THIS harness answers 403 with a
375
+ // null body, so there is no floor to name and the honest render is the subject
376
+ // clause alone. What the test was really pinning — the section seals, the page
377
+ // does not blank — is unchanged and is what it asserts.
378
+ //
379
+ // The floor-derivation itself is executed in tests/watch_sealed_floor.mjs,
380
+ // which scripts real envelopes at both floors.
371
381
  const h = await boot({ queueStatus: 403 });
372
- assert.match(h.gate().textContent, /Archons only/);
382
+ assert.match(h.gate().textContent, /not visible at your rank/);
383
+ assert.doesNotMatch(h.gate().textContent, /Archon|Metic/,
384
+ 'a denial with no floor in its body must name no rank');
373
385
  assert.equal(h.rows().length, 0);
374
386
  });
375
387
 
@@ -0,0 +1,241 @@
1
+ // tests/watch_sealed_floor.mjs — a sealed panel names the floor the SERVER
2
+ // enforced, EXECUTED (task 1003449).
3
+ //
4
+ // THE DEFECT. Every panel on /watch answered a 403 with the literal string
5
+ // "Archons only." ADR 0157 moved most of this surface to metic — five of the
6
+ // atoms behind these panels carry "(ADR 0157, was archon)" in their own catalog
7
+ // entry — so the page was teaching a permission model the server had stopped
8
+ // implementing. The hall audit filed it against watch.html:30 and sessions.html:27;
9
+ // both of those lines are gone (the page heads were rewritten by task 1003307) and
10
+ // scouting.js was deleted by task 1003440, so the copy actually lived in the
11
+ // fourteen `sealedMsg` strings in watch.js. The reported symptom was real; the
12
+ // reported location was not.
13
+ //
14
+ // WHY THE FIX IS NOT "CHANGE archon TO metic". The floors are MIXED, and a blanket
15
+ // rewrite would have been wrong in the other direction:
16
+ //
17
+ // access_request.review → archon ("Archons only" was CORRECT here)
18
+ // override_request.decide → metic (ADR 0157, was archon)
19
+ // builder.roster.read → metic (ADR 0157, was archon)
20
+ // session.search → metic (ADR 0157, was archon)
21
+ // audit_log.read → metic (ADR 0157, was archon)
22
+ // box.fleet.manage → metic (ADR 0157, was archon)
23
+ //
24
+ // A hardcoded 'metic' is the same defect one ADR later, which is why the fix reads
25
+ // the floor off the DENIAL: requirePermission already rides `details.floors` on
26
+ // every 403, and its own comment in src/bongos/auth.js names hardcoded prose as
27
+ // "the D-16/N-3 failure shape ADR 0157 left behind". The page just never read it.
28
+ //
29
+ // These run the real watch.js in a vm against the real watch.html — the harness
30
+ // tests/watch_applications_queue.mjs established — because the claim under test is
31
+ // what a denied builder READS. A source grep can see that the string changed; it
32
+ // cannot see which sentence a 403 actually paints.
33
+ //
34
+ // Run: node tests/watch_sealed_floor.mjs
35
+
36
+ import { strict as assert } from 'node:assert';
37
+ import fs from 'node:fs';
38
+ import path from 'node:path';
39
+ import vm from 'node:vm';
40
+ import { fileURLToPath } from 'node:url';
41
+ import { makeRunner, makeEl, parseInto } from './helpers.mjs';
42
+
43
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
44
+ const HALL = path.join(ROOT, 'modules/hall-ui/public');
45
+ const SRC = fs.readFileSync(path.join(HALL, 'watch.js'), 'utf8');
46
+ const PAGE = fs.readFileSync(path.join(HALL, 'watch.html'), 'utf8');
47
+
48
+ const { test, summary } = makeRunner();
49
+
50
+ const escapeHtml = (s) => String(s == null ? '' : s).replace(/[&<>"']/g,
51
+ (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
52
+
53
+ const tick = () => new Promise((r) => setTimeout(r, 0));
54
+ async function settle() { for (let i = 0; i < 40; i++) await tick(); }
55
+
56
+ const ok = (data) => ({ status: 200, ok: true, data });
57
+
58
+ // The ADR 0116 envelope requirePermission actually sends: the sentence, plus
59
+ // `details.floors` mapping each MISSING permission key to its catalog floor.
60
+ const denied = (floors) => ({
61
+ status: 403,
62
+ ok: false,
63
+ data: {
64
+ error: {
65
+ code: 'permission_forbidden',
66
+ message: 'missing permission',
67
+ details: { required: Object.keys(floors || {}), held: [], floors: floors || null },
68
+ },
69
+ },
70
+ });
71
+
72
+ // The label the page is expected to render the rank THROUGH. Distinct from the
73
+ // bare rank key on purpose: if a test passed when the page printed the raw key,
74
+ // it would also pass on an instance that renamed its ranks and got the old word.
75
+ const LABELS = { archon: 'Archon', metic: 'Metic', thetes: 'Thetes', xenos: 'Xenos' };
76
+
77
+ // ===========================================================================
78
+ // Boot the real page, scripting one answer per route.
79
+ // ===========================================================================
80
+
81
+ async function boot({ answers = {}, fallback = ok({}) } = {}) {
82
+ const api = {
83
+ async request(method, p) {
84
+ const url = String(p).replace('/api/bongos', '');
85
+ const route = url.split('?')[0];
86
+ if (method === 'GET' && route === '/me') {
87
+ return ok({ builder: { id: 1, rank: 'metic', github_login: 'someone' } });
88
+ }
89
+ if (Object.prototype.hasOwnProperty.call(answers, route)) return answers[route];
90
+ return fallback;
91
+ },
92
+ };
93
+
94
+ const documentObj = {
95
+ readyState: 'complete',
96
+ body: makeEl('body'),
97
+ documentElement: makeEl('html'),
98
+ createElement: (tag) => makeEl(tag),
99
+ getElementById(id) { return this.body.querySelector(`#${id}`); },
100
+ querySelector(sel) { return this.body.querySelector(sel); },
101
+ querySelectorAll(sel) { return this.body.querySelectorAll(sel); },
102
+ addEventListener() {},
103
+ };
104
+ parseInto(documentObj.body, PAGE);
105
+
106
+ const locationObj = { hostname: 'builders.example.test', pathname: '/builders/watch', search: '', hash: '', assign() {} };
107
+
108
+ const kit = {
109
+ makeTabs: () => ({}),
110
+ makeLedger: () => ({ render() {} }),
111
+ makePager: () => ({ page: 1, perPage: 25 }),
112
+ emptyStateHtml: (msg) => `<div class="empty-state"><p>${escapeHtml(msg)}</p></div>`,
113
+ makeFilterBar: (o) => ({ el: o.mount, values: () => ({}) }),
114
+ };
115
+
116
+ const windowObj = {
117
+ __BRANDING__: { project: { joinability: 'apply' } },
118
+ OTB: {
119
+ escapeHtml,
120
+ $: (sel) => documentObj.querySelector(sel),
121
+ num: (v) => Number(v),
122
+ fmtInt: (n) => String(n),
123
+ fmtCompact: (n) => String(n),
124
+ fmtDate: (iso) => String(iso).slice(0, 10),
125
+ rankLabel: (r) => LABELS[String(r)] || '',
126
+ toast: () => {},
127
+ },
128
+ OTBKit: kit,
129
+ OTBBranding: { currencyLabel: () => 'Credits' },
130
+ BongosClient: { createClient: () => api },
131
+ location: locationObj,
132
+ addEventListener() {},
133
+ };
134
+
135
+ const sandbox = {
136
+ window: windowObj, document: documentObj, location: locationObj,
137
+ console: { log() {}, warn() {}, error() {} },
138
+ setTimeout, clearTimeout, Promise, Date, Math, JSON, Error, isNaN,
139
+ Number, String, Object, Array, Boolean, Set, Map, RegExp,
140
+ encodeURIComponent, decodeURIComponent, parseInt, parseFloat,
141
+ };
142
+ sandbox.globalThis = sandbox;
143
+ vm.createContext(sandbox);
144
+ vm.runInContext(SRC, sandbox, { filename: 'watch.js' });
145
+ await settle();
146
+
147
+ const sealed = (id) => {
148
+ const el = documentObj.getElementById(id);
149
+ const p = el && el.querySelector('.watch-unavailable');
150
+ return p ? p.textContent : null;
151
+ };
152
+ return { sealed, documentObj };
153
+ }
154
+
155
+ // ===========================================================================
156
+ // The floor comes from the denial
157
+ // ===========================================================================
158
+
159
+ await test('an archon-floored panel still says Archon', async () => {
160
+ // access_request.review really is archon-floored, so the old copy happened to
161
+ // be right HERE. The fix must not "correct" a sentence that was already true.
162
+ const h = await boot({ answers: { '/access-requests': denied({ 'access_request.review': 'archon' }) } });
163
+ const msg = h.sealed('gate-body');
164
+ assert.ok(msg, 'the access-requests panel rendered no sealed message');
165
+ assert.match(msg, /^Archon and up\./, `expected the archon floor, got: ${msg}`);
166
+ assert.match(msg, /Access requests are not visible at your rank\./, `the subject clause was lost: ${msg}`);
167
+ });
168
+
169
+ await test('a metic-floored panel says Metic — the defect, in one assertion', async () => {
170
+ // This is the sentence a metic used to read as "Archons only" on a page they
171
+ // could open, having been told the ladder was something it is not.
172
+ const h = await boot({ answers: { '/audit-log': denied({ 'audit_log.read': 'metic' }) } });
173
+ const msg = h.sealed('audit-body');
174
+ assert.ok(msg, 'the audit-log panel rendered no sealed message');
175
+ assert.match(msg, /^Metic and up\./, `expected the metic floor, got: ${msg}`);
176
+ assert.doesNotMatch(msg, /Archon/, `the retired floor came back: ${msg}`);
177
+ });
178
+
179
+ await test('two panels denied at DIFFERENT floors say different things', async () => {
180
+ // The whole reason the floor is read rather than written: one page, two true
181
+ // answers. A blanket rewrite in either direction fails this test.
182
+ const h = await boot({
183
+ answers: {
184
+ '/access-requests': denied({ 'access_request.review': 'archon' }),
185
+ '/builders/roster': denied({ 'builder.roster.read': 'metic' }),
186
+ },
187
+ });
188
+ assert.match(h.sealed('gate-body'), /^Archon and up\./);
189
+ assert.match(h.sealed('roster-body'), /^Metic and up\./);
190
+ });
191
+
192
+ // ===========================================================================
193
+ // The honest silences
194
+ // ===========================================================================
195
+
196
+ await test('a denial carrying NO floor names no rank at all', async () => {
197
+ // The government port is optional (ADR 0083), so `floors` can legitimately be
198
+ // absent. Naming a rank anyway is guessing, and guessing is what this task is.
199
+ const h = await boot({ answers: { '/audit-log': { status: 403, ok: false, data: null } } });
200
+ const msg = h.sealed('audit-body');
201
+ assert.equal(msg, 'The audit log is not visible at your rank.',
202
+ `a floorless denial must render the subject alone, got: ${msg}`);
203
+ });
204
+
205
+ await test('a denial on keys with CONFLICTING floors names no rank', async () => {
206
+ // A multi-atom gate can deny on two keys with different floors. There is no
207
+ // single honest sentence, so the panel says none rather than picking the one
208
+ // that happens to sort first.
209
+ const h = await boot({
210
+ answers: { '/audit-log': denied({ 'audit_log.read': 'metic', 'something.else': 'archon' }) },
211
+ });
212
+ const msg = h.sealed('audit-body');
213
+ assert.equal(msg, 'The audit log is not visible at your rank.',
214
+ `an ambiguous floor must not be resolved by guessing, got: ${msg}`);
215
+ });
216
+
217
+ // ===========================================================================
218
+ // The literal cannot come back
219
+ // ===========================================================================
220
+
221
+ await test('no sealed message on this page hardcodes a rank name', async () => {
222
+ // The source half of the claim: a future edit that re-writes "Archons only."
223
+ // into a sealedMsg reds here even if it never renders in a test.
224
+ const strings = [...SRC.matchAll(/sealedMsg:\s*'([^']*)'/g)].map((m) => m[1]);
225
+ assert.ok(strings.length >= 10, `expected the page's sealed messages, found ${strings.length}`);
226
+ for (const s of strings) {
227
+ assert.doesNotMatch(s, /Archon|Metic|Thetes|Xenos/i,
228
+ `a sealed message names a rank in its own text: "${s}". The rank comes from `
229
+ + 'the 403, or the sentence does not claim one.');
230
+ }
231
+ });
232
+
233
+ await test('the 403 path carries the floor rather than discarding the body', async () => {
234
+ // fetchJson used to throw a bare { kind: '403' }, which is what made every
235
+ // panel's sentence a guess. Pin the shape, not just the rendered output.
236
+ assert.match(SRC, /details\.floors/,
237
+ 'fetchJson must read details.floors off the denial — otherwise the page is '
238
+ + 'back to writing the floor itself');
239
+ });
240
+
241
+ summary('watch sealed floor (task 1003449)');