@bongos/core 1.19.882 → 1.19.884

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 (40) hide show
  1. package/.bongos-core.json +63 -58
  2. package/clients/bongos-client/index.d.ts +3 -3
  3. package/docs/adr/0309-durable-module-enablement-db-override.md +116 -0
  4. package/docs/adr/README.md +1 -0
  5. package/docs/api/openapi.json +10 -2
  6. package/docs/copy-inventory.md +24 -23
  7. package/docs/copy-registry.json +34 -25
  8. package/docs/file-map.md +1 -2
  9. package/docs/module-api-changelog.md +5 -1
  10. package/docs/recipes/bongos-cli-release.md +1 -1
  11. package/docs/recipes/managed-settings-remote-control.md +1 -1
  12. package/migrations/core_248_module_enablement_overrides.sql +50 -0
  13. package/modules/dev-box/app/README.md +4 -3
  14. package/modules/hall-ui/public/collab-lib.js +33 -1
  15. package/modules/hall-ui/public/collab.css +33 -4
  16. package/modules/hall-ui/public/collab.html +9 -2
  17. package/modules/hall-ui/public/collab.js +68 -15
  18. package/modules/hall-ui/public/dom-utils.js +23 -1
  19. package/modules/lifecycle/routes/tasks.js +18 -4
  20. package/package-lock.json +2 -2
  21. package/package.json +1 -1
  22. package/scripts/gds/ship-land.js +103 -14
  23. package/scripts/gds/ship-merge.js +28 -0
  24. package/scripts/gds/ship.js +8 -0
  25. package/scripts/hall-preview/server.js +38 -1
  26. package/src/bongos/module-overrides.js +117 -0
  27. package/src/bongos/routes/modules.js +56 -23
  28. package/src/module-api.js +1 -1
  29. package/src/modules.js +120 -14
  30. package/src/platform-server.js +47 -5
  31. package/tests/box_sweep_docs.mjs +7 -3
  32. package/tests/collab_page.mjs +136 -1
  33. package/tests/modules.mjs +110 -0
  34. package/tests/modules_route_wiring.mjs +103 -0
  35. package/tests/publish_branch_route.mjs +70 -2
  36. package/tests/ship_land_proof.mjs +218 -0
  37. package/docs/recipes/builder-box-lifecycle.md +0 -181
  38. package/docs/recipes/devbox-app-release.md +0 -103
  39. package/docs/recipes/devbox-app-windows-test.md +0 -128
  40. package/docs/recipes/work-on-a-task-from-your-dev-box.md +0 -122
