@bongos/core 1.19.1013 → 1.19.1014

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 (51) hide show
  1. package/.bongos-core.json +77 -52
  2. package/docs/copy-inventory.md +27 -16
  3. package/docs/copy-registry.json +153 -32
  4. package/docs/module-api-changelog.md +2 -0
  5. package/modules/hall-ui/public/atlas.html +1 -1
  6. package/modules/hall-ui/public/blockers.html +1 -1
  7. package/modules/hall-ui/public/board-room.html +1 -1
  8. package/modules/hall-ui/public/collab.html +1 -1
  9. package/modules/hall-ui/public/copy-desk.html +1 -1
  10. package/modules/hall-ui/public/deploy.html +1 -1
  11. package/modules/hall-ui/public/diagrams.html +1 -1
  12. package/modules/hall-ui/public/drachmae.html +1 -1
  13. package/modules/hall-ui/public/fleet-costs.js +80 -0
  14. package/modules/hall-ui/public/fleet.html +64 -0
  15. package/modules/hall-ui/public/fleet.js +45 -0
  16. package/modules/hall-ui/public/fleet.states.json +14 -0
  17. package/modules/hall-ui/public/gate.html +1 -1
  18. package/modules/hall-ui/public/goal-map.html +1 -1
  19. package/modules/hall-ui/public/goals.html +1 -1
  20. package/modules/hall-ui/public/government.html +1 -1
  21. package/modules/hall-ui/public/idea.html +1 -1
  22. package/modules/hall-ui/public/ideas.html +1 -1
  23. package/modules/hall-ui/public/index.html +1 -1
  24. package/modules/hall-ui/public/modules.html +1 -1
  25. package/modules/hall-ui/public/people.html +1 -1
  26. package/modules/hall-ui/public/primer.html +1 -1
  27. package/modules/hall-ui/public/profile.html +1 -1
  28. package/modules/hall-ui/public/project-settings.html +1 -1
  29. package/modules/hall-ui/public/ranks.html +1 -1
  30. package/modules/hall-ui/public/roadmap.html +1 -1
  31. package/modules/hall-ui/public/roster.html +1 -1
  32. package/modules/hall-ui/public/sessions.html +1 -1
  33. package/modules/hall-ui/public/settings.html +1 -1
  34. package/modules/hall-ui/public/shell.js +4 -0
  35. package/modules/hall-ui/public/studio.html +1 -1
  36. package/modules/hall-ui/public/task.html +1 -1
  37. package/modules/hall-ui/public/watch.html +1 -1
  38. package/modules/hall-ui/public/work.html +1 -1
  39. package/modules/provisioning/routes/provisioning.js +4 -4
  40. package/modules/public-landing/public/projects.html +7 -0
  41. package/modules/ui-design/kit/fixtures/provisioning-instances.json +1 -0
  42. package/package-lock.json +2 -2
  43. package/package.json +1 -1
  44. package/src/bongos/auth.js +8 -0
  45. package/src/bongos/serve-internal.js +5 -0
  46. package/src/module-api.js +1 -1
  47. package/tests/auth_resolve_failure.mjs +3 -2
  48. package/tests/fleet_cost_view.mjs +189 -0
  49. package/tests/hall_nav.mjs +3 -1
  50. package/tests/hall_page_gate_map.mjs +2 -0
  51. package/tests/nav_permission_atoms.mjs +1 -1
@@ -56,7 +56,7 @@
56
56
 
57
57
  <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
58
58
 
59
- <script src="/builders/shell.js?v=2026-09-22-movefeedback1"></script>
59
+ <script src="/builders/shell.js?v=2026-09-24-fleetcosts1"></script>
60
60
  <script src="/builders/palette.js" defer></script>
61
61
  <script src="/builders/dom-utils.js?v=2026-06-23-contrib"></script>
62
62
  <script src="/builders/hall-kit.js"></script>
@@ -235,7 +235,7 @@
235
235
 
236
236
  <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
237
237
 
238
- <script src="/builders/shell.js?v=2026-09-22-movefeedback1"></script>
238
+ <script src="/builders/shell.js?v=2026-09-24-fleetcosts1"></script>
239
239
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
240
240
  <script src="/builders/palette.js" defer></script>
241
241
  <script src="/builders/dom-utils.js?v=2026-06-14-domutils"></script>
@@ -134,7 +134,7 @@
134
134
 
135
135
  <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
136
136
 
137
- <script src="/builders/shell.js?v=2026-09-22-movefeedback1"></script>
137
+ <script src="/builders/shell.js?v=2026-09-24-fleetcosts1"></script>
138
138
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
139
139
  <script src="/builders/palette.js" defer></script>
140
140
  <script src="/builders/dom-utils.js?v=2026-06-16-gate"></script>
