@bongos/core 1.19.773 → 1.19.774

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 (90) hide show
  1. package/.bongos-core.json +189 -74
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +5 -1
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0083-modular-architecture-strangler-build-plan.md +1 -0
  8. package/docs/adr/0177-goal-membership-kind.md +26 -2
  9. package/docs/adr/0181-teardown-state-gate-and-address-retention.md +2 -0
  10. package/docs/adr/0285-a-shared-box-holds-about-twelve-projects-per-gb-and-memory-is-the-wall.md +1 -1
  11. package/docs/adr/0288-the-goal-wall-has-a-shared-floor-for-paths-no-module-owns.md +33 -0
  12. package/docs/adr/0289-an-unconsumed-contribution-is-a-ci-failure-not-a-boot-refusal.md +33 -0
  13. package/docs/adr/0290-disconnect-is-the-owner-initiated-exception-to-address-retention.md +40 -0
  14. package/docs/adr/README.md +3 -0
  15. package/docs/api/openapi.json +102 -11
  16. package/docs/api-reference.md +5 -4
  17. package/docs/copy-inventory.md +205 -190
  18. package/docs/copy-registry.json +435 -269
  19. package/docs/file-map.md +1 -0
  20. package/docs/module-api-changelog.md +2 -0
  21. package/docs/modules-contract.md +1 -1
  22. package/modules/builder-settings/render-prefs.js +281 -33
  23. package/modules/builder-settings/routes/settings.js +20 -12
  24. package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
  25. package/modules/dev-box/module.json +4 -0
  26. package/modules/hall-ui/public/settings-render.js +138 -0
  27. package/modules/hall-ui/public/settings.html +16 -9
  28. package/modules/hall-ui/public/settings.js +3 -70
  29. package/modules/lifecycle/ship-card.js +49 -13
  30. package/modules/platform-identity/migrations/platform_identity_021_drop_legacy_role.sql +94 -0
  31. package/modules/platform-identity/platform-identity.js +8 -19
  32. package/modules/platform-identity/project-catalog.js +24 -0
  33. package/modules/platform-identity/routes/sso.js +11 -14
  34. package/modules/platform-identity/tests/platform-identity.mjs +7 -9
  35. package/modules/provisioning/capacity-gate.js +54 -0
  36. package/modules/provisioning/catalog-bridge.js +2 -0
  37. package/modules/provisioning/disconnect.js +35 -0
  38. package/modules/provisioning/migrations/provisioning_016_disconnect.sql +21 -0
  39. package/modules/provisioning/module.json +1 -1
  40. package/modules/provisioning/provisioning.js +3 -3
  41. package/modules/provisioning/routes/disconnect.js +63 -0
  42. package/modules/provisioning/routes/provisioning.js +6 -6
  43. package/modules/public-landing/public/projects.html +116 -11
  44. package/modules/public-landing/public/projects.probes.json +3 -0
  45. package/modules/ui-design/kit/serve.js +3 -2
  46. package/modules/ui-design/module.json +1 -0
  47. package/package-lock.json +2 -2
  48. package/package.json +1 -1
  49. package/scripts/gds/address-park-sweep.sh +78 -0
  50. package/scripts/gds/cli-lib.js +37 -0
  51. package/scripts/gds/fitness-migration-contraction.js +50 -0
  52. package/scripts/gds/fitness.js +18 -0
  53. package/scripts/gds/land-watch.js +8 -2
  54. package/scripts/gds/provision-config.js +4 -4
  55. package/scripts/gds/provision-disconnect.js +90 -0
  56. package/scripts/gds/provision-teardown.js +108 -0
  57. package/scripts/gds/provision.js +6 -65
  58. package/scripts/gds/seam-wiring-guard.js +273 -0
  59. package/scripts/gds/ship-finish.js +17 -10
  60. package/scripts/gds/ship-flow.js +5 -5
  61. package/scripts/gds/start.js +13 -7
  62. package/src/bongos/auth-admission.js +7 -8
  63. package/src/bongos/auth-config.js +3 -2
  64. package/src/bongos/goal-scope-check.js +23 -8
  65. package/src/bongos/module-scope-map.js +40 -0
  66. package/src/module-api.js +1 -1
  67. package/src/module-loader/manifest-schema.js +79 -1
  68. package/tests/catalog_only_client.mjs +8 -5
  69. package/tests/fitness.mjs +22 -0
  70. package/tests/goal_scope_check.mjs +63 -1
  71. package/tests/hall_settings_world.mjs +57 -1
  72. package/tests/hub_disconnect_ui.mjs +105 -0
  73. package/tests/land_watch.mjs +44 -0
  74. package/tests/membership_kind_lockstep.mjs +86 -27
  75. package/tests/module-scope-map.mjs +39 -0
  76. package/tests/module_exports_unique_keys.mjs +70 -0
  77. package/tests/module_manifest.mjs +85 -0
  78. package/tests/project_catalog_forget.mjs +83 -0
  79. package/tests/projects_hub.mjs +10 -4
  80. package/tests/projects_hub_pre_uat.mjs +5 -3
  81. package/tests/provision_disconnect.mjs +231 -0
  82. package/tests/provision_teardown_address.mjs +230 -0
  83. package/tests/provisioning_capacity_gate.mjs +192 -0
  84. package/tests/provisioning_disconnect.mjs +231 -0
  85. package/tests/provisioning_restart.mjs +5 -5
  86. package/tests/public_landing_projects.mjs +4 -3
  87. package/tests/render_prefs.mjs +152 -0
  88. package/tests/seam_wiring_guard.mjs +234 -0
  89. package/tests/ship_card.mjs +67 -1
  90. package/tests/sso_checkin_legacy_field_gone.mjs +246 -0