@@ -0,0 +1,117 @@
1
+ // src/bongos/module-overrides.js — durable module enablement (task 1002857,
2
+ // migration core_248).
3
+ //
4
+ // WHAT THIS FIXES. The hall's Modules-tab toggle wrote config/modules.json and
5
+ // reloaded the cache, so the effect was immediate on the box — but that file is
6
+ // the committed instance pack, and the pull-deploy runs `git reset --hard
7
+ // origin/main`. The toggle therefore reverted on the next deploy, silently,
8
+ // unless a human remembered to commit it. This module moves the durable half of
9
+ // that decision into the database, where the rest of this instance's live state
10
+ // already lives.
11
+ //
12
+ // THE SHAPE, and why it is this shape. src/modules.js's loadModules() is
13
+ // SYNCHRONOUS — a lazy cached singleton every caller reaches through modules() —
14
+ // and making it async would ripple into effectively every consumer of a module
15
+ // flag. So the database is never read inline. Instead this file reads the
16
+ // overrides ONCE per refresh and hands src/modules.js a plain snapshot, which its
17
+ // pure resolver treats as one more precedence layer. The refresh points are the
18
+ // two moments the answer can change: server boot, and a write through this file.
19
+ //
20
+ // FAILURE IS A NO-OP, DELIBERATELY. Every function here swallows its database
21
+ // error and leaves the snapshot alone. An empty snapshot means "no opinion", not
22
+ // "everything off", so an unreachable database resolves modules exactly as this
23
+ // instance resolved them before the table existed. The alternative — letting a
24
+ // DB outage change which modules are mounted — would make a read-path dependency
25
+ // out of a feature whose entire purpose is durability.
26
+
27
+ const { pool } = require('./pool');
28
+ // Named imports rather than a namespace object (task 1004052 strand walk): knip cannot
29
+ // follow property access on a CommonJS namespace, so reaching these through one made
30
+ // moduleScopeKey and setModuleOverrides read as unused exports of src/modules.js and
31
+ // tripped the dead-code ratchet — which is what held this branch out of main for 20h.
32
+ const { moduleScopeKey, setModuleOverrides } = require('../modules');
33
+
34
+ // One log line per distinct failure reason, not per call: refreshOverrides() runs
35
+ // on boot and after every toggle, and a persistently unreachable database would
36
+ // otherwise fill the log with the same sentence.
37
+ const _warned = new Set();
38
+ function warnOnce(reason, err, log = console) {
39
+ if (_warned.has(reason)) return;
40
+ _warned.add(reason);
41
+ log.warn(
42
+ `[bongos] module overrides ${reason}: ${err && err.message}. Module enablement falls back to the `
43
+ + 'config packs and env for now — no module changes state because of this.',
44
+ );
45
+ }
46
+
47
+ // Read every override for THIS instance's scope and install it as the live
48
+ // snapshot. Returns the resolved module set on success, or null if the database
49
+ // could not be read (in which case the previous snapshot stands).
50
+ async function refreshOverrides({ scope = moduleScopeKey() } = {}) {
51
+ let rows;
52
+ try {
53
+ ({ rows } = await pool.query(
54
+ 'SELECT module_key, enabled FROM module_enablement_overrides WHERE scope = $1',
55
+ [scope],
56
+ ));
57
+ } catch (err) {
58
+ warnOnce('could not be read', err);
59
+ return null;
60
+ }
61
+ const map = {};
62
+ for (const r of rows) map[r.module_key] = r.enabled;
63
+ return setModuleOverrides(map);
64
+ }
65
+
66
+ // Record an operator's toggle and make it live in the same call. The UPSERT is
67
+ // keyed on (scope, module_key) so a second toggle of the same module updates the
68
+ // row rather than accumulating history — the audit trail this table carries is
69
+ // "who set the CURRENT state, and when", which is what an operator looking at a
70
+ // surprising module needs. Throws on a write failure: unlike a read, a write that
71
+ // silently did nothing would tell the operator their toggle was durable when it
72
+ // was not, which is the original bug wearing a different hat.
73
+ async function setOverride(moduleKey, enabled, { builderId = null, scope = moduleScopeKey() } = {}) {
74
+ await pool.query(
75
+ `INSERT INTO module_enablement_overrides (scope, module_key, enabled, set_by, set_at)
76
+ VALUES ($1, $2, $3, $4, now())
77
+ ON CONFLICT (scope, module_key)
78
+ DO UPDATE SET enabled = EXCLUDED.enabled, set_by = EXCLUDED.set_by, set_at = now()`,
79
+ [scope, moduleKey, enabled, builderId],
80
+ );
81
+ return refreshOverrides({ scope });
82
+ }
83
+
84
+ // Drop the override and fall back to whatever the packs and env say. This is the
85
+ // only way back to "no opinion": setting the override to the pack's own value
86
+ // would look identical in the UI but would keep outranking a later edit to the
87
+ // committed pack.
88
+ async function clearOverride(moduleKey, { scope = moduleScopeKey() } = {}) {
89
+ await pool.query(
90
+ 'DELETE FROM module_enablement_overrides WHERE scope = $1 AND module_key = $2',
91
+ [scope, moduleKey],
92
+ );
93
+ return refreshOverrides({ scope });
94
+ }
95
+
96
+ // The rows behind the current snapshot, for the hall's Modules tab: who set each
97
+ // override and when, which the file-write path never recorded. Read-only and
98
+ // failure-tolerant for the same reason refreshOverrides is — a broken audit read
99
+ // must not break the page that lists the modules.
100
+ async function listOverrides({ scope = moduleScopeKey() } = {}) {
101
+ try {
102
+ const { rows } = await pool.query(
103
+ `SELECT o.module_key, o.enabled, o.set_by, o.set_at, b.github_login AS set_by_login
104
+ FROM module_enablement_overrides o
105
+ LEFT JOIN builders b ON b.id = o.set_by
106
+ WHERE o.scope = $1
107
+ ORDER BY o.module_key`,
108
+ [scope],
109
+ );
110
+ return rows;
111
+ } catch (err) {
112
+ warnOnce('could not be listed', err);
113
+ return [];
114
+ }
115
+ }
116
+
117
+ module.exports = { refreshOverrides, setOverride, clearOverride, listOverrides };
@@ -23,23 +23,28 @@
23
23
  // /backup/trigger — it changes running behavior for the whole instance (or
