@bongos/core 1.24.36 → 1.24.38

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 (73) hide show
  1. package/.bongos-core.json +92 -87
  2. package/docs/copy-inventory.md +65 -64
  3. package/docs/copy-registry.json +170 -173
  4. package/docs/module-api-changelog.md +4 -0
  5. package/docs/onboarding/primer.md +2 -0
  6. package/docs/page-inventory.json +34 -34
  7. package/docs/page-readings.json +687 -684
  8. package/modules/hall-ui/public/ai-studio.html +2 -2
  9. package/modules/hall-ui/public/atlas.html +2 -2
  10. package/modules/hall-ui/public/blockers.html +2 -2
  11. package/modules/hall-ui/public/board-room.html +2 -2
  12. package/modules/hall-ui/public/city.html +2 -2
  13. package/modules/hall-ui/public/collab.html +2 -2
  14. package/modules/hall-ui/public/commands.html +2 -2
  15. package/modules/hall-ui/public/copy-desk.html +2 -2
  16. package/modules/hall-ui/public/craft-mode.js +6 -4
  17. package/modules/hall-ui/public/deploy.html +2 -2
  18. package/modules/hall-ui/public/fleet-costs.js +3 -2
  19. package/modules/hall-ui/public/gate.html +2 -2
  20. package/modules/hall-ui/public/goal-map.html +2 -2
  21. package/modules/hall-ui/public/goals.html +2 -2
  22. package/modules/hall-ui/public/government.html +2 -2
  23. package/modules/hall-ui/public/idea.html +2 -2
  24. package/modules/hall-ui/public/ideas.html +2 -2
  25. package/modules/hall-ui/public/index.html +2 -2
  26. package/modules/hall-ui/public/market-watch-item.html +2 -2
  27. package/modules/hall-ui/public/market-watch-rules.html +2 -2
  28. package/modules/hall-ui/public/market-watch.html +2 -2
  29. package/modules/hall-ui/public/modules.html +2 -2
  30. package/modules/hall-ui/public/money.html +92 -0
  31. package/modules/hall-ui/public/money.js +302 -0
  32. package/modules/hall-ui/public/money.states.json +15 -0
  33. package/modules/hall-ui/public/my-agents.html +2 -2
  34. package/modules/hall-ui/public/palette.js +9 -2
  35. package/modules/hall-ui/public/primer-live.js +18 -105
  36. package/modules/hall-ui/public/primer.html +7 -8
  37. package/modules/hall-ui/public/primer.js +16 -18
  38. package/modules/hall-ui/public/profile.html +2 -2
  39. package/modules/hall-ui/public/project-settings.css +2 -1
  40. package/modules/hall-ui/public/project-settings.html +6 -16
  41. package/modules/hall-ui/public/project-settings.js +2 -142
  42. package/modules/hall-ui/public/roadmap.html +2 -2
  43. package/modules/hall-ui/public/roster.html +2 -2
  44. package/modules/hall-ui/public/sessions.html +2 -2
  45. package/modules/hall-ui/public/settings.html +2 -2
  46. package/modules/hall-ui/public/shell.js +113 -94
  47. package/modules/hall-ui/public/street.html +2 -2
  48. package/modules/hall-ui/public/studio.html +2 -2
  49. package/modules/hall-ui/public/task.html +2 -2
  50. package/modules/hall-ui/public/thinking.html +2 -2
  51. package/modules/hall-ui/public/tweak-editor.html +2 -2
  52. package/modules/hall-ui/public/watch.html +2 -2
  53. package/modules/hall-ui/public/work.html +2 -2
  54. package/package-lock.json +2 -2
  55. package/package.json +1 -1
  56. package/release-notes.json +12 -0
  57. package/src/bongos/auth.js +0 -8
  58. package/src/bongos/serve-internal.js +15 -6
  59. package/src/module-api.js +1 -1
  60. package/tests/auth_resolve_failure.mjs +5 -4
  61. package/tests/craft_mode_nav.mjs +12 -10
  62. package/tests/fleet_cost_view.mjs +2 -18
  63. package/tests/goal_map_page.mjs +5 -2
  64. package/tests/hall_loading.mjs +1 -1
  65. package/tests/hall_money_page.mjs +272 -0
  66. package/tests/hall_nav.mjs +23 -15
  67. package/tests/hall_oversight_world.mjs +1 -1
  68. package/tests/hall_page_gate_map.mjs +3 -2
  69. package/tests/hall_reading_room.mjs +21 -62
  70. package/tests/nav_permission_atoms.mjs +1 -1
  71. package/modules/hall-ui/public/fleet.html +0 -69
  72. package/modules/hall-ui/public/fleet.js +0 -41
  73. package/modules/hall-ui/public/fleet.states.json +0 -14
@@ -167,8 +167,8 @@
167
167
  chrome exists before the page script runs. -->
168
168
  <script src="/builders/dom-utils.js?v=2026-10-25-escape"></script>
169
169
  <script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
170
- <script src="/builders/craft-mode.js?v=2026-11-04-reading-room"></script>
171
- <script src="/builders/shell.js?v=2026-11-04-reading-room"></script>
170
+ <script src="/builders/craft-mode.js?v=2026-11-05-sections"></script>
171
+ <script src="/builders/shell.js?v=2026-11-05-sections"></script>
172
172
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
173
173
  <script src="/builders/palette.js" defer></script>
174
174
  <!-- shared component kit (window.OTBKit) — tabs, ledger, pager (task 2166). -->
@@ -173,8 +173,8 @@
173
173
  time so the chrome exists before the deferred page scripts execute. -->
174
174
  <script src="/builders/dom-utils.js?v=2026-10-25-escape"></script>
175
175
  <script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
