@bongos/core 1.19.789 → 1.19.790

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 (47) hide show
  1. package/.bongos-core.json +110 -40
  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 +31 -14
  12. package/docs/copy-registry.json +205 -31
  13. package/docs/module-api-changelog.md +2 -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/shell.js +4 -0
  23. package/modules/provisioning/control-plane-guard.js +102 -0
  24. package/modules/provisioning/core-upgrade.js +289 -0
  25. package/modules/provisioning/migrations/provisioning_018_core_upgrade_intent.sql +98 -0
  26. package/modules/provisioning/module.json +1 -1
  27. package/modules/provisioning/provisioning.js +8 -8
  28. package/modules/provisioning/routes/core-upgrade.js +95 -0
  29. package/package-lock.json +2 -2
  30. package/package.json +1 -1
  31. package/scripts/gds/enroll-control-plane.js +101 -0
  32. package/scripts/gds/provision-core-upgrade.js +311 -0
  33. package/scripts/gds/provision.js +4 -2
  34. package/scripts/gds/upgrade.js +53 -14
  35. package/src/bongos/auth.js +19 -0
  36. package/src/bongos/serve-internal.js +21 -0
  37. package/src/module-api.js +1 -1
  38. package/tests/auth_resolve_failure.mjs +4 -2
  39. package/tests/core_upgrade_door.mjs +408 -0
  40. package/tests/core_upgrade_runner.mjs +377 -0
  41. package/tests/hall_nav.mjs +4 -1
  42. package/tests/hall_page_gate_map.mjs +7 -0
  43. package/tests/provisioning_disconnect.mjs +30 -5
  44. package/tests/provisioning_paid_shape_gate.mjs +14 -4
  45. package/tests/provisioning_restart.mjs +25 -9
  46. package/tests/provisioning_settings_apply.mjs +11 -2
  47. package/tests/provisioning_teardown_intent.mjs +7 -0
