@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
@@ -23,6 +23,16 @@ function lib() {
23
23
  new Function('window', src)(win);
24
24
  return win.OTBCollabLib;
25
25
  }
26
+
27
+ // The role vocabulary is pure too, so RUN it for the same reason — a grep over
28
+ // roleWord()'s source would agree with a roleWord() that was wrong.
29
+ function domUtils() {
30
+ const src = read('modules', 'hall-ui', 'public', 'dom-utils.js');
31
+ const win = { location: { search: '', hash: '', pathname: '/' }, addEventListener() {}, localStorage: null };
32
+ const doc = { addEventListener() {}, querySelector: () => null, querySelectorAll: () => [], documentElement: { dataset: {} } };
33
+ new Function('window', 'document', src)(win, doc);
34
+ return win.OTB;
35
+ }
26
36
  const NOW = Date.parse('2026-08-24T12:00:00Z');
27
37
  const daysAgo = (d) => new Date(NOW - d * 86400000).toISOString();
28
38
 
@@ -71,8 +81,75 @@ test('ageing changes how a row LOOKS, not only where it sits', () => {
71
81
  test('the row markup carries the band class and the label', () => {
72
82
  const js = read('modules', 'hall-ui', 'public', 'collab.js');
73
83
  assert.match(js, /cb-age cb-age--\$\{band\.id\}/, 'the pill\'s class comes from the band');
74
- assert.match(js, /escapeHtml\(band\.label\)/, 'the label is rendered, escaped');
84
+ assert.match(js, /escapeHtml\(L\.ageLabel\(req\.created_at, nowMs\)\)/, 'the label is rendered, escaped');
75
85
  assert.match(js, /L\.ageBand\(req\.created_at, nowMs\)/, 'through the lib, so the boundaries are the tested ones');
86
+ // THE BAND AND THE LABEL ARE TWO SOURCES ON PURPOSE (task 1004041). Until then
87
+ // the pill rendered `band.label`, so the band decided both how loud the row
88
+ // looked AND what it said — which is why fourteen rows past a week carried
89
+ // fourteen copies of "waiting over a week". The class still comes from the
90
+ // band (four treatments, readable down a column) and the words now come from
91
+ // ageLabel (the real age, different on every row). Asserting only one of the
92
+ // two would let them be collapsed back onto each other.
93
+ assert.ok(!/escapeHtml\(band\.label\)/.test(js), 'the words are the real age, not the band name again');
94
+ });
95
+
96
+ test('two rows in the same band do not read identically', () => {
97
+ // The artist's finding, as an executable claim: a signal that never varies is
98
+ // not a signal. Every one of these is band 'stale', and no two say the same
99
+ // thing.
100
+ const L = lib();
101
+ const said = [8, 9, 13, 21, 40, 400].map((d) => {
102
+ assert.equal(L.ageBand(daysAgo(d), NOW).id, 'stale', `${d} days is stale`);
103
+ return L.ageLabel(daysAgo(d), NOW);
104
+ });
105
+ assert.equal(new Set(said).size, said.length, `six stale rows, six different readings: ${said.join(' / ')}`);
106
+ });
107
+
108
+ test('the age label says how long, coarsely, and never accuses an unknown age', () => {
109
+ const L = lib();
110
+ assert.equal(L.ageLabel(daysAgo(0.4), NOW), 'just asked');
111
+ assert.equal(L.ageLabel(daysAgo(1), NOW), 'waiting a day');
112
+ assert.equal(L.ageLabel(daysAgo(4), NOW), 'waiting 4 days');
113
+ assert.equal(L.ageLabel(daysAgo(13), NOW), 'waiting 13 days');
114
+ // Past a fortnight it goes to weeks: 'waiting 38 days' is a precision nobody
115
+ // acts on, the same argument the bands were written on.
116
+ assert.equal(L.ageLabel(daysAgo(21), NOW), 'waiting 3 weeks');
117
+ for (const bad of [null, undefined, '', 'nonsense']) {
118
+ assert.equal(L.ageLabel(bad, NOW), 'just asked', `${JSON.stringify(bad)} must not look neglected`);
119
+ }
120
+ });
121
+
122
+ test('the two calm bands are not chips, and the date is not said twice', () => {
123
+ // The chip appears where there is something to say. All four bands keep their
124
+ // own ink (the test above), but fresh and waiting drop the pill's ring, so a
125
+ // board of healthy asks does not open as a wall of chips.
126
+ const css = read('modules', 'hall-ui', 'public', 'collab.css');
127
+ assert.match(
128
+ css,
129
+ /\.cb-age--fresh,\s*\n\.cb-age--waiting\s*\{[^}]*border-color: transparent/,
130
+ 'the calm bands read as quiet text, not as chips',
131
+ );
132
+ // And the separate date line is gone: the pill carries the age in words and
133
+ // the exact timestamp on its title, so a date beside it was one fact twice.
134
+ const js = read('modules', 'hall-ui', 'public', 'collab.js');
135
+ assert.ok(!/ov-row__when/.test(js), 'no second rendering of the same fact');
136
+ assert.match(js, /title="Asked \$\{escapeHtml\(String\(req\.created_at/, 'the exact date is still reachable');
137
+ });
138
+
139
+ test('the alarm does not sit beside the accent control', () => {
140
+ // DESIGN.md spends the accent only on what is pressable or live. On "Your
141
+ // asks" the age pill (danger ink) used to share the right column with the
142
+ // solid-accent "Someone helped" button, so a warning and a control sat two
143
+ // centimetres apart in one colour family. The age is a FACT about the ask now;
144
+ // the right column on that row holds only what you can press.
145
+ const js = read('modules', 'hall-ui', 'public', 'collab.js');
146
+ const mine = js.slice(js.indexOf('function mineRow('), js.indexOf('function allRow('));
147
+ assert.ok(mine.length > 100, 'found mineRow');
148
+ const right = mine.slice(mine.indexOf('ov-row__right'));
149
+ assert.ok(!right.includes('ageChip'), 'no age pill in the column that holds the accent button');
150
+ assert.ok(right.includes('btn-accent'), 'the accent button is still there');
151
+ const facts = mine.slice(mine.indexOf('ov-row__facts'), mine.indexOf('ov-row__right'));
152
+ assert.ok(facts.includes('ageChip'), 'the age reads as a fact about the ask');
76
153
  });
77
154
 
78
155
  test('oldest first WITHIN the page, in addition to the band', () => {
@@ -264,6 +341,64 @@ test('the page is the three blocks, led by faces, with the ledes gone', () => {
264
341
  assert.match(html, /class="cb-foot"/, 'and it is the foot, not the opener');
265
342
  });
266
343
 
344
+ test('a person meets the page\'s NUMBERS before its pills', () => {
345
+ // Task 1003942 promised "a glass figure strip at the top (waiting on you /
346
+ // your open asks / crafts you answer for)" and shipped without it; the
347
+ // grader's narc worker caught the omission and the artist's verdict named
348
+ // what it cost — with nothing calm at the top, the page opens on age pills.
349
+ const html = read('modules', 'hall-ui', 'public', 'collab.html');
350
+ const js = read('modules', 'hall-ui', 'public', 'collab.js');
351
+ assert.match(html, /<div class="stat-row ov-summary cb-figs" id="cb-figs"/, 'the strip is the shell\'s glass band');
352
+ // Position is the whole claim: before the blocks, not beside them.
353
+ assert.ok(html.indexOf('id="cb-figs"') < html.indexOf('cb-band'), 'the strip is ABOVE the blocks');
354
+ assert.ok(html.indexOf('id="cb-figs"') < html.indexOf('id="cb-all"'), 'and above the board');
355
+ assert.match(js, /function paintFigures\(/, 'the page fills it');
356
+ assert.match(js, /paintFigures\(rowsOf\(forMe\), rowsOf\(mine\), crafts\)/, 'from the reads it already makes — no fourth fetch');
357
+ for (const label of ['Waiting on you', 'Your open asks', 'Roles you answer for']) {
358
+ assert.ok(js.includes(label), `the strip carries "${label}"`);
359
+ }
360
+ // It is painted BEFORE the blocks, so the numbers are on screen first even on
361
+ // a slow paint.
362
+ assert.ok(js.indexOf('paintFigures(') < js.indexOf("paintBlock('for-me'"), 'and painted first');
363
+ });
364
+
365
+ test('ONE WORD, ONE CASING: a sentence takes roleWord, a control takes roleLabel', () => {
366
+ // The artist's third finding, and the design error under it. Collab said
367
+ // "Asks to Builders" in its sub line and "You asked artists" six lines down,
368
+ // because task 1003936 routed one call site through roleLabel() — correct for
369
+ // a picker, wrong in prose — and reasoned about the other separately.
370
+ const dom = read('modules', 'hall-ui', 'public', 'dom-utils.js');
371
+ const js = read('modules', 'hall-ui', 'public', 'collab.js');
372
+ assert.match(dom, /function roleWord\(d\)/, 'prose has its own helper');
373
+ assert.match(dom, /roleAxisLabel, roleLabel, roleWord,/, 'and it is exported');
374
+ // The rule is written down next to the helper that made it necessary, which is
375
+ // the third DONE WHEN clause of task 1004041.
376
+ const nearby = dom.slice(dom.indexOf('function roleLabel('), dom.indexOf('function roleWord('));
377
+ assert.match(nearby, /A CONTROL TAKES roleLabel\(\); A SENTENCE TAKES roleWord\(\)/, 'the rule sits beside roleLabel');
378
+ // The page's sentence no longer capitalises a common noun, and no longer
379
+ // opens on a clause the next clause repeats.
380
+ assert.match(js, /roleWord\(c\)\}s/, 'the sub line names roles as common nouns');
381
+ assert.ok(!/Builders asking builders/.test(js), 'the tautology is gone');
382
+ // A CALL, not a mention: the comment above the sub line names roleLabel() to
383
+ // explain why it is not used, and a bare /roleLabel\(/ would read that as the
384
+ // defect it warns about.
385
+ assert.ok(!/window\.OTB\.roleLabel\(/.test(js), 'no control-voice label inside a sentence on this page');
386
+ });
387
+
388
+ test('roleWord and roleLabel disagree exactly where they should', () => {
389
+ // Executed, not grepped: the acronym exception has to survive both ways, or
390
+ // "You asked uis" ships the next time a ui task files a help request.
391
+ const { roleLabel, roleWord } = domUtils();
392
+ assert.equal(roleLabel('engineer'), 'Engineer');
393
+ assert.equal(roleWord('engineer'), 'engineer');
394
+ assert.equal(roleLabel('ui'), 'UI');
395
+ assert.equal(roleWord('ui'), 'UI', 'an acronym is not a common noun in any sentence');
396
+ assert.equal(roleLabel('unclassified'), 'No role yet');
397
+ assert.equal(roleWord('unclassified'), 'no role yet');
398
+ assert.equal(roleWord(''), '');
399
+ assert.equal(roleWord(null), '');
400
+ });
401
+
267
402
  test('the by-name ask is the one LIT thing — the accent goes on the personal call', () => {
268
403
  const js = read('modules', 'hall-ui', 'public', 'collab.js');
269
404
  const css = read('modules', 'hall-ui', 'public', 'collab.css');
package/tests/modules.mjs CHANGED
@@ -3,6 +3,10 @@ import assert from 'node:assert/strict';
3
3
  import { test } from 'node:test';
4
4
  import { existsSync } from 'node:fs';
5
5
  import modulesMod from '../src/modules.js';
6
+ // Named, not modulesMod.moduleOverrides (task 1004052 strand walk): knip does not credit
7
+ // property access on a default import as using the named export, so the snapshot getter
8
+ // read as dead code and tripped the ratchet that held this branch out of main.
9
+ import { moduleOverrides } from '../src/modules.js';
6
10
 
7
11
  const {
8
12
  resolveModules, isModuleEnabled, enabledModules, enabledDisciplines, clientModules,
@@ -221,3 +225,109 @@ test('a branded sibling (GDS_BRANDING_FILE set, no GDS_MODULES_FILE) defaults to
221
225
  // assertion above never reaches it (task 1003487).
222
226
  assert.deepEqual(enabledDisciplines(m), ['engineer', 'artist', 'ideator', 'ui']);
223
227
  });
228
+
229
+ // ── the DB override layer (task 1002857, migration core_248) ────────────────────
230
+ // The toggle used to write config/modules.json, which the pull-deploy's
231
+ // `git reset --hard origin/main` then reverted. These pin the precedence the
232
+ // durable replacement has to hold, because getting the ORDER wrong is the only way
233
+ // this feature can be worse than the bug it replaces.
234
+
235
+ test('1002857: an override outranks the committed instance pack', () => {
236
+ // The whole point: the pack is what a deploy restores, so an override that sat
237
+ // below it would be erased by the very event it exists to survive.
238
+ const m = resolveModules({ instance: OTB, override: { 'dev-box': false }, registry: SAMPLE_REG });
239
+ assert.equal(m['dev-box'], false, 'the override wins over the pack');
240
+ assert.equal(m.discord, true, 'a module with no override still reads the pack');
241
+ });
242
+
243
+ test('1002857: an override turns a module ON as well as off', () => {
244
+ const m = resolveModules({ instance: VANILLA, override: { discord: true }, registry: SAMPLE_REG });
245
+ assert.equal(m.discord, true);
246
+ });
247
+
248
+ test('1002857: env still outranks an override — the incident knob needs no database', () => {
249
+ const m = resolveModules({
250
+ instance: VANILLA,
251
+ override: { 'dev-box': true },
252
+ env: { OTB_MODULE_DEV_BOX: '0' },
253
+ envPrefix: 'OTB',
254
+ registry: SAMPLE_REG,
255
+ });
256
+ assert.equal(m['dev-box'], false, 'env must be able to kill a module the DB turned on');
257
+ });
258
+
259
+ test('1002857: an override for a module this build does not carry is inert, not fatal', () => {
260
+ // The database outlives any one build: a module can be uninstalled while its row
261
+ // remains. fromConfig throws on enabling an unknown key (a hand-authored file is
262
+ // written for THIS build); a table is not, so it must not stop the process.
263
+ const m = resolveModules({ override: { 'a-module-that-left': true }, registry: SAMPLE_REG });
264
+ assert.ok(!('a-module-that-left' in m), 'unknown key dropped');
265
+ // NOT 'enables nothing': some discovered modules (ui-design and friends) are
266
+ // default-ON for every instance, so the enabled set is legitimately non-empty.
267
+ // The claim under test is narrower and the one that matters — the unknown key
268
+ // did not conjure itself into the enabled list.
269
+ assert.ok(!enabledModules(m).includes('a-module-that-left'), 'and it enables nothing');
270
+ });
271
+
272
+ test('1002857: no override is "no opinion" — an unreachable database resolves as before', () => {
273
+ const withEmpty = resolveModules({ instance: OTB, override: {}, registry: SAMPLE_REG });
274
+ const without = resolveModules({ instance: OTB, registry: SAMPLE_REG });
275
+ assert.deepEqual({ ...withEmpty }, { ...without }, 'empty snapshot changes nothing');
276
+ });
277
+
278
+ test('1002857: each module reports the layer that produced its state', () => {
279
+ const m = resolveModules({
280
+ neutral: { modules: { discord: false } },
281
+ instance: OTB,
282
+ override: { 'dev-box': false },
283
+ env: { OTB_MODULE_GAME: '0' },
284
+ envPrefix: 'OTB',
285
+ registry: SAMPLE_REG,
286
+ });
287
+ assert.equal(m.sources['dev-box'], 'override');
288
+ assert.equal(m.sources.game, 'env');
289
+ assert.equal(m.sources.discord, 'instance', 'the instance pack outranks the neutral one');
290
+ assert.equal(m.sources['art-pipeline'], 'instance');
291
+ // sources must never join the boolean map: clientModules and every JSON
292
+ // projection of it would start shipping an object where a boolean belongs.
293
+ assert.ok(!Object.keys(m).includes('sources'), 'sources is non-enumerable');
294
+ assert.ok(!('sources' in JSON.parse(JSON.stringify(m))), 'and never serialises');
295
+ });
296
+
297
+ test('1002857: the scope key separates two instances sharing one database', () => {
298
+ // One checkout can run the default instance and a vanilla sibling, differentiated
299
+ // only by env. A single global row per module would let one flip the other's.
300
+ const dflt = modulesMod.moduleScopeKey({ env: {} });
301
+ const sibling = modulesMod.moduleScopeKey({ env: { GDS_BRANDING_FILE: 'config/branding.cloudbongos.json' } });
302
+ assert.notEqual(dflt, sibling, 'the two instances must not share a scope');
303
+ assert.equal(dflt, 'config/modules.json');
304
+ assert.equal(sibling, 'config/modules.neutral.json');
305
+ // Posix separators and no absolute path, so the key is stable across a moved
306
+ // checkout and across Windows vs Linux.
307
+ assert.ok(!dflt.includes(String.fromCharCode(92)), "posix separators, not a Windows path");
308
+ assert.deepEqual(
309
+ modulesMod.moduleScopeKey({ env: { GDS_MODULES_FILE: 'config/other.json' } }),
310
+ 'config/other.json',
311
+ );
312
+ });
313
+
314
+ test('1002857: setModuleOverrides installs the snapshot AND invalidates the cache', () => {
315
+ // Setting without reloading would leave the live process on a stale answer —
316
+ // the exact class of bug this feature exists to remove.
317
+ try {
318
+ const before = modulesMod.modules();
319
+ assert.equal(before['dev-box'], false, 'precondition: off on a vanilla resolve');
320
+ const after = modulesMod.setModuleOverrides({ 'dev-box': true });
321
+ assert.equal(after['dev-box'], true, 'the returned set reflects the override');
322
+ assert.equal(modulesMod.modules()['dev-box'], true, 'and so does the live singleton');
323
+ assert.equal(moduleOverrides()['dev-box'], true);
324
+ } finally {
325
+ modulesMod._resetForTests();
326
+ }
327
+ });
328
+
329
+ test('1002857: _resetForTests clears the override snapshot too', () => {
330
+ modulesMod.setModuleOverrides({ 'dev-box': true });
331
+ modulesMod._resetForTests();
332
+ assert.deepEqual(moduleOverrides(), {}, 'a leaked snapshot would cross-contaminate tests');
333
+ });
@@ -0,0 +1,103 @@
1
+ // tests/modules_route_wiring.mjs — task 1002857.
2
+ //
3
+ // WHY THIS FILE EXISTS. The DB-backed enablement override shipped its first round
4
+ // with `moduleOverrides.listOverrides()` and `moduleOverrides.setOverride()` called
5
+ // in src/bongos/routes/modules.js and NOTHING binding that name — the require was
6
+ // lost to a patch that aborted before writing. Every call to GET /modules and
7
+ // POST /modules/:key/enable would have thrown ReferenceError.
8
+ //
9
+ // tests/modules.mjs did not catch it and could not: it covers the PURE layer —
10
+ // resolveModules, moduleScopeKey, setModuleOverrides — and a resolver cannot know
11
+ // whether the route that calls it can see its collaborators. The gap was the
12
+ // seam, so this file tests the seam: it INVOKES the handlers rather than merely
13
+ // importing the module, because an unbound identifier is a runtime error and
14
+ // importing a file with one is perfectly quiet.
15
+ //
16
+ // Hermetic. No DB, no port, no supertest: the router is built, its handler pulled
17
+ // off the Express stack and driven with a fake req/res. listOverrides() swallows
18
+ // its own connection error and returns [], which is the documented no-database
19
+ // behaviour — so GET must still answer here.
20
+ //
21
+ // Run: node --test tests/modules_route_wiring.mjs
22
+
23
+ import { strict as assert } from 'node:assert';
24
+ import { test } from 'node:test';
25
+ import { createRequire } from 'node:module';
26
+ import { listOverrides, setOverride, refreshOverrides, clearOverride } from '../src/bongos/module-overrides.js';
27
+
28
+ const require = createRequire(import.meta.url);
29
+ const buildModulesRouter = require('../src/bongos/routes/modules.js');
30
+
31
+ // The LAST handler on a route is the asyncHandler-wrapped business logic; the
32
+ // earlier layers are auth, which this test deliberately does not exercise.
33
+ function handlerFor(router, method, path) {
34
+ const layer = router.stack.find((l) => l.route && l.route.path === path && l.route.methods[method]);
35
+ assert.ok(layer, `no ${method.toUpperCase()} ${path} on the router`);
36
+ const stack = layer.route.stack;
37
+ return stack[stack.length - 1].handle;
38
+ }
39
+
40
+ function fakeRes() {
41
+ const res = {
42
+ statusCode: 200,
43
+ body: undefined,
44
+ json(b) { this.body = b; return this; },
45
+ status(c) { this.statusCode = c; return this; },
46
+ fail(code, opts) { this.body = { error: code, ...opts }; return this; },
47
+ };
48
+ return res;
49
+ }
50
+
51
+ // An error thrown inside the handler must reach us, not be swallowed into a
52
+ // passing test — asyncHandler forwards to next(err), so capture that too.
53
+ async function invoke(handler, req) {
54
+ const res = fakeRes();
55
+ let nextErr = null;
56
+ await handler(req, res, (err) => { nextErr = err; });
57
+ if (nextErr) throw nextErr;
58
+ return res;
59
+ }
60
+
61
+ test('1002857: GET /modules runs — every collaborator it calls is actually bound', async () => {
62
+ const router = buildModulesRouter();
63
+ const res = await invoke(handlerFor(router, 'get', '/modules'), { params: {}, query: {}, body: {} });
64
+ assert.ok(Array.isArray(res.body.modules), 'it answered with a module list');
65
+ // The fields this task added, proving the override collaborator really ran
66
+ // rather than being skipped by a short-circuit somewhere.
67
+ assert.equal(typeof res.body.scope, 'string', 'the scope key is reported');
68
+ assert.ok(res.body.scope.length > 0);
69
+ for (const m of res.body.modules) {
70
+ assert.equal(typeof m.enabled_source, 'string', `${m.key} reports which layer won`);
71
+ assert.ok('override' in m, `${m.key} reports its override row (null when none)`);
72
+ }
73
+ });
74
+
75
+ test('1002857: POST /modules/:key/enable rejects a non-boolean before touching the database', async () => {
76
+ // Drives the toggle handler far enough to prove IT is wired too, without a DB:
77
+ // the body check comes first, so this exercises the handler's own code path and
78
+ // would still have thrown ReferenceError had the require stayed missing... which
79
+ // is why the next assertion matters more than this one.
80
+ const router = buildModulesRouter();
81
+ const res = await invoke(handlerFor(router, 'post', '/modules/:key/enable'), {
82
+ params: { key: 'dev-box' }, query: {}, body: { enabled: 'yes-please' }, builder: { id: 1 },
83
+ });
84
+ assert.equal(res.body.error, 'invalid_body');
85
+ });
86
+
87
+ test('1002857: the route module can see module-overrides — the require the first round lost', () => {
88
+ // The direct form of the blocker, stated so it cannot regress by a different
89
+ // route: the file must BIND the collaborator, and that collaborator must carry
90
+ // the two methods the handlers call. src/modules.js also exports a
91
+ // `moduleOverrides` (a sync getter for the snapshot), which is NOT this module —
92
+ // mistaking one for the other is exactly how the first round read as plausible.
93
+ const src = require('node:fs').readFileSync('src/bongos/routes/modules.js', 'utf8');
94
+ assert.match(src, /require\('\.\.\/module-overrides'\)/, 'the route requires ../module-overrides');
95
+ // Asserted through the NAMED IMPORTS at the top of this file rather than by indexing the
96
+ // module with strings. Two reasons, and the second is why it changed: a missing export now
97
+ // fails at load naming the export, instead of at an assertion; and knip cannot see a string
98
+ // as a usage, so indexing left clearOverride reading as dead code and tripped the ratchet
99
+ // that held this branch out of main for 20h (task 1004052 strand walk).
100
+ for (const [name, fn] of Object.entries({ listOverrides, setOverride, refreshOverrides, clearOverride })) {
101
+ assert.equal(typeof fn, 'function', `module-overrides exports ${name}`);
102
+ }
103
+ });
@@ -471,11 +471,11 @@ await t('POST /tasks/:id/attest-gate records the pushed tip on the claim, even w
471
471
  const router = buildTasksRouter();
472
472
  const layer = findRouteLayer(router, '/tasks/:id/attest-gate', 'post');
473
473
  const handler = layer.route.stack[layer.route.stack.length - 1].handle;
474
- const out = { failCode: null, jsonBody: null };
474
+ const out = { failCode: null, failOpts: null, jsonBody: null };
475
475
  const res = {
476
476
  status() { return this; },
477
477
  json(b) { out.jsonBody = b; return this; },
478
- fail(code) { out.failCode = code; return this; },
478
+ fail(code, opts) { out.failCode = code; out.failOpts = opts; return this; },
479
479
  };
480
480
  // rank archon → gateTaskOwnership passes with no DB lookup, keeping the test hermetic.
481
481
  const req = { params: { id: '77' }, body: { head_sha: 'a'.repeat(40) }, builder: { id: 95, rank: 'archon' } };
@@ -483,9 +483,77 @@ await t('POST /tasks/:id/attest-gate records the pushed tip on the claim, even w
483
483
  assert.equal(out.failCode, 'push_unconfigured', 'no credential → still the usual 503 to the caller');
484
484
  assert.deepEqual(recorded, [{ taskId: 77, builderId: 95, sha: 'a'.repeat(40) }],
485
485
  'the tip is recorded BEFORE the credential check — the wave-strand cure');
486
+ // task 1004055: and the 503 must SAY the tip landed. This exit is the one a
487
+ // caller is most likely to misread as total failure; ship.js decides whether
488
+ // to warn the builder off this flag, so it has to travel on the error too.
489
+ assert.equal(out.failOpts && out.failOpts.details && out.failOpts.details.tip_recorded, true,
490
+ 'the 503 must carry tip_recorded:true — the write happened before it');
491
+ });
492
+ } finally {
493
+ dbMod.setClaimPublishedSha = savedSet;
494
+ }
495
+ });
496
+
497
+ // task 1004055: the SILENT MISS. setClaimPublishedSha returns null when the UPDATE
498
+ // matched no claim row, and the old route discarded that return value — so a caller
499
+ // that had already merged to main was told nothing was wrong, and only found out one
500
+ // call later as an opaque ship_land_not_proven 409, with the task parked at
501
+ // 'confirmed' gating its builder's whole queue. `tip_recorded` is what makes that
502
+ // answerable at the moment it happens.
503
+ await t('POST /tasks/:id/attest-gate reports tip_recorded:false when no claim row matched', async () => {
504
+ const dbMod = require('../modules/lifecycle/db.js');
505
+ const savedSet = dbMod.setClaimPublishedSha;
506
+ const savedHolder = dbMod.getLastClaimHolder;
507
+ // null = "the UPDATE matched nothing", the shape db-ship.js really returns.
508
+ dbMod.setClaimPublishedSha = async () => null;
509
+ dbMod.getLastClaimHolder = async () => ({ builder_id: 95 });
510
+ try {
511
+ // A credential IS configured, so the handler reaches res.json; a below-Metic rank
512
+ // means postGateAuthorTrust declines on the pure rank check and never fetches.
513
+ await withEnv({ GITHUB_APP_ID: undefined, GITHUB_APP_INSTALLATION_ID: undefined, GITHUB_APP_PRIVATE_KEY: undefined, GITHUB_PUSH_TOKEN: 'ghp_fake_for_isConfigured' }, async () => {
514
+ const router = buildTasksRouter();
515
+ const layer = findRouteLayer(router, '/tasks/:id/attest-gate', 'post');
516
+ const handler = layer.route.stack[layer.route.stack.length - 1].handle;
517
+ const out = { failCode: null, jsonBody: null };
518
+ const res = {
519
+ status() { return this; },
520
+ json(b) { out.jsonBody = b; return this; },
521
+ fail(code) { out.failCode = code; return this; },
522
+ };
523
+ const req = { params: { id: '77' }, body: { head_sha: 'b'.repeat(40) }, builder: { id: 95, rank: 'thetes' } };
524
+ await handler(req, res);
525
+ assert.equal(out.failCode, null, 'a below-Metic attest is a 200, not a failure');
526
+ assert.equal(out.jsonBody.ok, true);
527
+ assert.equal(out.jsonBody.attested, false, 'below Metic → no attestation, as before');
528
+ assert.equal(out.jsonBody.tip_recorded, false,
529
+ 'nothing was recorded, and the response must say so rather than reading as success');
530
+ });
531
+ } finally {
532
+ dbMod.setClaimPublishedSha = savedSet;
533
+ dbMod.getLastClaimHolder = savedHolder;
534
+ }
535
+ });
536
+
537
+ await t('POST /tasks/:id/attest-gate reports tip_recorded:true on the happy path', async () => {
538
+ const dbMod = require('../modules/lifecycle/db.js');
539
+ const savedSet = dbMod.setClaimPublishedSha;
540
+ const savedHolder = dbMod.getLastClaimHolder;
541
+ dbMod.setClaimPublishedSha = async () => 4242; // a claim id = the UPDATE matched
542
+ dbMod.getLastClaimHolder = async () => ({ builder_id: 95 });
543
+ try {
544
+ await withEnv({ GITHUB_APP_ID: undefined, GITHUB_APP_INSTALLATION_ID: undefined, GITHUB_APP_PRIVATE_KEY: undefined, GITHUB_PUSH_TOKEN: 'ghp_fake_for_isConfigured' }, async () => {
545
+ const router = buildTasksRouter();
546
+ const layer = findRouteLayer(router, '/tasks/:id/attest-gate', 'post');
547
+ const handler = layer.route.stack[layer.route.stack.length - 1].handle;
548
+ const out = { jsonBody: null };
549
+ const res = { status() { return this; }, json(b) { out.jsonBody = b; return this; }, fail() { return this; } };
550
+ const req = { params: { id: '77' }, body: { head_sha: 'c'.repeat(40) }, builder: { id: 95, rank: 'thetes' } };
551
+ await handler(req, res);
552
+ assert.equal(out.jsonBody.tip_recorded, true);
486
553
  });
487
554
  } finally {
488
555
  dbMod.setClaimPublishedSha = savedSet;
556
+ dbMod.getLastClaimHolder = savedHolder;
489
557
  }
490
558
  });
491
559