@bongos/core 1.19.789 → 1.19.791

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 (64) hide show
  1. package/.bongos-core.json +143 -53
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +6 -0
  4. package/clients/bongos-client/index.cjs +6 -0
  5. package/clients/bongos-client/index.d.ts +6 -0
  6. package/clients/bongos-client/index.mjs +6 -0
  7. package/docs/adr/0293-the-owners-deploy-door-is-an-intent-the-control-plane-drains.md +124 -0
  8. package/docs/adr/README.md +1 -0
  9. package/docs/api/openapi.json +129 -1
  10. package/docs/api-reference.md +5 -2
  11. package/docs/copy-inventory.md +89 -72
  12. package/docs/copy-registry.json +272 -98
  13. package/docs/module-api-changelog.md +4 -0
  14. package/docs/recipes/core-release-pipeline.md +12 -0
  15. package/migrations/core_241_core_upgrades_actor.sql +65 -0
  16. package/migrations/core_242_core_version_snapshot.sql +54 -0
  17. package/modules/dev-box/app/src/vendor/bongos-client.cjs +6 -0
  18. package/modules/government/catalog.js +8 -0
  19. package/modules/government/migrations/government_014_core_pin_move.sql +46 -0
  20. package/modules/hall-ui/public/deploy.html +64 -0
  21. package/modules/hall-ui/public/deploy.js +188 -0
  22. package/modules/hall-ui/public/hall-kit.js +7 -65
  23. package/modules/hall-ui/public/hall-render.js +1 -1
  24. package/modules/hall-ui/public/hall-visual.js +85 -0
  25. package/modules/hall-ui/public/index.html +1 -0
  26. package/modules/hall-ui/public/profile.html +1 -0
  27. package/modules/hall-ui/public/profile.js +2 -2
  28. package/modules/hall-ui/public/shell.js +4 -0
  29. package/modules/hall-ui/public/studio.css +1 -1
  30. package/modules/hall-ui/public/studio.html +1 -0
  31. package/modules/hall-ui/public/studio.js +4 -3
  32. package/modules/hall-ui/public/style.css +0 -16
  33. package/modules/hall-ui/public/task.html +1 -0
  34. package/modules/hall-ui/public/task.js +10 -13
  35. package/modules/provisioning/control-plane-guard.js +102 -0
  36. package/modules/provisioning/core-upgrade.js +289 -0
  37. package/modules/provisioning/migrations/provisioning_018_core_upgrade_intent.sql +98 -0
  38. package/modules/provisioning/module.json +1 -1
  39. package/modules/provisioning/provisioning.js +8 -8
  40. package/modules/provisioning/routes/core-upgrade.js +95 -0
  41. package/package-lock.json +2 -2
  42. package/package.json +1 -1
  43. package/scripts/gds/accessibility-replay.js +195 -0
  44. package/scripts/gds/enroll-control-plane.js +101 -0
  45. package/scripts/gds/provision-core-upgrade.js +311 -0
  46. package/scripts/gds/provision.js +4 -2
  47. package/scripts/gds/upgrade.js +53 -14
  48. package/src/bongos/auth.js +19 -0
  49. package/src/bongos/serve-internal.js +21 -0
  50. package/src/module-api.js +1 -1
  51. package/tests/accessibility_replay.mjs +201 -0
  52. package/tests/auth_resolve_failure.mjs +4 -2
  53. package/tests/core_upgrade_door.mjs +408 -0
  54. package/tests/core_upgrade_runner.mjs +377 -0
  55. package/tests/fixtures/accessibility-replay.json +93 -0
  56. package/tests/hall_nav.mjs +4 -1
  57. package/tests/hall_page_gate_map.mjs +7 -0
  58. package/tests/hall_studio_world.mjs +19 -11
  59. package/tests/provisioning_disconnect.mjs +30 -5
  60. package/tests/provisioning_paid_shape_gate.mjs +14 -4
  61. package/tests/provisioning_restart.mjs +25 -9
  62. package/tests/provisioning_settings_apply.mjs +11 -2
  63. package/tests/provisioning_teardown_intent.mjs +7 -0
  64. package/tests/task_visuals.mjs +91 -27