24
24
  // files something into a shared review queue), not just the caller.
25
25
  //
26
- // Persistence note: /modules/:key/enable writes config/modules.json on disk
27
- // and reloads the in-process cache, so the effect is immediate on this box.
28
- // Like any other hand-edit of that file, surviving the next `git reset --hard`
29
- // deploy needs the change committed — this route does not auto-commit (an
30
- // HTTP handler auto-pushing to main bypasses the ship/grade/deploy pipeline,
31
- // which the project's trust boundary treats as a bigger risk than a toggle
32
- // that needs a human/agent to land it properly). Captured as a follow-up
33
- // idea: durable (DB-backed) module enablement that survives a deploy with no
34
- // extra step.
26
+ // Persistence note (task 1002857, migration core_248, ADR 0309):
27
+ // /modules/:key/enable writes a row in module_enablement_overrides and reloads
28
+ // the in-process cache, so the toggle is both immediate on this box AND durable
29
+ // past the next deploy. It used to write config/modules.json, which the
30
+ // pull-deploy's `git reset --hard origin/main` then reverted unless a human
31
+ // remembered to commit it — silently, which was the bug.
32
+ //
33
+ // The route still does not auto-commit, and that has not changed: an HTTP
34
+ // handler pushing to main would bypass the ship/grade/deploy pipeline and the
35
+ // ADR 0016 trust boundary. Durability comes from the database, where this
36
+ // instance's live state already lives, not from a writer on main.
37
+ //
38
+ // Precedence, stated where the toggle is: neutral pack -> instance pack -> THIS
39
+ // override -> env. An operator can still force a module off on one box with
40
+ // <PREFIX>_MODULE_<KEY>, and that env knob outranks anything this route writes —
41
+ // deliberately, because the incident lever must not need a reachable database.
35
42
  //
36
43
  // Why the review queue is a table in THIS instance's own DB (not a live
37
44
  // cross-repo call to the extracted core repo): see
38
45
  // docs/adr/0135-module-upstream-submission-interim-queue.md.
39
46
 
40
47
  const express = require('express');
41
- const fs = require('node:fs');
42
- const path = require('node:path');
43
48
  const auth = require('../auth');
44
49
  const { asyncHandler, validateOrRespond, LIMITS } = require('./_helpers');
45
50
  const loader = require('../../module-loader/loader');
@@ -53,6 +58,10 @@ const modulesLib = require('../../modules');
53
58
  // server process.
54
59
  const moduleCli = require('../../../scripts/gds/module');
55
60
  const moduleSubmissions = require('../module-submissions');
61
+ // Named imports, not a namespace object (task 1004052 strand walk): knip cannot follow
62
+ // property access on a CommonJS namespace, so reaching these through one made them read
63
+ // as unused exports and tripped the dead-code ratchet that held this branch out of main.
64
+ const { listOverrides, setOverride } = require('../module-overrides');
56
65
 
57
66
  // A packaged submission embeds every file's content (base64) — cap the total
58
67
  // so a runaway/misbuilt module can't bloat the review-queue table. Generous
@@ -75,11 +84,6 @@ function sourceRepoLabel() {
75
84
  } catch { return null; }
76
85
  }
77
86
 
78
- function readInstanceConfig() {
79
- try { return JSON.parse(fs.readFileSync(modulesLib.INSTANCE_PATH, 'utf8')); }
80
- catch (e) { if (e.code === 'ENOENT') return { modules: {} }; throw e; }
81
- }
82
-
83
87
  // The catalog model (src/module-loader/catalog.js) is the SINGLE builder behind
84
88
  // both surfaces — this route and `bongos module list` — so the hall and the
85
89
  // terminal can never disagree about who built a module or which tree it ships
