@bongos/core 1.19.259 → 1.19.261
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.
- package/.bongos-core.json +34 -24
- package/docs/adr/0181-teardown-state-gate-and-address-retention.md +63 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +1 -1
- package/docs/copy-inventory.md +134 -130
- package/docs/copy-registry.json +236 -191
- package/modules/hall-ui/public/roadmap.js +25 -0
- package/modules/provisioning/provisioning.js +15 -4
- package/modules/provisioning/routes/provisioning.js +23 -8
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/assets/cosmos.css +6 -0
- package/public-landing/projects.html +144 -6
- package/scripts/gds/copy-inventory.js +9 -5
- package/scripts/gds/provision.js +4 -4
- package/src/module-api.js +5 -1
- package/tests/projects_hub.mjs +67 -0
- package/tests/provisioning_teardown_states.mjs +176 -0
- package/tests/public_landing_projects.mjs +22 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// tests/provisioning_teardown_states.mjs
|
|
2
|
+
//
|
|
3
|
+
// The teardown STATE gate (task 1003042, walk finding N5). Sibling of
|
|
4
|
+
// provisioning_teardown_intent.mjs, which covers the intent-collision mapping;
|
|
5
|
+
// this file covers which STATES may be torn down at all, and how the one
|
|
6
|
+
// remaining refusal reads.
|
|
7
|
+
//
|
|
8
|
+
// The bug: TEARDOWN_FROM was a hand-kept whitelist, ['provisioning','active',
|
|
9
|
+
// 'tearing_down'], that silently omitted 'requested' and 'error'. Because the
|
|
10
|
+
// archon force-teardown route reuses the SAME set, there was no path — owner or
|
|
11
|
+
// archon — to remove a snagged project, so errored rows accumulated forever
|
|
12
|
+
// (instance 15 'swag' from 2026-08-07). Two things are pinned here:
|
|
13
|
+
//
|
|
14
|
+
// • the set is DERIVED from INSTANCE_STATES minus 'torn_down', so a future
|
|
15
|
+
// state is teardownable by default and cannot be silently excluded again;
|
|
16
|
+
// • the surviving refusal is a plain SENTENCE on BOTH routes. force-teardown
|
|
17
|
+
// used the positional res.fail(code, 409, details) form with no message,
|
|
18
|
+
// and envelope()'s `message || code` fallback then put the bare string
|
|
19
|
+
// "not_teardownable" on the wire as the human text (§5.1's plain-sentence
|
|
20
|
+
// rule). This test mounts the REAL attachFail, not a shim, so that exact
|
|
21
|
+
// regression is caught on the wire and not merely in the call shape.
|
|
22
|
+
//
|
|
23
|
+
// DB-free: the pool is never queried — the data-access fns the routes need are
|
|
24
|
+
// stubbed on the cached module object (the provisioning_teardown_intent.mjs
|
|
25
|
+
// pattern). No network beyond a loopback listener.
|
|
26
|
+
//
|
|
27
|
+
// Run: node tests/provisioning_teardown_states.mjs
|
|
28
|
+
|
|
29
|
+
import { strict as assert } from 'node:assert';
|
|
30
|
+
import { createRequire } from 'node:module';
|
|
31
|
+
import http from 'node:http';
|
|
32
|
+
|
|
33
|
+
const require = createRequire(import.meta.url);
|
|
34
|
+
const provisioning = require('../modules/provisioning/provisioning.js');
|
|
35
|
+
|
|
36
|
+
let passed = 0, failed = 0;
|
|
37
|
+
function t(name, fn) {
|
|
38
|
+
try { fn(); passed++; console.log(` PASS ${name}`); }
|
|
39
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
40
|
+
}
|
|
41
|
+
async function ta(name, fn) {
|
|
42
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
43
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── the constant itself ──────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
console.log('TEARDOWN_FROM — every state except the one with nothing left:');
|
|
49
|
+
|
|
50
|
+
t('every INSTANCE_STATE except torn_down may be torn down', () => {
|
|
51
|
+
for (const s of provisioning.INSTANCE_STATES) {
|
|
52
|
+
assert.equal(provisioning.TEARDOWN_FROM.has(s), s !== 'torn_down',
|
|
53
|
+
`${s} should ${s === 'torn_down' ? 'NOT ' : ''}be teardownable`);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
t("'requested' and 'error' are IN — the two the old whitelist dropped (N5)", () => {
|
|
58
|
+
assert.equal(provisioning.TEARDOWN_FROM.has('requested'), true);
|
|
59
|
+
assert.equal(provisioning.TEARDOWN_FROM.has('error'), true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
t('the set is derived, not hand-listed — size tracks INSTANCE_STATES', () => {
|
|
63
|
+
assert.equal(provisioning.TEARDOWN_FROM.size, provisioning.INSTANCE_STATES.length - 1,
|
|
64
|
+
'a state added to INSTANCE_STATES must be teardownable by default, never silently excluded');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// ── the routes, over a real mounted router + the REAL error envelope ─────────
|
|
68
|
+
|
|
69
|
+
let currentStatus = 'active';
|
|
70
|
+
let teardownCalls = [];
|
|
71
|
+
provisioning.getInstanceById = async (_db, id) =>
|
|
72
|
+
({ id, owner_builder_id: 7, slug: 'swag', status: currentStatus });
|
|
73
|
+
provisioning.enqueueTeardown = async (_db, id, requestedBy) => {
|
|
74
|
+
teardownCalls.push({ id, requestedBy, status: currentStatus });
|
|
75
|
+
return { intent: { action: 'teardown', state: 'pending' }, created: true, superseded: false };
|
|
76
|
+
};
|
|
77
|
+
provisioning.recordEvent = async () => {};
|
|
78
|
+
|
|
79
|
+
const api = require('../src/module-api.js');
|
|
80
|
+
api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'owner', rank: 'archon' }; next(); };
|
|
81
|
+
api.requirePermission = () => (_req, _res, next) => next();
|
|
82
|
+
|
|
83
|
+
const express = require('express');
|
|
84
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
85
|
+
const provisioningRoutes = require('../modules/provisioning/routes/provisioning.js');
|
|
86
|
+
const app = express();
|
|
87
|
+
app.use(express.json());
|
|
88
|
+
app.use(attachFail); // the REAL envelope — a shim would hide the bare-code bug
|
|
89
|
+
app.use('/api/bongos', provisioningRoutes());
|
|
90
|
+
|
|
91
|
+
const server = app.listen(0);
|
|
92
|
+
await new Promise((r) => server.once('listening', r));
|
|
93
|
+
const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
|
|
94
|
+
|
|
95
|
+
function post(path) {
|
|
96
|
+
return new Promise((resolve, reject) => {
|
|
97
|
+
const req = http.request(`${base}${path}`, {
|
|
98
|
+
method: 'POST',
|
|
99
|
+
headers: { 'Content-Type': 'application/json', 'Content-Length': 2 },
|
|
100
|
+
}, (res) => {
|
|
101
|
+
let d = '';
|
|
102
|
+
res.on('data', (c) => { d += c; });
|
|
103
|
+
res.on('end', () => { let j = null; try { j = JSON.parse(d); } catch { /* non-JSON */ } resolve({ status: res.statusCode, body: j, raw: d }); });
|
|
104
|
+
});
|
|
105
|
+
req.on('error', reject);
|
|
106
|
+
req.end('{}');
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const OWNER = '/provisioning/instances/15/teardown';
|
|
111
|
+
const FORCE = '/provisioning/instances/15/force-teardown';
|
|
112
|
+
|
|
113
|
+
console.log('\nEvery non-torn-down state is accepted — on BOTH routes:');
|
|
114
|
+
|
|
115
|
+
for (const state of ['requested', 'provisioning', 'active', 'tearing_down', 'error']) {
|
|
116
|
+
await ta(`owner teardown queues from '${state}'`, async () => {
|
|
117
|
+
currentStatus = state; teardownCalls = [];
|
|
118
|
+
const res = await post(OWNER);
|
|
119
|
+
assert.equal(res.status, 200, res.raw);
|
|
120
|
+
assert.equal(res.body.queued, true);
|
|
121
|
+
assert.equal(teardownCalls.length, 1, 'the intent was actually enqueued');
|
|
122
|
+
});
|
|
123
|
+
await ta(`force-teardown queues from '${state}'`, async () => {
|
|
124
|
+
currentStatus = state; teardownCalls = [];
|
|
125
|
+
const res = await post(FORCE);
|
|
126
|
+
assert.equal(res.status, 200, res.raw);
|
|
127
|
+
assert.equal(res.body.queued, true);
|
|
128
|
+
assert.equal(teardownCalls.length, 1);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
await ta("the live repro: instance 15 'swag' sat at 'error' and could not be removed by ANYONE", async () => {
|
|
133
|
+
currentStatus = 'error';
|
|
134
|
+
teardownCalls = [];
|
|
135
|
+
const owner = await post(OWNER);
|
|
136
|
+
const force = await post(FORCE);
|
|
137
|
+
assert.equal(owner.status, 200, `owner teardown: ${owner.raw}`);
|
|
138
|
+
assert.equal(force.status, 200, `force-teardown: ${force.raw}`);
|
|
139
|
+
assert.equal(teardownCalls.length, 2, 'both roles now have a real path out');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
console.log("\nOnly 'torn_down' still refuses — and it refuses in a sentence:");
|
|
143
|
+
|
|
144
|
+
for (const [label, path] of [['owner teardown', OWNER], ['force-teardown', FORCE]]) {
|
|
145
|
+
await ta(`${label} refuses a torn-down project with 409 not_teardownable`, async () => {
|
|
146
|
+
currentStatus = 'torn_down'; teardownCalls = [];
|
|
147
|
+
const res = await post(path);
|
|
148
|
+
assert.equal(res.status, 409, res.raw);
|
|
149
|
+
assert.equal(res.body.error.code, 'not_teardownable');
|
|
150
|
+
assert.equal(res.body.error.details.status, 'torn_down', 'the real state is named');
|
|
151
|
+
assert.equal(teardownCalls.length, 0, 'nothing was enqueued');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
await ta(`${label}'s message is a plain sentence, never the bare code (the N5 §5.1 violation)`, async () => {
|
|
155
|
+
currentStatus = 'torn_down';
|
|
156
|
+
const res = await post(path);
|
|
157
|
+
const m = String(res.body.error.message || '');
|
|
158
|
+
assert.notEqual(m, 'not_teardownable', 'the bare code must never be the human text');
|
|
159
|
+
assert.doesNotMatch(m, /_/, 'no snake_case identifier leaks into owner-facing copy');
|
|
160
|
+
assert.ok(/\s/.test(m) && /\.$/.test(m), `a real sentence, got: ${JSON.stringify(m)}`);
|
|
161
|
+
assert.match(m, /torn down/i, 'it says what is actually true of the project');
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
await ta('both routes give the SAME refusal — the copies cannot drift again', async () => {
|
|
166
|
+
currentStatus = 'torn_down';
|
|
167
|
+
const owner = await post(OWNER);
|
|
168
|
+
const force = await post(FORCE);
|
|
169
|
+
assert.equal(owner.body.error.message, force.body.error.message);
|
|
170
|
+
assert.equal(owner.status, force.status);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
server.close();
|
|
174
|
+
|
|
175
|
+
console.log(`\nprovisioning_teardown_states: ${passed} passed, ${failed} failed`);
|
|
176
|
+
if (failed) process.exit(1);
|
|
@@ -121,6 +121,28 @@ test('the manage view is served: identity block + honest not-yet sections (task
|
|
|
121
121
|
assert.doesNotMatch(missing, /permission|allowed|access|owner/i);
|
|
122
122
|
});
|
|
123
123
|
|
|
124
|
+
test('the delete section is served live, and never claims to erase (task 1003042)', async () => {
|
|
125
|
+
const { body } = await request('/projects', 'game.test.local');
|
|
126
|
+
assert.match(body, /id="mDel"/, 'the live section body is served');
|
|
127
|
+
assert.match(body, /id="mDelWhat"/, 'the teardown-vs-delete fold is served');
|
|
128
|
+
assert.match(body, /does <b>not<\/b> erase the project/i,
|
|
129
|
+
'the distinction survives to the wire, not just the source');
|
|
130
|
+
assert.match(body, /no permanent-delete button/i, 'the absent capability is admitted');
|
|
131
|
+
// the confirmation asks for the project's own name — the heaviest gate on the page
|
|
132
|
+
assert.match(body, /Type the project’s name to confirm/, 'the typed confirmation is served');
|
|
133
|
+
assert.match(body, /data-del-yes disabled/, 'and the act starts unreachable');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('the served teardown copy no longer claims the address goes quiet (task 1003042)', async () => {
|
|
137
|
+
const { body } = await request('/projects', 'game.test.local');
|
|
138
|
+
// teardown has no DNS or Caddy leg: the hostname keeps resolving and 502s, so
|
|
139
|
+
// this sentence was false on the wire. Task 1002897 owns actually releasing it.
|
|
140
|
+
assert.doesNotMatch(body, /the address stops answering/,
|
|
141
|
+
'no served copy may promise a silence the runner does not deliver');
|
|
142
|
+
assert.match(body, /stays reserved for this project/,
|
|
143
|
+
'the truthful replacement is what ships');
|
|
144
|
+
});
|
|
145
|
+
|
|
124
146
|
test('the repo-visibility section is served live, with the private direction refused (task 1003045)', async () => {
|
|
125
147
|
const { body } = await request('/projects', 'game.test.local');
|
|
126
148
|
assert.match(body, /id="mRepoVis"/, 'the live section body is served');
|