@bongos/core 1.19.772 → 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 (97) hide show
  1. package/.bongos-core.json +204 -79
  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 +4 -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/db-tasks.js +7 -1
  30. package/modules/lifecycle/dead-deps.js +10 -2
  31. package/modules/lifecycle/ship-card.js +49 -13
  32. package/modules/lifecycle/task-list-limits.js +44 -0
  33. package/modules/platform-identity/migrations/platform_identity_021_drop_legacy_role.sql +94 -0
  34. package/modules/platform-identity/platform-identity.js +8 -19
  35. package/modules/platform-identity/project-catalog.js +24 -0
  36. package/modules/platform-identity/routes/sso.js +11 -14
  37. package/modules/platform-identity/tests/platform-identity.mjs +7 -9
  38. package/modules/provisioning/capacity-gate.js +54 -0
  39. package/modules/provisioning/catalog-bridge.js +2 -0
  40. package/modules/provisioning/disconnect.js +35 -0
  41. package/modules/provisioning/migrations/provisioning_016_disconnect.sql +21 -0
  42. package/modules/provisioning/module.json +1 -1
  43. package/modules/provisioning/provisioning.js +3 -3
  44. package/modules/provisioning/routes/disconnect.js +63 -0
  45. package/modules/provisioning/routes/provisioning.js +6 -6
  46. package/modules/public-landing/public/projects.html +116 -11
  47. package/modules/public-landing/public/projects.probes.json +3 -0
  48. package/modules/ui-design/kit/serve.js +3 -2
  49. package/modules/ui-design/module.json +1 -0
  50. package/package-lock.json +2 -2
  51. package/package.json +1 -1
  52. package/scripts/gds/address-park-sweep.sh +78 -0
  53. package/scripts/gds/audit-deps.js +38 -4
  54. package/scripts/gds/cli-lib.js +37 -0
  55. package/scripts/gds/fitness-migration-contraction.js +50 -0
  56. package/scripts/gds/fitness.js +18 -0
  57. package/scripts/gds/land-watch.js +8 -2
  58. package/scripts/gds/provision-config.js +4 -4
  59. package/scripts/gds/provision-disconnect.js +90 -0
  60. package/scripts/gds/provision-teardown.js +108 -0
  61. package/scripts/gds/provision.js +6 -65
  62. package/scripts/gds/seam-wiring-guard.js +273 -0
  63. package/scripts/gds/ship-finish.js +17 -10
  64. package/scripts/gds/ship-flow.js +28 -6
  65. package/scripts/gds/ship-resume.js +32 -4
  66. package/scripts/gds/start.js +13 -7
  67. package/src/bongos/auth-admission.js +7 -8
  68. package/src/bongos/auth-config.js +3 -2
  69. package/src/bongos/goal-scope-check.js +23 -8
  70. package/src/bongos/module-scope-map.js +40 -0
  71. package/src/module-api.js +1 -1
  72. package/src/module-loader/manifest-schema.js +79 -1
  73. package/tests/catalog_only_client.mjs +8 -5
  74. package/tests/fitness.mjs +22 -0
  75. package/tests/goal_scope_check.mjs +63 -1
  76. package/tests/hall_settings_world.mjs +57 -1
  77. package/tests/hub_disconnect_ui.mjs +105 -0
  78. package/tests/land_watch.mjs +44 -0
  79. package/tests/membership_kind_lockstep.mjs +86 -27
  80. package/tests/module-scope-map.mjs +39 -0
  81. package/tests/module_exports_unique_keys.mjs +70 -0
  82. package/tests/module_manifest.mjs +85 -0
  83. package/tests/project_catalog_forget.mjs +83 -0
  84. package/tests/projects_hub.mjs +10 -4
  85. package/tests/projects_hub_pre_uat.mjs +5 -3
  86. package/tests/provision_disconnect.mjs +231 -0
  87. package/tests/provision_teardown_address.mjs +230 -0
  88. package/tests/provisioning_capacity_gate.mjs +192 -0
  89. package/tests/provisioning_disconnect.mjs +231 -0
  90. package/tests/provisioning_restart.mjs +5 -5
  91. package/tests/public_landing_projects.mjs +4 -3
  92. package/tests/render_prefs.mjs +152 -0
  93. package/tests/seam_wiring_guard.mjs +234 -0
  94. package/tests/ship_card.mjs +67 -1
  95. package/tests/ship_resume.mjs +86 -12
  96. package/tests/sso_checkin_legacy_field_gone.mjs +246 -0
  97. package/tests/task_list_paging.mjs +178 -0