@@ -108,8 +112,25 @@ module.exports = function buildModulesRouter() {
108
112
  // rank: any-builder — list every on-disk module with attribution + provenance (same read gate as the atlas/primer pages).
109
113
  router.get('/modules', auth.requireBuilder, asyncHandler('GET /modules', async (_req, res) => {
110
114
  const all = listModules();
115
+ // WHICH LAYER PRODUCED EACH STATE, so an operator can tell a durable override
116
+ // from a committed pack value from an env knob without opening a shell
117
+ // (task 1002857). Both reads are failure-tolerant, so a broken override table
118
+ // degrades this to the listing it was before rather than breaking the tab.
119
+ const resolvedSet = modulesLib.modules();
120
+ const sources = (resolvedSet && resolvedSet.sources) || {};
121
+ const overrideRows = await listOverrides();
122
+ const byKey = new Map(overrideRows.map((r) => [r.module_key, r]));
123
+ for (const m of all) {
124
+ m.enabled_source = sources[m.key] || 'default';
125
+ m.override = byKey.has(m.key)
126
+ ? { enabled: byKey.get(m.key).enabled, set_by: byKey.get(m.key).set_by_login || null, set_at: byKey.get(m.key).set_at }
127
+ : null;
128
+ }
111
129
  res.json({
112
130
  modules: all,
131
+ // The override scope this process runs under — two instances sharing one
132
+ // database differ only here, so a surprising answer is diagnosable.
133
+ scope: modulesLib.moduleScopeKey(),
113
134
  own_built: all.filter((m) => m.provenance === 'own').length,
114
135
  core_loaded: all.filter((m) => m.provenance === 'core').length,
115
136
  // The catalog roll-up (task 1001772): how many modules the core ships that
@@ -137,15 +158,27 @@ module.exports = function buildModulesRouter() {
137
158
  return res.fail('not_found', { status: 404, message: `No on-disk module "${key}" — only a real modules/<key>/ can be toggled.` });
138
159
  }
139
160
 
140
- const cfg = readInstanceConfig();
141
- if (!cfg.modules || typeof cfg.modules !== 'object') cfg.modules = {};
142
- cfg.modules[key] = enabled;
143
- fs.mkdirSync(path.dirname(modulesLib.INSTANCE_PATH), { recursive: true });
144
- fs.writeFileSync(modulesLib.INSTANCE_PATH, JSON.stringify(cfg, null, 2) + '\n');
145
- modulesLib.reload();
161
+ // setOverride throws if the write fails, and that is the point: a toggle
162
+ // that reported success while persisting nothing would be the original bug
163
+ // with better manners. The read path (refreshOverrides) is failure-tolerant;
164
+ // the write path deliberately is not.
165
+ const resolvedSet = await setOverride(key, enabled, { builderId: req.builder && req.builder.id });
166
+ const effective = resolvedSet ? resolvedSet[key] === true : enabled;
146
167
 
147
168
  console.log(`[gds] module "${key}" ${enabled ? 'enabled' : 'disabled'} by builder ${req.builder && req.builder.id}`);
148
- res.json({ ok: true, key, enabled, note: 'Live on this box now. Persisting past the next deploy needs config/modules.json committed.' });
169
+ res.json({
170
+ ok: true,
171
+ key,
172
+ enabled,
173
+ // What the module ACTUALLY resolved to, which is not always what was asked:
174
+ // an env override or the spend guard outranks this write, and an operator
175
+ // who is not told so will re-click the toggle and blame the feature.
176
+ effective,
177
+ source: (resolvedSet && resolvedSet.sources && resolvedSet.sources[key]) || 'override',
178
+ note: effective === enabled
179
+ ? 'Live on this box now, and durable — it survives the next deploy.'
180
+ : `Recorded, but "${key}" still resolves to ${effective}: a higher-precedence layer (env, or the spend guard) is winning.`,
181
+ });
149
182
  }));
150
183
 
151
184
  // POST /api/bongos/modules/:key/submit — ADR 0107 §1 upstreaming submission.
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.882'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.884'; // 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
@@ -186,6 +186,12 @@ function resolveModules({
186
186
  // is the one that reads the real posture, and the one that fails closed when it
187
187
  // cannot.
188
188
  spendGuard = NO_SPEND_RISK, warn = warnSpendRefusal,
189
+ // THE DB OVERRIDE LAYER (task 1002857) — a { <moduleKey>: boolean } snapshot of
190
+ // module_enablement_overrides for this scope, injected rather than read, so this
191
+ // resolver stays pure and synchronous. Defaulted to {} so every existing caller
192
+ // resolves byte-identically to before; an unreachable database passes {} too,
193
+ // which is what makes the DB a durable ADDITION rather than a boot dependency.
194
+ override = {},
189
195
  } = {}) {
190
196
  // The known-key set + defaults come from the passed registry: callers inject
191
197
  // the built-in MODULE_REGISTRY (the default, what the pure unit tests use) or
@@ -217,7 +223,38 @@ function resolveModules({
217
223
  return out;
218
224
  };
219
225
 
220
- const merged = { ...fromConfig(neutral, 'neutral'), ...fromConfig(instance, 'instance') };
226
+ // PRECEDENCE: neutral pack -> instance pack -> DB override -> env -> registry default.
227
+ //
228
+ // ABOVE THE INSTANCE PACK, because outranking the committed file is the entire
229
+ // point: the file is what `git reset --hard` restores on every deploy, so an
230
+ // override that sat below it would be erased by the very event it exists to
231
+ // survive.
232
+ //
233
+ // BELOW ENV, because env is the incident knob. An operator must always be able
234
+ // to force a module off on one box without a working database — and if the DB
235
+ // outranked env, the failure mode would be "the module I am trying to kill is
236
+ // being switched back on by a row I cannot reach to delete".
237
+ const fromOverride = (o) => {
238
+ const out = {};
239
+ for (const [k, v] of Object.entries(isObj(o) ? o : {})) {
240
+ // A row for a module this build does not carry is INERT, not an error. The
241
+ // DB outlives any one build: a module can be uninstalled while its override
242
+ // row remains, and that must not stop the process booting. This is the one
243
+ // place the unknown-key rule is deliberately softer than fromConfig's, which
244
+ // throws on enabling an unknown module — a config file is authored by hand
245
+ // for THIS build, a table is not.
246
+ if (known.includes(k)) out[k] = v;
247
+ }
248
+ return out;
249
+ };
250
+ const layers = [
251
+ ['neutral', fromConfig(neutral, 'neutral')],
252
+ ['instance', fromConfig(instance, 'instance')],
253
+ ['override', fromOverride(override)],
254
+ ['env', {}], // filled by the env pass below; named here so the order is stated once
255
+ ];
256
+ const envLayer = layers[3][1];
257
+ const merged = Object.assign({}, ...layers.map(([, m]) => m));
221
258
 
222
259
  // Env overrides: <PREFIX>_MODULE_<KEY> where KEY is upper-snake of the module
223
260
  // name (dev-box -> DEV_BOX). Highest precedence.
@@ -232,6 +269,7 @@ function resolveModules({
232
269
  const b = coerceBool(raw);
233
270
  if (b === undefined) throw new Error(`modules: ${envName}="${raw}" is not a boolean (use 1/0, on/off, true/false)`);
234
271
  merged[key] = b;
272
+ envLayer[key] = b;
235
273
  }
236
274
  }
237
275
 
@@ -240,14 +278,27 @@ function resolveModules({
240
278
  const v = key in merged ? coerceBool(merged[key]) : undefined;
241
279
  resolved[key] = v === undefined ? registry[key].default : v;
242
280
  }
281
+ // WHICH LAYER WON, computed in the SAME pass as the value so the two cannot
282
+ // disagree (task 1002857). An operator looking at a module that is on needs to
283
+ // know whether that came from the committed pack, a durable override or an env
284
+ // knob on this box — without it, "why is this on?" costs a shell and a grep.
285
+ // Non-enumerable so every existing consumer (Object.keys, JSON.stringify, the
286
+ // clientModules projection) sees exactly the boolean map it saw before.
287
+ const sources = {};
288
+ for (const key of known) {
289
+ const hit = [...layers].reverse().find(([, m]) => key in m);
290
+ sources[key] = hit ? hit[0] : 'default';
291
+ }
243
292
  // THE SPEND GUARD, applied LAST — after config, after env, after the default.
244
293
  // It is deliberately not another precedence level: a refusal that env could
245
294
  // outrank would be a suggestion, and the whole point is that the one knob an
246
295
  // untrusted-but-cheap deploy would reach for cannot turn it back on.
247
296
  for (const key of spendRefusals(resolved, registry, spendGuard)) {
248
297
  resolved[key] = false;
298
+ sources[key] = 'spend-refusal'; // the guard outranks every layer, so it owns the answer
249
299
  warn(key, envPrefix);
250
300
  }
301
+ Object.defineProperty(resolved, 'sources', { value: Object.freeze(sources), enumerable: false });
251
302
  return Object.freeze(resolved);
252
303
  }
253
304
 
@@ -276,19 +327,53 @@ function readJson(p) {
276
327
  // Like branding's selector, these are FIXED env names (read before the resolved
277
328
  // envPrefix is known), not <PREFIX>_ ones. A relative path resolves against the
278
329
  // repo root.
330
+ // WHICH instance pack this process runs, by the precedence documented above.
331
+ // Extracted from loadModules (task 1002857) so moduleScopeKey() and the actual
332
+ // read cannot disagree about which pack is in play — a scope key derived from a
333
+ // second, parallel copy of this ladder would drift the first time one changed.
334
+ function resolveInstancePackPath({ instancePath, env = process.env } = {}) {
335
+ if (instancePath !== undefined) return instancePath;
336
+ // Canonical spelling pinned to FALLBACK_ENV_PREFIX for the same reason as
337
+ // branding's selector — read before the pack that names the prefix. The
338
+ // legacy prefixes still resolve (one warning each), so an already-deployed
339
+ // BONGOS_MODULES_FILE / BONGOS_BRANDING_FILE keeps selecting the same pack.
340
+ const fromEnv = resolveEnv('MODULES_FILE', { env, prefix: FALLBACK_ENV_PREFIX });
341
+ if (fromEnv) return path.resolve(resolveInstanceRoot(), fromEnv);
342
+ if (resolveEnv('BRANDING_FILE', { env, prefix: FALLBACK_ENV_PREFIX })) return NEUTRAL_PATH;
343
+ return INSTANCE_PATH;
344
+ }
345
+
346
+ // THE SCOPE KEY for module_enablement_overrides (task 1002857).
347
+ //
348
+ // One deployed checkout can run two instances against ONE database, differentiated
349
+ // only by BONGOS_MODULES_FILE / BONGOS_BRANDING_FILE — the default instance on
350
+ // config/modules.json and a vanilla sibling on config/modules.neutral.json. The
351
+ // pack path is therefore not an incidental detail: it is the ONLY axis those two
352
+ // processes differ on, which makes it the honest scope key. A single global row
353
+ // per module would let one sibling's toggle flip the other's module.
354
+ //
355
+ // Normalised — relative to the instance root, posix separators — so the key is
356
+ // stable across a moved checkout, an absolute-vs-relative env value, and Windows
357
+ // vs Linux. A path OUTSIDE the instance root (possible via an absolute
358
+ // BONGOS_MODULES_FILE) keeps its absolute form rather than a '../../..' walk, which
359
+ // would encode the checkout's position on disk into a database row.
360
+ function moduleScopeKey({ instancePath, env = process.env } = {}) {
361
+ const p = resolveInstancePackPath({ instancePath, env });
362
+ const rel = path.relative(resolveInstanceRoot(), p);
363
+ const key = rel && !rel.startsWith('..') && !path.isAbsolute(rel) ? rel : p;
364
+ return key.split(path.sep).join('/');
365
+ }
366
+
367
+ // The DB override snapshot, read SYNCHRONOUSLY by loadModules and refreshed
368
+ // asynchronously by src/bongos/module-overrides.js. It starts empty and stays
369
+ // empty if the database is never reached, which is what keeps this a durable
370
+ // addition rather than a boot-time dependency: no row, no opinion, resolve as
371
+ // before.
372
+ let _overrides = {};
373
+
279
374
  function loadModules({ neutralPath = NEUTRAL_PATH, instancePath, env = process.env } = {}) {
280
375
  const neutral = readJson(neutralPath) || {};
281
- let ip = instancePath;
282
- if (ip === undefined) {
283
- // Canonical spelling pinned to FALLBACK_ENV_PREFIX for the same reason as
284
- // branding's selector — read before the pack that names the prefix. The
285
- // legacy prefixes still resolve (one warning each), so an already-deployed
286
- // GDS_MODULES_FILE / GDS_BRANDING_FILE keeps selecting the same pack.
287
- const override = resolveEnv('MODULES_FILE', { env, prefix: FALLBACK_ENV_PREFIX });
288
- if (override) ip = path.resolve(resolveInstanceRoot(), override);
289
- else if (resolveEnv('BRANDING_FILE', { env, prefix: FALLBACK_ENV_PREFIX })) ip = NEUTRAL_PATH;
290
- else ip = INSTANCE_PATH;
291
- }
376
+ const ip = resolveInstancePackPath({ instancePath, env });
292
377
  const instance = readJson(ip) || {};
293
378
  // Reuse the branding env prefix so an instance has ONE prefix, not two.
294
379
  let envPrefix = FALLBACK_ENV_PREFIX;
@@ -323,7 +408,9 @@ function loadModules({ neutralPath = NEUTRAL_PATH, instancePath, env = process.e
323
408
  } catch { /* unreadable posture — the fail-closed default above stands */ }
324
409
  // Resolve against the EFFECTIVE registry (built-in + discovered) so a config
325
410
  // flag for a discovered module is honored. No-op until a module exists.
326
- return resolveModules({ neutral, instance, env, envPrefix, registry: effectiveRegistry(), spendGuard });
411
+ return resolveModules({
412
+ neutral, instance, env, envPrefix, registry: effectiveRegistry(), spendGuard, override: _overrides,
413
+ });
327
414
  }
328
415
 
329
416
  // Resolved singleton for app code; lazy so require-time never crashes tooling.
@@ -333,7 +420,22 @@ const modules = () => (_cached ||= loadModules());
333
420
  // read (a config-file write from the hall's Modules-tab toggle, ADR 0107 §9;
334
421
  // also used by tests). Cheap: the next modules() call just re-reads disk.
335
422
  const reload = () => { _cached = null; return modules(); };
336
- const _resetForTests = () => { _cached = null; };
423
+ // Replace the override snapshot and invalidate, in one step — a caller that set
424
+ // the snapshot without reloading would leave the live process on a stale answer,
425
+ // which is the exact class of bug this feature exists to remove. Returns the newly
426
+ // resolved set so a route can report what it actually produced rather than what it
427
+ // asked for.
428
+ const setModuleOverrides = (map) => {
429
+ const clean = {};
430
+ for (const [k, v] of Object.entries(map && typeof map === 'object' ? map : {})) {
431
+ const b = coerceBool(v);
432
+ if (b !== undefined) clean[k] = b;
433
+ }
434
+ _overrides = Object.freeze(clean);
435
+ return reload();
436
+ };
437
+ const moduleOverrides = () => _overrides;
438
+ const _resetForTests = () => { _cached = null; _overrides = {}; };
337
439
 
338
440
  function isModuleEnabled(name, set = modules()) {
339
441
  const known = knownModules();
@@ -388,6 +490,10 @@ module.exports = {
388
490
  spendRefusals,
389
491
  warnSpendRefusal,
390
492
  resolveModules,
493
+ resolveInstancePackPath,
494
+ moduleScopeKey,
495
+ setModuleOverrides,
496
+ moduleOverrides,
391
497
  loadModules,
392
498
  modules,
393
499
  reload,
@@ -49,6 +49,13 @@ const { mountInternalSurfaces, isStatusHost, isBuildersHost, injectBrandingGloba
49
49
  const { isApiShapedPath, apiNotFoundBody } = require('./bongos/api-path-404');
50
50
  const moduleLoader = require('./module-loader/loader');
51
51
  const { isModuleEnabled } = require('./modules');
52
+ // Hoisted out of an inline require inside the boot IIFE below (task 1004052 strand walk).
53
+ // knip does not follow an inline require(), so every export of module-overrides.js read as
54
+ // dead code and tripped the ratchet that held this branch out of main. Requiring it here
55
+ // does not weaken the property the comment at the call site protects: refreshOverrides()
56
+ // still swallows its own database error, and node-postgres constructs a Pool without
57
+ // connecting, so a box with no reachable database still boots on the packs and env alone.
58
+ const { refreshOverrides } = require('./bongos/module-overrides');
52
59
  const { logger, requestIdMiddleware } = require('./bongos/logger');
53
60
 
54
61
  const PORT = Number(process.env.PORT) || 3000;
@@ -311,11 +318,46 @@ function startBackgroundServices() {
311
318
  // Start only when run as the entry (`node src/platform-server.js`). Importing the
312
319
  // module (the smoke test) must NOT bind a port.
313
320
  if (require.main === module) {
314
- const app = buildPlatformApp();
315
- const httpServer = http.createServer(app);
316
- httpServer.listen(PORT, HOST, () => {
317
- logger.info({ host: HOST, port: PORT }, 'Cloud Bongos platform (GDS-only, no game; ADR 0062 R51) listening');
318
- startBackgroundServices();
321
+ // DURABLE MODULE ENABLEMENT, read BEFORE the app is built (task 1002857).
322
+ //
323
+ // It has to be before: buildPlatformApp() mounts routes from the resolved module
324
+ // set, so an override that landed after construction would not change what is
325
+ // mounted until the next restart — which is precisely the "my toggle did not
326
+ // stick" complaint this feature exists to answer.
327
+ //
328
+ // And it is NOT a boot dependency. refreshOverrides() swallows its own database
329
+ // error and returns null, so an unreachable database boots this process exactly
330
+ // as it booted before the table existed — on the config packs and env alone. The
331
+ // await buys a better answer when the database is there; it never withholds the
332
+ // server when it is not.
333
+ (async () => {
334
+ await refreshOverrides();
335
+ const app = buildPlatformApp();
336
+ const httpServer = http.createServer(app);
337
+ httpServer.listen(PORT, HOST, () => {
338
+ logger.info({ host: HOST, port: PORT }, 'Cloud Bongos platform (GDS-only, no game; ADR 0062 R51) listening');
339
+ startBackgroundServices();
340
+ });
341
+ })().catch((err) => {
342
+ // THE AWAIT ABOVE MUST NOT SOFTEN A FATAL BOOT (task 1002857, caught by
343
+ // tests/platform_boot.mjs's 1003820 case). buildPlatformApp() THROWS when an
344
+ // enabled module consumes a port nothing provided, and before this block was
345
+ // async that throw was an uncaught exception: the process died, non-zero, which
346
+ // is the correct answer to "half the instance cannot be wired". Inside a promise
347
+ // it became a rejection instead, and the unhandled-rejection backstop below
348
+ // explicitly keeps the process UP — so the server came up half-wired and told
349
+ // nobody. Re-assert the old contract explicitly: a boot failure is fatal.
350
+ //
351
+ // RETHROWN, not logged-and-exited. Before the await this WAS an uncaught
352
+ // exception, and Node's own handling of one is exactly the behaviour wanted:
353
+ // the raw stack on stderr, then a non-zero exit. Re-logging it instead would
354
+ // mean choosing between logger.fatal — which JSON-escapes the module-loader's
355
+ // carefully written "module X consumes port Y, here is how to fix it" into a
356
+ // wall of backslashes at the moment an operator has to read it — and a
357
+ // console call that says less than Node already says. A throw from nextTick
358
+ // is an uncaughtException, NOT an unhandledRejection, so the backstop below
359
+ // (which deliberately keeps the process up) never sees it.
360
+ process.nextTick(() => { throw err; });
319
361
  });
320
362
 
321
363
  // Graceful shutdown — stop each module poller (e.g. close the Discord gateway
@@ -35,7 +35,11 @@ const read = (p) => fs.readFileSync(path.join(ROOT, p), 'utf8');
35
35
  const BOX_JS = 'scripts/gds/box.js';
36
36
  const IDLE_SKILL = '.claude/scheduled-tasks/box-idle-suspend/SKILL.md';
37
37
  const DORMANT_SKILL = '.claude/scheduled-tasks/box-dormant-reclaim/SKILL.md';
38
- const RECIPE = 'docs/recipes/builder-box-lifecycle.md';
38
+ // The fourth surface this guard used to read, docs/recipes/builder-box-lifecycle.md,
39
+ // was deleted with the other three published dev-box recipes (task 1004013,
40
+ // ADR 0305: a recipe lives with what it documents). A file that does not exist
41
+ // cannot invert against box.js, so it drops out of the lists below rather than
42
+ // weakening them — the two SKILL.md surfaces are still read in full.
39
43
 
40
44
  // Comments are stripped before scanning prose: each surface deliberately keeps a
41
45
  // sentence SAYING which model it used to describe, and a naive substring check
@@ -76,7 +80,7 @@ test('no box-sweep doc claims the IDLE sweep destroys, while the code parks', ()
76
80
  /single job is to DESTROY/i,
77
81
  ];
78
82
 
79
- for (const file of [IDLE_SKILL, DORMANT_SKILL, RECIPE]) {
83
+ for (const file of [IDLE_SKILL, DORMANT_SKILL]) {
80
84
  const prose = stripHtmlComments(read(file));
81
85
  for (const lie of lies) {
82
86
  assert.doesNotMatch(prose, lie,
@@ -107,7 +111,7 @@ test('every surface names sweep-dormant as the only automated snapshot delete',
107
111
  // The safety property box.js implements in prose: full reclaim warns first, so
108
112
  // no automated path takes uncommitted work silently. A reader who misses this
109
113
  // is the reader who reaches for deprovision.
110
- for (const file of [IDLE_SKILL, DORMANT_SKILL, RECIPE]) {
114
+ for (const file of [IDLE_SKILL, DORMANT_SKILL]) {
111
115
  assert.match(stripHtmlComments(read(file)), /only automated path that destroys a snapshot|only automated snapshot delete/i,
112
116
  `${file} never tells the reader that sweep-dormant is the only automated path that `
113
117
  + 'destroys a snapshot (and that it warns first) — the safety property this subsystem rests on.');