@@ -52,7 +52,7 @@
52
52
 
53
53
  <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
54
54
 
55
- <script src="/builders/shell.js?v=2026-09-22-movefeedback1"></script>
55
+ <script src="/builders/shell.js?v=2026-09-24-fleetcosts1"></script>
56
56
  <script src="/builders/palette.js" defer></script>
57
57
  <script src="/builders/dom-utils.js?v=2026-06-23-contrib" defer></script>
58
58
  <!-- task-detail.js still owns the #/idea/N and #/blocker/N overlay, so a ref
@@ -225,7 +225,7 @@
225
225
 
226
226
  <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
227
227
 
228
- <script src="/builders/shell.js?v=2026-09-22-movefeedback1"></script>
228
+ <script src="/builders/shell.js?v=2026-09-24-fleetcosts1"></script>
229
229
  <script src="/builders/palette.js" defer></script>
230
230
  <script src="/builders/dom-utils.js?v=2026-06-23-contrib"></script>
231
231
  <!-- task 1003069 (R08) — the quick-filing form's routed result links a task
@@ -102,7 +102,7 @@
102
102
  <script src="/builders/brand-apply.js?v=2026-06-27-r75" defer></script>
103
103
  <!-- The app shell (sidebar + top bar + view router). Dependency-free; loaded
104
104
  BEFORE builders.js so the chrome ids exist when events are wired. -->
105
- <script src="/builders/shell.js?v=2026-09-22-movefeedback1" defer></script>
105
+ <script src="/builders/shell.js?v=2026-09-24-fleetcosts1" defer></script>
106
106
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
107
107
  <script src="/builders/palette.js" defer></script>
108
108
  <!-- task 2166: the shared component kit (window.OTBKit) — pager/ledger/rail.
@@ -80,7 +80,7 @@
80
80
 
81
81
  <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
82
82
 
83
- <script src="/builders/shell.js?v=2026-09-22-movefeedback1"></script>
83
+ <script src="/builders/shell.js?v=2026-09-24-fleetcosts1"></script>
84
84
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
85
85
  <script src="/builders/palette.js" defer></script>
86
86
  <script src="/builders/dom-utils.js?v=2026-06-23-contrib"></script>
@@ -55,7 +55,7 @@
55
55
 
56
56
  <!-- The app shell (sidebar + top bar). Dependency-free; loaded FIRST so the
57
57
  chrome exists before the page script runs. -->
58
- <script src="/builders/shell.js?v=2026-09-22-movefeedback1"></script>
58
+ <script src="/builders/shell.js?v=2026-09-24-fleetcosts1"></script>
59
59
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
60
60
  <script src="/builders/palette.js" defer></script>
61
61
  <!-- shared frontend toolkit (window.OTB — rank labels, number formats) + the
@@ -115,7 +115,7 @@
115
115
 
116
116
  <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
117
117
 
118
- <script src="/builders/shell.js?v=2026-09-22-movefeedback1"></script>
118
+ <script src="/builders/shell.js?v=2026-09-24-fleetcosts1"></script>
119
119
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
120
120
  <script src="/builders/palette.js" defer></script>
121
121
  <!-- the shared markdown reader (window.OTBMdReader) — primer.js renders through
@@ -187,7 +187,7 @@
187
187
 
188
188
  <!-- The app shell (sidebar + top bar). Dependency-free; loaded FIRST so the
189
189
  chrome exists before the page script runs. -->
190
- <script src="/builders/shell.js?v=2026-09-22-movefeedback1"></script>
190
+ <script src="/builders/shell.js?v=2026-09-24-fleetcosts1"></script>
191
191
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
192
192
  <script src="/builders/palette.js" defer></script>
193
193
  <!-- shared frontend toolkit (window.OTB) — loaded before profile.js, which pulls from it. -->
@@ -58,7 +58,7 @@
58
58
 
59
59
  <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
60
60
 
61
- <script src="/builders/shell.js?v=2026-09-22-movefeedback1"></script>
61
+ <script src="/builders/shell.js?v=2026-09-24-fleetcosts1"></script>
62
62
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
63
63
  <script src="/builders/palette.js" defer></script>
64
64
  <script src="/builders/dom-utils.js?v=2026-06-16-gate"></script>
@@ -68,7 +68,7 @@
68
68
 
69
69
  <!-- The app shell (sidebar + top bar). Dependency-free; loaded FIRST so the
70
70
  chrome exists before the page script runs. -->
71
- <script src="/builders/shell.js?v=2026-09-22-movefeedback1"></script>
71
+ <script src="/builders/shell.js?v=2026-09-24-fleetcosts1"></script>
72
72
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
73
73
  <script src="/builders/palette.js" defer></script>
74
74
  <!-- shared frontend toolkit (window.OTB) — loaded before ranks.js, which pulls from it. -->
