@bongos/core 1.19.1035 → 1.19.1036
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 +72 -57
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0339-a-projects-owner-upgrades-it-and-a-hosted-project-hands-off-to-the-hub-door.md +84 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +7 -5
- package/docs/copy-inventory.md +10 -10
- package/docs/copy-registry.json +13 -13
- package/docs/module-api-changelog.md +2 -0
- package/modules/hall-ui/public/atlas.html +1 -1
- package/modules/hall-ui/public/blockers.html +1 -1
- package/modules/hall-ui/public/board-room.html +1 -1
- package/modules/hall-ui/public/collab.html +1 -1
- package/modules/hall-ui/public/copy-desk.html +1 -1
- package/modules/hall-ui/public/deploy.html +1 -1
- package/modules/hall-ui/public/diagrams.html +1 -1
- package/modules/hall-ui/public/drachmae.html +1 -1
- package/modules/hall-ui/public/fleet.html +1 -1
- package/modules/hall-ui/public/gate.html +1 -1
- package/modules/hall-ui/public/goals.html +1 -1
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/idea.html +1 -1
- package/modules/hall-ui/public/ideas.html +1 -1
- package/modules/hall-ui/public/index.html +1 -1
- package/modules/hall-ui/public/modules.html +1 -1
- package/modules/hall-ui/public/people.html +1 -1
- package/modules/hall-ui/public/primer.html +1 -1
- package/modules/hall-ui/public/profile.html +1 -1
- package/modules/hall-ui/public/project-settings.html +1 -1
- package/modules/hall-ui/public/ranks.html +1 -1
- package/modules/hall-ui/public/roadmap.html +1 -1
- package/modules/hall-ui/public/roster.html +1 -1
- package/modules/hall-ui/public/sessions.html +1 -1
- package/modules/hall-ui/public/settings.html +1 -1
- package/modules/hall-ui/public/shell.js +36 -5
- package/modules/hall-ui/public/studio.html +1 -1
- package/modules/hall-ui/public/task.html +1 -1
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/work.html +1 -1
- package/modules/provisioning/core-upgrade-authz.js +85 -0
- package/modules/provisioning/core-upgrade.js +2 -1
- package/modules/provisioning/provisioning.js +4 -4
- package/modules/provisioning/routes/core-upgrade.js +12 -6
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +10 -0
- package/scripts/gds/provision-core-upgrade.js +12 -4
- package/src/bongos/auth.js +8 -2
- package/src/bongos/routes/core-update.js +25 -3
- package/src/module-api.js +1 -1
- package/tests/core_upgrade_owner_door.mjs +444 -0
- package/tests/nav_permission_atoms.mjs +1 -1
- package/tests/provisioning_teardown_intent.mjs +28 -2
- package/tests/scouting_page_gate.mjs +11 -3
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
// tests/core_upgrade_owner_door.mjs — a project's owner may move ITS core (task 1004174, ADR 0339).
|
|
2
|
+
//
|
|
3
|
+
// The owner's ruling (2026-09-26): only a project's owner upgrades it. The deploy door was
|
|
4
|
+
// `core.pin.move` alone, so the owner of a hosted project below Archon could see they were
|
|
5
|
+
// behind and never act. This file proves the widening is exactly that wide and no wider:
|
|
6
|
+
//
|
|
7
|
+
// 1. the owner of a TENANT row is admitted, to the preview and the move, for that row only;
|
|
8
|
+
// 2. everyone else meets the unmodified atom gate, and a non-owner is refused byte-for-byte
|
|
9
|
+
// as before, whether or not the id exists;
|
|
10
|
+
// 3. the platform's own row stays atom-only, even for the caller who owns it;
|
|
11
|
+
// 4. the intent's actor names which door was used, and the runner's parse accepts both and
|
|
12
|
+
// nothing else (the id is interpolated into a shell command);
|
|
13
|
+
// 5. the /deploy page shell admits a project owner and still refuses a stranger;
|
|
14
|
+
// 6. a hosted project's hall hands its owner to the hub's door and never trusts a bad URL.
|
|
15
|
+
//
|
|
16
|
+
// These EXECUTE the real gate, router, page gate and shell.js; none of them reads source text.
|
|
17
|
+
//
|
|
18
|
+
// Run: node tests/core_upgrade_owner_door.mjs
|
|
19
|
+
|
|
20
|
+
import { strict as assert } from 'node:assert';
|
|
21
|
+
import { createRequire } from 'node:module';
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import vm from 'node:vm';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
|
|
27
|
+
const require = createRequire(import.meta.url);
|
|
28
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
29
|
+
|
|
30
|
+
// The session double must be installed BEFORE auth.js loads: auth.js destructures
|
|
31
|
+
// lookupSession at require time (the tests/scouting_page_gate.mjs precedent).
|
|
32
|
+
const dbKernel = require('../src/bongos/db-kernel.js');
|
|
33
|
+
let nextSession = null;
|
|
34
|
+
dbKernel.lookupSession = async () => nextSession;
|
|
35
|
+
|
|
36
|
+
const auth = require('../src/bongos/auth.js');
|
|
37
|
+
const seams = require('../src/module-seams.js');
|
|
38
|
+
const catalog = require('../modules/government/catalog.js');
|
|
39
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
40
|
+
const AZ = require('../modules/provisioning/core-upgrade-authz.js');
|
|
41
|
+
const { requestedByOf } = require('../scripts/gds/provision-core-upgrade.js');
|
|
42
|
+
const { hubDeployUrl } = require('../src/bongos/routes/core-update.js');
|
|
43
|
+
|
|
44
|
+
let passed = 0;
|
|
45
|
+
let failed = 0;
|
|
46
|
+
async function t(name, fn) {
|
|
47
|
+
try { await fn(); passed += 1; console.log(` PASS ${name}`); }
|
|
48
|
+
catch (e) { failed += 1; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The cast. Builder 7 owns the hosted project hoodify-finance (a xenos on the hub, as
|
|
52
|
+
// erikwils is); 8 is a stranger; 42 is an Archon. Builder 7 is also recorded as owning the
|
|
53
|
+
// platform row here, which is the case the ruling must NOT open.
|
|
54
|
+
const OWNER = { id: 7, rank: 'xenos' };
|
|
55
|
+
const STRANGER = { id: 8, rank: 'xenos' };
|
|
56
|
+
const ARCHON = { id: 42, rank: 'archon' };
|
|
57
|
+
const TENANT = Object.freeze({ id: 41, slug: 'hoodify-finance', status: 'active', hosting_shape: 'cloud-host', owner_builder_id: 7 });
|
|
58
|
+
const COTENANT = Object.freeze({ id: 43, slug: 'mercury', status: 'active', hosting_shape: 'co-tenant', owner_builder_id: 7 });
|
|
59
|
+
const PLATFORM = Object.freeze({ id: 1, slug: 'cloudbongos', status: 'active', hosting_shape: 'control-plane', owner_builder_id: 7 });
|
|
60
|
+
const ROWS = new Map([[41, TENANT], [43, COTENANT], [1, PLATFORM]]);
|
|
61
|
+
|
|
62
|
+
// ── 1. the predicate ──────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
console.log('\nwho owns an upgradable project:');
|
|
65
|
+
|
|
66
|
+
await t('the owner of a cloud-host or co-tenant row owns it', () => {
|
|
67
|
+
assert.equal(AZ.ownsUpgradableProject(TENANT, OWNER), true);
|
|
68
|
+
assert.equal(AZ.ownsUpgradableProject(COTENANT, OWNER), true);
|
|
69
|
+
assert.equal(AZ.ownsUpgradableProject(TENANT, { id: '7' }), true, 'ids compare as ids, not as types');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
await t('the platform row is never an owner-movable project, even for its recorded owner', () => {
|
|
73
|
+
assert.equal(AZ.ownsUpgradableProject(PLATFORM, OWNER), false);
|
|
74
|
+
assert.equal(AZ.OWNER_SHAPES.has('control-plane'), false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
await t('it fails closed on every missing piece', () => {
|
|
78
|
+
assert.equal(AZ.ownsUpgradableProject(TENANT, STRANGER), false, 'someone else');
|
|
79
|
+
assert.equal(AZ.ownsUpgradableProject(null, OWNER), false, 'no row');
|
|
80
|
+
assert.equal(AZ.ownsUpgradableProject(TENANT, null), false, 'no builder');
|
|
81
|
+
assert.equal(AZ.ownsUpgradableProject(TENANT, { id: null }), false, 'a builder with no id');
|
|
82
|
+
assert.equal(AZ.ownsUpgradableProject({ ...TENANT, owner_builder_id: null }, { id: null }), false, 'null never equals null');
|
|
83
|
+
assert.equal(AZ.ownsUpgradableProject({ ...TENANT, hosting_shape: 'something-new' }, OWNER), false, 'a later shape is excluded by omission');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// ── 2. the middleware ─────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
console.log('\nthe OR gate:');
|
|
89
|
+
|
|
90
|
+
// The atom gate as a spy: admits an Archon, refuses everyone else with the ONE body the
|
|
91
|
+
// door has always sent. Nothing about the refusal depends on the id.
|
|
92
|
+
function pinGateSpy() {
|
|
93
|
+
const spy = (req, res, next) => {
|
|
94
|
+
spy.calls += 1;
|
|
95
|
+
if (req.builder && req.builder.rank === 'archon') return next();
|
|
96
|
+
res.statusCode = 403;
|
|
97
|
+
res.body = { ok: false, error: 'forbidden', permission: 'core.pin.move' };
|
|
98
|
+
return undefined;
|
|
99
|
+
};
|
|
100
|
+
spy.calls = 0;
|
|
101
|
+
return spy;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function runGate({ id, builder, getInstanceById, log }) {
|
|
105
|
+
const pinGate = pinGateSpy();
|
|
106
|
+
const lookups = [];
|
|
107
|
+
const provisioning = {
|
|
108
|
+
getInstanceById: getInstanceById || (async (_db, n) => { lookups.push(n); return ROWS.get(n) || null; }),
|
|
109
|
+
};
|
|
110
|
+
const gate = AZ.requireOwnProjectOrPin({ pinGate, provisioning, pool: {}, log });
|
|
111
|
+
const req = { params: { id }, builder };
|
|
112
|
+
const res = {};
|
|
113
|
+
let admitted = false;
|
|
114
|
+
await gate(req, res, () => { admitted = true; });
|
|
115
|
+
return { admitted, pinCalls: pinGate.calls, authority: req.coreUpgradeAuthority, res, lookups };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
await t('the owner is admitted to THEIR project without meeting the atom', async () => {
|
|
119
|
+
const r = await runGate({ id: '41', builder: OWNER });
|
|
120
|
+
assert.equal(r.admitted, true);
|
|
121
|
+
assert.equal(r.pinCalls, 0, 'ownership answered first');
|
|
122
|
+
assert.equal(r.authority, 'owner');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
await t('a stranger meets the atom and is refused', async () => {
|
|
126
|
+
const r = await runGate({ id: '41', builder: STRANGER });
|
|
127
|
+
assert.equal(r.admitted, false);
|
|
128
|
+
assert.equal(r.pinCalls, 1);
|
|
129
|
+
assert.equal(r.authority, 'pin');
|
|
130
|
+
assert.equal(r.res.statusCode, 403);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
await t('the refusal is byte-identical whether the id exists or not', async () => {
|
|
134
|
+
const real = await runGate({ id: '41', builder: STRANGER });
|
|
135
|
+
const ghost = await runGate({ id: '999', builder: STRANGER });
|
|
136
|
+
assert.deepEqual(ghost.res, real.res, 'a stranger learns nothing about which ids exist');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
await t('the owner of the platform row is NOT admitted by ownership', async () => {
|
|
140
|
+
const r = await runGate({ id: '1', builder: OWNER });
|
|
141
|
+
assert.equal(r.admitted, false);
|
|
142
|
+
assert.equal(r.pinCalls, 1, 'the platform row stays on the atom');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
await t('an Archon is admitted by the atom, on a project they do not own', async () => {
|
|
146
|
+
const r = await runGate({ id: '41', builder: ARCHON });
|
|
147
|
+
assert.equal(r.admitted, true);
|
|
148
|
+
assert.equal(r.pinCalls, 1);
|
|
149
|
+
assert.equal(r.authority, 'pin', 'so the actor records the Archon door');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
await t('an id that is not a plain positive integer is never looked up', async () => {
|
|
153
|
+
for (const id of ['abc', '0', '-1', '1e3', '41; DROP TABLE x', '', ' 41', '041x']) {
|
|
154
|
+
const r = await runGate({ id, builder: OWNER });
|
|
155
|
+
assert.deepEqual(r.lookups, [], `"${id}" reached the database`);
|
|
156
|
+
assert.equal(r.admitted, false, `"${id}" admitted a xenos`);
|
|
157
|
+
assert.equal(r.pinCalls, 1, `"${id}" skipped the atom`);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
await t('a failing ownership read falls to the atom: it can deny an owner, never admit anyone', async () => {
|
|
162
|
+
const logged = [];
|
|
163
|
+
const log = { error: (m) => logged.push(m) };
|
|
164
|
+
const boom = async () => { throw new Error('connection reset (row: secret-owner-data)'); };
|
|
165
|
+
const owner = await runGate({ id: '41', builder: OWNER, getInstanceById: boom, log });
|
|
166
|
+
assert.equal(owner.admitted, false, 'an outage denies the owner');
|
|
167
|
+
assert.equal(owner.pinCalls, 1);
|
|
168
|
+
const archon = await runGate({ id: '41', builder: ARCHON, getInstanceById: boom, log });
|
|
169
|
+
assert.equal(archon.admitted, true, 'and leaves the Archon path working');
|
|
170
|
+
assert.ok(logged.length >= 1 && logged.every((m) => /falling back to core\.pin\.move/.test(m)), 'the fallback is logged');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// ── 3. the actor and the runner's parse ───────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
console.log('\nthe actor says which door was used:');
|
|
176
|
+
|
|
177
|
+
await t('owner and Archon moves are labelled apart, and both carry the builder id', () => {
|
|
178
|
+
assert.equal(AZ.coreUpgradeActor({ coreUpgradeAuthority: 'owner', builder: { id: 7 } }), 'api:owner:7');
|
|
179
|
+
assert.equal(AZ.coreUpgradeActor({ coreUpgradeAuthority: 'pin', builder: { id: 42 } }), 'api:archon:42');
|
|
180
|
+
assert.equal(AZ.coreUpgradeActor({ builder: { id: 42 } }), 'api:archon:42', 'no recorded door is the Archon door');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
await t('the runner attributes both, and passes nothing but digits into its command', () => {
|
|
184
|
+
assert.equal(requestedByOf({ requested_by: 'api:archon:42' }), '42');
|
|
185
|
+
assert.equal(requestedByOf({ requested_by: 'api:owner:7' }), '7');
|
|
186
|
+
for (const bad of ['api:owner:7; rm -rf /', 'api:owner:', 'api:self', 'api:owner:7 ', 'owner:7', 'api:root:1']) {
|
|
187
|
+
assert.equal(requestedByOf({ requested_by: bad }), '', `"${bad}" must not pass`);
|
|
188
|
+
}
|
|
189
|
+
assert.equal(requestedByOf({ requested_by: 42 }), '');
|
|
190
|
+
assert.equal(requestedByOf(null), '');
|
|
191
|
+
assert.equal(requestedByOf({}), '');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// ── 4. the assembled router, driven by real requests ──────────────────────────
|
|
195
|
+
|
|
196
|
+
console.log('\nthe routes, end to end:');
|
|
197
|
+
|
|
198
|
+
await t('POST: the owner queues a move on their project; a stranger and the platform row are refused', async () => {
|
|
199
|
+
const api = require('../src/module-api.js');
|
|
200
|
+
const prov = require('../modules/provisioning/provisioning.js');
|
|
201
|
+
const routePath = require.resolve('../modules/provisioning/routes/core-upgrade.js');
|
|
202
|
+
const saved = {};
|
|
203
|
+
const stub = (key, value) => {
|
|
204
|
+
saved[key] = Object.getOwnPropertyDescriptor(api, key);
|
|
205
|
+
Object.defineProperty(api, key, { value, configurable: true, writable: true });
|
|
206
|
+
};
|
|
207
|
+
// The caller rides a test header; the atom gate is the spy's rule (Archon only) with
|
|
208
|
+
// the door's own refusal shape. Everything between them is the real code.
|
|
209
|
+
stub('requireBuilder', (req, _res, next) => { req.builder = JSON.parse(req.headers['x-test-builder']); next(); });
|
|
210
|
+
stub('requirePermission', (atom) => (req, res, next) => {
|
|
211
|
+
if (req.builder.rank === 'archon') return next();
|
|
212
|
+
return res.status(403).json({ ok: false, error: 'forbidden', permission: atom });
|
|
213
|
+
});
|
|
214
|
+
stub('pool', {});
|
|
215
|
+
const queued = [];
|
|
216
|
+
const savedProv = { get: prov.getInstanceById, enq: prov.enqueueIntent, rec: prov.recordEvent };
|
|
217
|
+
prov.getInstanceById = async (_db, n) => ROWS.get(Number(n)) || null;
|
|
218
|
+
prov.enqueueIntent = async (_db, id, action, requestedBy, targetVersion) => {
|
|
219
|
+
queued.push({ id, action, requestedBy, targetVersion });
|
|
220
|
+
return { intent: { id: queued.length, action, state: 'pending', target_version: targetVersion }, created: true };
|
|
221
|
+
};
|
|
222
|
+
prov.recordEvent = async () => {};
|
|
223
|
+
delete require.cache[routePath];
|
|
224
|
+
|
|
225
|
+
const express = require('express');
|
|
226
|
+
const app = express();
|
|
227
|
+
app.use(express.json());
|
|
228
|
+
app.use(require(routePath)());
|
|
229
|
+
const server = app.listen(0);
|
|
230
|
+
await new Promise((r) => server.once('listening', r));
|
|
231
|
+
const base = `http://127.0.0.1:${server.address().port}`;
|
|
232
|
+
const post = (id, who) => fetch(`${base}/provisioning/instances/${id}/core-upgrade`, {
|
|
233
|
+
method: 'POST',
|
|
234
|
+
headers: { 'content-type': 'application/json', 'x-test-builder': JSON.stringify(who) },
|
|
235
|
+
body: JSON.stringify({ to: '1.19.1033', dry_run: true }),
|
|
236
|
+
});
|
|
237
|
+
try {
|
|
238
|
+
const own = await post(41, OWNER);
|
|
239
|
+
assert.notEqual(own.status, 403, 'the owner is not refused on their own project');
|
|
240
|
+
assert.deepEqual(queued.map((q) => [q.id, q.requestedBy]), [[41, 'api:owner:7']], 'queued, and attributed to the owner door');
|
|
241
|
+
|
|
242
|
+
const strangerReal = await post(41, STRANGER);
|
|
243
|
+
const strangerGhost = await post(999, STRANGER);
|
|
244
|
+
assert.equal(strangerReal.status, 403);
|
|
245
|
+
assert.equal(strangerGhost.status, 403);
|
|
246
|
+
assert.deepEqual(await strangerReal.json(), await strangerGhost.json(), 'the same refusal for a real and a missing id');
|
|
247
|
+
|
|
248
|
+
const platform = await post(1, OWNER);
|
|
249
|
+
assert.equal(platform.status, 403, 'owning the platform row does not open the platform to a non-Archon');
|
|
250
|
+
|
|
251
|
+
const archon = await post(41, ARCHON);
|
|
252
|
+
assert.notEqual(archon.status, 403);
|
|
253
|
+
assert.deepEqual(queued.map((q) => q.requestedBy), ['api:owner:7', 'api:archon:42'], 'nothing else was queued');
|
|
254
|
+
|
|
255
|
+
const preview = await fetch(`${base}/provisioning/instances/41/core-upgrade`, { headers: { 'x-test-builder': JSON.stringify(STRANGER) } });
|
|
256
|
+
assert.equal(preview.status, 403, 'the preview is gated like the move');
|
|
257
|
+
|
|
258
|
+
const cp = await fetch(`${base}/provisioning/control-plane`, { headers: { 'x-test-builder': JSON.stringify(OWNER) } });
|
|
259
|
+
assert.equal(cp.status, 403, 'the platform entrance keeps the atom alone');
|
|
260
|
+
} finally {
|
|
261
|
+
server.close();
|
|
262
|
+
for (const [k, d] of Object.entries(saved)) Object.defineProperty(api, k, d);
|
|
263
|
+
prov.getInstanceById = savedProv.get;
|
|
264
|
+
prov.enqueueIntent = savedProv.enq;
|
|
265
|
+
prov.recordEvent = savedProv.rec;
|
|
266
|
+
delete require.cache[routePath];
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// ── 5. the /deploy page shell ─────────────────────────────────────────────────
|
|
271
|
+
|
|
272
|
+
console.log('\nthe /deploy page shell:');
|
|
273
|
+
|
|
274
|
+
function fakeRes() {
|
|
275
|
+
const res = {
|
|
276
|
+
_redirect: null,
|
|
277
|
+
status() { return this; },
|
|
278
|
+
json() { return this; },
|
|
279
|
+
redirect(a, b) { this._redirect = b === undefined ? { code: 302, loc: a } : { code: a, loc: b }; return this; },
|
|
280
|
+
end() { return this; },
|
|
281
|
+
};
|
|
282
|
+
attachFail({}, res, () => {});
|
|
283
|
+
return res;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function runPage(session, hub) {
|
|
287
|
+
nextSession = session;
|
|
288
|
+
seams._reset();
|
|
289
|
+
seams.registerProvider('government', {
|
|
290
|
+
resolveBuilderPermissions: async () => catalog.seedForRank(session ? session.rank : 'xenos'),
|
|
291
|
+
});
|
|
292
|
+
if (hub) seams.registerProvider('platform-identity.hub', hub);
|
|
293
|
+
const res = fakeRes();
|
|
294
|
+
let nexted = false;
|
|
295
|
+
await auth.requireCorePinPage({ headers: { cookie: 'gds_session=t' } }, res, () => { nexted = true; });
|
|
296
|
+
return { res, nexted };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const OWNER_SESSION = { builder_id: 7, github_id: '9001', rank: 'xenos' };
|
|
300
|
+
const STRANGER_SESSION = { builder_id: 8, github_id: '9002', rank: 'xenos' };
|
|
301
|
+
const ARCHON_SESSION = { builder_id: 42, github_id: '9042', rank: 'archon' };
|
|
302
|
+
const owns = { isProjectOwner: async (githubId) => String(githubId) === '9001' };
|
|
303
|
+
|
|
304
|
+
await t('a project owner holding no atom reaches the shell', async () => {
|
|
305
|
+
const { nexted } = await runPage(OWNER_SESSION, owns);
|
|
306
|
+
assert.ok(nexted, 'without this the widening is real in the API and unreachable in the product');
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
await t('a stranger gets exactly the redirect the page sent before the second door existed', async () => {
|
|
310
|
+
const withDoor = await runPage(STRANGER_SESSION, owns);
|
|
311
|
+
const noDoor = await runPage(STRANGER_SESSION, null);
|
|
312
|
+
assert.equal(withDoor.nexted, false);
|
|
313
|
+
assert.deepEqual(withDoor.res._redirect, noDoor.res._redirect);
|
|
314
|
+
assert.match(withDoor.res._redirect.loc, /denied=core\.pin\.move/, 'and it still names the atom');
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
await t('an Archon is admitted by the atom without the second door being asked', async () => {
|
|
318
|
+
const spy = { isProjectOwner: () => { throw new Error('must not be consulted for an atom holder'); } };
|
|
319
|
+
const { nexted } = await runPage(ARCHON_SESSION, spy);
|
|
320
|
+
assert.ok(nexted);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
seams._reset();
|
|
324
|
+
nextSession = null;
|
|
325
|
+
|
|
326
|
+
// ── 6. the hand-off address, and the hall that uses it ────────────────────────
|
|
327
|
+
|
|
328
|
+
console.log('\nthe hand-off to the hub:');
|
|
329
|
+
|
|
330
|
+
await t('deploy_url is the hub origin plus this project\'s slug, and null without a hub', () => {
|
|
331
|
+
assert.equal(hubDeployUrl({ origin: 'https://cloudbongos.com', clientId: 'hoodify-finance' }),
|
|
332
|
+
'https://cloudbongos.com/deploy?project=hoodify-finance');
|
|
333
|
+
assert.equal(hubDeployUrl({ origin: 'https://cloudbongos.com/', clientId: 'mercury' }),
|
|
334
|
+
'https://cloudbongos.com/deploy?project=mercury', 'a trailing slash is not doubled');
|
|
335
|
+
assert.equal(hubDeployUrl({ origin: 'https://h.test', clientId: 'a b&c' }), 'https://h.test/deploy?project=a%20b%26c');
|
|
336
|
+
assert.equal(hubDeployUrl({ origin: 'https://cloudbongos.com' }), null, 'no client id: not federated');
|
|
337
|
+
assert.equal(hubDeployUrl({ origin: 'javascript:alert(1)', clientId: 'x' }), null);
|
|
338
|
+
assert.equal(hubDeployUrl({ origin: 'https://h.test/"><script>', clientId: 'x' }), null);
|
|
339
|
+
assert.equal(hubDeployUrl(null), null);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
const SHELL = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/shell.js'), 'utf8');
|
|
343
|
+
|
|
344
|
+
// The real shell.js in a DOM stub (the tests/core_update_banner.mjs harness), with the
|
|
345
|
+
// /core-update answer carrying a deploy_url and elements that remember their attributes.
|
|
346
|
+
function boot({ provisioning, deployUrl }) {
|
|
347
|
+
const els = new Map();
|
|
348
|
+
const inserted = [];
|
|
349
|
+
const makeEl = () => ({
|
|
350
|
+
dataset: {}, style: {}, textContent: '', hidden: false, _html: '', _attrs: {},
|
|
351
|
+
classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } },
|
|
352
|
+
setAttribute(k, v) { this._attrs[k] = String(v); }, getAttribute(k) { return k in this._attrs ? this._attrs[k] : null; }, removeAttribute(k) { delete this._attrs[k]; },
|
|
353
|
+
addEventListener() {},
|
|
354
|
+
querySelector() { return { addEventListener() {} }; },
|
|
355
|
+
querySelectorAll() { return []; },
|
|
356
|
+
appendChild() {}, focus() {}, click() {}, scrollIntoView() {}, remove() {},
|
|
357
|
+
insertAdjacentElement(_pos, el) { inserted.push(el); },
|
|
358
|
+
set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
|
|
359
|
+
});
|
|
360
|
+
// The rail's Deploy item as the rendered markup makes it: data-gate="archon"
|
|
361
|
+
// data-module="provisioning" (shell.js renders both attributes onto the <a>).
|
|
362
|
+
const deploy = makeEl();
|
|
363
|
+
deploy.dataset.gate = 'archon';
|
|
364
|
+
deploy.dataset.module = 'provisioning';
|
|
365
|
+
els.set('nav-deploy', deploy);
|
|
366
|
+
const documentObj = {
|
|
367
|
+
body: { dataset: { page: 'settings' } },
|
|
368
|
+
documentElement: { dataset: {}, style: {}, classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } } },
|
|
369
|
+
getElementById(id) { if (!els.has(id)) els.set(id, makeEl()); return els.get(id); },
|
|
370
|
+
querySelectorAll() { return []; },
|
|
371
|
+
createElement() { return makeEl(); },
|
|
372
|
+
addEventListener() {}, dispatchEvent() {},
|
|
373
|
+
get cookie() { return ''; }, set cookie(_v) {},
|
|
374
|
+
};
|
|
375
|
+
const location = { hostname: 'h', origin: 'https://h', pathname: '/settings', hash: '', assign() {}, replace() {} };
|
|
376
|
+
const update = { running: '1.19.871', latest: '1.19.1033', behind: true, reading: 'ok' };
|
|
377
|
+
const windowObj = {
|
|
378
|
+
__BRANDING__: {}, __MODULES__: { enabled: { provisioning } },
|
|
379
|
+
BongosClient: {
|
|
380
|
+
createClient: () => ({
|
|
381
|
+
request: (_method, p) => (p === '/api/bongos/core-update'
|
|
382
|
+
? Promise.resolve({ status: 200, data: { ok: true, update, deploy_url: deployUrl } })
|
|
383
|
+
: new Promise(() => {})),
|
|
384
|
+
}),
|
|
385
|
+
},
|
|
386
|
+
matchMedia: () => ({ matches: false, addEventListener() {} }),
|
|
387
|
+
localStorage: { getItem: () => null, setItem() {} },
|
|
388
|
+
requestAnimationFrame() {}, scrollTo() {}, location, addEventListener() {},
|
|
389
|
+
};
|
|
390
|
+
const sandbox = { window: windowObj, document: documentObj, location, console, setTimeout, clearTimeout, queueMicrotask,
|
|
391
|
+
CustomEvent: class { constructor(ty, i) { this.type = ty; Object.assign(this, i); } } };
|
|
392
|
+
sandbox.globalThis = sandbox;
|
|
393
|
+
vm.createContext(sandbox);
|
|
394
|
+
vm.runInContext(SHELL, sandbox, { filename: 'shell.js' });
|
|
395
|
+
windowObj.OTBShell.applyAccess({ authed: true, rank: 'archon', permissions: { 'core.pin.move': true } });
|
|
396
|
+
return {
|
|
397
|
+
banner: () => inserted.find((e) => String(e._html).includes('is available')),
|
|
398
|
+
deployItem: () => els.get('nav-deploy'),
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
const settle = () => new Promise((r) => setTimeout(r, 0));
|
|
402
|
+
const HUB = 'https://cloudbongos.com/deploy?project=hoodify-finance';
|
|
403
|
+
|
|
404
|
+
await t('on a hosted project, the banner\'s Upgrade goes to the hub door for THIS project', async () => {
|
|
405
|
+
const b = boot({ provisioning: false, deployUrl: HUB });
|
|
406
|
+
await settle();
|
|
407
|
+
assert.ok(b.banner(), 'the banner renders');
|
|
408
|
+
assert.ok(b.banner().innerHTML.includes(`href="${HUB}"`), 'Upgrade links to the hub');
|
|
409
|
+
assert.doesNotMatch(b.banner().innerHTML, /href="\/deploy"/, 'never the dead local page');
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
await t('on a hosted project, the rail\'s Deploy item is re-pointed at the hub', async () => {
|
|
413
|
+
const b = boot({ provisioning: false, deployUrl: HUB });
|
|
414
|
+
await settle();
|
|
415
|
+
const item = b.deployItem();
|
|
416
|
+
assert.equal(item.getAttribute('href'), HUB);
|
|
417
|
+
assert.equal('module' in item.dataset, false, 'its module gate is lifted so the access loop stops hiding it');
|
|
418
|
+
assert.equal(item.hidden, false, 'an Archon on their own project sees it');
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
await t('a hosted project with no hub, or a hub address that is not plain http(s), offers no link', async () => {
|
|
422
|
+
for (const url of [null, 'javascript:alert(1)', 'https://x.test/"><img src=x onerror=alert(1)>', 42]) {
|
|
423
|
+
const b = boot({ provisioning: false, deployUrl: url });
|
|
424
|
+
await settle();
|
|
425
|
+
assert.ok(b.banner(), 'the news is still told');
|
|
426
|
+
assert.doesNotMatch(b.banner().innerHTML, /core-update-notice__action/, `no action for ${JSON.stringify(url)}`);
|
|
427
|
+
const item = b.deployItem();
|
|
428
|
+
assert.equal(item.getAttribute('href'), null, `the rail is not re-pointed for ${JSON.stringify(url)}`);
|
|
429
|
+
assert.equal(item.dataset.module, 'provisioning', 'and it keeps its module gate, so a hosted project still hides it');
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
await t('on the platform, the local door wins: the hub address is ignored', async () => {
|
|
434
|
+
const b = boot({ provisioning: true, deployUrl: HUB });
|
|
435
|
+
await settle();
|
|
436
|
+
assert.match(b.banner().innerHTML, /href="\/deploy"/);
|
|
437
|
+
assert.ok(!b.banner().innerHTML.includes(HUB));
|
|
438
|
+
const item = b.deployItem();
|
|
439
|
+
assert.notEqual(item.getAttribute('href'), HUB, 'the rail keeps the local /deploy');
|
|
440
|
+
assert.equal(item.dataset.module, 'provisioning', 'and its module gate, which the platform satisfies');
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
444
|
+
process.exit(failed === 0 ? 0 : 1);
|
|
@@ -87,7 +87,7 @@ test('the hall-preview fixture carries every atom /me resolves', () => {
|
|
|
87
87
|
// the file and left the query alone, so a returning browser kept the cached copy —
|
|
88
88
|
// a fix that reaches nobody looks identical to a fix that does not work.
|
|
89
89
|
|
|
90
|
-
const SHELL_SHA = '
|
|
90
|
+
const SHELL_SHA = '983282a83fd5aa71';
|
|
91
91
|
|
|
92
92
|
test('shell.js has not changed without its cache-bust being bumped', () => {
|
|
93
93
|
const actual = crypto.createHash('sha256').update(SHELL).digest('hex').slice(0, 16);
|
|
@@ -47,20 +47,22 @@ async function ta(name, fn) {
|
|
|
47
47
|
// A stateful fake of provisioning_intents that ENFORCES ux_provisioning_intents_open
|
|
48
48
|
// (at most ONE open pending|running intent per instance, action-blind) — so the exact
|
|
49
49
|
// collision the bug rode on is reproduced, not approximated. Dispatches on SQL shape.
|
|
50
|
-
function fakeIntentDb(seed = []) {
|
|
50
|
+
function fakeIntentDb(seed = [], { shape = 'co-tenant' } = {}) {
|
|
51
51
|
let nextId = 1;
|
|
52
|
+
let updates = 0;
|
|
52
53
|
const rows = seed.map((r) => ({ id: nextId++, requested_by: null, last_error: null, resolved_at: null, ...r }));
|
|
53
54
|
const open = (instanceId) => rows.filter((r) =>
|
|
54
55
|
String(r.instance_id) === String(instanceId) && (r.state === 'pending' || r.state === 'running'));
|
|
55
56
|
return {
|
|
56
57
|
rows,
|
|
58
|
+
get updates() { return updates; },
|
|
57
59
|
query: async (sql, params) => {
|
|
58
60
|
// The self-row guard's shape lookup (task 1003159, ADR 0293 D2): every enqueue
|
|
59
61
|
// now asks "is this the control plane?" before it writes. These cases are all
|
|
60
62
|
// ORDINARY instances, so the honest answer is a tenant shape — answering
|
|
61
63
|
// 'control-plane' here would refuse every teardown in this file.
|
|
62
64
|
if (/SELECT hosting_shape FROM provisioning_instances/.test(sql)) {
|
|
63
|
-
return { rows: [{ hosting_shape:
|
|
65
|
+
return { rows: [{ hosting_shape: shape }] };
|
|
64
66
|
}
|
|
65
67
|
if (/INSERT INTO provisioning_intents/.test(sql)) {
|
|
66
68
|
const [instance_id, action, requested_by] = params;
|
|
@@ -80,6 +82,12 @@ function fakeIntentDb(seed = []) {
|
|
|
80
82
|
// push, task 1003238 to the restart; an open teardown is left alone for the
|
|
81
83
|
// idempotent path).
|
|
82
84
|
assert.match(sql, /action IN \('provision', 'settings-apply', 'restart', 'repo-private'\) AND state = 'pending'/);
|
|
85
|
+
updates++;
|
|
86
|
+
// The supersede must skip the platform's own row IN ITS OWN SQL (task 1004286): the
|
|
87
|
+
// guard refuses that teardown only afterwards, in enqueueIntent. Modelled, not assumed.
|
|
88
|
+
if (shape === 'control-plane' && /NOT EXISTS \(SELECT 1 FROM provisioning_instances WHERE id = \$1 AND hosting_shape = 'control-plane'\)/.test(sql)) {
|
|
89
|
+
return { rows: [] };
|
|
90
|
+
}
|
|
83
91
|
const hit = rows.filter((r) =>
|
|
84
92
|
String(r.instance_id) === String(params[0]) && SUPERSEDABLE.has(r.action) && r.state === 'pending');
|
|
85
93
|
for (const r of hit) { r.state = 'error'; r.last_error = 'superseded: teardown requested'; }
|
|
@@ -135,6 +143,24 @@ await ta('a clean queue just enqueues (no supersede)', async () => {
|
|
|
135
143
|
assert.equal(r.intent.requested_by, 'api:archon:1');
|
|
136
144
|
});
|
|
137
145
|
|
|
146
|
+
// task 1004286 — the supersede runs BEFORE the control-plane guard (which lives in
|
|
147
|
+
// enqueueIntent, the one place every intent passes), and the two share no transaction. So a teardown pressed on
|
|
148
|
+
// the platform's own row was correctly refused — after it had already cancelled a
|
|
149
|
+
// pending restart. 'restart' is the one action both allowed on the control plane and
|
|
150
|
+
// superseded by a teardown, so that is the whole blast radius. The owner was told nothing
|
|
151
|
+
// happened while the restart they asked for no longer ran. The supersede now skips that row in
|
|
152
|
+
// its own statement, so the guard still runs once, in one place.
|
|
153
|
+
await ta('a REFUSED teardown of the control-plane row leaves a pending restart pending', async () => {
|
|
154
|
+
const db = fakeIntentDb([{ instance_id: 1, action: 'restart', state: 'pending' }], { shape: 'control-plane' });
|
|
155
|
+
await assert.rejects(provisioning.enqueueTeardown(db, 1, 'api:archon:1'),
|
|
156
|
+
(e) => e.code === 'control_plane_protected', 'the teardown is refused, as before');
|
|
157
|
+
const restart = db.rows.find((x) => x.action === 'restart');
|
|
158
|
+
assert.equal(restart.state, 'pending', 'the refused teardown cancelled nothing');
|
|
159
|
+
assert.equal(restart.last_error, null);
|
|
160
|
+
assert.equal(db.rows.filter((x) => x.action === 'teardown').length, 0, 'and queued nothing');
|
|
161
|
+
assert.equal(db.updates, 1, 'one supersede statement ran, and it matched nothing on the control-plane row');
|
|
162
|
+
});
|
|
163
|
+
|
|
138
164
|
t('PROVISION_ENQUEUE_FROM: an active or tearing-down instance never re-enqueues a provision intent', () => {
|
|
139
165
|
assert.equal(provisioning.PROVISION_ENQUEUE_FROM.has('active'), false, 'active — the enabling misstep');
|
|
140
166
|
assert.equal(provisioning.PROVISION_ENQUEUE_FROM.has('tearing_down'), false, 'tearing_down — mid-reclaim');
|
|
@@ -174,11 +174,19 @@ await t('the OTHER page gates are untouched — their seam is never consulted at
|
|
|
174
174
|
assert.ok(vote, 'board.vote.cast floors at xenos on purpose (ADR 0266) — admitted without the seam');
|
|
175
175
|
});
|
|
176
176
|
|
|
177
|
-
await t('only
|
|
177
|
+
await t('only the gates WITH A PROOF declare a second door (a widening must be opted into, one gate at a time)', () => {
|
|
178
178
|
const src = require('node:fs').readFileSync(new URL('../src/bongos/auth.js', import.meta.url), 'utf8');
|
|
179
|
-
|
|
180
|
-
|
|
179
|
+
// Keyed by GATE, not just the door's name, so a third gate borrowing 'isProjectOwner'
|
|
180
|
+
// fails here instead of hiding behind an unchanged list of names. Each entry names the
|
|
181
|
+
// file that proves its widening:
|
|
182
|
+
// requireProjectCuratePage — this file (ADR 0210 D3);
|
|
183
|
+
// requireCorePinPage — tests/core_upgrade_owner_door.mjs (ADR 0339, task 1004174).
|
|
184
|
+
const declarations = [...src.matchAll(/(require\w+Page):\s*Object\.freeze\(\{[^}]*alsoAdmits:\s*'([^']+)'/g)]
|
|
185
|
+
.map((m) => `${m[1]}:${m[2]}`);
|
|
186
|
+
assert.deepEqual(declarations, ['requireProjectCuratePage:isProjectOwner', 'requireCorePinPage:isProjectOwner'],
|
|
181
187
|
'a second alsoAdmits appearing without its own proof is how a page gate quietly widens');
|
|
188
|
+
const plain = [...src.matchAll(/alsoAdmits:\s*'([^']+)'/g)].length;
|
|
189
|
+
assert.equal(plain, declarations.length, 'every alsoAdmits sits on a named page gate this test can see');
|
|
182
190
|
});
|
|
183
191
|
|
|
184
192
|
seams._reset();
|