@@ -0,0 +1,192 @@
1
+ // tests/provisioning_capacity_gate.mjs
2
+ //
3
+ // A full box refused at the create route (task 1003938, ADR 0285).
4
+ //
5
+ // Task 1003927 put the ceiling in the RUNNER: a project that does not fit is refused at
6
+ // placement, retried, then retired with the reason on the card — minutes after the wizard
7
+ // said "requested". This gate answers the same ceiling up front, as a 409 `box_full`
8
+ // naming N of M, before any row or intent exists.
9
+ //
10
+ // What these tests pin: the refusal lands before createInstance; a box the web tier cannot
11
+ // count (dedicated, a remote co-host) is never refused here; a re-request of a row that
12
+ // already holds its slot stays idempotent, while a revive (which takes a slot) is counted;
13
+ // the live-memory floor stays the runner's; and a capacity read that fails ADMITS, because
14
+ // the runner remains the wall.
15
+ //
16
+ // Hermetic: capacity.fleetCapacity is re-pointed at the real arithmetic over a fake count
17
+ // and a fixed meminfo; the route's data access is stubbed; auth is faked. No Postgres.
18
+ //
19
+ // Run: node tests/provisioning_capacity_gate.mjs
20
+
21
+ import { strict as assert } from 'node:assert';
22
+ import { createRequire } from 'node:module';
23
+ import http from 'node:http';
24
+
25
+ const require = createRequire(import.meta.url);
26
+
27
+ let passed = 0, failed = 0;
28
+ async function ta(name, fn) {
29
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
30
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
31
+ }
32
+
33
+ // The live control plane on 2026-09-16: 1968 MiB total → a measured ceiling of 15.
34
+ const MEMINFO_2GB = 'MemTotal: 2015216 kB\nMemAvailable: 1135180 kB\n';
35
+ const MEMINFO_TIGHT = 'MemTotal: 2015216 kB\nMemAvailable: 120000 kB\n'; // under the 256 MB floor
36
+
37
+ const capacity = require('../modules/provisioning/capacity.js');
38
+ const realFleetCapacity = capacity.fleetCapacity;
39
+ let placed = 0, meminfo = MEMINFO_2GB, capacityReads = 0, capacityFails = false;
40
+ capacity.fleetCapacity = async () => {
41
+ capacityReads++;
42
+ if (capacityFails) throw new Error('count unavailable');
43
+ const db = { query: async () => ({ rows: [{ n: placed }] }) };
44
+ return realFleetCapacity(db, { meminfo, remoteHost: null });
45
+ };
46
+
47
+ // ── stubs: the route's data access + auth ────────────────────────────────────────
48
+ const provisioning = require('../modules/provisioning/provisioning.js');
49
+ let existing = null, lastCreateArgs = null;
50
+ provisioning.getInstanceBySlug = async () => existing;
51
+ provisioning.createInstance = async (_db, args) => {
52
+ lastCreateArgs = args;
53
+ if (existing) return { instance: existing, created: false };
54
+ return { instance: { id: 42, owner_builder_id: 7, slug: args.slug, hosting_shape: args.hostingShape, status: 'requested' }, created: true };
55
+ };
56
+ provisioning.enqueueIntent = async () => ({ intent: { id: 1, state: 'pending' }, created: true });
57
+ provisioning.getOpenIntent = async () => null;
58
+ provisioning.recordEvent = async () => {};
59
+
60
+ const api = require('../src/module-api.js');
61
+ api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'owner' }; next(); };
62
+ api.requirePermission = () => (_req, _res, next) => next();
63
+
64
+ const express = require('express');
65
+ const provisioningRoutes = require('../modules/provisioning/routes/provisioning.js');
66
+ const app = express();
67
+ app.use(express.json());
68
+ app.use((_req, res, next) => {
69
+ res.fail = (code, opts, details) => {
70
+ const status = typeof opts === 'number' ? opts : (opts && opts.status) || 400;
71
+ res.status(status).json({ error: { code, ...(details || (typeof opts === 'object' ? opts : {})) } });
72
+ };
73
+ next();
74
+ });
75
+ app.use('/api/bongos', provisioningRoutes());
76
+
77
+ const server = app.listen(0);
78
+ await new Promise((r) => server.once('listening', r));
79
+ const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
80
+
81
+ function post(body) {
82
+ return new Promise((resolve, reject) => {
83
+ const payload = JSON.stringify(body);
84
+ const req = http.request(`${base}/provisioning/instances`, {
85
+ method: 'POST',
86
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
87
+ }, (res) => {
88
+ let d = '';
89
+ res.on('data', (c) => { d += c; });
90
+ res.on('end', () => { let j = null; try { j = JSON.parse(d); } catch { /* non-JSON */ } resolve({ status: res.statusCode, body: j, raw: d }); });
91
+ });
92
+ req.on('error', reject);
93
+ req.end(payload);
94
+ });
95
+ }
96
+
97
+ function reset({ count = 0, row = null, mem = MEMINFO_2GB, fails = false } = {}) {
98
+ placed = count; existing = row; meminfo = mem; capacityFails = fails;
99
+ capacityReads = 0; lastCreateArgs = null;
100
+ delete process.env.PROVISION_REMOTE_HOST;
101
+ }
102
+
103
+ const WIZARD = { slug: 'lantern', hosting_shape: 'standalone', no_address: true };
104
+
105
+ // ── the wall ──────────────────────────────────────────────────────────────────────
106
+
107
+ await ta('a full box answers 409 box_full naming N of M, and no row is created', async () => {
108
+ reset({ count: 15 });
109
+ const res = await post(WIZARD);
110
+ assert.equal(res.status, 409, res.raw);
111
+ assert.equal(res.body.error.code, 'box_full');
112
+ assert.match(res.body.error.message, /15 of 15 project slots/);
113
+ assert.equal(res.body.error.details.placed, 15);
114
+ assert.equal(res.body.error.details.ceiling, 15);
115
+ assert.equal(lastCreateArgs, null, 'the refusal lands before createInstance');
116
+ });
117
+
118
+ await ta('a box with one slot left admits the create', async () => {
119
+ reset({ count: 14 });
120
+ const res = await post(WIZARD);
121
+ assert.equal(res.status, 201, res.raw);
122
+ assert.equal(lastCreateArgs && lastCreateArgs.slug, 'lantern');
123
+ });
124
+
125
+ await ta('an absent shape (the co-tenant default) on a local box is counted too', async () => {
126
+ reset({ count: 15 });
127
+ const res = await post({ slug: 'lantern', no_address: true });
128
+ assert.equal(res.status, 409, res.raw);
129
+ assert.equal(res.body.error.code, 'box_full');
130
+ });
131
+
132
+ // ── boxes the web tier cannot count are the runner's ──────────────────────────────
133
+
134
+ await ta('dedicated is its own box: never refused here, and the count is never read', async () => {
135
+ reset({ count: 15 });
136
+ const res = await post({ slug: 'lantern', hosting_shape: 'dedicated', no_address: true });
137
+ assert.equal(res.status, 201, res.raw);
138
+ assert.equal(capacityReads, 0);
139
+ });
140
+
141
+ await ta('a co-tenant under remote-exec lands on a box this host cannot measure: admitted', async () => {
142
+ reset({ count: 15 });
143
+ process.env.PROVISION_REMOTE_HOST = 'overflow.example';
144
+ const res = await post({ slug: 'lantern', hosting_shape: 'co-tenant', no_address: true });
145
+ assert.equal(res.status, 201, res.raw);
146
+ assert.equal(capacityReads, 0);
147
+ delete process.env.PROVISION_REMOTE_HOST;
148
+ });
149
+
150
+ await ta('the live-memory floor is left to the runner: slots free + memory tight still admits', async () => {
151
+ reset({ count: 3, mem: MEMINFO_TIGHT });
152
+ const res = await post(WIZARD);
153
+ assert.equal(res.status, 201, res.raw);
154
+ });
155
+
156
+ // ── re-requests ───────────────────────────────────────────────────────────────────
157
+
158
+ await ta('a re-request of the owner\'s ACTIVE project on a full box stays idempotent (no box_full)', async () => {
159
+ reset({ count: 15, row: { id: 42, owner_builder_id: 7, slug: 'lantern', hosting_shape: 'standalone', status: 'active' } });
160
+ const res = await post(WIZARD);
161
+ assert.notEqual(res.status, 409, res.raw);
162
+ assert.ok(res.status < 300, res.raw);
163
+ });
164
+
165
+ await ta('a revive of a torn-down project takes a slot, so a full box refuses it', async () => {
166
+ reset({ count: 15, row: { id: 42, owner_builder_id: 7, slug: 'lantern', hosting_shape: 'standalone', status: 'torn_down' } });
167
+ const res = await post(WIZARD);
168
+ assert.equal(res.status, 409, res.raw);
169
+ assert.equal(res.body.error.code, 'box_full');
170
+ assert.equal(lastCreateArgs, null);
171
+ });
172
+
173
+ // ── the gate is an early answer, not the enforcement ──────────────────────────────
174
+
175
+ await ta('a capacity read that fails ADMITS — the runner remains the wall', async () => {
176
+ reset({ count: 15, fails: true });
177
+ const res = await post(WIZARD);
178
+ assert.equal(res.status, 201, res.raw);
179
+ });
180
+
181
+ await ta('a malformed slug or shape gets the handler\'s 400, not box_full', async () => {
182
+ for (const body of [{ slug: 'Bad Slug!', hosting_shape: 'standalone' }, { slug: 'lantern', hosting_shape: 'giant' }]) {
183
+ reset({ count: 15 });
184
+ const res = await post(body);
185
+ assert.equal(res.status, 400, `${JSON.stringify(body)} → ${res.raw}`);
186
+ }
187
+ });
188
+
189
+ capacity.fleetCapacity = realFleetCapacity;
190
+ server.close();
191
+ console.log(`\nprovisioning_capacity_gate: ${passed} passed, ${failed} failed`);
192
+ process.exit(failed ? 1 : 0);
@@ -0,0 +1,231 @@
1
+ // tests/provisioning_disconnect.mjs
2
+ //
3
+ // "Disconnect from Bongos entirely" — the web tier (task 1003249, ADR 0290).
4
+ //
5
+ // "Take it offline" only tears a project down: the database, backups and address stay
6
+ // reserved (ADR 0181). The owner asked for a separate, truer delete. The web tier's half
7
+ // is the same shape as every other provisioning action — it only ENQUEUES; the runner does
8
+ // the destruction (scripts/gds/provision-disconnect.js, tests/provision_disconnect.mjs).
9
+ //
10
+ // What these tests pin:
11
+ // • the schema: a 'disconnect' intent action, admitted by migration provisioning_016,
12
+ // which (as the NEWEST action migration) owns the strict INTENT_ACTIONS set equality
13
+ // • the queue: a disconnect is refused while ANY other intent is open (never superseded —
14
+ // a destructive run must not race a standup), and is idempotent against itself
15
+ // • the route: own-scoped (404 for anyone else) with the archon override, a server-side
16
+ // typed-name confirm, a 409 naming the blocking run, an audit event, and copy that makes
17
+ // no wall-clock promise
18
+ //
19
+ // DB-free: data access is stubbed on the cached module objects; auth is faked.
20
+ //
21
+ // Run: node tests/provisioning_disconnect.mjs
22
+
23
+ import { strict as assert } from 'node:assert';
24
+ import { createRequire } from 'node:module';
25
+ import { readFileSync } from 'node:fs';
26
+ import http from 'node:http';
27
+
28
+ const require = createRequire(import.meta.url);
29
+ const provisioning = require('../modules/provisioning/provisioning.js');
30
+ const disconnect = require('../modules/provisioning/disconnect.js');
31
+
32
+ let passed = 0, failed = 0;
33
+ function t(name, fn) {
34
+ try { fn(); passed++; console.log(` PASS ${name}`); }
35
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
36
+ }
37
+ async function ta(name, fn) {
38
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
39
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
40
+ }
41
+
42
+ const PROMISE = /shortly|briefly|minute|second|soon\b/i;
43
+
44
+ console.log('the schema:');
45
+
46
+ t("INTENT_ACTIONS includes 'disconnect', in strict lockstep with the provisioning_016 CHECK (the newest action migration)", () => {
47
+ assert.ok(provisioning.INTENT_ACTIONS.includes('disconnect'));
48
+ const sql = readFileSync(new URL('../modules/provisioning/migrations/provisioning_016_disconnect.sql', import.meta.url), 'utf8');
49
+ const m = /CHECK \(action IN \(([^)]*)\)\)/.exec(sql);
50
+ assert.ok(m, 'the migration widens the action CHECK');
51
+ const inSql = m[1].split(',').map((s) => s.trim().replace(/^'|'$/g, '')).sort();
52
+ assert.deepEqual(inSql, [...provisioning.INTENT_ACTIONS].sort(), 'the CHECK a live DB ends with and INTENT_ACTIONS carry the same set');
53
+ assert.match(sql, /INSERT INTO schema_migrations \(version\) VALUES \('provisioning_016_disconnect'\)/, 'self-registers');
54
+ });
55
+
56
+ console.log('\nthe queue:');
57
+
58
+ // A fake db over one open-intent slot, mirroring ux_provisioning_intents_open.
59
+ function queueDb({ open = null } = {}) {
60
+ const inserts = [], deletes = [];
61
+ return {
62
+ inserts, deletes,
63
+ query: async (sql, params) => {
64
+ if (/SELECT \* FROM provisioning_intents/.test(sql)) return { rows: open ? [open] : [] };
65
+ if (/INSERT INTO provisioning_intents/.test(sql)) {
66
+ if (open) { const e = new Error('dup'); e.code = '23505'; throw e; }
67
+ inserts.push(params);
68
+ return { rows: [{ id: 99, instance_id: params[0], action: params[1], requested_by: params[2], state: 'pending' }] };
69
+ }
70
+ if (/DELETE FROM provisioning_instances/.test(sql)) { deletes.push(params); return { rowCount: 1, rows: [] }; }
71
+ throw new Error(`unexpected SQL: ${sql}`);
72
+ },
73
+ };
74
+ }
75
+
76
+ await ta('an idle project queues a disconnect intent', async () => {
77
+ const db = queueDb();
78
+ const r = await disconnect.enqueueDisconnect(db, 7, 'api:self');
79
+ assert.equal(r.created, true);
80
+ assert.deepEqual(db.inserts, [[7, 'disconnect', 'api:self']]);
81
+ });
82
+
83
+ for (const [action, state] of [['provision', 'pending'], ['provision', 'running'], ['teardown', 'running'], ['restart', 'pending']]) {
84
+ await ta(`a ${state} '${action}' run REFUSES the disconnect — it is never superseded`, async () => {
85
+ const db = queueDb({ open: { id: 5, action, state } });
86
+ const r = await disconnect.enqueueDisconnect(db, 7, 'api:self');
87
+ assert.equal(r.conflict, 'open_intent');
88
+ assert.equal(r.intent.action, action);
89
+ assert.equal(db.inserts.length, 0);
90
+ });
91
+ }
92
+
93
+ await ta('an open disconnect makes a second request idempotent, not a conflict', async () => {
94
+ const db = queueDb({ open: { id: 5, action: 'disconnect', state: 'pending' } });
95
+ const r = await disconnect.enqueueDisconnect(db, 7, 'api:self');
96
+ assert.equal(r.created, false);
97
+ assert.equal(r.conflict, undefined);
98
+ });
99
+
100
+ await ta('deleteInstance removes the row by id (its intents, events and manifests cascade)', async () => {
101
+ const db = queueDb();
102
+ assert.equal(await disconnect.deleteInstance(db, 7), 1);
103
+ assert.deepEqual(db.deletes, [[7]]);
104
+ });
105
+
106
+ console.log('\nthe route:');
107
+
108
+ let instance = null, enqueued = [], events = [], enqueueResult = null;
109
+ provisioning.getInstanceById = async (_db, id) => (instance ? { ...instance, id } : null);
110
+ provisioning.recordEvent = async (_db, e) => { events.push(e); };
111
+ disconnect.enqueueDisconnect = async (_db, id, requestedBy) => { enqueued.push([id, requestedBy]); return enqueueResult; };
112
+
113
+ const api = require('../src/module-api.js');
114
+ let builder = { id: 7, rank: 'xenos', github_login: 'owner' };
115
+ api.requireBuilder = (req, _res, next) => { req.builder = builder; next(); };
116
+
117
+ const express = require('express');
118
+ const disconnectRoutes = require('../modules/provisioning/routes/disconnect.js');
119
+ const app = express();
120
+ app.use(express.json());
121
+ app.use((_req, res, next) => {
122
+ res.fail = (code, opts, details) => {
123
+ const status = typeof opts === 'number' ? opts : (opts && opts.status) || 400;
124
+ const extra = typeof opts === 'object' ? opts : {};
125
+ res.status(status).json({ error: { code, message: extra.message || code, details: details || extra.details } });
126
+ };
127
+ next();
128
+ });
129
+ app.use('/api/bongos', disconnectRoutes());
130
+ const server = app.listen(0);
131
+ await new Promise((r) => server.once('listening', r));
132
+ const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
133
+
134
+ function post(path, body) {
135
+ return new Promise((resolve, reject) => {
136
+ const payload = JSON.stringify(body);
137
+ const req = http.request(`${base}${path}`, {
138
+ method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
139
+ }, (res) => {
140
+ let d = '';
141
+ res.on('data', (c) => { d += c; });
142
+ res.on('end', () => { let j = null; try { j = JSON.parse(d); } catch { /* non-JSON */ } resolve({ status: res.statusCode, body: j, raw: d }); });
143
+ });
144
+ req.on('error', reject);
145
+ req.end(payload);
146
+ });
147
+ }
148
+
149
+ function reset({ inst = { owner_builder_id: 7, slug: 'lantern', status: 'active' }, who = { id: 7, rank: 'xenos', github_login: 'owner' }, result = { intent: { id: 1, action: 'disconnect', state: 'pending' }, created: true } } = {}) {
150
+ instance = inst; builder = who; enqueueResult = result; enqueued = []; events = [];
151
+ }
152
+ const URL_ = '/provisioning/instances/40/disconnect';
153
+
154
+ await ta('the owner, typing the name, queues it — with an audit event and no wall-clock promise', async () => {
155
+ reset();
156
+ const res = await post(URL_, { confirm_slug: 'lantern' });
157
+ assert.equal(res.status, 200, res.raw);
158
+ assert.equal(res.body.queued, true);
159
+ assert.equal(res.body.action, 'disconnect');
160
+ assert.doesNotMatch(res.body.message, PROMISE);
161
+ assert.deepEqual(enqueued, [[40, 'api:self']]);
162
+ assert.equal(events.length, 1);
163
+ assert.equal(events[0].event, 'disconnect');
164
+ assert.equal(events[0].actor, 'api:self');
165
+ });
166
+
167
+ for (const state of ['requested', 'active', 'error', 'torn_down']) {
168
+ await ta(`reachable from '${state}' — the state machine is the queue's job, not a status gate`, async () => {
169
+ reset({ inst: { owner_builder_id: 7, slug: 'lantern', status: state } });
170
+ const res = await post(URL_, { confirm_slug: 'lantern' });
171
+ assert.equal(res.status, 200, res.raw);
172
+ });
173
+ }
174
+
175
+ await ta('the typed name is checked on the SERVER: a wrong or missing name is refused and nothing is queued', async () => {
176
+ // A missing name fails validation outright — it is never stringified and compared, so a
177
+ // project literally named `undefined` cannot be disconnected by omitting the field.
178
+ for (const [body, code] of [[{ confirm_slug: 'lanterns' }, 'confirm_mismatch'], [{ confirm_slug: '' }, 'confirm_mismatch'], [{}, 'validation_failed']]) {
179
+ reset();
180
+ const res = await post(URL_, body);
181
+ assert.equal(res.status, 400, `${JSON.stringify(body)} → ${res.raw}`);
182
+ assert.equal(res.body.error.code, code, `${JSON.stringify(body)} → ${res.raw}`);
183
+ assert.equal(enqueued.length, 0);
184
+ }
185
+ reset();
186
+ const ok = await post(URL_, { confirm_slug: ' Lantern ' });
187
+ assert.equal(ok.status, 200, 'case and surrounding space are forgiven, the name is not');
188
+ });
189
+
190
+ await ta('someone else\'s project is a 404 — it does not leak that the id exists', async () => {
191
+ reset({ who: { id: 8, rank: 'metic', github_login: 'other' } });
192
+ const res = await post(URL_, { confirm_slug: 'lantern' });
193
+ assert.equal(res.status, 404, res.raw);
194
+ assert.equal(enqueued.length, 0);
195
+ });
196
+
197
+ await ta('an archon may disconnect any project, and the event names WHICH archon', async () => {
198
+ reset({ who: { id: 3, rank: 'archon', github_login: 'arch' } });
199
+ const res = await post(URL_, { confirm_slug: 'lantern' });
200
+ assert.equal(res.status, 200, res.raw);
201
+ assert.deepEqual(enqueued, [[40, 'api:archon:3']]);
202
+ assert.equal(events[0].actor, 'api:archon:3');
203
+ });
204
+
205
+ await ta('an open run answers 409 naming it, and records nothing', async () => {
206
+ reset({ result: { intent: { id: 5, action: 'provision', state: 'running' }, created: false, conflict: 'open_intent' } });
207
+ const res = await post(URL_, { confirm_slug: 'lantern' });
208
+ assert.equal(res.status, 409, res.raw);
209
+ assert.equal(res.body.error.code, 'intent_conflict');
210
+ assert.match(res.body.error.message, /provision/);
211
+ assert.doesNotMatch(res.body.error.message, PROMISE);
212
+ assert.equal(events.length, 0);
213
+ });
214
+
215
+ await ta('a repeated request while the disconnect is queued says so, and writes no second event', async () => {
216
+ reset({ result: { intent: { id: 1, action: 'disconnect', state: 'running' }, created: false } });
217
+ const res = await post(URL_, { confirm_slug: 'lantern' });
218
+ assert.equal(res.status, 200, res.raw);
219
+ assert.equal(res.body.queued, false);
220
+ assert.equal(events.length, 0);
221
+ });
222
+
223
+ await ta('a missing project is a 404', async () => {
224
+ reset({ inst: null });
225
+ const res = await post(URL_, { confirm_slug: 'lantern' });
226
+ assert.equal(res.status, 404, res.raw);
227
+ });
228
+
229
+ server.close();
230
+ console.log(`\nprovisioning_disconnect: ${passed} passed, ${failed} failed`);
231
+ process.exit(failed ? 1 : 0);
@@ -56,11 +56,11 @@ t("INTENT_ACTIONS includes 'restart', in lockstep with the provisioning_011 CHEC
56
56
  const sql = readFileSync(new URL('../modules/provisioning/migrations/provisioning_011_restart.sql', import.meta.url), 'utf8');
57
57
  const m = /CHECK \(action IN \(([^)]*)\)\)/.exec(sql);