@@ -0,0 +1,85 @@
1
+ // modules/hall-ui/public/hall-visual.js — the ship-time task visual, in one place
2
+ // (task 1003835; the renderers themselves are tasks 1003109 and 1003829).
3
+ //
4
+ // A satellite of hall-kit.js rather than a part of it, and that is the decision
5
+ // this file records. These three functions lived in the kit, which is 61KB of
6
+ // pager/ledger/filter/chart machinery. The task record page (task.html) renders a
7
+ // visual and needs NOTHING else the kit holds, so converging it onto the kit's
8
+ // renderer would have meant loading 61KB on the hall's most-visited record page
9
+ // for one function — a real cost, paid on the wrong page. The alternative was to
10
+ // let task.js keep its own copy, which is how the hall ended up with two.
11
+ //
12
+ // So the helpers moved OUT. Any page that renders a visual loads this file; the
13
+ // kit no longer carries them and the pages that only use the kit no longer pay
14
+ // for them. The owner's call (2026-09-16) was that the kit may grow satellites
15
+ // like this one.
16
+ //
17
+ // Contract, identical to the kit's own: dependency-free (its own esc(), no
18
+ // window.OTB use) so load order only needs "before the page script";
19
+ // node-require-safe, so tests/task_visuals.mjs can require it with no DOM;
20
+ // string-template builders returning HTML, no state, no render loop.
21
+ //
22
+ // CSS: each caller supplies its own BEM base, because SIZE is the surface's
23
+ // decision and not this file's — the task record caps at 52vh in a record column
24
+ // (.tk-visual, style.css/task.css), the Studio gives the plate the card's whole
25
+ // width (.studio-plate). The markup, the URL guard and the escaping are shared.
26
+ (() => {
27
+ 'use strict';
28
+
29
+ // HTML-escape for text interpolated into innerHTML.
30
+ function esc(s) {
31
+ return String(s ?? '').replace(/[&<>"']/g, (c) => ({
32
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
33
+ }[c]));
34
+ }
35
+
36
+ // Same-origin, SERVER-MINTED paths only. The URL arrives from our own API, but
37
+ // it lands in an href and a src, so it is matched literally against the shape
38
+ // modules/lifecycle/task-visuals.js mints rather than trusted. Anything else
39
+ // renders nothing at all.
40
+ const VISUAL_URL_RE = /^\/api\/(gds|bongos)\/task-visuals\/task-\d+-[0-9a-f]{16}\.(png|jpg|gif|webp)$/;
41
+ function isTaskVisualUrl(url) {
42
+ return VISUAL_URL_RE.test(String(url ?? ''));
43
+ }
44
+
45
+ // The compact thumbnail, for a FEED — where the summary is one line in a list,
46
+ // so the image appears small and links to the full-size file. Three feeds draw
47
+ // it (the hall's ship ledger, a profile's recent ships, the status dashboard).
48
+ // Returns '' when the row carries no visual: a task without one must never look
49
+ // incomplete, so nothing is drawn and there is no placeholder.
50
+ function shipVisualThumbHtml(row) {
51
+ const url = String((row && row.visual_url) ?? '');
52
+ if (!isTaskVisualUrl(url)) return '';
53
+ const alt = String((row && row.visual_alt) || `Visual for task ${(row && row.id) ?? ''}`).trim();
54
+ return `<a href="${esc(url)}" target="_blank" rel="noopener" title="${esc(alt)}">` +
55
+ `<img class="ship-visual-thumb" src="${esc(url)}" alt="${esc(alt)}" loading="lazy"></a>`;
56
+ }
57
+
58
+ // The FULL-SIZE form: the picture as the thing being looked at, with visual_alt
59
+ // as its caption. For a surface whose subject IS the image — the task record
60
+ // (.tk-visual) and the Studio's review card (.studio-plate).
61
+ //
62
+ // `className` is the caller's BEM base; it is sanitised because it reaches a
63
+ // class attribute. Returns '' with no visual, like the thumbnail.
64
+ function taskVisualFigureHtml(row, { className = 'task-visual' } = {}) {
65
+ const url = String((row && row.visual_url) ?? '');
66
+ if (!isTaskVisualUrl(url)) return '';
67
+ const written = String((row && row.visual_alt) || '').trim();
68
+ const alt = written || `Visual for task ${(row && row.id) ?? ''}`;
69
+ const base = String(className).replace(/[^A-Za-z0-9_-]/g, '');
70
+ // The caption is only drawn when the shipper WROTE one — the fallback alt is
71
+ // a machine sentence for a screen reader, not a caption to show a reader.
72
+ const caption = written ? `<figcaption class="${base}__caption">${esc(written)}</figcaption>` : '';
73
+ return `<figure class="${base}">` +
74
+ `<a href="${esc(url)}" target="_blank" rel="noopener">` +
75
+ `<img class="${base}__img" src="${esc(url)}" alt="${esc(alt)}" loading="lazy">` +
76
+ `</a>${caption}</figure>`;
77
+ }
78
+
79
+ const api = { isTaskVisualUrl, shipVisualThumbHtml, taskVisualFigureHtml };
80
+
81
+ // Node-require-safe (the dom-utils.js / hall-kit.js pattern): assignment is the
82
+ // only eval-time side effect, and only when a window exists.
83
+ if (typeof window !== 'undefined') window.OTBVisual = api;
84
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
85
+ })();
@@ -110,6 +110,7 @@
110
110
  <script src="/builders/box-panel.js?v=2026-06-14-domutils" defer></script>
111
111
  <!-- task 2166: the shared component kit (window.OTBKit) — pager/ledger/rail.
112
112
  Loaded BEFORE hall-widgets.js, whose skeletons call OTBKit.pagerBarHtml(). -->
113
+ <script src="/builders/hall-visual.js?v=2026-09-16-visual1" defer></script>
113
114
  <script src="/builders/hall-kit.js" defer></script>
114
115
  <!-- BV1.R75: the CORE hall widgets register into window.OTB's hall-widget registry.
115
116
  Loaded BEFORE builders.js so the registry is populated when builders.js stamps
@@ -196,6 +196,7 @@
196
196
  <!-- hall-kit (window.OTBKit) — profile.js renders ship-time task visuals through
197
197
  its shared thumbnail helper (task 1003109) and builds the section rail with
198
198
  it (task 1003489), so it must load first. -->
199
+ <script src="/builders/hall-visual.js?v=2026-09-16-visual1"></script>
199
200
  <script src="/builders/hall-kit.js?v=2026-08-27-activity"></script>
200
201
  <!-- Connections (section 6). Loaded BEFORE profile.js so window.OTBConnections