176
- <script src="/builders/craft-mode.js?v=2026-11-04-reading-room"></script>
177
- <script src="/builders/shell.js?v=2026-11-04-reading-room"></script>
176
+ <script src="/builders/craft-mode.js?v=2026-11-05-sections"></script>
177
+ <script src="/builders/shell.js?v=2026-11-05-sections"></script>
178
178
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
179
179
  <script src="/builders/palette.js" defer></script>
180
180
  <!-- task 1004727: the ONE karma vote control every task/idea list renders -->
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.24.36",
3
+ "version": "1.24.38",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.24.36",
9
+ "version": "1.24.38",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.24.36",
3
+ "version": "1.24.38",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -11179,5 +11179,17 @@
11179
11179
  "id": "1005046",
11180
11180
  "text": "The Docs section now has one reading room instead of four pages: ranks, credits and karma, and the five diagrams all live in the Primer, each topic explained once with its picture beside it. Old links to Ranks, Economy and Dia"
11181
11181
  }
11182
+ ],
11183
+ "1.24.37": [
11184
+ {
11185
+ "id": "1005047",
11186
+ "text": "Owners now have one Money page in the Government group: what the project spent and paid out, its treasury, and what each hosted project costs, all in one place. Project settings and the Primer link to it instead of repeating t"
11187
+ }
11188
+ ],
11189
+ "1.24.38": [
11190
+ {
11191
+ "id": "1005071",
11192
+ "text": "The hall sidebar is now seven labelled sections (Work, Make, Community, You, Govern, Run the project, Help), and every craft can reach Help."
11193
+ }
11182
11194
  ]
11183
11195
  }
@@ -406,10 +406,6 @@ const PAGE_GATES = Object.freeze({
406
406
  // live project; which project they may read or move is still the routes' question,
407
407
  // asked per request against that project's row.
408
408
  requireCorePinPage: Object.freeze({ permission: 'core.pin.move', deniedTo: '/builders', alsoAdmits: 'isProjectOwner' }),
409
- // The /fleet cost page (task 1001949) — on `provisioning.fleet.manage`, the SAME atom
410
- // its one read (GET /provisioning/fleet/cost-ledger) checks: every owner's projects
411
- // and what they cost, so the page reaches exactly as far as the data does.
412
- requireFleetPage: Object.freeze({ permission: 'provisioning.fleet.manage', deniedTo: '/builders' }),
413
409
  });
414
410
 
415
411
  // The `alsoAdmits` door — "is this visitor the page's OTHER audience?", asked of