@@ -41,7 +41,7 @@
41
41
 
42
42
  <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
43
43
 
44
- <script src="/builders/shell.js?v=2026-09-22-movefeedback1"></script>
44
+ <script src="/builders/shell.js?v=2026-09-24-fleetcosts1"></script>
45
45
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
46
46
  <script src="/builders/palette.js" defer></script>
47
47
  <script src="/builders/dom-utils.js?v=2026-06-14-domutils"></script>
@@ -50,7 +50,7 @@
50
50
 
51
51
  <!-- The app shell (sidebar + top bar). Dependency-free; loaded FIRST so the
52
52
  chrome exists before the page script runs. -->
53
- <script src="/builders/shell.js?v=2026-09-22-movefeedback1"></script>
53
+ <script src="/builders/shell.js?v=2026-09-24-fleetcosts1"></script>
54
54
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
55
55
  <script src="/builders/palette.js" defer></script>
56
56
  <!-- shared frontend toolkit (window.OTB — rank labels, number formats) + the
@@ -99,7 +99,7 @@
99
99
 
100
100
  <!-- The app shell (sidebar + top bar). Dependency-free; loaded FIRST so the
101
101
  chrome exists before the page script runs. -->
102
- <script src="/builders/shell.js?v=2026-09-22-movefeedback1"></script>
102
+ <script src="/builders/shell.js?v=2026-09-24-fleetcosts1"></script>
103
103
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
104
104
  <script src="/builders/palette.js" defer></script>
105
105
  <!-- shared frontend toolkit (window.OTB) — loaded BEFORE sessions.js so it can pull from it. -->
@@ -416,7 +416,7 @@
416
416
 
417
417
  <!-- The app shell (sidebar + top bar). Dependency-free; loaded FIRST so the
418
418
  chrome exists before the page scripts run. -->
419
- <script src="/builders/shell.js?v=2026-09-22-movefeedback1"></script>
419
+ <script src="/builders/shell.js?v=2026-09-24-fleetcosts1"></script>
420
420
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
421
421
  <script src="/builders/palette.js" defer></script>
422
422
  <!-- shared frontend toolkit (window.OTB) — loaded before consuming IIFEs. -->
@@ -334,6 +334,10 @@
334
334
  // `core.pin.move` atom its page and both its routes check — cosmetic only, as
335
335
  // every item in this list is; the wall is server-side.
336
336
  { id: 'deploy', label: 'Deploy', icon: 'admin', href: '/deploy', gate: 'archon' },
337
+ // task 1001949: what each hosted project costs. 'government' mirrors the metic floor
338
+ // of `provisioning.fleet.manage`, the atom its page and its one read check (the
339
+ // Deploy precedent above); dropped where the provisioning module is not enabled.
340
+ { id: 'fleet', label: 'Fleet costs', icon: 'admin', href: '/fleet', gate: 'government', module: 'provisioning' },
337
341
  // BV1.R113: the roles-and-permissions tab (ADR 0151). The one genuinely
338
342
  // Archon-only item the 'government' gate comment anticipated — cosmetic
339
343
  // only. The wall is the `government.manage` atom checked on each of the
@@ -66,7 +66,7 @@
66
66
 
67
67
  <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
68
68
 
69
- <script src="/builders/shell.js?v=2026-09-22-movefeedback1"></script>
69
+ <script src="/builders/shell.js?v=2026-09-24-fleetcosts1"></script>
70
70
  <script src="/builders/palette.js" defer></script>
71
71
  <script src="/builders/dom-utils.js?v=2026-06-23-contrib"></script>
72
72
  <!-- The kit, for its ONE renderer: the full-size ship-time visual figure
@@ -57,7 +57,7 @@
57
57
 
58
58
  <!-- The app shell (sidebar + top bar + footer). Dependency-free; runs at parse
59
59
  time so the chrome exists before the deferred page scripts execute. -->
60
- <script src="/builders/shell.js?v=2026-09-22-movefeedback1"></script>
60
+ <script src="/builders/shell.js?v=2026-09-24-fleetcosts1"></script>
61
61
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
62
62
  <script src="/builders/palette.js" defer></script>
63
63
  <!-- shared frontend toolkit (window.OTB) — loaded FIRST so task.js can pull from it. -->
@@ -162,7 +162,7 @@
162
162
 
163
163
  <!-- The app shell (sidebar + top bar). Dependency-free; loaded FIRST so the
164
164
  chrome exists before the page script runs. -->
165
- <script src="/builders/shell.js?v=2026-09-22-movefeedback1"></script>
165
+ <script src="/builders/shell.js?v=2026-09-24-fleetcosts1"></script>
166
166
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
167
167
  <script src="/builders/palette.js" defer></script>