201
202
  exists by the time renderProfile runs; profile.js guards on it anyway, so a
@@ -176,7 +176,7 @@
176
176
  const summary = s.value_summary
177
177
  ? `<span style="display:block;margin-top:2px;font-size:12px;color:var(--ink-faint)">${escapeHtml(s.value_summary)}</span>`
178
178
  : '';
179
- const visual = window.OTBKit && window.OTBKit.shipVisualThumbHtml ? window.OTBKit.shipVisualThumbHtml(s) : '';
179
+ const visual = window.OTBVisual ? window.OTBVisual.shipVisualThumbHtml(s) : '';
180
180
  return `<tr>
181
181
  <td class="ledger__num">#${escapeHtml(String(s.id))}</td>
182
182
  <td><a href="${taskHref(s.id)}">${escapeHtml(s.title || `Task ${s.id}`)}</a>${summary}${visual ? `<span style="display:block;margin-top:6px">${visual}</span>` : ''}</td>
@@ -473,7 +473,7 @@
473
473
  const summary = t.value_summary
474
474
  ? `<span style="display:block;margin-top:2px;font-size:12px;color:var(--ink-faint)">${escapeHtml(t.value_summary)}</span>`
475
475
  : '';
476
- const visual = kit.shipVisualThumbHtml ? kit.shipVisualThumbHtml(t) : '';
476
+ const visual = window.OTBVisual ? window.OTBVisual.shipVisualThumbHtml(t) : '';
477
477
  return `<tr>
478
478
  <td class="ledger__num">#${escapeHtml(String(t.id))}</td>
479
479
  <td><a href="${taskHref(t.id)}">${escapeHtml(t.title || `Task ${t.id}`)}</a>${summary}${visual ? `<span style="display:block;margin-top:6px">${visual}</span>` : ''}</td>
@@ -311,6 +311,10 @@
311
311
  // The project's own fundamentals (task 1003280, goal 1000072). Mirrors the
312
312
  // server's requireGovernmentPage floor; cosmetic only, as every item here is.
313
313
  { id: 'project-settings', label: 'Project settings', icon: 'admin', href: '/project-settings', gate: 'government' },
314
+ // The deploy door (task 1003159, ADR 0293). Gated 'archon' here to mirror the
315
+ // `core.pin.move` atom its page and both its routes check — cosmetic only, as
316
+ // every item in this list is; the wall is server-side.
317
+ { id: 'deploy', label: 'Deploy', icon: 'admin', href: '/deploy', gate: 'archon' },
314
318
  // BV1.R113: the roles-and-permissions tab (ADR 0151). The one genuinely
315
319
  // Archon-only item the 'government' gate comment anticipated — cosmetic
316
320
  // only. The wall is the `government.manage` atom checked on each of the
@@ -155,7 +155,7 @@
155
155
  IS the card: an artist is being asked to LOOK at something, so it is given the
156
156
  card's full width and as much height as a screen can spare, not the 44px
157
157
  thumbnail a ship feed uses. Markup comes from the kit's one renderer
158
- (OTBKit.taskVisualFigureHtml); only the size is this room's decision.
158
+ (OTBVisual.taskVisualFigureHtml); only the size is this room's decision.
159
159
 
160
160
  max-height in vh, and MEASURED at 1440x900 rather than chosen. The size an
161
161
  artist can judge it at wins over a zero-scroll card — that is the task's own
@@ -72,6 +72,7 @@
72
72
  <!-- The kit, for its ONE renderer: the full-size ship-time visual figure
73
73
  (task 1003829). The copy desk — the artist's other room — already loads it,
74
74
  and a fourth hand-rolled <figure> is what the kit exists to stop. -->
75
+ <script src="/builders/hall-visual.js?v=2026-09-16-visual1"></script>
75
76
  <script src="/builders/hall-kit.js"></script>
76
77
  <script src="/builders/studio.js?v=2026-09-10-studio2"></script>
77
78
  </body>
@@ -132,7 +132,7 @@
132
132
  // ---------------------------------------------------------------------------
133
133
 
134
134
  // THE PLATE — the reviewed work's ship-time visual, at the size the card can
135
- // give it. Through the kit's one renderer (OTBKit.taskVisualFigureHtml, which
135
+ // give it. Through the one renderer (OTBVisual.taskVisualFigureHtml, which
136
136
  // carries the server-minted-URL guard and the escaping) with the Studio's own
137
137
  // class, because the size is this surface's decision: here the picture is the
138
138
  // subject, not an illustration beside a record.
@@ -141,8 +141,9 @@
141
141
  // read, or when the kit is absent — a review with no picture must read as a
142
142
  // review, never as an empty frame (the degrade rule).
143
143
  function plateHtml(p) {
144
- if (!p.shipped || !kit || typeof kit.taskVisualFigureHtml !== 'function') return '';
145
- return kit.taskVisualFigureHtml(p.shipped, { className: 'studio-plate' });
144
+ const vis = window.OTBVisual;
145
+ if (!p.shipped || !vis || typeof vis.taskVisualFigureHtml !== 'function') return '';
146
+ return vis.taskVisualFigureHtml(p.shipped, { className: 'studio-plate' });
146
147
  }
147
148
 
148
149
  // The three weights, and the one field they share. Offered only to a viewer
@@ -2841,22 +2841,6 @@ main [id] { scroll-margin-top: calc(var(--topbar-h) + 16px); }
2841
2841
  overflow-y: auto;