@@ -0,0 +1,230 @@
1
+ // tests/provision_teardown_address.mjs
2
+ //
3
+ // Teardown reclaims the address itself (task 1002897, ADR 0181 §2, ADR 0200 §2).
4
+ //
5
+ // Before this, a torn-down project's hostname kept resolving to the platform and its
6
+ // Caddy block kept proxying it to a port nothing listened on: every visitor got a bare
7
+ // 502, and teardown only LOGGED "remove the Caddy block" for an operator. Live on
8
+ // 2026-09-16, the five projects retired under task 1003946 all answered 502.
9
+ //
10
+ // What these tests pin: the teardown run removes the instance's <slug>.caddy snippet and
11
+ // reloads Caddy on the box the project ran on, and parks the row's hostname at the
12
+ // RFC 5737 blackhole through the same release env a re-point uses, from the control
13
+ // plane. Both legs are best-effort — a hook failure never strands the row. The row keeps
14
+ // its domain, so a revive stands the project back up at the same hostname. A platform-
15
+ // reserved hostname is never parked. The database and dumps stay operator-only.
16
+ //
17
+ // Hermetic: recorded executors, a fake db, no shell, no network.
18
+ //
19
+ // Run: node tests/provision_teardown_address.mjs
20
+
21
+ import { strict as assert } from 'node:assert';
22
+ import { createRequire } from 'node:module';
23
+
24
+ const require = createRequire(import.meta.url);
25
+ const P = require('../scripts/gds/provision.js');
26
+ const apexMod = require('../scripts/gds/instance-apex.js');
27
+
28
+ let passed = 0, failed = 0;
29
+ async function ta(name, fn) {
30
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
31
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
32
+ }
33
+
34
+ const HOOK = '/opt/infra/box-dns-cloudflare.sh';
35
+ const lantern = { id: 5, slug: 'lantern', hosting_shape: 'standalone', status: 'active', owner_builder_id: 7, port: 3021, db_name: 'lantern', domain: 'lantern.cloudbongos.com', target_ref: null };
36
+
37
+ // One ordered ledger across both executors and the status writes, so ordering is assertable.
38
+ function rig({ apply = true, privileged = false, hookThrows = false } = {}) {
39
+ const events = [], logs = [];
40
+ const deps = {
41
+ apply, privileged, log: (m) => logs.push(String(m)),
42
+ db: { query: async () => ({ rows: [] }) },
43
+ provisioning: {
44
+ setInstanceStatus: async (_db, _id, status, extra) => { events.push({ where: 'status', status, extra }); },
45
+ recordEvent: async () => {},
46
+ },
47
+ exec: (cmd, opts) => { events.push({ where: 'box', cmd, env: opts && opts.env }); return { ok: true, stdout: '' }; },
48
+ controlExec: (cmd, opts) => {
49
+ events.push({ where: 'control', cmd, env: opts && opts.env });
50
+ if (hookThrows && cmd === HOOK) throw new Error('zone not reachable');
51
+ return { ok: true, stdout: '' };
52
+ },
53
+ writeFile: () => ({ ok: true }), controlWriteFile: () => ({ ok: true }),
54
+ doApi: { deleteDroplet: async (id) => { events.push({ where: 'do', cmd: `delete ${id}` }); } },
55
+ };
56
+ const on = (where) => events.filter((e) => e.where === where).map((e) => e.cmd);
57
+ const hookCalls = () => events.filter((e) => e.cmd === HOOK);
58
+ const at = (pred) => events.findIndex(pred);
59
+ return { deps, events, logs, on, hookCalls, at };
60
+ }
61
+
62
+ async function withHook(hook, fn) {
63
+ const saved = P.CONFIG.dnsHook;
64
+ P.CONFIG.dnsHook = hook;
65
+ try { return await fn(); } finally { P.CONFIG.dnsHook = saved; }
66
+ }
67
+
68
+ console.log('the Caddy leg:');
69
+
70
+ await ta('standalone: the <slug>.caddy snippet is removed, THEN Caddy reloads — on the control plane, where it ran', async () => {
71
+ await withHook(HOOK, async () => {
72
+ const r = rig();
73
+ await P.teardownInstance(lantern, r.deps);
74
+ const control = r.on('control');
75
+ const rm = control.findIndex((c) => c === 'rm -f /etc/caddy/sites/lantern.caddy');
76
+ const reload = control.findIndex((c) => c === 'systemctl reload caddy');
77
+ assert.ok(rm >= 0, `snippet removed: ${control.join(' | ')}`);
78
+ assert.ok(reload > rm, 'reload after the rm, so Caddy forgets the site');
79
+ assert.equal(r.on('box').length, 0, 'nothing crosses to the co-hosting box for a standalone');
80
+ });
81
+ });
82
+
83
+ await ta('co-tenant: the snippet comes off the co-hosting box it was written to', async () => {
84
+ await withHook(HOOK, async () => {
85
+ const r = rig();
86
+ await P.teardownInstance({ ...lantern, hosting_shape: 'co-tenant' }, r.deps);
87
+ assert.ok(r.on('box').includes('rm -f /etc/caddy/sites/lantern.caddy'), r.on('box').join(' | '));
88
+ assert.ok(r.on('box').includes('systemctl reload caddy'));
89
+ });
90
+ });
91
+
92
+ await ta('privileged: the rm under /etc and the reload are sudo-prefixed', async () => {
93
+ await withHook(HOOK, async () => {
94
+ const r = rig({ privileged: true });
95
+ await P.teardownInstance(lantern, r.deps);
96
+ assert.ok(r.on('control').includes('sudo rm -f /etc/caddy/sites/lantern.caddy'));
97
+ assert.ok(r.on('control').includes('sudo systemctl reload caddy'));
98
+ });
99
+ });
100
+
101
+ await ta('dedicated: no Caddy steps — the droplet (and its Caddy) is deleted whole', async () => {
102
+ await withHook(HOOK, async () => {
103
+ const r = rig();
104
+ await P.teardownInstance({ ...lantern, hosting_shape: 'dedicated', droplet_id: 9 }, r.deps);
105
+ assert.ok(!r.events.some((e) => /caddy/.test(e.cmd || '')), r.events.map((e) => e.cmd).join(' | '));
106
+ });
107
+ });
108
+
109
+ console.log('\nthe DNS leg:');
110
+
111
+ await ta('the hostname is parked at the blackhole through the release env, from the control plane, before the row reads torn_down', async () => {
112
+ await withHook(HOOK, async () => {
113
+ const r = rig();
114
+ const res = await P.teardownInstance(lantern, r.deps);
115
+ assert.equal(res.ok, true);
116
+ const calls = r.hookCalls();
117
+ assert.equal(calls.length, 1);
118
+ assert.equal(calls[0].where, 'control', 'the Cloudflare token never leaves the control plane');
119
+ assert.deepEqual(calls[0].env, { BOX_HOSTNAME: 'lantern.cloudbongos.com', BOX_IP: '192.0.2.1', CLOUDFLARE_ZONE: 'cloudbongos.com', BOX_PROXIED: '0' });
120
+ assert.ok(r.at((e) => e.cmd === HOOK) < r.at((e) => e.status === 'torn_down'), 'parked before the row says torn_down');
121
+ });
122
+ });
123
+
124
+ await ta('dedicated: its hostname is parked too — the droplet IP it pointed at is gone', async () => {
125
+ await withHook(HOOK, async () => {
126
+ const r = rig();
127
+ await P.teardownInstance({ ...lantern, hosting_shape: 'dedicated', droplet_id: 9 }, r.deps);
128
+ assert.equal(r.hookCalls().length, 1);
129
+ assert.equal(r.hookCalls()[0].where, 'control');
130
+ });
131
+ });
132
+
133
+ await ta('best-effort: a hook that throws is logged and the row still reaches torn_down', async () => {
134
+ await withHook(HOOK, async () => {
135
+ const r = rig({ hookThrows: true });
136
+ const res = await P.teardownInstance(lantern, r.deps);
137
+ assert.equal(res.ok, true);
138
+ assert.equal(r.events.filter((e) => e.where === 'status').pop().status, 'torn_down');
139
+ assert.ok(r.logs.some((l) => /lantern\.cloudbongos\.com/.test(l) && /zone not reachable/.test(l)), r.logs.join('\n'));
140
+ });
141
+ });
142
+
143
+ await ta('no domain: nothing to park, and the snippet is still removed', async () => {
144
+ await withHook(HOOK, async () => {
145
+ const r = rig();
146
+ await P.teardownInstance({ ...lantern, domain: null }, r.deps);
147
+ assert.equal(r.hookCalls().length, 0);
148
+ assert.ok(r.on('control').includes('rm -f /etc/caddy/sites/lantern.caddy'));
149
+ });
150
+ });
151
+
152
+ await ta('no DNS hook configured: no call, and the log names the address to park by hand', async () => {
153
+ await withHook(null, async () => {
154
+ const r = rig();
155
+ await P.teardownInstance(lantern, r.deps);
156
+ assert.equal(r.events.filter((e) => e.env).length, 0);
157
+ assert.ok(r.logs.some((l) => /lantern\.cloudbongos\.com/.test(l) && /BOX_DNS_HOOK unset/.test(l)), r.logs.join('\n'));
158
+ });
159
+ });
160
+
161
+ await ta('dry run: the park is planned in the log, never executed', async () => {
162
+ await withHook(HOOK, async () => {
163
+ const r = rig({ apply: false });
164
+ await P.teardownInstance(lantern, r.deps);
165
+ assert.equal(r.hookCalls().length, 0);
166
+ assert.ok(r.logs.some((l) => /park lantern\.cloudbongos\.com -> 192\.0\.2\.1/.test(l)), r.logs.join('\n'));
167
+ });
168
+ });
169
+
170
+ await ta('a platform-reserved hostname (the apex, www, a deep label) is never parked', async () => {
171
+ const savedApex = apexMod.instanceApex;
172
+ apexMod.instanceApex = () => 'cloudbongos.com';
173
+ try {
174
+ await withHook(HOOK, async () => {
175
+ for (const domain of ['cloudbongos.com', 'www.cloudbongos.com', 'a.b.cloudbongos.com']) {
176
+ const r = rig();
177
+ await P.teardownInstance({ ...lantern, domain }, r.deps);
178
+ assert.equal(r.hookCalls().length, 0, `${domain} must not be blackholed`);
179
+ assert.ok(r.logs.some((l) => l.includes(domain) && /reserved/.test(l)), r.logs.join('\n'));
180
+ }
181
+ const ok = rig();
182
+ await P.teardownInstance(lantern, ok.deps);
183
+ assert.equal(ok.hookCalls().length, 1, 'an ordinary <slug>.<apex> is still parked');
184
+ });
185
+ } finally { apexMod.instanceApex = savedApex; }
186
+ });
187
+
188
+ console.log('\nwhat teardown still leaves, and what a revive gets back:');
189
+
190
+ await ta('the operator line names only the database and the dumps — never the Caddy block', async () => {
191
+ await withHook(HOOK, async () => {
192
+ const r = rig();
193
+ await P.teardownInstance(lantern, r.deps);
194
+ const line = r.logs.find((l) => /left for the operator/.test(l));
195
+ assert.ok(line, r.logs.join('\n'));
196
+ assert.match(line, /dropdb lantern/);
197
+ assert.match(line, /prune .*lantern-\*\.sql\.gz/);
198
+ assert.doesNotMatch(line, /Caddy/i);
199
+ assert.ok(!r.events.some((e) => /dropdb|\.sql\.gz/.test(e.cmd || '')), 'data is never destroyed by a teardown');
200
+ });
201
+ });
202
+
203
+ await ta('the row keeps its domain and port, and a revive re-UPSERTs the SAME hostname to the box and rewrites its snippet', async () => {
204
+ const savedCp = P.CONFIG.controlPlaneIp;
205
+ P.CONFIG.controlPlaneIp = '198.51.100.20';
206
+ try {
207
+ await withHook(HOOK, async () => {
208
+ const r = rig();
209
+ await P.teardownInstance({ ...lantern, target_ref: 'example-owner/lantern' }, r.deps);
210
+ for (const e of r.events.filter((x) => x.where === 'status')) {
211
+ assert.ok(!e.extra || (!('domain' in e.extra) && !('port' in e.extra)), `status write must not release the claim: ${JSON.stringify(e.extra)}`);
212
+ }
213
+ const dns = [], writes = [];
214
+ await P.provisionInstance({ ...lantern, status: 'torn_down', target_ref: 'example-owner/lantern' }, {
215
+ apply: false, log: () => {},
216
+ db: { query: async () => ({ rows: [] }) },
217
+ provisioning: { setInstanceStatus: async () => {}, recordEvent: async () => {} },
218
+ exec: () => ({ ok: true, stdout: '' }),
219
+ controlExec: (cmd, opts) => { if (cmd === HOOK) dns.push(opts.env); return { ok: true, stdout: '' }; },
220
+ writeFile: () => ({ ok: true }), controlWriteFile: (p) => { writes.push(p); return { ok: true }; },
221
+ doApi: null, checkDnsTokenScope: async () => ({ reachable: true, zone: 'cloudbongos.com' }),
222
+ });
223
+ assert.ok(dns.some((env) => env.BOX_HOSTNAME === 'lantern.cloudbongos.com' && env.BOX_IP === '198.51.100.20'), JSON.stringify(dns));
224
+ assert.ok(writes.some((p) => p.endsWith('/lantern.caddy')), writes.join(' | '));
225
+ });
226
+ } finally { P.CONFIG.controlPlaneIp = savedCp; }
227
+ });
228
+
229
+ console.log(`\nprovision_teardown_address: ${passed} passed, ${failed} failed`);
230
+ process.exit(failed ? 1 : 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