168
168
  <!-- shared frontend toolkit (window.OTB) — loaded before watch.js, which pulls from it. -->
@@ -171,7 +171,7 @@
171
171
 
172
172
  <!-- The app shell (sidebar + top bar + footer). Dependency-free; runs at parse
173
173
  time so the chrome exists before the deferred page scripts execute. -->
174
- <script src="/builders/shell.js?v=2026-09-22-movefeedback1"></script>
174
+ <script src="/builders/shell.js?v=2026-09-24-fleetcosts1"></script>
175
175
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
176
176
  <script src="/builders/palette.js" defer></script>
177
177
  <!-- shared frontend toolkit (window.OTB) — loaded FIRST so work.js can pull from it. -->
@@ -49,7 +49,7 @@ const express = require('express');
49
49
  const api = require('../../../src/module-api');
50
50
  const auth = api, { pool, validateOrRespond, parseId } = api;
51
51
  const provisioning = require('../provisioning');
52
- const { costLedger } = require('../cost-ledger');
52
+ const { costLedger, ownerMonthlyUsd } = require('../cost-ledger');
53
53
  const { onboardPlan, stepsForShape } = require('../onboard-plan');
54
54
  const { ALWAYS_ON_CORE, OPTIONAL_MODULES, starterBundleAnswerTable, optionalModules, effectiveModules } = require('../starter-bundles');
55
55
  const { recommendationsForDetails } = require('../recommendations');
@@ -144,10 +144,10 @@ function publishVerdict(row) {
144
144
  }
145
145
 
146
146
  // The owner-facing projection every instance read returns: the allow-listed row
147
- // plus the derived publish verdict. One helper so a new read cannot ship the row
148
- // without the verdict and leave an owner unable to see why they are dark matter.
147
+ // plus the derived publish verdict and the estimate of the owner's own hosting bill
148
+ // (task 1001949; null = unpriced, never 0). Owner- or archon-scoped reads only.
149
149
  function ownerInstance(row) {
150
- return { ...provisioning.publicInstance(row), publish: publishVerdict(row) };
150
+ return { ...provisioning.publicInstance(row), publish: publishVerdict(row), owner_monthly_usd: ownerMonthlyUsd({ hostingShape: row.hosting_shape, tier: row.tier }) };
151
151
  }
152
152
 
153
153
  // Project an instance row for the fleet view — the owner identity + the same
@@ -5573,6 +5573,12 @@ summary{min-height:24px;padding:3px 0;}
5573
5573
 
5574
5574
  /* one label · value pair of the manage ledger */
5575
5575
  function ledgerRow(r) { return '<span class="k">' + r[0] + '</span><span class="v">' + r[1] + '</span>'; }
5576
+ /* the owner's own hosting bill (task 1001949, ADR 0327): an estimate, since the
5577
+ provider bills them directly. null is unpriced — a $0 would be a price nobody quoted */
5578
+ function hostingBill(usd) {
5579
+ if (typeof usd !== 'number' || !isFinite(usd)) return 'not priced yet';
5580
+ return '$' + (usd % 1 ? usd.toFixed(2) : String(usd)) + '/month, estimated. Render bills you directly.';
5581
+ }
5576
5582
  /* the head's planet and the ledger's planet row (task 1003345) — one resolution
5577
5583
  (planetOf) for both, so the head, the row and the sky can never disagree; the
5578
5584
  planet row is the slot the picker (task 1003347) fills */
@@ -5601,6 +5607,7 @@ summary{min-height:24px;padding:3px 0;}
5601
5607
  var server = inst.hosting_shape ? esc(SHAPE_LABEL[inst.hosting_shape] || inst.hosting_shape) : '';
5602
5608
  if (inst.onboard_mode === 'adopt') server += (server ? ', ' : '') + 'adopted repo';
5603
5609
  if (server) rows.push(['server', server]);
5610
+ if (inst.hosting_shape === 'cloud-host' && inst.status !== 'torn_down') rows.push(['hosting bill', hostingBill(inst.owner_monthly_usd)]);
5604
5611
  $('mMeta').innerHTML = rows.map(ledgerRow).join('');
5605
5612
  $('mMeta').hidden = !rows.length;
5606
5613
  var addr = addressRow(inst, true);
@@ -26,6 +26,7 @@
26
26
  "domain": null,
27
27
  "target_ref": "builder/harbor",
28
28
  "hosting_shape": "cloud-host",
29
+ "owner_monthly_usd": null,
29
30
  "onboard_mode": "scaffold",
30
31
  "created_at": "$now-6m",