2842
2842
  }
2843
2843
  .td-touches { font-size: 12.5px; color: var(--ink-faint); font-family: var(--font-mono); word-break: break-word; margin: 0 0 12px; }
2844
- /* task 1003109: the ship-time VISUAL, rendered directly under the value summary
2845
- it restates. Sized to be READ (this exists so a non-engineer can see a change),
2846
- capped so a tall screenshot can't push the rest of the record off the page, and
2847
- never drawn at all when a task has no visual. */
2848
- .td-visual { margin: 0 0 12px; }
2849
- .td-visual__img {
2850
- display: block;
2851
- max-width: 100%;
2852
- max-height: 52vh;
2853
- width: auto;
2854
- height: auto;
2855
- border: var(--line) solid var(--rule-soft);
2856
- border-radius: var(--radius-sm);
2857
- background: var(--glass);
2858
- }
2859
- .td-visual__caption { font-size: 12.5px; color: var(--ink-faint); margin: 6px 0 0; }
2860
2844
  /* The compact form: a thumbnail beside a value summary in a ship FEED, where the
2861
2845
  summary is one line in a list and a full-width image would drown it. */
2862
2846
  .ship-visual-thumb {
@@ -68,6 +68,7 @@
68
68
  <script src="/builders/task-detail.js?v=2026-07-03-taskdetail2" defer></script>
69
69
  <script src="/builders/brand-apply.js?v=2026-06-19-r56" defer></script>
70
70
  <script src="/builders/md-reader.js" defer></script>
71
+ <script src="/builders/hall-visual.js?v=2026-09-16-visual1" defer></script>
71
72
  <script src="/builders/task.js?v=2026-07-03-taskpage1" defer></script>
72
73
  </body>
73
74
  </html>
@@ -292,20 +292,17 @@
292
292
  // a detail can be opened full-size. The caption (visual_alt) doubles as the alt
293
293
  // text, which is why it is stored: an image with no alt tells a screen reader
294
294
  // nothing, and "a picture of the change" is exactly the audience that needs it.
295
+ // The shared renderer (hall-visual.js), not a copy of it — task 1003835. This
296
+ // page hand-rolled the same <figure> until then; the markup it produced is what
297
+ // OTBVisual emits, so .tk-visual's rules are unchanged. `className` keeps the
298
+ // sizing this page's own CSS owns (52vh inside a record column).
299
+ //
300
+ // Guarded rather than assumed: hall-visual.js is a separate <script>, so a page
301
+ // that forgot it should render no picture, never a broken one.
295
302
  function taskVisualHtml(t) {
296
- const url = String(t.visual_url || '');
297
- // Same-origin, server-generated paths only. The URL comes from our own API,
298
- // but rendering it into an href/src is a place to be literal rather than
299
- // trusting: anything that is not the shape our serve route mints is dropped.
300
- if (!/^\/api\/(gds|bongos)\/task-visuals\/task-\d+-[0-9a-f]{16}\.(png|jpg|gif|webp)$/.test(url)) return '';
301
- const alt = String(t.visual_alt || `Visual for task ${t.id}`);
302
- const caption = t.visual_alt
303
- ? `<figcaption class="tk-visual__caption">${escapeHtml(t.visual_alt)}</figcaption>`
304
- : '';
305
- return `<figure class="tk-visual">` +
306
- `<a href="${escapeHtml(url)}" target="_blank" rel="noopener">` +
307
- `<img class="tk-visual__img" src="${escapeHtml(url)}" alt="${escapeHtml(alt)}" loading="lazy">` +
308
- `</a>${caption}</figure>`;
303
+ const vis = window.OTBVisual;
304
+ if (!vis || typeof vis.taskVisualFigureHtml !== 'function') return '';
305
+ return vis.taskVisualFigureHtml(t, { className: 'tk-visual' });
309
306
  }
310
307
 
311
308
  async function copyText(text) {
@@ -0,0 +1,102 @@
1
+ 'use strict';
2
+
3
+ // modules/provisioning/control-plane-guard.js — what may be aimed at the box this
4
+ // software is running on (task 1003159, ADR 0293 D2).
5
+ //
6
+ // WHY THIS EXISTS. `provisioning_intents.instance_id` is NOT NULL REFERENCES
7
+ // provisioning_instances(id), so an intent must name a row. The control plane has
8
+ // never had one — the table holds tenant projects — which is precisely why the
9
+ // owner had no deploy door: there was nothing to point at. Enrolling it as a
10
+ // `hosting_shape='control-plane'` row opens the door.
11
+ //
12
+ // It also puts a 'teardown' button one query away from the platform itself. That
13
+ // is the risk this file exists to close, and it is the reason the shape is a
14
+ // distinct VALUE rather than a reuse of 'standalone': a distinct value is something
15
+ // every destructive leg can refuse BY NAME.
16
+ //
17
+ // AN ALLOW-LIST, NOT A DENY-LIST. Listing today's destructive actions and refusing
18
+ // those would silently admit tomorrow's — a new action added to INTENT_ACTIONS
19
+ // would be permitted on the self row by omission, and the failure mode of
20
+ // "permitted by omission" here is the platform deleting itself. So the rule is
21
+ // inverted: nothing touches the self row unless it is named below.
22
+ //
23
+ // Carved out of provisioning.js rather than living beside INTENT_ACTIONS because
24
+ // that file sits on the oversized-file ratchet; a guard whose whole value is being
25
+ // read before it is edited should not be the thing that reds the build either.
26
+
27
+ // The only acts that cannot destroy the box they run on.
28
+ //
29
+ // core-upgrade — moves the pin, health-gated, auto-rollback on failure. This is
30
+ // the door task 1003159 builds.
31
+ // restart — the liveness remedy (task 1003238). Restarting the platform is
32
+ // recoverable and already owner-initiated elsewhere.
33
+ //
34
+ // Adding to this list is a deliberate edit someone has to argue for. It is pinned
35
+ // by name in tests/core_upgrade_door.mjs so it cannot grow as a side effect.
36
+ const CONTROL_PLANE_ACTIONS = Object.freeze(['core-upgrade', 'restart']);
37
+
38
+ /**
39
+ * May this intent action be aimed at an instance of this hosting shape? PURE.
40
+ *
41
+ * AN UNKNOWN SHAPE IS ORDINARY, NOT CONTROL-PLANE. Deliberate, and the one place
42
+ * this guard fails OPEN. It protects one specific row; treating every
43
+ * unrecognised shape as the self row would refuse ordinary provisioning for any
44
+ * shape added later without touching this file — a far larger blast radius than
45
+ * the thing being guarded, and one that would present as provisioning mysteriously
46
+ * breaking rather than as a guard doing its job.
47
+ *
48
+ * @param {string} action an INTENT_ACTIONS member
49
+ * @param {string} hostingShape a HOSTING_SHAPES member
50
+ * @returns {boolean}
51
+ */
52
+ function intentAllowedForShape(action, hostingShape) {
53
+ if (hostingShape !== 'control-plane') return true;
54
+ return CONTROL_PLANE_ACTIONS.includes(action);
55
+ }
56
+
57
+ /**
58
+ * Refuse, loudly, any intent aimed at the control-plane row that is not allowed there.
59
+ *
60
+ * CALLED FROM enqueueIntent — the ONE place every intent passes, whichever route or
61
+ * helper queued it. That placement is the point: the core-upgrade route checks the
62
+ * same rule earlier so the owner reads a sentence instead of a 500, but every OTHER
63
+ * action reaches the queue through a route that has never heard of this shape. A
64
+ * guard that must hold for all of them cannot live in each of them.
65
+ *
66
+ * IT THROWS RATHER THAN RETURNING A CONFLICT, because the two are different
67
+ * statements. A conflict means "not now", and a caller may reasonably retry it; this
68
+ * means "never, and if you got here something upstream is already wrong". A retry
69
+ * loop spinning on a refusal that cannot change is the worse failure.
70
+ *
71
+ * AN ABSENT ROW IS NOT REFUSED. The insert that follows carries a NOT NULL FK, so a
72
+ * missing instance fails there with the error that actually describes it; answering
73
+ * "control plane protected" for a row that does not exist would be a lie that sends
74
+ * the reader to the wrong file.
75
+ *
76
+ * One extra SELECT, on a path that runs a handful of times a day.
77
+ *
78
+ * @param {{query: Function}} db
79
+ * @param {number|string} instanceId
80
+ * @param {string} action an INTENT_ACTIONS member
81
+ * @throws {Error} code 'control_plane_protected'
82
+ */
83
+ async function assertIntentAllowed(db, instanceId, action) {
84
+ const { rows } = await db.query(
85
+ 'SELECT hosting_shape FROM provisioning_instances WHERE id = $1', [instanceId]);
86
+ const shape = rows[0] && rows[0].hosting_shape;
87
+ if (!shape || intentAllowedForShape(action, shape)) return;
88
+ // It carries the { code, status, message } shape credential-preflight.js established,
89
+ // so a route that wants to answer 409 with the sentence can, without re-deriving it.
90
+ // NO ROUTE MAPS IT TODAY, and that is the honest state rather than an oversight: every
91
+ // action that can reach this throw has its own earlier refusal, so arriving here means
92
+ // a route forgot one — a bug in us, not a thing the caller did. A 500 plus a logged
93
+ // stack is the right loudness for that, and the fields are here so the day a route
94
+ // legitimately needs to say it to a person, the wording is already written.
95
+ const err = new Error(`'${action}' cannot be aimed at the control plane — only ${CONTROL_PLANE_ACTIONS.join(' and ')} may run against the box the platform itself runs on`);
96
+ err.code = 'control_plane_protected';
97
+ err.status = 409;
98
+ err.publicMessage = `That action cannot be aimed at the machine the platform itself runs on. Only ${CONTROL_PLANE_ACTIONS.join(' and ')} are allowed there.`;
99
+ throw err;
100
+ }
101
+
102
+ module.exports = { CONTROL_PLANE_ACTIONS, intentAllowedForShape, assertIntentAllowed };
@@ -0,0 +1,289 @@
1
+ 'use strict';
2
+
3
+ // modules/provisioning/core-upgrade.js — the owner's deploy door, web-tier half
4
+ // (task 1003159, ADR 0293).
5
+ //
6
+ // Moving an instance's core pin is gate 3 of the release pipeline and the only act
7
+ // that makes shipped work visible to users. It was reachable from exactly one
8
+ // place — a shell on the control-plane box with an ssh key — so the owner could not
9
+ // take it, and neither could an agent (the permission classifier blocks a mutating
10
+ // `sudo -u lars` over ssh by design).
11
+ //
12
+ // THIS FILE ONLY ASKS. It validates and queues an intent; the control-plane runner
13
+ // drains it and drives the proven `bongos upgrade` path with its health gate and
14
+ // auto-rollback intact. The web process gains no sudo and no ssh key — that split
15
+ // is ADR 0111 §2's rule and ADR 0293 D1's decision, and it is why a stolen builder
16
+ // token can request a deploy but never perform one.
17
+ //
18
+ // WHY THE REGISTRY CHECK IS NOT HERE, though ADR 0293 D3 demands one. The only
19
+ // tool that can answer "is this version published" is update-channel.js's
20
+ // `listAvailableVersions`, which shells out via spawnSync — it would block the
21
+ // entire event loop for the seconds npm takes, stalling every other request on a
22
+ // single-process server. It also lives in scripts/gds/, which a module may not
23
+ // require (ADR 0083). So publication and channel policy are enforced by the RUNNER,
24
+ // which is a CLI process where both are free. What is enforced here is everything
25
+ // that can be decided without the network: shape, state, format, and the guard.
26
+ //
27
+ // Lives in its own file for the mechanical reason paid-shape-gate.js and
28
+ // credential-preflight.js do: provisioning.js and routes/provisioning.js both sit
29
+ // AT the 1500-line fitness ratchet, and at that ceiling a new function costs more
30
+ // than the build will give back.
31
+
32
+
33
+ // Exactly the states in which there is a running instance whose pin can move.
34
+ // Narrower than RESTART_FROM on purpose: an instance mid-standup has no settled pin
35
+ // to move, and one that is tearing down is on its way out.
36
+ const CORE_UPGRADE_FROM = Object.freeze(new Set(['active']));
37
+
38
+ // The shapes whose box the runner can actually reach to perform an upgrade.
39
+ // Mirrors RESTART_SHAPES and adds the control plane (which the runner IS on).
40
+ //
41
+ // A 'dedicated' droplet is stood up by cloud-init and the runner has no post-hoc
42
+ // exec path to it — the same fact the restart route refuses on. Queueing an
43
+ // upgrade for one would be the F16 lie: a request that is accepted and never
44
+ // lands. This is a SEPARATE question from the control-plane guard, which asks
45
+ // what may be aimed at the platform itself; a first cut conflated the two and
46
+ // silently accepted upgrades for dedicated droplets.
47
+ const UPGRADE_SHAPES = Object.freeze(new Set(['co-tenant', 'standalone', 'control-plane']));
48
+
49
+ // The shape of a core version as the pin records it. Deliberately strict and
50
+ // three-part: the column's own CHECK carries the same pattern, so a value that
51
+ // slips past one still cannot reach the table. This says the string is WELL-FORMED,
52
+ // never that the version exists — see the header.
53
+ const VERSION_RE = /^[0-9]+\.[0-9]+\.[0-9]+$/;
54
+
55
+ const isCoreVersion = (v) => typeof v === 'string' && VERSION_RE.test(v);
56
+
57
+ /**
58
+ * Why this core-upgrade request must be refused, or null to admit it. PURE.
59
+ *
60
+ * Returns the shape credential-preflight.js established — `{ code, status, message }`
61
+ * for the route to `res.fail` with — so the refusal text lives beside the rule that
62
+ * produced it rather than being re-derived at the call site. Every message names
63
+ * what the caller can actually DO instead, which is the ADR 0190 §7 convention, and
64
+ * none of them promises a duration: how long a deploy takes is exactly what this
65
+ * layer cannot know.
66
+ *
67
+ * @param {object} inst the provisioning_instances row
68
+ * @param {string} to the requested target core version
69
+ * @param {string?} live the version the instance currently serves, when known
70
+ */
71
+ function coreUpgradeFault(inst, to, live = null) {
72
+ // UPGRADE_SHAPES IS THE WHOLE SHAPE CHECK, and the self-row guard is deliberately
73
+ // NOT consulted here. `intentAllowedForShape('core-upgrade', …)` returns true for
74
+ // every shape there is — for a tenant shape unconditionally, and for the control
75
+ // plane because 'core-upgrade' is permanently a member of CONTROL_PLANE_ACTIONS.
76
+ // A first cut called it anyway, which read as a second gate and could not refuse
77
+ // anything: the same "present but unreachable" failure this file's own header
78
+ // records about `inst.core_version ||`. The guard's real job is refusing the OTHER
79
+ // actions on the self row, and it does that at enqueueIntent, where every intent
80
+ // passes. Naming it a third time here would only make the dead branch look alive.
81
+ if (!UPGRADE_SHAPES.has(inst.hosting_shape)) {
82
+ return {
83
+ code: 'core_upgrade_unavailable',
84
+ status: 409,
85
+ message: `This project runs on a machine the platform doesn’t manage, so its core can’t be moved from here — whoever runs that machine has to upgrade it there.`,
86
+ };
87
+ }
88
+ if (!CORE_UPGRADE_FROM.has(inst.status)) {
89
+ const offline = inst.status === 'torn_down' || inst.status === 'tearing_down';
90
+ return {
91
+ code: 'not_upgradable',
92
+ status: 409,
93
+ message: offline
94
+ ? 'This project is offline, so there is no running core to move — stand it up again first.'
95
+ : 'This project hasn’t finished standing up yet, so it has no settled core version to move.',
96
+ };
97
+ }
98
+ if (!isCoreVersion(to)) {
99
+ return {
100
+ code: 'bad_target_version',
101
+ status: 400,
102
+ message: 'Name the exact core version to move to, as three numbers — for example 1.19.757.',
103
+ };
104
+ }
105
+ // Asking for the version already running is not an error worth a 4xx on its own,
106
+ // but queueing it would restart the service to change nothing. Say so instead.
107
+ if (live && String(live) === String(to)) {
108
+ return {
109
+ code: 'already_on_version',
110
+ status: 409,
111
+ message: `This project already runs core ${to}, so there is nothing to move — it would restart and land exactly where it is.`,
112
+ };
113
+ }
114
+ return null;
115
+ }
116
+
117
+ /**
118
+ * Queue the upgrade. Mirrors enqueueRestart's arbitration of the single open slot.
119
+ *
120
+ * A core-upgrade already open is NOT reported as success the way a duplicate
121
+ * restart is: a restart carries no payload, so "already under way" is the truth,
122
+ * but this one names a TARGET and the open intent may name a different one.
123
+ * Reporting that as done would tell the owner their version is coming when another
124
+ * is. So a second request is a conflict the route names, including when the open
125
+ * intent is itself a core-upgrade.
126
+ */
127
+ async function enqueueCoreUpgrade(provisioning, db, instanceId, to, requestedBy) {
128
+ const { intent, created } = await provisioning.enqueueIntent(db, instanceId, 'core-upgrade', requestedBy, to);
129
+ if (created) return { intent, created: true };
130
+ return { intent, created: false, conflict: 'open_intent' };
131
+ }
132
+
133
+ /**
134
+ * The POST handler, as a factory over its dependencies.
135
+ *
136
+ * It lives here rather than inline in routes/provisioning.js because that file is
137
+ * AT the 1500-line fitness ratchet — the same reason callback-page.js and
138
+ * body-validators.js were carved out of it (see the module's CLAUDE.md). The
139
+ * router keeps the declaration and its gate, which is the part a reader scanning
140
+ * the route table needs to see; the body is one require away.
141
+ *
142
+ * THE SECOND STEP IS THE BODY. The owner previews, then confirms by naming the
143
+ * version they saw. Requiring an explicit `to` IS the confirmation (ADR 0293 D4):
144
+ * a request that cannot fire without naming its target cannot fire from a stray
145
+ * click, and "move to whatever is newest", resolved at drain time, would be a
146
+ * different act from the one that was previewed — main moves between the two.
147
+ */
148
+ function makeCoreUpgradeHandler({ provisioning, pool, parseId, validateOrRespond, log }) {
149
+ return async function coreUpgradeHandler(req, res) {
150
+ const id = parseId(req, res);
151
+ if (!id) return;
152
+ if (validateOrRespond(req, res, { to: { type: 'string', maxLength: 32 } }, { strict: true })) return;
153
+ try {
154
+ const inst = await provisioning.getInstanceById(pool, id);
155
+ if (!inst) return res.fail('instance_not_found', 404);
156
+ const to = req.body && typeof req.body.to === 'string' ? req.body.to.trim() : '';
157
+ // THE LIVE VERSION IS NOT KNOWN HERE, and saying so beats pretending.
158
+ // provisioning_instances carries no core_version column, so the first cut's
159
+ // `inst.core_version || null` was always undefined and the already-on-version
160
+ // refusal could never fire from this route — a guard that reads as present
161
+ // and is unreachable, which is worse than no guard at all.
162
+ //
163
+ // What actually refuses a same-version move is `bongos upgrade`'s own
164
+ // same-version guard, in the runner, which is also what makes re-running a
165
+ // settled-but-unconfirmed intent a no-op (ADR 0293 Consequences). The
166
+ // parameter stays because the PREVIEW does know the live version — it reads
167
+ // the box's own /version (ADR 0293 D6) — and passes it.
168
+ const fault = coreUpgradeFault(inst, to, null);
169
+ if (fault) return res.fail(fault.code, { status: fault.status, message: fault.message });
170
+
171
+ // The actor rides the intent, and the runner carries it through to the
172
+ // core_upgrades ledger (ADR 0293 D5): a privileged action with an
173
+ // unattributed row is not acceptable here.
174
+ const actor = `api:archon:${req.builder.id}`;
175
+ const r = await enqueueCoreUpgrade(provisioning, pool, id, to, actor);
176
+ if (r.conflict) {
177
+ return res.fail('intent_conflict', {
178
+ status: 409,
179
+ message: `A '${r.intent.action}' run is currently ${r.intent.state} for this project — the core move could not be queued; try again once that settles.`,
180
+ details: { action: r.intent.action, state: r.intent.state, target_version: r.intent.target_version || null },
181
+ });
182
+ }
183
+ await provisioning.recordEvent(pool, {
184
+ instanceId: id, ownerBuilderId: inst.owner_builder_id, event: 'core-upgrade',
185
+ detail: `core pin move to ${to} requested`, actor,
186
+ }).catch(() => {});
187
+ // No duration is promised and no success is claimed: this says the request
188
+ // was RECORDED, which is the only thing true at this moment (ADR 0190 §7).
189
+ return res.json({
190
+ ok: true, queued: true, action: 'core-upgrade', target_version: to,
191
+ message: `Queued a move to core ${to}. The project will restart; this page reports the version it actually serves afterwards, and rolls back on its own if the new one does not answer.`,
192
+ });
193
+ } catch (err) {
194
+ log.error('[provisioning] POST /provisioning/instances/:id/core-upgrade', err);
195
+ return res.fail('core_upgrade_failed', { status: 500, message: 'internal error' });
196
+ }
197
+ };
198
+ }
199
+
200
+ /**
201
+ * What the control shows BEFORE anything is pressed — step one of the two-step (D4).
202
+ *
203
+ * Reads the snapshot the control-plane runner writes (migration core_242), because
204
+ * neither fact is reachable from here: the served version lives on the box's loopback
205
+ * and the available versions need a blocking `npm view`. The web tier asks; it does not
206
+ * go and look.
207
+ *
208
+ * EVERY FIELD THAT COULD BE STALE CARRIES ITS AGE. `observed_at` is returned, not hidden,
209
+ * and a missing snapshot returns `observed_at: null` rather than a confident-looking
210
+ * empty one — a control that renders a cache as though it were live is how someone is
211
+ * told their box is on a version it left an hour ago.
212
+ */
213
+ function previewFromSnapshot(inst, snap, openIntent) {
214
+ const served = (snap && snap.served_version) || null;
215
+ const available = (snap && Array.isArray(snap.available) ? snap.available : []);
216
+ const recommended = (snap && snap.recommended) || null;
217
+ // Only what a move could actually go TO: newer than what is running, and admitted by
218
+ // the channel the runner will apply. Listing anything else would offer the owner a
219
+ // button whose press the runner then refuses.
220
+ const behind = served && recommended && recommended !== served;
221
+ return {
222
+ instance_id: inst.id,
223
+ slug: inst.slug,
224
+ hosting_shape: inst.hosting_shape,
225
+ served_version: served,
226
+ recommended_version: recommended,
227
+ available_count: available.length,
228
+ behind: !!behind,
229
+ // Named so the UI cannot accidentally render "up to date" over a missing reading.
230
+ reading: served ? 'ok' : 'no_answer',
231
+ observed_at: (snap && snap.observed_at) || null,
232
+ open_intent: openIntent ? { action: openIntent.action, state: openIntent.state, target_version: openIntent.target_version || null } : null,
233
+ };
234
+ }
235
+
236
+ async function loadPreview(pool, provisioning, inst) {
237
+ const { rows } = await pool.query('SELECT * FROM core_version_snapshot WHERE instance_id = $1', [inst.id]);
238
+ const openIntent = provisioning.getOpenIntent ? await provisioning.getOpenIntent(pool, inst.id) : null;
239
+ return previewFromSnapshot(inst, rows[0], openIntent);
240
+ }
241
+
242
+ function makeCoreUpgradePreviewHandler({ provisioning, pool, parseId, log }) {
243
+ return async function coreUpgradePreviewHandler(req, res) {
244
+ const id = parseId(req, res);
245
+ if (!id) return;
246
+ try {
247
+ const inst = await provisioning.getInstanceById(pool, id);
248
+ if (!inst) return res.fail('instance_not_found', 404);
249
+ return res.json({ ok: true, preview: await loadPreview(pool, provisioning, inst) });
250
+ } catch (err) {
251
+ log.error('[provisioning] GET /provisioning/instances/:id/core-upgrade', err);
252
+ return res.fail('core_upgrade_preview_failed', { status: 500, message: 'internal error' });
253
+ }
254
+ };
255
+ }
256
+
257
+ /**
258
+ * Where the deploy page starts: "which row is this platform?"
259
+ *
260
+ * THE PAGE CANNOT BE EXPECTED TO KNOW THE ID. The self row is created once, by hand, at
261
+ * install time (scripts/gds/enroll-control-plane.js) — so its id differs per install and
262
+ * is nobody's to memorise. The alternative was to have the page list the fleet and filter
263
+ * it client-side, which would make a page about ONE box read every project row to find it,
264
+ * and would answer nothing at all for an Archon who does not own the control-plane row.
265
+ *
266
+ * A MISSING SELF ROW IS A 404 WITH AN INSTRUCTION, not an error. It is the expected state
267
+ * of an install that has not run the enrolment yet, and the only useful thing to say is
268
+ * which command creates it.
269
+ */
270
+ function makeControlPlaneHandler({ provisioning, pool, log }) {
271
+ return async function controlPlaneHandler(req, res) {
272
+ try {
273
+ const { rows } = await pool.query("SELECT * FROM provisioning_instances WHERE hosting_shape = 'control-plane' LIMIT 1");
274
+ if (!rows[0]) {
275
+ return res.fail('control_plane_not_enrolled', {
276
+ status: 404,
277
+ message: 'This platform has no deploy door yet. Whoever administers the box runs `node scripts/gds/enroll-control-plane.js --owner <builderId> --apply` once, and it appears here.',
278
+ });
279
+ }
280
+ return res.json({ ok: true, preview: await loadPreview(pool, provisioning, rows[0]) });
281
+ } catch (err) {
282
+ log.error('[provisioning] GET /provisioning/control-plane', err);
283
+ return res.fail('control_plane_read_failed', { status: 500, message: 'internal error' });
284
+ }
285
+ };
286
+ }
287
+
288
+ module.exports = { CORE_UPGRADE_FROM, UPGRADE_SHAPES, VERSION_RE, isCoreVersion, coreUpgradeFault, enqueueCoreUpgrade, makeCoreUpgradeHandler, previewFromSnapshot, makeCoreUpgradePreviewHandler, makeControlPlaneHandler };
289
+