@@ -0,0 +1,46 @@
1
+ -- government_014_core_pin_move.sql — grant `core.pin.move` to the Archon
2
+ -- rank-role (task 1003159, ADR 0293).
3
+ --
4
+ -- The atom gates the owner's deploy door: POST /provisioning/instances/:id/
5
+ -- core-upgrade, which queues an intent the control-plane runner drains to move an
6
+ -- instance's core pin. That is gate 3 of the release pipeline (ADR 0137) and the
7
+ -- only act that makes shipped work visible to users.
8
+ --
9
+ -- ARCHON ONLY, AND system:true. Two separate statements about the same key:
10
+ --
11
+ -- • the FLOOR is archon because deploy is one of the hard gates the owner holds.
12
+ -- Cloud Bongos is AI-first — agents write the code, the human holds the gates —
13
+ -- and this task exists because that gate had no instrument, not because it
14
+ -- should be widely held.
15
+ --
16
+ -- • `system: true` is what makes the floor un-delegatable. The catalog's own
17
+ -- invariant (tests/government_catalog.mjs) forbids granting a system key to
18
+ -- metic, so no custom rank can be handed this without clearing the flag first —
19
+ -- a deliberate, visible edit. It sits beside `version.close` and
20
+ -- `task.confirm.grade_bypass` for exactly the reason ADR 0157 gives about the
21
+ -- escalation keys: a power that can be delegated downward by someone holding it
22
+ -- is not really a gate.
23
+ --
24
+ -- ONLY archon is granted, so unlike government_007 this adds exactly one row.
25
+ -- There is no metic grant to write, which is the point.
26
+ --
27
+ -- The compat seed (governance_002) is RUN-ONCE, so a new atom cannot be added by
28
+ -- editing it — every already-migrated database would never see the grant. This is
29
+ -- the follow-on grant migration that header calls for.
30
+ --
31
+ -- tests/governance_seed.mjs reads the UNION of the seed and every later government_*
32
+ -- grant migration and asserts it equals catalog.js's floor-derived RANK_ROLE_SEED,
33
+ -- so this file is what keeps the SQL snapshot equal to the catalog entry.
34
+ --
35
+ -- Additive, idempotent (ON CONFLICT DO NOTHING), forward-safe: INSERT only, nothing
36
+ -- dropped or retyped, no down-migration.
37
+
38
+ BEGIN;
39
+
40
+ INSERT INTO government_rank_permissions (rank_key, permission_key)
41
+ SELECT 'archon', unnest(ARRAY[
42
+ 'core.pin.move'
43
+ ])
44
+ ON CONFLICT (rank_key, permission_key) DO NOTHING;
45
+
46
+ COMMIT;
@@ -0,0 +1,64 @@
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>Deploy — {{worldName}}</title>
7
+ <meta name="description" content="Move this instance onto a newer core and read back the version the box actually serves — the deploy gate, without a shell or an ssh key.">
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-07-02-shell1">
12
+ <link rel="stylesheet" href="/builders/oversight.css">
13
+ <link rel="stylesheet" href="/builders/project-settings.css">
14
+ </head>
15
+ <body data-page="deploy" data-page-title="Deploy">
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
+ <div class="page-head page-head--board">
24
+ <h1 class="page-head__h">Deploy</h1>
25
+ <p class="page-head__sub" id="dp-sub">Loading…</p>
26
+ </div>
27
+
28
+ <main id="hall-main">
29
+ <section class="scroll" id="dp-sealed" aria-labelledby="dp-sealed-h" hidden>
30
+ <h2 class="scroll__h" id="dp-sealed-h">Restricted</h2>
31
+ <p class="scroll__lede" id="dp-sealed-body">This page opens at a higher rank.</p>
32
+ </section>
33
+
34
+ <section class="scroll" id="dp-body" aria-labelledby="dp-body-h" hidden>
35
+ <h2 class="scroll__h" id="dp-body-h">What users can see</h2>
36
+ <!-- The honest framing, and the one thing a reader must not misread:
37
+ shipping work and deploying it are different acts. "Merged" does not
38
+ mean visible (ADR 0137's three gates); this page is the third one. -->
39
+ <p class="scroll__lede">
40
+ Merging work does not put it in front of anyone. This is the step that does:
41
+ it moves this instance onto a newer core, waits for the box to answer, and
42
+ puts itself back if it does not.
43
+ </p>
44
+ <!-- One mount, written by deploy.js. Nothing here is authored as markup with
45
+ a version in it: every number on this page is a reading the control plane
46
+ took, carrying the time it took it. -->
47
+ <div id="dp-panel"><span class="kit-skel"></span></div>
48
+ </section>
49
+ </main>
50
+
51
+ <footer class="app-foot" id="app-foot"></footer>
52
+ </div>
53
+ </div>
54
+ </div>
55
+
56
+ <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
57
+
58
+ <script src="/builders/shell.js?v=2026-07-02-shell1"></script>
59
+ <script src="/builders/palette.js" defer></script>
60
+ <script src="/builders/dom-utils.js?v=2026-06-16-gate"></script>
61
+ <script src="/builders/hall-kit.js"></script>
62
+ <script src="/builders/deploy.js?v=2026-09-16-deploy1"></script>
63
+ </body>
64
+ </html>
@@ -0,0 +1,188 @@
1
+ // deploy.js — the owner's deploy door (task 1003159, ADR 0293).
2
+ //
3
+ // WHAT THIS PAGE IS FOR. The owner, twice in a month: "I don't think I can make anything
4
+ // live", then "this is above my paygrade." Moving this instance's core pin is gate 3 of
5
+ // the release pipeline and the ONLY act that makes shipped work visible to users — and it
6
+ // was reachable from exactly one place, a shell on the control-plane box holding an ssh
7
+ // key. An agent could not close the loop either: the permission classifier blocks a
8
+ // mutating `sudo -u lars` over ssh by design. So the one gate that is the owner's to hold
9
+ // was the one gate with no instrument. This page is the instrument.
10
+ //
11
+ // THE RULE THAT SHAPES EVERY LINE HERE: this page KNOWS NOTHING. Every version on it is a
12
+ // reading the control-plane runner took on the box, carried through the server with the
13
+ // time it was taken. Nothing is computed here, no version is compared here, and "up to
14
+ // date" is never inferred from an absence — the server says `behind` or it does not, and
15
+ // a missing reading renders as a missing reading. A deploy control that guesses is worse
16
+ // than none, because it is trusted.
17
+ //
18
+ // TWO STEPS, NEVER ONE (ADR 0293 D4). The owner asked for the confirmation. Auto-rollback
19
+ // already guards the bad release; this guards the wrong button. Step one shows what would
20
+ // move; step two names the version it saw and sends that. The confirm button is built
21
+ // FROM the previewed version, so a release that lands between the two steps cannot
22
+ // silently change what the press does.
23
+ (() => {
24
+ 'use strict';
25
+
26
+ const API = '/api/bongos';
27
+ const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
28
+ const { escapeHtml } = window.OTB;
29
+ const toast = (msg) => window.OTB.toast(msg);
30
+ const $ = (id) => document.getElementById(id);
31
+
32
+ // The preview the server last handed us. The confirm step reads its version from HERE
33
+ // rather than from the DOM, so what is sent is what was previewed even if a poll
34
+ // repaints between the two clicks.
35
+ let previewed = null;
36
+
37
+ async function fetchJson(path) {
38
+ const res = await api.request('GET', `${API}${path}`);
39
+ if (res.status === 401) { window.location.assign(`${API}/auth/web/start`); throw { kind: '401' }; }
40
+ if (res.status === 403) { throw { kind: '403' }; }
41
+ if (!res.ok) {
42
+ const err = (res.data && res.data.error) || {};
43
+ throw new Error(err.message && err.message !== err.code ? err.message : (err.code || `${path} → ${res.status}`));
44
+ }
45
+ return res.data;
46
+ }
47
+
48
+ async function sendJson(method, path, body) {
49
+ const res = await api.request(method, `${API}${path}`, { body: body || {} });
50
+ if (res.status === 401) { window.location.assign(`${API}/auth/web/start`); throw { kind: '401' }; }
51
+ const data = res.data || {};
52
+ if (!res.ok) {
53
+ const err = data.error || {};
54
+ // The server's sentence when it wrote one — its refusals name what to do instead,
55
+ // and re-wording them here would lose that. Never a message invented on this page.
56
+ throw new Error(err.message && err.message !== err.code ? err.message : (err.code || `${path} → ${res.status}`));
57
+ }
58
+ return data;
59
+ }
60
+
61
+ // "3 minutes ago" for a reading's age. The age is part of the answer, not decoration:
62
+ // every number on this page came from a cache, and a cache rendered as though it were
63
+ // live is how someone is told their box is on a version it left an hour ago.
64
+ function ago(iso) {
65
+ if (!iso) return 'never';
66
+ const secs = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 1000));
67
+ if (secs < 90) return `${secs}s ago`;
68
+ const mins = Math.round(secs / 60);
69
+ if (mins < 90) return `${mins} min ago`;
70
+ return `${Math.round(mins / 60)}h ago`;
71
+ }
72
+
73
+ function versionHtml(v) {
74
+ return v ? `<span class="ov-mono">${escapeHtml(String(v))}</span>` : '<span class="ps-pill ps-pill--absent">no reading</span>';
75
+ }
76
+
77
+ // ---- the three states this page can be in --------------------------------
78
+
79
+ // Something is already running. The press is not offered at all — offering a button
80
+ // that the server will refuse with 'intent_conflict' is a worse experience than saying
81
+ // plainly that the box is busy.
82
+ function busyHtml(p) {
83
+ const i = p.open_intent;
84
+ return `
85
+ <p class="ov-row__sub">A <strong>${escapeHtml(i.action)}</strong> is ${escapeHtml(i.state)} for this box${
86
+ i.target_version ? ` (to core ${escapeHtml(i.target_version)})` : ''}.
87
+ Nothing else can be queued until it settles; this page will notice when it does.</p>`;
88
+ }
89
+
90
+ function stepOneHtml(p) {
91
+ return `
92
+ <p class="ov-row__sub">Core <strong>${escapeHtml(String(p.recommended_version))}</strong> is published and
93
+ this box is not on it yet.</p>
94
+ <div class="ps-controls">
95
+ <button type="button" class="btn" id="dp-preview">See what would change</button>
96
+ </div>`;
97
+ }
98
+
99
+ // Step two. The version is written into the button's own label and into `previewed`,
100
+ // so the owner confirms the thing they were shown rather than "the newest", which is a
101
+ // different act by the time it is resolved.
102
+ function stepTwoHtml(p) {
103
+ return `
104
+ <div class="ps-controls" id="dp-confirm-wrap">
105
+ <p class="ov-row__sub">This restarts the platform onto core
106
+ <strong>${escapeHtml(String(p.recommended_version))}</strong>. It waits for the box to answer and
107
+ puts itself back on ${escapeHtml(String(p.served_version))} if it does not.</p>
108
+ <button type="button" class="btn btn--primary" id="dp-confirm">Move to ${escapeHtml(String(p.recommended_version))}</button>
109
+ <button type="button" class="btn" id="dp-cancel">Cancel</button>
110
+ </div>`;
111
+ }
112
+
113
+ function panelHtml(p, step) {
114
+ const rows = [
115
+ `<li class="ov-row"><div class="ov-row__main">
116
+ <span class="ov-row__title">Serving now</span>
117
+ <p class="ov-row__sub">${versionHtml(p.served_version)} — read from the box itself, ${escapeHtml(ago(p.observed_at))}.</p>
118
+ ${p.reading === 'no_answer'
119
+ ? `<p class="ps-invalid">The box did not answer when the control plane last looked, so this version is unknown rather than unchanged. A move is not offered while that is true.</p>`
120
+ : ''}
121
+ </div></li>`,
122
+ ];
123
+ let action = '';
124
+ if (p.open_intent) action = busyHtml(p);
125
+ else if (p.reading === 'no_answer') action = '';
126
+ else if (!p.behind) action = `<p class="ov-row__sub">Nothing newer is published that this box can take. There is nothing to deploy.</p>`;
127
+ else action = step === 'confirm' ? stepTwoHtml(p) : stepOneHtml(p);
128
+ rows.push(`<li class="ov-row"><div class="ov-row__main">
129
+ <span class="ov-row__title">Make it live</span>
130
+ ${action}
131
+ </div></li>`);
132
+ return `<ul class="ov-lines">${rows.join('')}</ul>
133
+ <p class="ps-prov">reads the control plane's own observation · ADR 0293 · every move is recorded with who asked</p>`;
134
+ }
135
+
136
+ // ---- wiring --------------------------------------------------------------
137
+
138
+ function render(p, step) {
139
+ previewed = p;
140
+ $('dp-panel').innerHTML = panelHtml(p, step);
141
+ const preview = $('dp-preview');
142
+ if (preview) preview.addEventListener('click', () => render(p, 'confirm'));
143
+ const cancel = $('dp-cancel');
144
+ if (cancel) cancel.addEventListener('click', () => render(p, 'preview'));
145
+ const confirm = $('dp-confirm');
146
+ if (confirm) confirm.addEventListener('click', () => move(p));
147
+ }
148
+
149
+ async function move(p) {
150
+ const btn = $('dp-confirm');
151
+ if (btn) { btn.disabled = true; btn.textContent = 'Queueing…'; }
152
+ try {
153
+ // `p.recommended_version` — the version this owner was SHOWN, not whatever is
154
+ // newest at the moment of the click.
155
+ const r = await sendJson('POST', `/provisioning/instances/${p.instance_id}/core-upgrade`, { to: p.recommended_version });
156
+ toast(r.message || 'Queued.');
157
+ await load();
158
+ } catch (e) {
159
+ if (e && e.kind === '401') return;
160
+ toast(e.message || 'Could not queue the move.');
161
+ if (btn) { btn.disabled = false; btn.textContent = `Move to ${p.recommended_version}`; }
162
+ }
163
+ }
164
+
165
+ async function load() {
166
+ try {
167
+ const d = await fetchJson('/provisioning/control-plane');
168
+ $('dp-sub').textContent = `${d.preview.slug} — the box this platform runs on`;
169
+ $('dp-body').hidden = false;
170
+ render(d.preview, 'preview');
171
+ } catch (e) {
172
+ if (e && e.kind === '401') return;
173
+ if (e && e.kind === '403') {
174
+ $('dp-sealed').hidden = false;
175
+ $('dp-sub').textContent = 'Restricted';
176
+ return;
177
+ }
178
+ // Including 'control_plane_not_enrolled', whose message names the one command
179
+ // that fixes it — which is why the server's sentence is shown rather than a
180
+ // generic failure line.
181
+ $('dp-body').hidden = false;
182
+ $('dp-sub').textContent = 'Unavailable';
183
+ $('dp-panel').innerHTML = `<p class="ps-invalid">${escapeHtml(e.message || 'Could not read the deploy state.')}</p>`;
184
+ }
185
+ }
186
+
187
+ document.addEventListener('DOMContentLoaded', load);
188
+ })();
@@ -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
@@ -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 };