@@ -568,9 +564,6 @@ async function requireBoardVotePage(req, res, next) {
568
564
  async function requireCorePinPage(req, res, next) {
569
565
  return runPageGate('requireCorePinPage', req, res, next);
570
566
  }
571
- async function requireFleetPage(req, res, next) {
572
- return runPageGate('requireFleetPage', req, res, next);
573
- }
574
567
 
575
568
  // ---------------------------------------------------------------------------
576
569
  // Route-gate inventory (V3.R06 #231) — the canonical "who can call what" list.
@@ -862,7 +855,6 @@ module.exports = {
862
855
  requireGovernmentPage,
863
856
  requireBoardVotePage,
864
857
  requireCorePinPage,
865
- requireFleetPage,
866
858
  requireRank,
867
859
  rankMeetsThreshold,
868
860
  requirePermission,
@@ -1010,8 +1010,9 @@ function mountInternalSurfaces(app) {
1010
1010
  // statically, and a comment in that gap makes the page test as UNGATED while
1011
1011
  // looking gated here. Prose goes above the const, as here.
1012
1012
  const DEPLOY_PAGE_RE = /^\/builders\/deploy(?:\.html)?\/?$/;
1013
- // The fleet cost page (task 1001949), on the atom its one read checks.
1014
- const FLEET_PAGE_RE = /^\/builders\/fleet(?:\.html)?\/?$/;
1013
+ // /money — the owner's one money page (task 1005047): the government floor; each
1014
+ // section's read keeps its own wall. NB as at PROJECT_SETTINGS_PAGE_RE.
1015
+ const MONEY_PAGE_RE = /^\/builders\/money(?:\.html)?\/?$/;
1015
1016
  // /people and its legacy twin /scouting were the hall's cross-project builder
1016
1017
  // directory. Task 1003440 deleted the /scouting door; task 1003444 deleted
1017
1018
  // /people itself (owner call, 2026-09-25 — ADR 0337 D5): cross-project
@@ -1048,6 +1049,16 @@ function mountInternalSurfaces(app) {
1048
1049
  return res.redirect(302, `${isBuildersHost(req) ? '' : '/builders'}/market-watch${qs}#settings`);
1049
1050
  });
1050
1051
 
1052
+ // task 1005047: Fleet costs is /money's #hosting section now; 302 and no gate, as above.
1053
+ const RETIRED_FLEET_RE = /^\/builders\/fleet(?:\.html)?\/?$/;
1054
+ app.use((req, res, next) => {
1055
+ if (isModuleSurfaceHost(req)) return next();
1056
+ if (req.method !== 'GET' && req.method !== 'HEAD') return next();
1057
+ if (!RETIRED_FLEET_RE.test(path.posix.normalize(req.path))) return next();
1058
+ const qs = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';
1059
+ return res.redirect(302, `${isBuildersHost(req) ? '' : '/builders'}/money${qs}#hosting`);
1060
+ });
1061
+
1051
1062
  // task 1005046 (owner decision 2026-11-03, idea 1001311): ONE reading room. Ranks,
1052
1063
  // Economy (/drachmae) and Diagrams explained what the Primer already explained, so
1053
1064
  // they are Primer chapters now, each with its diagram, and each old URL lands on the
@@ -1094,7 +1105,8 @@ function mountInternalSurfaces(app) {
1094
1105
  if (!req.path.startsWith('/builders')) return next();
1095
1106
  const norm = path.posix.normalize(req.path);
1096
1107
  if (WATCH_PAGE_RE.test(norm) || GATE_PAGE_RE.test(norm)
1097
- || SESSIONS_PAGE_RE.test(norm) || PROJECT_SETTINGS_PAGE_RE.test(norm)) {
1108
+ || SESSIONS_PAGE_RE.test(norm) || PROJECT_SETTINGS_PAGE_RE.test(norm)
1109
+ || MONEY_PAGE_RE.test(norm)) {
1098
1110
  return gdsAuth.requireGovernmentPage(req, res, next);
1099
1111
  }
1100
1112
  // R16 (goal 1000069) widened the government shell from the manage atom
@@ -1125,9 +1137,6 @@ function mountInternalSurfaces(app) {
1125
1137
  if (DEPLOY_PAGE_RE.test(norm)) {
1126
1138
  return gdsAuth.requireCorePinPage(req, res, next);
1127
1139
  }
1128
- if (FLEET_PAGE_RE.test(norm)) {
1129
- return gdsAuth.requireFleetPage(req, res, next);
1130
- }
1131
1140
  next();
1132
1141
  });
1133
1142
 
package/src/module-api.js CHANGED
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
81
81
  // MAJOR (see allowBoxScope below): passes the request through untouched.
82
82
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
83
83
 
84
- const CORE_VERSION = '1.24.36'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.24.38'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
85
85
 
86
86
  // A namespaced logger so a module's log lines are attributable + consistent.
87
87
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -97,18 +97,19 @@ test('AUDIT: every async middleware mounted directly on a router owns its reject
97
97
  // requireBoardVotePage / BV1.R107 (and the ADR 0266 /board-room vote gate)
98
98
  // requireCorePinPage / ADR 0293's /deploy door — the narrowest of them,
99
99
  // on the archon-floor `core.pin.move` atom.
100
- // requireFleetPage — task 1001949's /fleet cost page, same delegation.
101
- // Task 1003450 removed two more — requireNonXenosPage and the R113
100
+ // Task 1005047 removed requireFleetPage with the /fleet page it fronted (its
101
+ // ledger is a section of /money now, behind the government page floor and the
102
+ // route's own atom). Task 1003450 removed two more — requireNonXenosPage and the R113
102
103
  // requireGovernmentManagePage — which had both ended up dispatched from no
103
104
  // route at all; tests/hall_page_gate_map.mjs now fails if that recurs. Task
104
105
  // 1003444 removed requireProjectCuratePage with the /people page it fronted.
105
106
  // Anything else async and router-mounted must be added with its own try/catch.
106
107
  const src = fs.readFileSync(path.join(ROOT, 'src', 'bongos', 'auth.js'), 'utf8');
107
108
  const asyncMw = [...src.matchAll(/^async function (require\w+|allow\w+)\s*\(req, res, next\)/gm)].map((m) => m[1]);
108
- assert.deepEqual(asyncMw.sort(), ['requireBoardVotePage', 'requireBuilder', 'requireBuilderPage', 'requireCorePinPage', 'requireFleetPage', 'requireGovernmentPage'],
109
+ assert.deepEqual(asyncMw.sort(), ['requireBoardVotePage', 'requireBuilder', 'requireBuilderPage', 'requireCorePinPage', 'requireGovernmentPage'],
109
110
  'a new async router middleware appeared — give it a try/catch and add it here');
110
111
  // The page gates must actually delegate (that is WHY they are safe).
111
- for (const name of ['requireBuilderPage', 'requireGovernmentPage', 'requireBoardVotePage', 'requireCorePinPage', 'requireFleetPage']) {
112
+ for (const name of ['requireBuilderPage', 'requireGovernmentPage', 'requireBoardVotePage', 'requireCorePinPage']) {
112
113
  const fn = src.slice(src.indexOf(`async function ${name}`));
113
114
  assert.match(sliceFn(fn), /runPageGate\(/, `${name} must delegate to the guarded runPageGate`);
114
115
  }
@@ -25,7 +25,7 @@ const CRAFT = read('craft-mode.js');
25
25
 
26
26
  // Every NAV id, and the Government group's, read from shell.js itself.
27
27
  const NAV_IDS = [...SHELL.matchAll(/\{ id: '([a-z-]+)', label:/g)].map((m) => m[1]);
28
- const GOV_BLOCK = SHELL.slice(SHELL.indexOf("{ group: 'Government'"));
28
+ const GOV_BLOCK = SHELL.slice(SHELL.indexOf("{ group: 'Run the project'"));
29
29
  const GOV_IDS = [...GOV_BLOCK.slice(0, GOV_BLOCK.indexOf('] }')).matchAll(/\{ id: '([a-z-]+)'/g)].map((m) => m[1]);
30
30
 
31
31
  // A small element. Elements parsed from the sidebar's markup are built ONCE, so
@@ -132,8 +132,9 @@ test('THE ALWAYS-PRESENT LIST: one shared list, Builders first, then Collab and
132
132
  const { craft } = boot();
133
133
  // task 1004685 (the living-city prototype): My agents joins as a PROPOSAL for the
134
134
  // owner — every craft has agents, and round-3 board 6 pairs it with the city.
135
- assert.deepEqual([...craft.ALWAYS_TABS], ['roster', 'collab', 'profile', 'my-agents'],
136
- 'Builders (NAV id roster) heads the list (owner, 2026-10-01); Collab and Profile join it (owner, 2026-10-03); My agents (prototype, task 1004685)');
135
+ // task 1005071 (owner, 2026-11-04): Help — the old Docs group — joins, so every craft has the Primer.
136
+ assert.deepEqual([...craft.ALWAYS_TABS], ['roster', 'collab', 'profile', 'my-agents', 'primer', 'commands', 'atlas', 'modules', 'apidocs'],
137
+ 'Builders (NAV id roster) heads the list (owner, 2026-10-01); Collab and Profile join it (owner, 2026-10-03); My agents (prototype, task 1004685); Help (task 1005071)');
137
138
  for (const m of CRAFTS) {
138
139
  const tabs = craft.tabsFor(m);
139
140
  assert.ok(Array.isArray(tabs), `${m} has a named tab list`);
@@ -180,7 +181,7 @@ test('each mode shows its own tabs, and switching updates the nav live', () => {
180
181
  assert.equal(p.root.dataset.craft, m);
181
182
  assert.deepEqual(new Set(p.showing()), new Set(p.craft.tabsFor(m)), `${m} shows exactly its list`);
182
183
  for (const id of ['roster', 'collab', 'profile']) assert.ok(p.showing().includes(id), `${id} stays in ${m}`);
183
- assert.equal(p.headShowing('government'), m === 'governing', `the Government head shows only where its tabs do (${m})`);
184
+ assert.equal(p.headShowing('run-the-project'), m === 'governing', `the Government head shows only where its tabs do (${m})`);
184
185
  }
185
186
  p.craft.choose('building');
186
187
  assert.deepEqual(new Set(p.showing()), new Set(p.craft.tabsFor('building')), 'back to Building, its tabs return without a reload');
@@ -190,11 +191,12 @@ test('a mode never shows a tab the gate hid, and a later gate pass keeps the mod
190
191
  const p = boot({ session: { 'hall-craft': 'governing' } });
191
192
  p.signIn('thetes');
192
193
  assert.ok(!p.showing().includes('watch'), 'a thetes in Governing does not get the metic-floored Watch tab');
193
- assert.ok(!p.headShowing('government'), 'nor its group head');
194
- assert.deepEqual(p.showing(), ['blockers', 'board-room', 'city', 'profile', 'my-agents', 'collab', 'roster'], 'what a thetes board member in Governing sees');
194
+ assert.ok(!p.headShowing('run-the-project'), 'nor its group head');
195
+ // task 1005071: sidebar order — Work, Community, You, Govern, then Help (every craft).
196
+ assert.deepEqual(p.showing(), ['blockers', 'collab', 'roster', 'profile', 'my-agents', 'city', 'board-room', 'primer', 'commands', 'atlas', 'modules', 'apidocs'], 'what a thetes board member in Governing sees');
195
197
  p.signIn('archon');
196
198
  assert.ok(p.showing().includes('watch'), 'promoted, the gate lets it through and the mode keeps it');
197
- assert.ok(p.headShowing('government'), 'and its group head comes back with it, which only a mode pass after the gate can know');
199
+ assert.ok(p.headShowing('run-the-project'), 'and its group head comes back with it, which only a mode pass after the gate can know');
198
200
  assert.ok(!p.showing().includes('work'), 'and the gate pass did not bring back a tab the mode left out');
199
201
  });
200
202
 
@@ -257,9 +259,9 @@ function bootTree({ mode = null, activeId = null } = {}) {
257
259
  let into = group;
258
260
  if (label) {
259
261
  const head = el('BUTTON', 'nav-group__head');
260
- head.dataset.group = label.toLowerCase();
262
+ head.dataset.group = label.toLowerCase().replace(/\s+/g, '-');
261
263
  group.appendChild(head);
262
- into = group.appendChild(el('DIV', 'nav-items', 'nav-items-' + label.toLowerCase()));
264
+ into = group.appendChild(el('DIV', 'nav-items', 'nav-items-' + label.toLowerCase().replace(/\s+/g, '-')));
263
265
  }
264
266
  for (const m of block.matchAll(/\{ id: '([a-z-]+)', label:/g)) {
265
267
  into.appendChild(el('A', 'nav-item' + (m[1] === activeId ? ' nav-item--active' : ''), 'nav-' + m[1]));
@@ -306,7 +308,7 @@ test('GOVERNING LEADS WITH THE CITY: its other tabs fold into one dropdown right
306
308
  assert.equal(head.getAttribute('aria-controls'), 'nav-items-craft-fold', 'the head names the region it opens');
307
309
  assert.match(head.innerHTML, /More governing/);
308
310
  assert.ok(head.hasAttribute('data-craft-off') === false, 'its head shows while a tab inside does');
309
- assert.ok(p.sidebar.querySelectorAll('.nav-group__head').find((h) => h.dataset.group === 'government').hasAttribute('data-craft-off'),
311
+ assert.ok(p.sidebar.querySelectorAll('.nav-group__head').find((h) => h.dataset.group === 'run-the-project').hasAttribute('data-craft-off'),
310
312
  'the emptied Government head hides, so no label sits over nothing');
311
313
  p.craft.choose('building');
312
314
  assert.equal(p.fold(), null, 'leaving Governing removes the dropdown');
@@ -103,24 +103,8 @@ test('no projects renders a sentence, not an empty table', () => {
103
103
  assert.match(costs.ledgerHtml(null), /No projects are hosted here yet/);
104
104
  });
105
105
 
106
- // ── the page: its wiring and its gate ────────────────────────────────────────
107
- test('the page loads the builders before its chrome and reads the ledger route', () => {
108
- const html = read(path.join(HALL, 'fleet.html'));
109
- assert.ok(html.indexOf('fleet-costs.js') !== -1 && html.indexOf('fleet-costs.js') < html.indexOf('/fleet.js'), 'fleet-costs.js loads first');
110
- const js = read(path.join(HALL, 'fleet.js'));
111
- assert.match(js, /\/provisioning\/fleet\/cost-ledger/);
112
- assert.match(js, /status === 403[\s\S]{0,200}fl-sealed/, 'a refusal shows the sealed state');
113
- });
114
-
115
- test('the page gate is the atom the ledger route checks, and the nav mirrors its floor', () => {
116
- const route = read(path.join(ROOT, 'modules', 'provisioning', 'routes', 'provisioning.js'));
117
- assert.match(route, /router\.get\('\/provisioning\/fleet\/cost-ledger', auth\.requireBuilder, auth\.requirePermission\('provisioning\.fleet\.manage'\)/);
118
- assert.match(read(path.join(ROOT, 'src', 'bongos', 'auth.js')), /requireFleetPage: Object\.freeze\(\{ permission: 'provisioning\.fleet\.manage'/);
119
- // The nav is cosmetic (the page gate is the wall); 'government' is the metic floor.
120
- const { byKey } = require(path.join(ROOT, 'modules', 'government', 'catalog.js'));
121
- assert.equal(byKey('provisioning.fleet.manage').floor, 'metic');
122
- assert.match(read(path.join(HALL, 'shell.js')), /href: '\/fleet', gate: 'government', module: 'provisioning'/);
123
- });
106
+ // The page's wiring and its gate moved to tests/hall_money_page.mjs with the page
107
+ // (task 1005047): the ledger is the money page's #hosting section, and /fleet redirects.
124
108
 
125
109
  // ── the owner's line on their own project page ───────────────────────────────
126
110
  const HUB = read(path.join(ROOT, 'modules', 'public-landing', 'public', 'projects.html'));
@@ -415,8 +415,11 @@ await test('Sky sits directly under Goals in the nav, member-gated, at the URL G
415
415
  const shell = read('shell.js');
416
416
  const item = shell.indexOf("{ id: 'goal-map', label: 'Sky', icon: 'sky', href: '/goal-map', gate: 'member' }");
417
417
  assert.ok(item > -1, 'the nav item, with the slot the map was denied (ADR 0227 §11)');
418
- const goals = shell.indexOf("{ id: 'goals', label: 'Goals'"); const work = shell.indexOf("{ id: 'work', label: 'Tasks'");
419
- assert.ok(goals > -1 && goals < item && item < work, 'under Goals, above Tasks');
418
+ // task 1005071 moved Tasks up to second in Work (owner, 2026-11-04); Sky stays the
419
+ // item straight after Goals.
420
+ const goals = shell.indexOf("{ id: 'goals', label: 'Goals'");
421
+ const between = shell.slice(goals, item);
422
+ assert.ok(goals > -1 && goals < item && (between.match(/\{ id: '/g) || []).length === 1, 'directly under Goals');
420
423
  assert.match(read('goals.html'), /href="\/builders\/goal-map"/, 'Goals still links to it');
421
424
  });
422
425
 
@@ -83,7 +83,7 @@ test('the page loaders step aside by themselves, and never show without :has()',
83
83
  assert.match(STYLE, /\.hall-loading--wait, \.hall-loading--zones \{ display: none; \}/);
84
84
  assert.match(STYLE, /@supports selector\(:has\(a\)\) \{\s*\.hall-loading--wait:not\(:has\(~ :not\(\[hidden\], script, template, style\)\)\) \{ display: flex; \}\s*\.hall-loading--zones:not\(:has\(~ :not\(\[hidden\]\) > :not\(\[hidden\], script, template, style\)\)\) \{ display: flex; \}\s*\}/);
85
85
  // A page whose sections all start [hidden] carries the --wait loader FIRST in its main.
86
- for (const page of ['board-room', 'deploy', 'fleet', 'gate', 'government', 'project-settings', 'sessions']) {
86
+ for (const page of ['board-room', 'deploy', 'gate', 'government', 'money', 'project-settings', 'sessions']) {
87
87
  const html = read(`${page}.html`);
88
88
  const main = html.slice(html.indexOf('<main id="hall-main">'));
89
89
  const first = main.replace(/<main id="hall-main">\s*(<!--[\s\S]*?-->\s*)*/, '');
@@ -0,0 +1,272 @@
1
+ // tests/hall_money_page.mjs — the owner's ONE money page (task 1005047, owner
2
+ // decision 2026-11-03, idea 1001312).
3
+ //
4
+ // What the project spent and paid out was drawn on three pages: Project settings
5
+ // ("Where the money went"), the Primer ("The treasury, so far", from the retired
6
+ // Economy page) and /fleet (what each hosted project costs). They are three sections
7
+ // of /money now. This file holds what makes that true and keeps it true:
8
+ //
9
+ // * THE PAGE DRAWS ALL THREE, each only when its own read answers. money.js is
10
+ // RUN in a vm against the hall-preview fixtures: a section whose read refuses
11
+ // (a 403, or a hall without the provisioning module) stays hidden, and a page
12
+ // where nothing answers says so in one line instead of loading forever.
13
+ // * NO MONEY FIGURE IS DRAWN ON TWO HALL PAGES. Project settings and the Primer
14
+ // keep their knobs and their prose, read none of the three feeds, and link here.
15
+ // * OLD URLS LAND ON THE RIGHT SECTION. /fleet 302s to /money#hosting through the
16
+ // real middleware, and /money sits behind the government
17
+ // page floor.
18
+ //
19
+ // The treasury cases moved here from tests/hall_reading_room.mjs, and the fleet
20
+ // page's wiring cases from tests/fleet_cost_view.mjs, with the code they test.
21
+
22
+ import { test } from 'node:test';
23
+ import assert from 'node:assert/strict';
24
+ import fs from 'node:fs';
25
+ import http from 'node:http';
26
+ import path from 'node:path';
27
+ import vm from 'node:vm';
28
+ import { createRequire } from 'node:module';
29
+ import { fileURLToPath } from 'node:url';
30
+ import { realOTB } from './helpers.mjs';
31
+
32
+ const require = createRequire(import.meta.url);
33
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
34
+ const HALL = path.join(ROOT, 'modules', 'hall-ui', 'public');
35
+ const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
36
+ const fixture = (name) => JSON.parse(fs.readFileSync(path.join(ROOT, 'scripts', 'hall-preview', 'fixtures', `${name}.json`), 'utf8'));
37
+
38
+ const moneyHtml = read('money.html');
39
+ const moneyJs = read('money.js');
40
+
41
+ const OUTFLOW = '/api/bongos/project-account/outflow';
42
+ const CHECKS = '/api/bongos/project-account/ledger-checks?limit=1';
43
+ const TREASURY = '/api/bongos/public/cost-summary';
44
+ const LEDGER = '/api/bongos/provisioning/fleet/cost-ledger';
45
+
46
+ const costSummaryFixture = JSON.parse(
47
+ fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'fixtures', 'cost-summary.json'), 'utf8'),
48
+ );
49
+
50
+ // Just enough of an element for money.js: it writes a body's innerHTML and a
51
+ // section's `hidden`, and asks a section to scroll into view.
52
+ function fakeEl(id) {
53
+ return { id, hidden: true, innerHTML: '', scrolled: false, scrollIntoView() { this.scrolled = true; } };
54
+ }
55
+
56
+ // money.js in a vm. `responses` maps a URL to a payload (200), a number (that
57
+ // status, no body) or an Error (the request threw). `page` boots it as the page.
58
+ async function loadMoney({ responses = {}, page = false, hash = '', kit = true, branding = { currency: { label: 'drachmae' } } } = {}) {
59
+ const els = {};
60
+ for (const id of ['payouts', 'payouts-body', 'the-treasury', 'the-treasury-body', 'hosting', 'hosting-body', 'money-empty']) els[id] = fakeEl(id);
61
+ const warnings = [];
62
+ const requests = [];
63
+ const window = {
64
+ OTB: realOTB(),
65
+ __BRANDING__: branding,
66
+ location: { hash },
67
+ BongosClient: {
68
+ createClient: () => ({
69
+ request: async (method, url) => {
70
+ requests.push(url);
71
+ const r = responses[url];
72
+ if (r instanceof Error) throw r;
73
+ if (r === undefined) return { ok: false, status: 404, data: null };
74
+ if (typeof r === 'number') return { ok: false, status: r, data: null };
75
+ return { ok: true, status: 200, data: r };
76
+ },
77
+ }),
78
+ },
79
+ };
80
+ if (kit) {
81
+ const kitPath = path.join(HALL, 'hall-kit.js');
82
+ window.OTBKit = await import(`file://${kitPath.replace(/\\/g, '/')}`).then((m) => m.default);
83
+ }
84
+ const sandbox = { window, console: { warn: (...a) => warnings.push(a.join(' ')) } };
85
+ vm.createContext(sandbox);
86
+ // the hosting section draws through the real fleet-costs.js, as the page loads it
87
+ vm.runInContext(read('fleet-costs.js'), sandbox, { filename: 'fleet-costs.js' });
88
+ sandbox.document = page
89
+ ? { readyState: 'complete', getElementById: (id) => els[id] || null, addEventListener() {} }
90
+ : { readyState: 'complete', getElementById: () => null, addEventListener() {} };
91
+ vm.runInContext(moneyJs, sandbox, { filename: 'money.js' });
92
+ return { money: window.OTBMoney, els, warnings, requests };
93
+ }
94
+
95
+ const ALL = { [OUTFLOW]: fixture('project-account__outflow'), [TREASURY]: costSummaryFixture, [LEDGER]: fixture('provisioning__fleet__cost-ledger') };
96
+
97
+ // ---- 1. one page, three sections, each on its own read ------------------------
98
+
99
+ test('RUN: an owner finds spend and payouts, the treasury and the hosting bills on one page', async () => {
100
+ const t = await loadMoney({ responses: ALL, page: true });
101
+ await t.money.ready;
102
+ for (const id of ['payouts', 'the-treasury', 'hosting']) assert.equal(t.els[id].hidden, false, `#${id} is shown`);
103
+ assert.equal(t.els['money-empty'].hidden, true);
104
+ assert.match(t.els['payouts-body'].innerHTML, /Owed but not paid/);
105
+ assert.match(t.els['payouts-body'].innerHTML, /Money out, by month/);
106
+ assert.match(t.els['the-treasury-body'].innerHTML, /id="treasury-spend"/);
107
+ assert.match(t.els['hosting-body'].innerHTML, /example-project/);
108
+ assert.deepEqual([...t.requests].sort(), [CHECKS, LEDGER, OUTFLOW, TREASURY].sort(), 'the three feeds the three pages read, and nothing new');
109
+ });
110
+
111
+ test('RUN: a section whose read refuses stays hidden — never an empty panel', async () => {
112
+ // payout.approve refused, and no provisioning module (its route is not mounted)
113
+ const t = await loadMoney({ responses: { [OUTFLOW]: 403, [TREASURY]: costSummaryFixture, [LEDGER]: 404 }, page: true });
114
+ await t.money.ready;
115
+ assert.equal(t.els.payouts.hidden, true);
116
+ assert.equal(t.els.hosting.hidden, true, 'the hosting section shows only where the provisioning module answers, as /fleet did');
117
+ assert.equal(t.els['the-treasury'].hidden, false);
118
+ assert.ok(!t.requests.includes(CHECKS), 'no ledger check is read for a reader the outflow refused');
119
+ });
120
+
121
+ test('RUN: a page where nothing answers says so in one line instead of loading forever', async () => {
122
+ for (const responses of [{}, { [OUTFLOW]: new Error('down'), [TREASURY]: { daily: [], daily_credits: [] }, [LEDGER]: 403 }]) {
123
+ const t = await loadMoney({ responses, page: true });
124
+ await t.money.ready;
125
+ assert.equal(t.els['money-empty'].hidden, false);
126
+ for (const id of ['payouts', 'the-treasury', 'hosting']) assert.equal(t.els[id].hidden, true);
127
+ }
128
+ });
129
+
130
+ test('RUN: an old /fleet link lands on the hosting section once it is drawn', async () => {
131
+ const t = await loadMoney({ responses: ALL, page: true, hash: '#hosting' });
132
+ await t.money.ready;
133
+ assert.equal(t.els.hosting.scrolled, true);
134
+ const none = await loadMoney({ responses: {}, page: true, hash: '#hosting' });
135
+ await none.money.ready;
136
+ assert.equal(none.els.hosting.scrolled, false, 'a hidden section is not scrolled to');
137
+ });
138
+
139
+ test('the page waits for its sections: a Loading… line until one is shown', () => {
140
+ assert.match(moneyHtml, /<p class="hall-loading hall-loading--page hall-loading--wait" role="status">Loading…<\/p>/);
141
+ for (const id of ['payouts', 'the-treasury', 'hosting', 'money-empty']) {
142
+ assert.match(moneyHtml, new RegExp(`<section class="scroll" id="${id}" [^>]*hidden>`), `#${id} starts hidden`);
143
+ }
144
+ });
145
+
146
+ test('the page loads the kit and the hosting builders before its own script', () => {
147
+ const loaded = [...moneyHtml.matchAll(/<script[^>]+src="([^"?]+)/g)].map((m) => m[1].split('/').pop());
148
+ const at = (f) => loaded.indexOf(f);
149
+ assert.ok(at('hall-kit.js') > -1 && at('fleet-costs.js') > -1 && at('money.js') > -1);
150
+ assert.ok(at('hall-kit.js') < at('money.js') && at('fleet-costs.js') < at('money.js'));
151
+ assert.match(moneyHtml, /project-settings\.css/, 'the payout figures keep their sheet');
152
+ });
153
+
154
+ // ---- 2. the payouts section (moved from project-settings.js) --------------------
155
+
156
+ test('RUN: the payouts lead with what is left after what is owed', async () => {
157
+ const { money } = await loadMoney();
158
+ const html = money.payoutsHtml(fixture('project-account__outflow'), null);
159
+ for (const label of ['Balance', 'Owed but not paid', 'Approved, not started', 'Left after all of that']) {
160
+ assert.ok(html.includes(`<span class="ps-money__label">${label}</span>`), label);
161
+ }
162
+ assert.equal(money.payoutsHtml(null, null), '');
163
+ assert.equal(money.payoutsHtml({ balance: null }, null), '');
164
+ assert.equal(money.lastTryWords({ last_outcome: 'held', last_reason: 'no_processor' }), 'Waiting — no payment processor connected yet');
165
+ assert.equal(money.lastTryWords({ last_outcome: 'failed', last_reason: 'some_code' }), 'Did not go through', 'an unknown code is left out, never shown as a slug');
166
+ });
167
+
168
+ // ---- 3. the treasury (moved from primer-live.js) -------------------------------
169
+
170
+ test('RUN: the treasury draws from the payload, and its line ends on the total the prose states', async () => {
171
+ const { money } = await loadMoney();
172
+ const html = money.treasuryHtml(costSummaryFixture);
173
+ const usd = `$${Number(costSummaryFixture.total_usd).toLocaleString(undefined, { maximumFractionDigits: 0 })}`;
174
+ assert.ok(html.includes(`<strong>${usd}</strong>`), 'the spend total is stated');
175
+ assert.match(html, /id="treasury-spend"/);
176
+ assert.match(html, /id="treasury-earned"/);
177
+ assert.match(html, /Drachmae awarded, cumulative/, 'the currency word is the branding pack\'s');
178
+ assert.match(html, /<h3 class="ps-money__h" id="treasury-by-category">Spent, by kind<\/h3>/, 'under the section\'s h2, named apart from "Where the money went"');
179
+ assert.doesNotMatch(html, /NaN|Infinity/, 'a NaN in a path renders as an invisible chart with no error anywhere');
180
+ assert.doesNotMatch(html, /tabindex="0"/, 'no focus stop: nothing here wires a readout');
181
+ assert.doesNotMatch(html, /&amp;amp;|&amp;#/, 'nothing is escaped twice');
182
+ });
183
+
184
+ test('RUN: a treasury that holds nothing yet, or no chart kit, draws nothing', async () => {
185
+ const { money } = await loadMoney();
186
+ assert.equal(money.treasuryHtml(null), '');
187
+ assert.equal(money.treasuryHtml({ daily: [], daily_credits: [] }), '');
188
+ const noKit = await loadMoney({ responses: { [TREASURY]: costSummaryFixture }, page: true, kit: false });
189
+ await noKit.money.ready;
190
+ assert.equal(noKit.els['the-treasury'].hidden, true, 'without the chart kit there is nothing honest to draw');
191
+ assert.match(noKit.warnings.join('\n'), /OTBKit missing/);
192
+ });
193
+
194
+ test('the treasury reads the endpoint the status page draws, and draws only through the kit', () => {
195
+ assert.match(moneyJs, /TREASURY_URL = `\$\{API\}\/public\/cost-summary`/);
196
+ for (const fn of ['cumulateByDay', 'timeStepSeriesHtml', 'plotSvgHtml', 'plotValueLabelHtml', 'barSeriesHtml']) {
197
+ assert.match(moneyJs, new RegExp(`kit\\.${fn}\\(`), `the treasury draws through ${fn}`);
198
+ }
199
+ assert.doesNotMatch(moneyJs, /<(?:path|polyline|rect)\b[^>]*\b(?:d|points)="\$\{/, 'no hand-built SVG geometry');
200
+ });
201
+
202
+ // ---- 4. no money figure on two hall pages ---------------------------------------
203
+
204
+ test('no other hall page reads the three money feeds', () => {
205
+ const FEEDS = [/project-account\/outflow/, /project-account\/ledger-checks/, /fleet\/cost-ledger/];
206
+ // fleet-costs.js is the hosting section's own drawing, loaded only by money.html.
207
+ const files = fs.readdirSync(HALL).filter((f) => /\.(js|html)$/.test(f) && !['money.js', 'fleet-costs.js'].includes(f));
208
+ for (const f of files) {
209
+ for (const re of FEEDS) assert.doesNotMatch(read(f), re, `${f} reads ${re} — that figure belongs on /money`);
210
+ }
211
+ // The treasury feed is also the Tasks board's credits tile (a count of credits,
212
+ // not money) — but no other page draws the treasury itself.
213
+ for (const f of ['project-settings.js', 'project-settings.html', 'primer.js', 'primer-live.js']) {
214
+ assert.doesNotMatch(read(f), /cost-summary|treasury-spend|Where the money went<\/h2>/, `${f} draws no money figure`);
215
+ }
216
+ });
217
+
218
+ test('Project settings and the Primer link to the money page instead', () => {
219
+ assert.match(read('project-settings.html'), /<a href="\/builders\/money">Money<\/a>/);
220
+ assert.doesNotMatch(read('project-settings.html'), /id="ps-money"/);
221
+ const md = fs.readFileSync(path.join(ROOT, 'docs', 'onboarding', 'primer.md'), 'utf8');
222
+ assert.match(md, /\[Money\]\(\.\.\/\.\.\/modules\/hall-ui\/public\/money\.html\)/, 'a hall-page link primer.js opens in the hall');
223
+ assert.doesNotMatch(read('primer-live.js'), /fillTreasury|treasuryHtml/);
224
+ });
225
+
226
+ // ---- 5. the gate, the nav and the old URL ---------------------------------------
227
+
228
+ test('/money is behind the government page floor, and the nav mirrors it', () => {
229
+ const src = fs.readFileSync(path.join(ROOT, 'src', 'bongos', 'serve-internal.js'), 'utf8');
230
+ assert.match(src, /const MONEY_PAGE_RE = \/\^\\\/builders\\\/money\(\?:\\\.html\)\?\\\/\?\$\/;/);
231
+ assert.match(src, /\|\| MONEY_PAGE_RE\.test\(norm\)\) \{\n\s+return gdsAuth\.requireGovernmentPage\(req, res, next\);/);
232
+ assert.match(read('shell.js'), /\{ id: 'money', label: 'Money', icon: 'money', href: '\/money', gate: 'government', position: true \}/);
233
+ assert.doesNotMatch(read('shell.js'), /id: 'fleet'/, 'Fleet costs left the sidebar');
234
+ // the hosting section's data keeps its own wall: the route's atom, unchanged
235
+ const route = fs.readFileSync(path.join(ROOT, 'modules', 'provisioning', 'routes', 'provisioning.js'), 'utf8');
236
+ assert.match(route, /router\.get\('\/provisioning\/fleet\/cost-ledger', auth\.requireBuilder, auth\.requirePermission\('provisioning\.fleet\.manage'\)/);
237
+ });
238
+
239
+ async function withHall(fn) {
240
+ const express = require('express');
241
+ const { mountInternalSurfaces } = require('../src/bongos/serve-internal.js');
242
+ const app = express();
243
+ mountInternalSurfaces(app);
244
+ const server = app.listen(0, '127.0.0.1');
245
+ await new Promise((r) => server.once('listening', r));
246
+ const { port } = server.address();
247
+ const get = (p, host) => new Promise((resolve, reject) => {
248
+ http.get({ host: '127.0.0.1', port, path: p, headers: { host } }, (res) => {
249
+ res.resume();
250
+ res.on('end', () => resolve({ status: res.statusCode, location: res.headers.location || '' }));
251
+ }).on('error', reject);
252
+ });
253
+ try { await fn(get); } finally { server.close(); }
254
+ }
255
+
256
+ test('RUN: /fleet 302s to the money page\'s hosting section, in every form it was linked', async () => {
257
+ await withHall(async (get) => {
258
+ for (const form of ['/fleet', '/fleet/', '/fleet.html']) {
259
+ const r = await get(form, 'builders.127.0.0.1');
260
+ assert.equal(r.status, 302, `${form} is a temporary redirect, so a later re-point is never cached past`);
261
+ assert.equal(r.location, '/money#hosting');
262
+ }
263
+ const q = await get('/fleet?from=mail', 'builders.127.0.0.1');
264
+ assert.equal(q.location, '/money?from=mail#hosting', 'a query string rides along');
265
+ });
266
+ });
267
+
268
+ test('the states file shoots the redirected deep link too', () => {
269
+ const states = JSON.parse(read('money.states.json'));
270
+ assert.equal(states.page, '/builders/money');
271
+ assert.match(states.states.hosting.url, /#hosting$/);
272
+ });