@bongos/core 1.24.36 → 1.24.37

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 (71) hide show
  1. package/.bongos-core.json +90 -85
  2. package/docs/copy-inventory.md +58 -57
  3. package/docs/copy-registry.json +163 -166
  4. package/docs/module-api-changelog.md +2 -0
  5. package/docs/onboarding/primer.md +2 -0
  6. package/docs/page-inventory.json +34 -34
  7. package/docs/page-readings.json +684 -680
  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 +1 -1
  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 +7 -5
  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 +6 -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/fleet_cost_view.mjs +2 -18
  62. package/tests/hall_loading.mjs +1 -1
  63. package/tests/hall_money_page.mjs +272 -0
  64. package/tests/hall_nav.mjs +5 -4
  65. package/tests/hall_oversight_world.mjs +1 -1
  66. package/tests/hall_page_gate_map.mjs +3 -2
  67. package/tests/hall_reading_room.mjs +21 -62
  68. package/tests/nav_permission_atoms.mjs +1 -1
  69. package/modules/hall-ui/public/fleet.html +0 -69
  70. package/modules/hall-ui/public/fleet.js +0 -41
  71. package/modules/hall-ui/public/fleet.states.json +0 -14
@@ -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
+ });
@@ -322,12 +322,13 @@ test('sidebar groups read by audience, in order', () => {
322
322
  // 'deploy' (task 1003159, ADR 0293) is the SECOND archon item, beside 'government':
323
323
  // its gate is the `core.pin.move` atom, archon-floor and system:true. It sits with
324
324
  // the steering surfaces because that is what it is — the owner's third release gate.
325
- // 'fleet' (task 1001949) follows it at the metic floor of `provisioning.fleet.manage`:
326
- // what each hosted project costs is an operator surface, like the rest of this group.
325
+ // 'money' (task 1005047, owner decision 2026-11-03) follows 'project-settings' at the
326
+ // same government floor: the one money page — spend and payouts, the treasury and the
327
+ // hosting bills. It replaced 'fleet' (task 1001949), now its #hosting section.
327
328
  // 'market-watch-settings' (task 1004938) LEFT the group in task 1005045: the owner saw
328
329
  // "Market Watch" twice and had to guess which was which (owner decision 2026-11-03,
329
330
  // idea 1001310). The settings are an owner-only Settings tab on /market-watch now.
330
- assert.deepEqual(nav[4].items.map((i) => i.id), ['watch', 'gate', 'sessions', 'project-settings', 'deploy', 'fleet', 'government']);
331
+ assert.deepEqual(nav[4].items.map((i) => i.id), ['watch', 'gate', 'sessions', 'project-settings', 'money', 'deploy', 'government']);
331
332
  // task 1005045: and no label is left twice anywhere in the sidebar, so the next pair
332
333
  // fails here instead of in front of the owner.
333
334
  const labels = [...boot().sidebarHtml.matchAll(/<a class="nav-item" [^>]*>[\s\S]*?<span class="nav-item__label">([^<]*)<\/span>/g)].map((m) => m[1]);
@@ -779,7 +780,7 @@ test('no glyph path data reads as an IP address to the release leak scan', () =>
779
780
  // block (task 1004713). These drive the real shell and the real gov-positions.js.
780
781
 
781
782
  const tick = () => new Promise((resolve) => setImmediate(resolve));
782
- const GOVERNING = ['board-room', 'city', 'watch', 'gate', 'sessions', 'project-settings', 'deploy', 'fleet', 'government'];
783
+ const GOVERNING = ['board-room', 'city', 'watch', 'gate', 'sessions', 'project-settings', 'money', 'deploy', 'government'];
783
784
  const lead = (building, title) => ({ kind: 'lead', seat: 'lead:' + building, building, title });
784
785
  const block = (held) => ({ held, title: held.length ? held[0].title : null, has_position: held.length > 0 });
785
786
 
@@ -278,7 +278,7 @@ test('RUN: a metric its snapshots never carried is skipped, not drawn as a cliff
278
278
 
279
279
  // ---- task 1004829: the government pages read like the rebuilt Home ----------
280
280
 
281
- const GOV_PAGES = ['watch', 'gate', 'sessions', 'project-settings', 'deploy', 'fleet', 'government'];
281
+ const GOV_PAGES = ['watch', 'gate', 'sessions', 'project-settings', 'money', 'deploy', 'government'];
282
282
 
283
283
  test('every government page head holds only its h1, and no script writes into it', () => {
284
284
  // No page shows its head (task 1004836). A sub-line there is a line nobody
@@ -126,6 +126,9 @@ const ORACLE = {
126
126
  // its neighbours: a steering surface, metic+ per ADR 0157. Its two WRITES gate
127
127
  // independently on `project.settings.manage`, so this row is only about the shell.
128
128
  '/builders/project-settings': 'requireGovernmentPage',
129
+ // The one money page (task 1005047): the same floor as project-settings, whose money
130
+ // section it took. Its hosting section's data keeps its own atom on the route.
131
+ '/builders/money': 'requireGovernmentPage',
129
132
  // The deploy door (task 1003159, ADR 0293) — the NARROWEST gate in this table, on
130
133
  // the `core.pin.move` atom: archon-floor and system:true, the same key its preview
131
134
  // and its move both check. Correct for the one page whose button restarts the live
@@ -133,8 +136,6 @@ const ORACLE = {
133
136
  // its write on purpose: the preview discloses the exact core version the box serves
134
137
  // and how far behind it is, which is a vulnerability window.
135
138
  '/builders/deploy': 'requireCorePinPage',
136
- // The fleet cost page (task 1001949), on the atom its one read checks.
137
- '/builders/fleet': 'requireFleetPage',
138
139
 
139
140
  // Gone (task 1003444, owner call 2026-09-25 — ADR 0337 D5): the cross-project
140
141
  // directory left the hall, and /people now redirects to /roster before this
@@ -15,10 +15,11 @@
15
15
  // real h2/h3 slug of docs/onboarding/primer.md, computed by the SAME slugify
16
16
  // the page runs — so retitling a chapter fails here, not silently in a
17
17
  // browser (the credits diagram was lost that way once).
18
- // * THE ECONOMY PAGE'S LIVE SECTIONS MOVED WHOLE. The treasury and the bounty
19
- // rate card (primer-live.js, from drachmae.js, tasks 1003446 and 2174) are
20
- // RUN in a vm against the checked-in fixtures: a Loading… line first, the
21
- // real numbers after, and NOTHING — not an empty panel — when a feed fails.
18
+ // * THE ECONOMY PAGE'S LIVE SECTION MOVED WHOLE. The bounty rate card
19
+ // (primer-live.js, from drachmae.js, task 2174) is RUN in a vm against the
20
+ // checked-in fixture: a Loading… line first, the real numbers after, and
21
+ // NOTHING — not an empty panel — when the feed fails. (The treasury beside
22
+ // it went to the money page in task 1005047: tests/hall_money_page.mjs.)
22
23
  // * THE SHARED READER the economy doc leaned on (h4, linked headings, nested
23
24
  // emphasis) stays pinned: drachmae-and-karma.md is still the full reference.
24
25
  //
@@ -188,7 +189,6 @@ function liftInsertLive({ hash = '', withObserver = true }) {
188
189
  location: { hash },
189
190
  OTBPrimerLive: {
190
191
  placeholder: (kind) => ({ kind }),
191
- fillTreasury: async (s) => { fills.push(s.kind); return true; },
192
192
  fillBounty: async (s) => { fills.push(s.kind); return true; },
193
193
  },
194
194
  };
@@ -216,7 +216,7 @@ function liftInsertLive({ hash = '', withObserver = true }) {
216
216
  test('RUN: the live reads wait until the reader comes near, so an ordinary Primer visit costs nothing', async () => {
217
217
  const t = liftInsertLive({ hash: '#the-rank-ladder' });
218
218
  await t.run();
219
- assert.deepEqual(t.placed, [['treasury', true], ['bounty', true]], 'both placed at the end of the credits chapter, ahead of the next one');
219
+ assert.deepEqual(t.placed, [['bounty', true]], 'placed at the end of the credits chapter, ahead of the next one (the treasury is on /money since task 1005047)');
220
220
  assert.deepEqual(t.fills, [], 'no fetch on load');
221
221
  assert.equal(t.observers.length, 1);
222
222
  t.observers[0].cb([{ isIntersecting: false }]);
@@ -224,20 +224,20 @@ test('RUN: the live reads wait until the reader comes near, so an ordinary Prime
224
224
  t.observers[0].cb([{ isIntersecting: true }]);
225
225
  t.observers[0].cb([{ isIntersecting: true }]);
226
226
  await Promise.resolve();
227
- assert.deepEqual(t.fills, ['treasury', 'bounty'], 'one read each, once, when the reader approaches');
227
+ assert.deepEqual(t.fills, ['bounty'], 'one read, once, when the reader approaches');
228
228
  assert.equal(t.observers[0].gone, true, 'and the watch is dropped');
229
229
  });
230
230
 
231
231
  test('RUN: an old /drachmae link (or a section address) fills at once, so the landing is on the final layout', async () => {
232
- for (const hash of ['#credits-and-karma', '#the-treasury', '#security-bounty-rates']) {
232
+ for (const hash of ['#credits-and-karma', '#security-bounty-rates']) {
233
233
  const t = liftInsertLive({ hash });
234
234
  await t.run();
235
- assert.deepEqual(t.fills, ['treasury', 'bounty'], `${hash} reads both before landing`);
235
+ assert.deepEqual(t.fills, ['bounty'], `${hash} reads it before landing`);
236
236
  assert.equal(t.observers.length, 0);
237
237
  }
238
238
  const old = liftInsertLive({ withObserver: false });
239
239
  await old.run();
240
- assert.deepEqual(old.fills, ['treasury', 'bounty'], 'a browser with no observer still gets the sections');
240
+ assert.deepEqual(old.fills, ['bounty'], 'a browser with no observer still gets the section');
241
241
  });
242
242
 
243
243
  test('a deep link lands after the article, its live sections and the fonts', () => {
@@ -251,11 +251,8 @@ test('the states file shoots a redirected deep link, not just the top of the pag
251
251
  assert.match(states.states.jump.url, /#credits-and-karma$/);
252
252
  });
253
253
 
254
- // ---- 3. the live sections, RUN ---------------------------------------------
254
+ // ---- 3. the live section, RUN ---------------------------------------------
255
255
 
256
- const costSummaryFixture = JSON.parse(
257
- fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'fixtures', 'cost-summary.json'), 'utf8'),
258
- );
259
256
  const bountyFixture = JSON.parse(
260
257
  fs.readFileSync(path.join(ROOT, 'scripts', 'hall-preview', 'fixtures', 'public__bounty-table.json'), 'utf8'),
261
258
  );
@@ -271,7 +268,7 @@ function fakeSection() {
271
268
  };
272
269
  }
273
270
 
274
- async function loadLive({ responses, branding = { currency: { label: 'drachmae' } }, kit = true }) {
271
+ async function loadLive({ responses, branding = { currency: { label: 'drachmae' } } }) {
275
272
  const warnings = [];
276
273
  const sandbox = {
277
274
  window: { OTB: realOTB(), __BRANDING__: branding },
@@ -284,21 +281,16 @@ async function loadLive({ responses, branding = { currency: { label: 'drachmae'
284
281
  return { ok: true, status: 200, json: async () => r };
285
282
  },
286
283
  };
287
- if (kit) {
288
- const kitPath = path.join(HALL, 'hall-kit.js');
289
- sandbox.window.OTBKit = await import(`file://${kitPath.replace(/\\/g, '/')}`).then((m) => m.default);
290
- }
291
284
  vm.createContext(sandbox);
292
285
  vm.runInContext(liveJs, sandbox, { filename: 'primer-live.js' });
293
286
  return { live: sandbox.window.OTBPrimerLive, warnings };
294
287
  }
295
288
 
296
- const TREASURY = '/api/bongos/public/cost-summary';
297
289
  const BOUNTY = '/api/bongos/public/bounty-table';
298
290
 
299
291
  test('RUN: a section says Loading… the moment it is placed, under the heading a deep link needs', async () => {
300
292
  const { live } = await loadLive({ responses: {} });
301
- for (const [kind, id] of [['treasury', 'the-treasury'], ['bounty', 'security-bounty-rates']]) {
293
+ for (const [kind, id] of [['bounty', 'security-bounty-rates']]) {
302
294
  const s = live.placeholder(kind);
303
295
  assert.match(s.innerHTML, new RegExp(`^<h3 id="${id}">`), `${kind} carries its address from the start`);
304
296
  assert.match(s.innerHTML, /<p class="hall-loading" role="status">Loading…<\/p>/);
@@ -306,38 +298,6 @@ test('RUN: a section says Loading… the moment it is placed, under the heading
306
298
  }
307
299
  });
308
300
 
309
- test('RUN: the treasury fills from the payload, and its line ends on the total the prose states', async () => {
310
- const { live } = await loadLive({ responses: { [TREASURY]: costSummaryFixture } });
311
- const s = live.placeholder('treasury');
312
- assert.equal(await live.fillTreasury(s), true);
313
- assert.equal(s.removed, false);
314
- assert.equal(s.attrs['aria-busy'], undefined, 'no longer busy');
315
- assert.doesNotMatch(s.innerHTML, /Loading…/);
316
- assert.match(s.innerHTML, /^<h3 id="the-treasury">The treasury, so far<\/h3>/);
317
- const usd = `$${Number(costSummaryFixture.total_usd).toLocaleString(undefined, { maximumFractionDigits: 0 })}`;
318
- assert.ok(s.innerHTML.includes(`<strong>${usd}</strong>`), 'the spend total is stated');
319
- assert.match(s.innerHTML, /id="treasury-spend"/);
320
- assert.match(s.innerHTML, /id="treasury-earned"/);
321
- assert.match(s.innerHTML, /Drachmae awarded, cumulative/, 'the currency word is the branding pack\'s');
322
- assert.doesNotMatch(s.innerHTML, /NaN|Infinity/, 'a NaN in a path renders as an invisible chart with no error anywhere');
323
- assert.doesNotMatch(s.innerHTML, /tabindex="0"/, 'no focus stop: nothing here wires a readout');
324
- assert.doesNotMatch(s.innerHTML, /&amp;amp;|&amp;#/, 'nothing is escaped twice');
325
- });
326
-
327
- test('RUN: a treasury that cannot be read, or holds nothing yet, leaves NO section behind', async () => {
328
- for (const responses of [{}, { [TREASURY]: new Error('down') }, { [TREASURY]: { daily: [], daily_credits: [] } }]) {
329
- const { live } = await loadLive({ responses });
330
- const s = live.placeholder('treasury');
331
- assert.equal(await live.fillTreasury(s), false);
332
- assert.equal(s.removed, true, 'the Loading… line goes with it — never an empty panel');
333
- }
334
- const noKit = await loadLive({ responses: { [TREASURY]: costSummaryFixture }, kit: false });
335
- const s = noKit.live.placeholder('treasury');
336
- assert.equal(await noKit.live.fillTreasury(s), false, 'without the chart kit there is nothing honest to draw');
337
- assert.equal(s.removed, true);
338
- assert.match(noKit.warnings.join('\n'), /OTBKit missing/);
339
- });
340
-
341
301
  test('RUN: the bounty rate card renders the live table, tuned tiers marked', async () => {
342
302
  const tuned = { ...bountyFixture, table: bountyFixture.table.map((r, i) => (i === 0 ? { ...r, overridden: true } : r)) };
343
303
  const { live } = await loadLive({ responses: { [BOUNTY]: tuned }, branding: {} });
@@ -358,20 +318,19 @@ test('RUN: a bounty feed that fails leaves no section', async () => {
358
318
  }
359
319
  });
360
320
 
361
- test('the live sections read endpoints that already existed, and draw only through the kit', () => {
362
- assert.match(liveJs, /TREASURY_URL = '\/api\/bongos\/public\/cost-summary'/);
321
+ test('the live section reads an endpoint that already existed, and the treasury is gone to /money', () => {
363
322
  assert.match(liveJs, /BOUNTY_URL = '\/api\/bongos\/public\/bounty-table'/);
364
- for (const fn of ['cumulateByDay', 'timeStepSeriesHtml', 'plotSvgHtml', 'plotValueLabelHtml', 'barSeriesHtml']) {
365
- assert.match(liveJs, new RegExp(`kit\\.${fn}\\(`), `the treasury draws through ${fn}`);
366
- }
323
+ assert.doesNotMatch(liveJs, /cost-summary|fillTreasury/, 'task 1005047: a money figure is drawn on the money page alone');
367
324
  assert.doesNotMatch(liveJs, /<(?:path|polyline|rect)\b[^>]*\b(?:d|points)="\$\{/, 'no hand-built SVG geometry');
368
325
  });
369
326
 
370
- test('the page loads the kit, then the live module, then the page script', () => {
327
+ test('the page loads the live module, then the page script', () => {
371
328
  const loaded = [...primerHtml.matchAll(/<script[^>]+src="([^"?]+)/g)].map((m) => m[1].split('/').pop());
372
329
  const at = (f) => loaded.indexOf(f);
373
- assert.ok(at('hall-kit.js') > -1 && at('primer-live.js') > -1 && at('primer.js') > -1);
374
- assert.ok(at('hall-kit.js') < at('primer-live.js') && at('primer-live.js') < at('primer.js'));
330
+ assert.ok(at('primer-live.js') > -1 && at('primer.js') > -1);
331
+ assert.ok(at('primer-live.js') < at('primer.js'));
332
+ // task 1005047: the chart kit drew the treasury, which is on the money page now.
333
+ assert.equal(at('hall-kit.js'), -1, 'nothing on the Primer draws a chart');
375
334
  // The figure chrome is shared (style.css), so the Primer needs no profile sheet.
376
335
  assert.match(primerHtml, /style\.css/);
377
336
  assert.doesNotMatch(primerHtml, /profile\.css/);
@@ -411,7 +370,7 @@ test('emphasis inside a bold run renders — the API list-price line showed its
411
370
 
412
371
  test('the room gives the fourth level a rule, the measure and the anchor offset', () => {
413
372
  const h4 = rule(roomCss, '.primer-article h4');
414
- assert.ok(h4, 'room.css styles h4 (the treasury\'s "Where it went" is one)');
373
+ assert.ok(h4, 'room.css styles h4 (the credits reference doc uses them)');
415
374
  assert.match(h4, /scroll-margin-top/, 'a deep link to an h4 clears the top bar');
416
375
  const measured = decomment(roomCss).match(/\.primer-article\s*>\s*p,[\s\S]*?\{([^}]*)\}/);
417
376
  assert.ok(measured, 'the measured-children list exists');
@@ -96,7 +96,7 @@ test('the hall-preview fixture carries every atom /me resolves', () => {
96
96
  // Market Watch settings nav item and its fuel glyph; task 1005041: the Update pill replaced the
97
97
  // core-update banner; task 1005045: the Market Watch settings item left (one sidebar item);
98
98
  // task 1005046: Ranks, Economy and Diagrams left Docs (one reading room, the Primer).
99
- const SHELL_SHA = '36c64a1cf84e9db1';
99
+ const SHELL_SHA = '662e036a340ad33f';
100
100
 
101
101
  test('shell.js has not changed without its cache-bust being bumped', () => {
102
102
  const actual = crypto.createHash('sha256').update(SHELL).digest('hex').slice(0, 16);
@@ -1,69 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <title>Fleet costs — {{worldName}}</title>
7
- <meta name="description" content="What each hosted project costs this platform, and an estimate of what its owner pays for their own hosting.">
8
- <link rel="icon" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text x='16' y='24' font-size='24' text-anchor='middle' font-family='sans-serif'>⌂</text></svg>">
9
- <link rel="preconnect" href="https://fonts.googleapis.com">
10
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
11
- <link rel="stylesheet" href="/builders/style.css?v=2026-09-23-changelist1">
12
- <link rel="stylesheet" href="/builders/oversight.css">
13
- <link rel="stylesheet" href="/builders/project-settings.css">
14
- </head>
15
- <body data-page="fleet" data-page-title="Fleet costs">
16
-
17
- <div class="app">
18
- <aside class="app-sidebar" id="app-sidebar" aria-label="Primary"></aside>
19
- <div class="app-scrim" id="app-scrim" hidden></div>
20
- <div class="app-body">
21
- <header class="app-topbar" id="app-topbar"></header>
22
- <div class="app-content">
23
- <!-- The page's one h1, for screen readers: no page shows its head (task
24
- 1004836), so its sub-line went in task 1004829. -->
25
- <div class="page-head page-head--board">
26
- <h1 class="page-head__h">Fleet costs</h1>
27
- </div>
28
-
29
- <main id="hall-main">
30
- <!-- task 1005043: the page loader, shown until any section below is revealed. -->
31
- <p class="hall-loading hall-loading--page hall-loading--wait" role="status">Loading…</p>
32
- <section class="scroll" id="fl-sealed" aria-labelledby="fl-sealed-h" hidden>
33
- <h2 class="scroll__h" id="fl-sealed-h">Restricted</h2>
34
- <p class="scroll__lede">This page opens at a higher rank.</p>
35
- </section>
36
-
37
- <section class="scroll" id="fl-body" aria-labelledby="fl-body-h" hidden>
38
- <h2 class="scroll__h" id="fl-body-h">What each project costs</h2>
39
- <!-- Two different bills, one column each (ADR 0327): a cloud host runs on its
40
- OWNER's account, so it costs this platform nothing and its owner pays their
41
- hosting provider directly. Nothing on this page is charged to anyone. -->
42
- <p class="scroll__lede">
43
- Two different bills, one column each. The first is what a project costs this
44
- platform. The second is an estimate of what its owner pays their own hosting
45
- provider, which bills them directly. A project with no recorded plan reads
46
- "not priced yet", never $0, and is left out of the total. A self-hosted
47
- project runs on its owner's own machine, so its bill is not visible to us.
48
- </p>
49
- <div id="fl-panel"><span class="kit-skel"></span></div>
50
- </section>
51
- </main>
52
-
53
- <footer class="app-foot" id="app-foot"></footer>
54
- </div>
55
- </div>
56
- </div>
57
-
58
- <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
59
-
60
- <script src="/builders/dom-utils.js?v=2026-10-25-escape"></script>
61
- <script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
62
- <script src="/builders/craft-mode.js?v=2026-11-04-reading-room"></script>
63
- <script src="/builders/shell.js?v=2026-11-04-reading-room"></script>
64
- <script src="/builders/palette.js" defer></script>
65
- <script src="/builders/hall-kit.js"></script>
66
- <script src="/builders/fleet-costs.js"></script>
67
- <script src="/builders/fleet.js"></script>
68
- </body>
69
- </html>
@@ -1,41 +0,0 @@
1
- // fleet.js — the fleet cost page (task 1001949, ADR 0327): the chrome around
2
- // fleet-costs.js, which does all the drawing.
3
- //
4
- // One read, GET /provisioning/fleet/cost-ledger, gated on `provisioning.fleet.manage`
5
- // exactly as this page is. A 403 shows the sealed state rather than an empty table,
6
- // so a refusal is never mistaken for a fleet with nothing in it.
7
- (() => {
8
- 'use strict';
9
-
10
- const API = '/api/bongos';
11
- const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
12
- const { escapeHtml } = window.OTB;
13
- const $ = (id) => document.getElementById(id);
14
-
15
- function fail(message) {
16
- $('fl-body').hidden = false;
17
- $('fl-panel').innerHTML = `<p class="ps-invalid">${escapeHtml(message)}</p>`;
18
- }
19
-
20
- async function load() {
21
- let res;
22
- try {
23
- res = await api.request('GET', `${API}/provisioning/fleet/cost-ledger`);
24
- } catch (e) {
25
- return fail('Could not reach the server.');
26
- }
27
- if (res.status === 401) { window.location.assign(`${API}/auth/web/start`); return; }
28
- if (res.status === 403) {
29
- $('fl-sealed').hidden = false;
30
- return;
31
- }
32
- if (!res.ok) {
33
- const err = (res.data && res.data.error) || {};
34
- return fail(err.message && err.message !== err.code ? err.message : 'Could not read the fleet costs.');
35
- }
36
- $('fl-body').hidden = false;
37
- $('fl-panel').innerHTML = window.OTBFleetCosts.ledgerHtml(res.data);
38
- }
39
-
40
- document.addEventListener('DOMContentLoaded', load);
41
- })();