58
58
  assert.ok(m, 'the migration widens the action CHECK');
59
- const inSql = m[1].split(',').map((s) => s.trim().replace(/^'|'$/g, '')).sort();
60
- // The NEWEST migration owns the strict set equality — the CHECK a live DB
61
- // ends up with is the last one applied, so this is the assertion that would
62
- // catch an action added in code but never admitted by the schema.
63
- assert.deepEqual(inSql, [...provisioning.INTENT_ACTIONS].sort(), 'the CHECK and INTENT_ACTIONS carry the same set');
59
+ const inSql = m[1].split(',').map((s) => s.trim().replace(/^'|'$/g, ''));
60
+ assert.ok(inSql.includes('restart'), 'this migration is the one that admitted it');
61
+ // The NEWEST action migration owns the strict set equality (provisioning_016,
62
+ // tests/provisioning_disconnect.mjs); an older one is a prefix of the set.
63
+ for (const a of inSql) assert.ok(provisioning.INTENT_ACTIONS.includes(a), `${a} is still an INTENT_ACTION`);
64
64
  assert.match(sql, /INSERT INTO schema_migrations \(version\) VALUES \('provisioning_011_restart'\)/, 'self-registers');
65
65
  });
66
66
 
@@ -138,7 +138,8 @@ test('the delete section is served live, and never claims to erase (task 1003042
138
138
  assert.match(body, /id="mDelWhat"/, 'the teardown-vs-delete fold is served');
139
139
  assert.match(body, /does <b>not<\/b> erase the project/i,
140
140
  'the distinction survives to the wire, not just the source');
141
- assert.match(body, /no permanent-delete button/i, 'the absent capability is admitted');
141
+ assert.match(body, /use <b>Disconnect from Bongos entirely<\/b> below/, 'the true delete is its own card (task 1003249), and the fold says where');
142
+ assert.match(body, /id="mDisconnect"/, 'and that card is served');
142
143
  // the confirmation asks for the project's own name — the heaviest gate on the page
143
144
  assert.match(body, /Type the project’s name to confirm/, 'the typed confirmation is served');
144
145
  assert.match(body, /data-del-yes disabled/, 'and the act starts unreachable');
@@ -146,8 +147,8 @@ test('the delete section is served live, and never claims to erase (task 1003042
146
147
 
147
148
  test('the served teardown copy no longer claims the address goes quiet (task 1003042)', async () => {
148
149
  const { body } = await request('/projects', 'game.test.local');
149
- // teardown has no DNS or Caddy leg: the hostname keeps resolving and 502s, so
150
- // this sentence was false on the wire. Task 1002897 owns actually releasing it.
150
+ // teardown parks the hostname and removes its Caddy block (task 1002897) but
151
+ // keeps the address for a revive, so "reserved" stays and "goes quiet" never did.
151
152
  assert.doesNotMatch(body, /the address stops answering/,
152
153
  'no served copy may promise a silence the runner does not deliver');
153
154
  assert.match(body, /stays reserved for this project/,
@@ -118,5 +118,157 @@ t('describeForApi reflects an explicit OFF override', () => {
118
118
  assert.equal(d.override, false);
119
119
  });
120
120
 
121
+ // ---- the card-richness ladder (task 1002518) --------------------------------
122
+ // The boolean became four rungs, and the rung a builder gets with no override is
123
+ // DERIVED from their interaction profile. These pin both halves, plus the
124
+ // compatibility surface every pinned CLI still reads.
125
+
126
+ console.log('\nthe ladder:');
127
+
128
+ t('LEVELS is ordered least → most rich, and rank follows the order', () => {
129
+ assert.deepEqual(r.LEVELS, ['minimal', 'text', 'standard', 'rich']);
130
+ assert.ok(r.levelRank('minimal') < r.levelRank('text'));
131
+ assert.ok(r.levelRank('text') < r.levelRank('standard'));
132
+ assert.ok(r.levelRank('standard') < r.levelRank('rich'));
133
+ });
134
+
135
+ t('the predicates say what each rung draws', () => {
136
+ // minimal: nothing but the confirmation line.
137
+ assert.equal(r.showsCard('minimal'), false);
138
+ assert.equal(r.showsWidget('minimal'), false);
139
+ // text: the monospace card, no HTML.
140
+ assert.equal(r.showsCard('text'), true);
141
+ assert.equal(r.showsWidget('text'), false);
142
+ // standard: the widget, summary folded.
143
+ assert.equal(r.showsWidget('standard'), true);
144
+ assert.equal(r.widgetExpanded('standard'), false);
145
+ // rich: the widget, summary open.
146
+ assert.equal(r.showsWidget('rich'), true);
147
+ assert.equal(r.widgetExpanded('rich'), true);
148
+ });
149
+
150
+ t('cardFlags bundles the three answers a card emitter needs', () => {
151
+ assert.deepEqual(r.cardFlags('rich'), { level: 'rich', showCard: true, showWidget: true, expanded: true });
152
+ assert.deepEqual(r.cardFlags('minimal'), { level: 'minimal', showCard: false, showWidget: false, expanded: false });
153
+ });
154
+
155
+ t('an unknown level rank-resolves to standard rather than throwing', () => {
156
+ // Defensive: a level string from an instance ahead of this code must degrade,
157
+ // not crash a ship. standard is the historical default rendering.
158
+ assert.equal(r.levelRank('nonsense'), r.levelRank('standard'));
159
+ });
160
+
161
+ console.log('\nderivation from the interaction profile:');
162
+
163
+ const withProfile = (interaction) => ({ interaction });
164
+
165
+ t('the DEFAULT builder (non-technical, concise) derives the RICHEST card', () => {
166
+ // The point of the task: the reader who most needs explanation, and who by
167
+ // definition never opened Settings, must not get the terser card.
168
+ assert.equal(r.derivedLevel({}), 'rich');
169
+ assert.equal(r.derivedLevel(null), 'rich');
170
+ assert.equal(r.resolveLevel({}), 'rich');
171
+ });
172
+
173
+ t('technical_level is the primary axis', () => {
174
+ assert.equal(r.derivedLevel(withProfile({ technical_level: 'non_technical' })), 'rich');
175
+ assert.equal(r.derivedLevel(withProfile({ technical_level: 'mixed' })), 'standard');
176
+ assert.equal(r.derivedLevel(withProfile({ technical_level: 'engineer' })), 'text');
177
+ });
178
+
179
+ t('machinery:quiet drops a rung — an expert who asked for less gets terse or none', () => {
180
+ assert.equal(r.derivedLevel(withProfile({ technical_level: 'engineer', machinery: 'quiet' })), 'minimal');
181
+ assert.equal(r.derivedLevel(withProfile({ technical_level: 'non_technical', machinery: 'quiet' })), 'standard');
182
+ });
183
+
184
+ t('verbosity:detailed raises a rung', () => {
185
+ assert.equal(r.derivedLevel(withProfile({ technical_level: 'engineer', verbosity: 'detailed' })), 'standard');
186
+ });
187
+
188
+ t('verbosity:concise does NOT drop a rung (it is the default — see the derivation note)', () => {
189
+ // Taxing the default would push the accessible-by-default builder from rich
190
+ // down to standard, inverting the whole point of deriving from the profile.
191
+ assert.equal(
192
+ r.derivedLevel(withProfile({ technical_level: 'non_technical', verbosity: 'concise' })),
193
+ r.derivedLevel(withProfile({ technical_level: 'non_technical' })),
194
+ );
195
+ });
196
+
197
+ t('the ladder clamps at both ends', () => {
198
+ // non_technical (rich) + detailed would be rank 4; engineer (text) + quiet
199
+ // twice over would go below 0. Neither may fall off the ladder.
200
+ assert.equal(r.derivedLevel(withProfile({ technical_level: 'non_technical', verbosity: 'detailed' })), 'rich');
201
+ assert.equal(r.derivedLevel(withProfile({ technical_level: 'engineer', machinery: 'quiet', verbosity: 'concise' })), 'minimal');
202
+ });
203
+
204
+ t('derivedReason names the fields that actually moved the level', () => {
205
+ const plain = r.derivedReason({});
206
+ assert.match(plain, /technical level: non-technical/);
207
+ assert.ok(!/machinery/.test(plain), 'a default field is not listed as a reason');
208
+ const quiet = r.derivedReason(withProfile({ technical_level: 'engineer', machinery: 'quiet' }));
209
+ assert.match(quiet, /machinery: quiet/);
210
+ });
211
+
212
+ console.log('\noverrides beat derivation:');
213
+
214
+ t('an explicit level override wins over the profile', () => {
215
+ const eng = { interaction: { technical_level: 'engineer' }, widgets: 'rich' };
216
+ assert.equal(r.derivedLevel(eng), 'text', 'the profile would have said text');
217
+ assert.equal(r.resolveLevel(eng), 'rich', 'the override wins');
218
+ assert.equal(r.overrideLevel(eng), 'rich');
219
+ });
220
+
221
+ t('clearing the override falls back to the derived level', () => {
222
+ const cleared = r.applyPatch({ widgets: 'minimal', interaction: { technical_level: 'mixed' } }, { widgets: null });
223
+ assert.ok(!('widgets' in cleared));
224
+ assert.equal(r.resolveLevel(cleared), 'standard', 'back to what the profile says');
225
+ assert.equal(r.overrideLevel(cleared), null);
226
+ });
227
+
228
+ console.log('\nlegacy boolean compatibility (a pinned CLI / an older hall build):');
229
+
230
+ t('a stored legacy boolean keeps the exact card it always meant', () => {
231
+ assert.equal(r.overrideLevel({ widgets: true }), 'standard');
232
+ assert.equal(r.overrideLevel({ widgets: false }), 'text');
233
+ // And it still beats the profile, because it IS an explicit choice.
234
+ assert.equal(r.resolveLevel({ widgets: false, interaction: { technical_level: 'non_technical' } }), 'text');
235
+ });
236
+
237
+ t('validatePatch still accepts booleans, normalising them to a level on the way in', () => {
238
+ assert.equal(r.validatePatch({ widgets: true }).patch.widgets, 'standard');
239
+ assert.equal(r.validatePatch({ widgets: false }).patch.widgets, 'text');
240
+ });
241
+
242
+ t('validatePatch accepts every level and rejects a non-level string', () => {
243
+ for (const lvl of r.LEVELS) {
244
+ assert.equal(r.validatePatch({ widgets: lvl }).ok, true, `accept ${lvl}`);
245
+ }
246
+ const bad = r.validatePatch({ widgets: 'verbose' });
247
+ assert.equal(bad.ok, false);
248
+ assert.equal(bad.errors[0].error, 'invalid_value');
249
+ });
250
+
251
+ t('widgetsEnabled — the boolean every pinned CLI reads — tracks the ladder', () => {
252
+ assert.equal(r.widgetsEnabled({ widgets: 'minimal' }), false);
253
+ assert.equal(r.widgetsEnabled({ widgets: 'text' }), false);
254
+ assert.equal(r.widgetsEnabled({ widgets: 'standard' }), true);
255
+ assert.equal(r.widgetsEnabled({ widgets: 'rich' }), true);
256
+ });
257
+
258
+ t('describeForApi keeps the legacy keys AND adds the ladder', () => {
259
+ const d = r.describeForApi({ interaction: { technical_level: 'engineer' } });
260
+ // legacy half — an older CLI reads only these and is correct
261
+ assert.equal(d.widgets_enabled, false, 'engineer derives text, so no widget');
262
+ assert.equal(d.default_enabled, true);
263
+ assert.equal(d.override, null, 'derived, not chosen');
264
+ // ladder half
265
+ assert.equal(d.level, 'text');
266
+ assert.equal(d.derived_level, 'text');
267
+ assert.equal(d.override_level, null);
268
+ assert.match(d.derived_reason, /technical level: engineer/);
269
+ assert.deepEqual(d.levels.map((l) => l.value), r.LEVELS);
270
+ assert.ok(d.levels.every((l) => l.label && l.blurb), 'every rung is labelled for the UI');
271
+ });
272
+
121
273
  console.log(`\n${passed} passed, ${failed} failed`);
122
274
  process.exit(failed === 0 ? 0 : 1);