31
32
  "provisioned_at": null,
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.1013",
3
+ "version": "1.19.1014",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.1013",
9
+ "version": "1.19.1014",
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.19.1013",
3
+ "version": "1.19.1014",
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",
@@ -462,6 +462,10 @@ const PAGE_GATES = Object.freeze({
462
462
  // — a vulnerability window, and exactly the kind of topology ADR 0043 keeps off
463
463
  // unauthenticated surfaces.
464
464
  requireCorePinPage: Object.freeze({ permission: 'core.pin.move', deniedTo: '/builders' }),
465
+ // The /fleet cost page (task 1001949) — on `provisioning.fleet.manage`, the SAME atom
466
+ // its one read (GET /provisioning/fleet/cost-ledger) checks: every owner's projects
467
+ // and what they cost, so the page reaches exactly as far as the data does.
468
+ requireFleetPage: Object.freeze({ permission: 'provisioning.fleet.manage', deniedTo: '/builders' }),
465
469
  });
466
470
 
467
471
  // The `alsoAdmits` door — "is this visitor the page's OTHER audience?", asked of
@@ -637,6 +641,9 @@ async function requireBoardVotePage(req, res, next) {
637
641
  async function requireCorePinPage(req, res, next) {
638
642
  return runPageGate('requireCorePinPage', req, res, next);
639
643
  }
644
+ async function requireFleetPage(req, res, next) {
645
+ return runPageGate('requireFleetPage', req, res, next);
646
+ }
640
647
 
641
648
  // ---------------------------------------------------------------------------
642
649
  // Route-gate inventory (V3.R06 #231) — the canonical "who can call what" list.
@@ -929,6 +936,7 @@ module.exports = {
929
936
  requireProjectCuratePage,
930
937
  requireBoardVotePage,
931
938
  requireCorePinPage,
939
+ requireFleetPage,
932
940
  requireRank,
933
941
  rankMeetsThreshold,
934
942
  requirePermission,
@@ -954,6 +954,8 @@ function mountInternalSurfaces(app) {
954
954
  // statically, and a comment in that gap makes the page test as UNGATED while
955
955
  // looking gated here. Prose goes above the const, as here.
956
956
  const DEPLOY_PAGE_RE = /^\/builders\/deploy(?:\.html)?\/?$/;
957
+ // The fleet cost page (task 1001949), on the atom its one read checks.
958
+ const FLEET_PAGE_RE = /^\/builders\/fleet(?:\.html)?\/?$/;
957
959
  // /scouting WAS the legacy twin of /people — the same /api/gds/scouting
958
960
  // directory, left behind when task 1002577 graduated it into the hall. Task
959
961
  // 1003470 had already made the two one page (scouting.html rendered people's
@@ -1004,6 +1006,9 @@ function mountInternalSurfaces(app) {
1004
1006
  if (DEPLOY_PAGE_RE.test(norm)) {
1005
1007
  return gdsAuth.requireCorePinPage(req, res, next);
1006
1008
  }
1009
+ if (FLEET_PAGE_RE.test(norm)) {
1010
+ return gdsAuth.requireFleetPage(req, res, next);
1011
+ }
1007
1012
  next();
1008
1013
  });
1009
1014
 
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.1013'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.1014'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -98,16 +98,17 @@ test('AUDIT: every async middleware mounted directly on a router owns its reject
98
98
  // requireBoardVotePage / ADR 0266 /board-room vote gate)
99
99
  // requireCorePinPage / ADR 0293's /deploy door — the narrowest of them,
100
100
  // on the archon-floor `core.pin.move` atom.
101
+ // requireFleetPage — task 1001949's /fleet cost page, same delegation.
101
102
  // 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.
104
105
  // Anything else async and router-mounted must be added with its own try/catch.
105
106
  const src = fs.readFileSync(path.join(ROOT, 'src', 'bongos', 'auth.js'), 'utf8');
106
107
  const asyncMw = [...src.matchAll(/^async function (require\w+|allow\w+)\s*\(req, res, next\)/gm)].map((m) => m[1]);
107
- assert.deepEqual(asyncMw.sort(), ['requireBoardVotePage', 'requireBuilder', 'requireBuilderPage', 'requireCorePinPage', 'requireGovernmentPage', 'requireProjectCuratePage'],
108
+ assert.deepEqual(asyncMw.sort(), ['requireBoardVotePage', 'requireBuilder', 'requireBuilderPage', 'requireCorePinPage', 'requireFleetPage', 'requireGovernmentPage', 'requireProjectCuratePage'],
108
109
  'a new async router middleware appeared — give it a try/catch and add it here');
109
110
  // The page gates must actually delegate (that is WHY they are safe).
110
- for (const name of ['requireBuilderPage', 'requireGovernmentPage', 'requireProjectCuratePage', 'requireBoardVotePage', 'requireCorePinPage']) {
111
+ for (const name of ['requireBuilderPage', 'requireGovernmentPage', 'requireProjectCuratePage', 'requireBoardVotePage', 'requireCorePinPage', 'requireFleetPage']) {
111
112
  const fn = src.slice(src.indexOf(`async function ${name}`));
112
113
  assert.match(sliceFn(fn), /runPageGate\(/, `${name} must delegate to the guarded runPageGate`);
113
114
  }
@@ -0,0 +1,189 @@
1
+ // tests/fleet_cost_view.mjs — Cost view v1 (task 1001949, ADR 0327): the fleet cost page
2
+ // for the platform owner, and the one estimate line on a cloud-host owner's project page.
3
+ //
4
+ // The rule every case below leans on: an UNPRICED cost (null) renders as unpriced and never
5
+ // as $0, and never moves a total. A $0 there would be a price nobody quoted.
6
+ import assert from 'node:assert/strict';
7
+ import { test } from 'node:test';
8
+ import http from 'node:http';
9
+ import vm from 'node:vm';
10
+ import { readFileSync } from 'node:fs';
11
+ import { createRequire } from 'node:module';
12
+ import { fileURLToPath } from 'node:url';
13
+ import path from 'node:path';
14
+
15
+ const require = createRequire(import.meta.url);
16
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
17
+ const HALL = path.join(ROOT, 'modules', 'hall-ui', 'public');
18
+ const read = (p) => readFileSync(p, 'utf8').replace(/\r\n/g, '\n');
19
+ const costs = require(path.join(HALL, 'fleet-costs.js'));
20
+ const FIXTURE = JSON.parse(read(path.join(ROOT, 'scripts', 'hall-preview', 'fixtures', 'provisioning__fleet__cost-ledger.json')));
21
+
22
+ // ── the fleet page's builders ────────────────────────────────────────────────
23
+ const row = (over) => ({ id: '1', slug: 'p', owner: 'o', hosting_shape: 'cloud-host', tier: null, status: 'active', platform_monthly_usd: 0, owner_monthly_usd: null, ...over });
24
+ const cells = (html) => [...html.matchAll(/<td[^>]*>([\s\S]*?)<\/td>/g)].map((m) => m[1].replace(/<[^>]+>/g, '').trim());
25
+ const foot = (html) => html.slice(html.indexOf('<tfoot>'));
26
+
27
+ test('money: whole dollars stay whole, cents show two places, and an unpriced value has no price', () => {
28
+ assert.equal(costs.money(13), '$13');
29
+ assert.equal(costs.money(12.5), '$12.50');
30
+ assert.equal(costs.money(0), '$0');
31
+ assert.equal(costs.money(null), null);
32
+ assert.equal(costs.money(undefined), null);
33
+ });
34
+
35
+ test('an unpriced row reads "not priced yet", never $0 — and a real $0 still reads $0', () => {
36
+ const html = costs.ledgerHtml({
37
+ instances: [row({ slug: 'a', platform_monthly_usd: 0, owner_monthly_usd: null })],
38
+ totals: { platform: { monthly_usd: 0, unpriced: 0 }, owner: { monthly_usd: 0, unpriced: 1 } },
39
+ });
40
+ const [, , , , us, owner] = cells(html.slice(0, html.indexOf('<tfoot>')));
41
+ assert.equal(us, '$0', 'a cloud-host costs us a real, measured $0');
42
+ assert.equal(owner, 'not priced yet');
43
+ });
44
+
45
+ test('a total sums only priced rows and names how many it left out', () => {
46
+ const html = costs.ledgerHtml({
47
+ instances: [
48
+ row({ slug: 'cp', hosting_shape: 'control-plane', tier: 's-1vcpu-2gb', platform_monthly_usd: 12, owner_monthly_usd: 0 }),
49
+ row({ slug: 'priced', tier: 'starter+basic-256mb', owner_monthly_usd: 13 }),
50
+ row({ slug: 'bare', owner_monthly_usd: null }),
51
+ ],
52
+ totals: { platform: { monthly_usd: 12, unpriced: 0 }, owner: { monthly_usd: 13, unpriced: 1 } },
53
+ });
54
+ const [us, owner] = cells(foot(html));
55
+ assert.equal(us, '$12');
56
+ assert.equal(owner, '$13 + 1 not priced', 'the null row is counted, not summed');
57
+ });
58
+
59
+ test('a column with NOTHING priced has no total — the sum of no prices is not $0', () => {
60
+ const html = costs.ledgerHtml({
61
+ instances: [row({ slug: 'a' }), row({ slug: 'b' })],
62
+ totals: { platform: { monthly_usd: 0, unpriced: 0 }, owner: { monthly_usd: 0, unpriced: 2 } },
63
+ });
64
+ const [us, owner] = cells(foot(html));
65
+ assert.equal(us, '$0', 'every row prices at a real $0 for us');
66
+ assert.equal(owner, 'not priced yet', 'the owner total is not printed as $0');
67
+ });
68
+
69
+ test('the harness fixture reads as live does today: our box priced, every owner bill but ours unpriced', () => {
70
+ const [us, owner] = cells(foot(costs.ledgerHtml(FIXTURE)));
71
+ assert.equal(us, '$12');
72
+ assert.equal(owner, '$0 + 2 not priced, 1 not visible');
73
+ });
74
+
75
+ test('the total says what its cells say: a self-hosted bill is "not visible", not "not priced"', () => {
76
+ const byo = (slug) => row({ slug, hosting_shape: 'byo-host' });
77
+ const mixed = costs.ledgerHtml({
78
+ instances: [row({ slug: 'p', tier: 'starter+basic-256mb', owner_monthly_usd: 13 }), byo('a'), byo('b')],
79
+ totals: { platform: { monthly_usd: 0, unpriced: 0 }, owner: { monthly_usd: 13, unpriced: 2 } },
80
+ });
81
+ assert.equal(cells(foot(mixed))[1], '$13 + 2 not visible');
82
+ const allByo = costs.ledgerHtml({ instances: [byo('a'), byo('b')], totals: { platform: { monthly_usd: 0, unpriced: 0 }, owner: { monthly_usd: 0, unpriced: 2 } } });
83
+ assert.equal(cells(foot(allByo))[1], 'not visible to us');
84
+ });
85
+
86
+ test('a self-hosted owner\'s bill is not ours to see — a different absence from an unrecorded plan', () => {
87
+ const html = costs.ledgerHtml({ instances: [row({ hosting_shape: 'byo-host' })], totals: { platform: { monthly_usd: 0, unpriced: 0 }, owner: { monthly_usd: 0, unpriced: 1 } } });
88
+ assert.equal(cells(html)[5], 'not visible to us');
89
+ assert.equal(cells(html)[2], 'Self-hosted');
90
+ });
91
+
92
+ test('every row of the fixture is listed, a missing plan says so, and row text is escaped', () => {
93
+ const html = costs.ledgerHtml(FIXTURE);
94
+ for (const r of FIXTURE.instances) assert.ok(html.includes(`>${r.slug}<`), `${r.slug} is a row`);
95
+ assert.match(html, /none recorded/);
96
+ const hostile = costs.ledgerHtml({ instances: [row({ slug: '<img src=x onerror=1>', owner: '"q"' })], totals: {} });
97
+ assert.doesNotMatch(hostile, /<img/);
98
+ assert.match(hostile, /&lt;img src=x onerror=1&gt;/);
99
+ });
100
+
101
+ test('no projects renders a sentence, not an empty table', () => {
102
+ assert.match(costs.ledgerHtml({ instances: [], totals: {} }), /No projects are hosted here yet/);
103
+ assert.match(costs.ledgerHtml(null), /No projects are hosted here yet/);
104
+ });
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
+ });
124
+
125
+ // ── the owner's line on their own project page ───────────────────────────────
126
+ const HUB = read(path.join(ROOT, 'modules', 'public-landing', 'public', 'projects.html'));
127
+ function hubFn(name) {
128
+ const start = HUB.indexOf(`function ${name}(`);
129
+ assert.ok(start !== -1, `${name} is defined in projects.html`);
130
+ const end = HUB.indexOf('\n function ', start + 1);
131
+ const box = {};
132
+ vm.runInNewContext(`${HUB.slice(start, end)}\nbox.fn = ${name};`, { box });
133
+ return box.fn;
134
+ }
135
+
136
+ test('the owner\'s hosting-bill line: a price when the plan is known, "not priced yet" when not', () => {
137
+ const hostingBill = hubFn('hostingBill');
138
+ assert.equal(hostingBill(null), 'not priced yet');
139
+ assert.equal(hostingBill(undefined), 'not priced yet');
140
+ assert.equal(hostingBill(13), '$13/month, estimated. Render bills you directly.');
141
+ assert.equal(hostingBill(12.5), '$12.50/month, estimated. Render bills you directly.');
142
+ });
143
+
144
+ test('the line is shown only on a live cloud-host — a self-hosted owner\'s bill is theirs', () => {
145
+ const body = HUB.slice(HUB.indexOf('function renderManage('), HUB.indexOf("$('mMeta').innerHTML", HUB.indexOf('function renderManage(')));
146
+ assert.match(body, /if \(inst\.hosting_shape === 'cloud-host' && inst\.status !== 'torn_down'\) rows\.push\(\['hosting bill', hostingBill\(inst\.owner_monthly_usd\)\]\);/);
147
+ });
148
+
149
+ // ── the payload the owner's page reads ───────────────────────────────────────
150
+ const provisioning = require(path.join(ROOT, 'modules', 'provisioning', 'provisioning.js'));
151
+ const api = require(path.join(ROOT, 'src', 'module-api.js'));
152
+ let detailRow = null;
153
+ provisioning.getInstanceById = async () => detailRow;
154
+ provisioning.getOpenIntent = async () => null;
155
+ provisioning.getLatestOAuthManifestForInstance = async () => null;
156
+ require(path.join(ROOT, 'modules', 'provisioning', 'catalog-bridge.js')).healInstanceCatalog = async () => {};
157
+ api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'owner' }; next(); };
158
+ const express = require('express');
159
+ const app = express();
160
+ app.use((_req, res, next) => {
161
+ res.fail = (code, opts) => res.status(typeof opts === 'number' ? opts : (opts && opts.status) || 400).json({ error: { code } });
162
+ next();
163
+ });
164
+ app.use('/api/bongos', require(path.join(ROOT, 'modules', 'provisioning', 'routes', 'provisioning.js'))());
165
+ const getJson = (url) => new Promise((resolve, reject) => {
166
+ http.get(url, (res) => { let d = ''; res.on('data', (c) => { d += c; }); res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(d || 'null') })); }).on('error', reject);
167
+ });
168
+
169
+ test('GET /provisioning/instances/:id carries the owner\'s estimate — null until a plan is recorded', async () => {
170
+ const server = app.listen(0);
171
+ await new Promise((r) => server.once('listening', r));
172
+ const url = `http://127.0.0.1:${server.address().port}/api/bongos/provisioning/instances/41`;
173
+ const base = { id: 41, owner_builder_id: 7, slug: 'p', status: 'active', hosting_shape: 'cloud-host', description: 'd' };
174
+ try {
175
+ detailRow = { ...base, tier: null };
176
+ let r = await getJson(url);
177
+ assert.equal(r.status, 200);
178
+ assert.ok('owner_monthly_usd' in r.body.instance, 'the field is present');
179
+ assert.equal(r.body.instance.owner_monthly_usd, null, 'no plan: unpriced, not 0');
180
+ detailRow = { ...base, tier: 'starter+basic-256mb' };
181
+ r = await getJson(url);
182
+ assert.equal(r.body.instance.owner_monthly_usd, 13, 'the ledger\'s own price for the named plan');
183
+ detailRow = { ...base, owner_builder_id: 8, tier: 'starter+basic-256mb' };
184
+ r = await getJson(url);
185
+ assert.equal(r.status, 404, 'another builder\'s project answers nothing');
186
+ } finally {
187
+ server.close();
188
+ }
189
+ });
@@ -287,7 +287,9 @@ test('sidebar groups read by audience, in order', () => {
287
287
  // 'deploy' (task 1003159, ADR 0293) is the SECOND archon item, beside 'government':
288
288
  // its gate is the `core.pin.move` atom, archon-floor and system:true. It sits with
289
289
  // the steering surfaces because that is what it is — the owner's third release gate.
290
- assert.deepEqual(nav[4].items.map((i) => i.id), ['watch', 'gate', 'sessions', 'project-settings', 'deploy', 'government']);
290
+ // 'fleet' (task 1001949) follows it at the metic floor of `provisioning.fleet.manage`:
291
+ // what each hosted project costs is an operator surface, like the rest of this group.
292
+ assert.deepEqual(nav[4].items.map((i) => i.id), ['watch', 'gate', 'sessions', 'project-settings', 'deploy', 'fleet', 'government']);
291
293
  });
292
294
 
293
295
  test('Economy lives in Docs and points at the reward-schedule page', () => {
@@ -103,6 +103,8 @@ const ORACLE = {
103
103
  // its write on purpose: the preview discloses the exact core version the box serves
104
104
  // and how far behind it is, which is a vulnerability window.
105
105
  '/builders/deploy': 'requireCorePinPage',
106
+ // The fleet cost page (task 1001949), on the atom its one read checks.
107
+ '/builders/fleet': 'requireFleetPage',
106
108
 
107
109
  // Metic+ via the `project.curate` atom — the exact permission GET /scouting
108
110
  // checks, so the page can never drift wider than the API it fronts. Its
@@ -87,7 +87,7 @@ test('the hall-preview fixture carries every atom /me resolves', () => {
87
87
  // the file and left the query alone, so a returning browser kept the cached copy —
88
88
  // a fix that reaches nobody looks identical to a fix that does not work.
89
89
 
90
- const SHELL_SHA = '02de48ca2ae07995';
90
+ const SHELL_SHA = 'db45ff41bd9c5fce';
91
91
 
92
92
  test('shell.js has not changed without its cache-bust being bumped', () => {
93
93
  const actual = crypto.createHash('sha256').update(SHELL).digest('hex').slice(0, 16);