@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.
- package/.bongos-core.json +90 -85
- package/docs/copy-inventory.md +58 -57
- package/docs/copy-registry.json +163 -166
- package/docs/module-api-changelog.md +2 -0
- package/docs/onboarding/primer.md +2 -0
- package/docs/page-inventory.json +34 -34
- package/docs/page-readings.json +684 -680
- package/modules/hall-ui/public/ai-studio.html +2 -2
- package/modules/hall-ui/public/atlas.html +2 -2
- package/modules/hall-ui/public/blockers.html +2 -2
- package/modules/hall-ui/public/board-room.html +2 -2
- package/modules/hall-ui/public/city.html +2 -2
- package/modules/hall-ui/public/collab.html +2 -2
- package/modules/hall-ui/public/commands.html +2 -2
- package/modules/hall-ui/public/copy-desk.html +2 -2
- package/modules/hall-ui/public/craft-mode.js +1 -1
- package/modules/hall-ui/public/deploy.html +2 -2
- package/modules/hall-ui/public/fleet-costs.js +3 -2
- package/modules/hall-ui/public/gate.html +2 -2
- package/modules/hall-ui/public/goal-map.html +2 -2
- package/modules/hall-ui/public/goals.html +2 -2
- package/modules/hall-ui/public/government.html +2 -2
- package/modules/hall-ui/public/idea.html +2 -2
- package/modules/hall-ui/public/ideas.html +2 -2
- package/modules/hall-ui/public/index.html +2 -2
- package/modules/hall-ui/public/market-watch-item.html +2 -2
- package/modules/hall-ui/public/market-watch-rules.html +2 -2
- package/modules/hall-ui/public/market-watch.html +2 -2
- package/modules/hall-ui/public/modules.html +2 -2
- package/modules/hall-ui/public/money.html +92 -0
- package/modules/hall-ui/public/money.js +302 -0
- package/modules/hall-ui/public/money.states.json +15 -0
- package/modules/hall-ui/public/my-agents.html +2 -2
- package/modules/hall-ui/public/palette.js +9 -2
- package/modules/hall-ui/public/primer-live.js +18 -105
- package/modules/hall-ui/public/primer.html +7 -8
- package/modules/hall-ui/public/primer.js +16 -18
- package/modules/hall-ui/public/profile.html +2 -2
- package/modules/hall-ui/public/project-settings.css +2 -1
- package/modules/hall-ui/public/project-settings.html +6 -16
- package/modules/hall-ui/public/project-settings.js +2 -142
- package/modules/hall-ui/public/roadmap.html +2 -2
- package/modules/hall-ui/public/roster.html +2 -2
- package/modules/hall-ui/public/sessions.html +2 -2
- package/modules/hall-ui/public/settings.html +2 -2
- package/modules/hall-ui/public/shell.js +7 -5
- package/modules/hall-ui/public/street.html +2 -2
- package/modules/hall-ui/public/studio.html +2 -2
- package/modules/hall-ui/public/task.html +2 -2
- package/modules/hall-ui/public/thinking.html +2 -2
- package/modules/hall-ui/public/tweak-editor.html +2 -2
- package/modules/hall-ui/public/watch.html +2 -2
- package/modules/hall-ui/public/work.html +2 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/src/bongos/auth.js +0 -8
- package/src/bongos/serve-internal.js +15 -6
- package/src/module-api.js +1 -1
- package/tests/auth_resolve_failure.mjs +5 -4
- package/tests/fleet_cost_view.mjs +2 -18
- package/tests/hall_loading.mjs +1 -1
- package/tests/hall_money_page.mjs +272 -0
- package/tests/hall_nav.mjs +5 -4
- package/tests/hall_oversight_world.mjs +1 -1
- package/tests/hall_page_gate_map.mjs +3 -2
- package/tests/hall_reading_room.mjs +21 -62
- package/tests/nav_permission_atoms.mjs +1 -1
- package/modules/hall-ui/public/fleet.html +0 -69
- package/modules/hall-ui/public/fleet.js +0 -41
- 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;|&#/, '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
|
+
});
|
package/tests/hall_nav.mjs
CHANGED
|
@@ -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
|
-
// '
|
|
326
|
-
//
|
|
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', '
|
|
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', '
|
|
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', '
|
|
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
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
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, [['
|
|
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, ['
|
|
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', '#
|
|
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, ['
|
|
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, ['
|
|
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
|
|
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' } }
|
|
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 [['
|
|
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;|&#/, '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
|
|
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
|
-
|
|
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
|
|
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('
|
|
374
|
-
assert.ok(at('
|
|
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
|
|
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 = '
|
|
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
|
-
})();
|