@bongos/core 1.19.536 → 1.19.538
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 +37 -22
- package/docs/adr/0248-a-hub-project-s-owner-is-the-provisioning-row-carried-on-a-port.md +144 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +2 -2
- package/docs/api-reference.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/modules/platform-identity/invite-authz.js +117 -0
- package/modules/platform-identity/routes/projects.js +49 -22
- package/modules/provisioning/module.json +1 -1
- package/modules/provisioning/provisioning.js +71 -4
- package/modules/provisioning/routes/provisioning.js +21 -1
- package/modules/provisioning/tests/provisioning.mjs +29 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/project_catalog_routes.mjs +22 -0
- package/tests/project_invite_ownership.mjs +366 -0
- package/tests/provisioning_module_selection.mjs +114 -5
|
@@ -492,6 +492,26 @@ function normalizeProjectDetail(input) {
|
|
|
492
492
|
return out;
|
|
493
493
|
}
|
|
494
494
|
|
|
495
|
+
// A detail PATCH split into what it SETS and what it CLEARS (task 1003577). An
|
|
496
|
+
// explicit `null` for a known question means UN-ANSWER it — the JSON-merge-patch
|
|
497
|
+
// convention (RFC 7396) this repo already uses for `description` on the same route
|
|
498
|
+
// and for every builder-settings knob. It exists because a merging write can add an
|
|
499
|
+
// answer but never take one back, which made "never answered" — the third value
|
|
500
|
+
// ADR 0240 and `effectiveModules` both read — a state a project could leave once and
|
|
501
|
+
// never return to. Everything else is unchanged: an off-vocabulary value is still
|
|
502
|
+
// dropped rather than treated as a clear, and an empty patch still changes nothing.
|
|
503
|
+
// PURE.
|
|
504
|
+
function projectDetailPatch(input) {
|
|
505
|
+
const given = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
|
|
506
|
+
const set = {};
|
|
507
|
+
const clear = [];
|
|
508
|
+
for (const [key, vocab] of Object.entries(PROJECT_DETAIL_VOCAB)) {
|
|
509
|
+
if (given[key] === null) clear.push(key);
|
|
510
|
+
else if (vocab.includes(given[key])) set[key] = given[key];
|
|
511
|
+
}
|
|
512
|
+
return { set, clear };
|
|
513
|
+
}
|
|
514
|
+
|
|
495
515
|
// What a row's detail answers READ as — the stored object filtered through the
|
|
496
516
|
// same vocabulary, so a value that was valid when written and has since left the
|
|
497
517
|
// vocabulary never leaks out of the read surface. Mirrors effectiveSettings, minus
|
|
@@ -661,6 +681,13 @@ async function updateInstanceSettings(db, id, patch) {
|
|
|
661
681
|
// is the honest consequence of deleting half the identity gate, not a bug.
|
|
662
682
|
// Undefined leaves the column alone.
|
|
663
683
|
//
|
|
684
|
+
// A merge alone could only ever ADD an answer, so `detail` also takes a PER-KEY null
|
|
685
|
+
// as "un-answer this question" (task 1003577, RFC 7396's rule and `description`'s own
|
|
686
|
+
// beside it): the key is DROPPED from the jsonb rather than stored as null, so the row
|
|
687
|
+
// reads back exactly as one that was never asked — which is the state ADR 0240 and
|
|
688
|
+
// `effectiveModules` treat as a distinct third value. `detail: {}` still changes
|
|
689
|
+
// nothing, since a form that sends no answers is not asking for any to be erased.
|
|
690
|
+
//
|
|
664
691
|
// `modules` is the SAME way back for the wizard's other optional step (BV1.R23,
|
|
665
692
|
// task 1002342, ADR 0245). It REPLACES like `description` and unlike `detail`: a
|
|
666
693
|
// module selection is one whole list, so merging would make removing a module
|
|
@@ -675,8 +702,11 @@ async function updateInstanceDetail(db, id, { description, detail, modules } = {
|
|
|
675
702
|
sets.push(`description = $${params.length}`);
|
|
676
703
|
}
|
|
677
704
|
if (detail !== undefined) {
|
|
678
|
-
|
|
679
|
-
|
|
705
|
+
const { set, clear } = projectDetailPatch(detail);
|
|
706
|
+
params.push(JSON.stringify(set));
|
|
707
|
+
const merged = params.length;
|
|
708
|
+
params.push(clear);
|
|
709
|
+
sets.push(`detail = (detail || $${merged}::jsonb) - $${params.length}::text[]`);
|
|
680
710
|
}
|
|
681
711
|
if (modules !== undefined) {
|
|
682
712
|
const picked = normalizeModuleSelection(modules);
|
|
@@ -1150,6 +1180,43 @@ async function instanceStateForSlug(db, slug) {
|
|
|
1150
1180
|
};
|
|
1151
1181
|
}
|
|
1152
1182
|
|
|
1183
|
+
// The `provisioning.projectOwnedBy` port (task 1003580): is THIS builder the owner of the
|
|
1184
|
+
// control-plane project a hub catalog row names? Lets a hub surface own-scope a project it
|
|
1185
|
+
// did not create — without importing this module (ADR 0083) and without promoting the hub's
|
|
1186
|
+
// own `owner_github_id` columns, which are attribution and never authority (ADR 0238). The
|
|
1187
|
+
// fact returned is the one `instanceAccessDenied` already enforces on every owner-facing
|
|
1188
|
+
// route here: `provisioning_instances.owner_builder_id`.
|
|
1189
|
+
//
|
|
1190
|
+
// BOTH HALVES OF THE PROJECTION MUST AGREE, which is why this takes a slug AND a host rather
|
|
1191
|
+
// than a slug alone. ADR 0246 projects an instance onto the map as `client_id = slug` at
|
|
1192
|
+
// `origin = https://<domain>`, so a caller speaks for a catalog row only when they own the
|
|
1193
|
+
// instance carrying BOTH. Matching the slug by itself would let someone who registers an
|
|
1194
|
+
// instance NAMED after a client the control plane never created — a hand-seeded row, whose
|
|
1195
|
+
// origin the catalog's own claim rule already refuses to overwrite — speak for that project.
|
|
1196
|
+
//
|
|
1197
|
+
// A BOOLEAN, not the owner's id: the comparison is authority and belongs on the plane that
|
|
1198
|
+
// holds the row, so no consumer ever handles an ownership fact it could mis-compare. Read-only
|
|
1199
|
+
// and fail-closed on an unidentifiable key or builder id.
|
|
1200
|
+
//
|
|
1201
|
+
// AN ABSENT HOST IS STILL ASKED, and matches nothing — `domain` is never the empty string (it
|
|
1202
|
+
// is a hostname or NULL, and `lower(NULL) = ''` is NULL, i.e. not true). Short-circuiting it
|
|
1203
|
+
// would make the caller's work depend on whether the catalog row exists, and a refusal that is
|
|
1204
|
+
// byte-identical but measurably FASTER is still an existence oracle. The query is the cheap
|
|
1205
|
+
// half of keeping every caller's path the same shape.
|
|
1206
|
+
async function projectOwnedBy(db, { slug, host, builderId } = {}) {
|
|
1207
|
+
const wantSlug = typeof slug === 'string' ? slug : '';
|
|
1208
|
+
const wantHost = typeof host === 'string' ? host.toLowerCase() : '';
|
|
1209
|
+
const owner = Number(builderId);
|
|
1210
|
+
if (!wantSlug || !Number.isInteger(owner) || owner <= 0) return false;
|
|
1211
|
+
const { rows } = await db.query(
|
|
1212
|
+
`SELECT 1 FROM provisioning_instances
|
|
1213
|
+
WHERE slug = $1 AND lower(domain) = $2 AND owner_builder_id = $3
|
|
1214
|
+
LIMIT 1`,
|
|
1215
|
+
[wantSlug, wantHost, owner],
|
|
1216
|
+
);
|
|
1217
|
+
return rows.length > 0;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1153
1220
|
// The `provisioning.projectPlanets` port (task 1003347): the owners' planet
|
|
1154
1221
|
// choices for a set of HOSTS (the catalog's origins, host-matched — the join the
|
|
1155
1222
|
// hub's sky makes), so platform-identity's public featured feed can carry them
|
|
@@ -1211,7 +1278,7 @@ module.exports = {
|
|
|
1211
1278
|
// pure
|
|
1212
1279
|
isValidSlug, costEstimateUsd, publicInstance, normalizeHostname,
|
|
1213
1280
|
defaultDomainForSlug, isReservedHostname, effectiveSettings, planetChoice,
|
|
1214
|
-
normalizeDescription, normalizeProjectDetail, projectDetail,
|
|
1281
|
+
normalizeDescription, normalizeProjectDetail, projectDetailPatch, projectDetail,
|
|
1215
1282
|
// pure — GitHub App Manifest flow (task 2080)
|
|
1216
1283
|
githubAppName, buildGithubAppManifest, manifestPostUrl, publicGithubApp,
|
|
1217
1284
|
// data access
|
|
@@ -1222,7 +1289,7 @@ module.exports = {
|
|
|
1222
1289
|
createInstance, setInstanceStatus, updateInstanceDomain, updateInstanceSettings, updateInstanceDetail,
|
|
1223
1290
|
createInstance, setInstanceStatus, updateInstanceDomain, clearReleasedDomains, updateInstanceSettings,
|
|
1224
1291
|
enqueueIntent, enqueueTeardown, enqueueSettingsApply, enqueueRestart, getOpenIntent, claimNextIntent, resolveIntent,
|
|
1225
|
-
recordEvent, instanceStateForSlug, planetChoicesForHosts, costLedger,
|
|
1292
|
+
recordEvent, instanceStateForSlug, planetChoicesForHosts, projectOwnedBy, costLedger,
|
|
1226
1293
|
// data access — GitHub App Manifest attempts (task 2080)
|
|
1227
1294
|
createOAuthManifest, getOAuthManifestByState, attachManifestCode,
|
|
1228
1295
|
getLatestOAuthManifestForInstance, claimNextOAuthManifest, resolveOAuthManifest,
|
|
@@ -113,8 +113,13 @@ function badProjectDetail(detail) {
|
|
|
113
113
|
if (!Object.prototype.hasOwnProperty.call(vocab, key)) {
|
|
114
114
|
return { code: 'bad_detail', message: `Unknown detail answer "${key}" — the interview asks: ${Object.keys(vocab).join(', ')}.` };
|
|
115
115
|
}
|
|
116
|
+
// An explicit null is the UN-ANSWER, not an off-vocabulary value (task 1003577):
|
|
117
|
+
// the merge-patch way back to "never answered". Only null — an empty string or any
|
|
118
|
+
// other sentinel stays a typo, so a caller trying to SET one is never silently
|
|
119
|
+
// read as having erased their answer.
|
|
120
|
+
if (value === null) continue;
|
|
116
121
|
if (!vocab[key].includes(value)) {
|
|
117
|
-
return { code: 'bad_detail', message: `"${key}" must be one of: ${vocab[key].join(', ')}.` };
|
|
122
|
+
return { code: 'bad_detail', message: `"${key}" must be one of: ${vocab[key].join(', ')} — or null to un-answer it.` };
|
|
118
123
|
}
|
|
119
124
|
}
|
|
120
125
|
return null;
|
|
@@ -154,10 +159,16 @@ function badModuleSelection(modules) {
|
|
|
154
159
|
// • PROVIDES port `provisioning.projectPlanets` (hosts[]) → { host: { planet_template,
|
|
155
160
|
// planet_accent } } — the owners' planet choices (task 1003347), so the hub's
|
|
156
161
|
// public featured feed can carry them without importing this module.
|
|
162
|
+
// • PROVIDES port `provisioning.projectOwnedBy` ({ slug, host, builderId }) → boolean —
|
|
163
|
+
// the OWNERSHIP fact behind a hub catalog row (task 1003580), so a hub surface can
|
|
164
|
+
// own-scope a project without importing this module and without treating the hub's
|
|
165
|
+
// `owner_github_id` columns as authority (ADR 0238). See projectOwnedBy for why the
|
|
166
|
+
// question names both halves of the ADR 0246 projection.
|
|
157
167
|
function registerProvisioningSeams() {
|
|
158
168
|
if (api.hasProvider('provisioning.instanceState')) return;
|
|
159
169
|
api.registerProvider('provisioning.instanceState', (slug) => provisioning.instanceStateForSlug(pool, slug));
|
|
160
170
|
api.registerProvider('provisioning.projectPlanets', (hosts) => provisioning.planetChoicesForHosts(pool, hosts));
|
|
171
|
+
api.registerProvider('provisioning.projectOwnedBy', (target) => provisioning.projectOwnedBy(pool, target));
|
|
161
172
|
}
|
|
162
173
|
|
|
163
174
|
// Is this project on the public map, and if not, what is it still missing?
|
|
@@ -682,6 +693,15 @@ module.exports = function provisioningRoutes() {
|
|
|
682
693
|
// column alone, so the "never asked" NULL (ADR 0240 §2) can never be written by
|
|
683
694
|
// a later edit. The always-on core stays unremovable by construction:
|
|
684
695
|
// badModuleSelection refuses its keys and `effectiveModules` re-adds it on read.
|
|
696
|
+
//
|
|
697
|
+
// AN ANSWER CAN BE TAKEN BACK (task 1003577). `detail` merges, so before this the
|
|
698
|
+
// first `team_shape` an owner sent was permanent — and "never answered" is a state
|
|
699
|
+
// `effectiveModules` reads (ADR 0240), not merely the absence of one. Sending
|
|
700
|
+
// `detail: { <key>: null }` UN-ANSWERS that question: the key is dropped from the
|
|
701
|
+
// jsonb, so the row reads back exactly as one that was never asked and the bundle
|
|
702
|
+
// the answer moved moves back. Only null does it — an empty string or any other
|
|
703
|
+
// value is still refused as off-vocabulary, and `detail: {}` still changes nothing,
|
|
704
|
+
// because a form that collected no answers is not asking for any to be erased.
|
|
685
705
|
router.patch('/provisioning/instances/:id/detail', auth.requireBuilder, async (req, res) => {
|
|
686
706
|
const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
|
|
687
707
|
if (!id) return;
|
|
@@ -323,12 +323,32 @@ t('normalizeProjectDetail: on-vocabulary keys only — an unknown key or value i
|
|
|
323
323
|
assert.deepEqual(P.normalizeProjectDetail({ team_shape: 'community' }), { team_shape: 'community' });
|
|
324
324
|
assert.deepEqual(P.normalizeProjectDetail({ team_shape: 'enormous' }), {}, 'an off-vocabulary answer is not stored');
|
|
325
325
|
assert.deepEqual(P.normalizeProjectDetail({ favourite_colour: 'blue' }), {}, 'an unknown question is not stored');
|
|
326
|
+
assert.deepEqual(P.normalizeProjectDetail({ team_shape: null }), {},
|
|
327
|
+
'the un-answer (task 1003577) is a PATCH verb — at creation there is nothing to clear, so it just stores nothing');
|
|
326
328
|
// skipping is the supported default, so every not-an-object shape reads as {}
|
|
327
329
|
for (const skipped of [undefined, null, 'solo', ['solo'], 7]) {
|
|
328
330
|
assert.deepEqual(P.normalizeProjectDetail(skipped), {}, 'a skipped interview stores {}');
|
|
329
331
|
}
|
|
330
332
|
});
|
|
331
333
|
|
|
334
|
+
t('projectDetailPatch: an explicit null CLEARS the answer; an off-vocabulary value is still dropped (task 1003577)', () => {
|
|
335
|
+
// The way back to "never answered" — the third value effectiveModules reads
|
|
336
|
+
// (ADR 0240) and that a merging write could never reach again once written.
|
|
337
|
+
assert.deepEqual(P.projectDetailPatch({ team_shape: 'community' }), { set: { team_shape: 'community' }, clear: [] });
|
|
338
|
+
assert.deepEqual(P.projectDetailPatch({ team_shape: null }), { set: {}, clear: ['team_shape'] },
|
|
339
|
+
'null is the un-answer, not an off-vocabulary value');
|
|
340
|
+
assert.deepEqual(P.projectDetailPatch({ team_shape: 'enormous' }), { set: {}, clear: [] },
|
|
341
|
+
'an off-vocabulary answer neither stores nor clears — normalizeProjectDetail\'s rule is unchanged');
|
|
342
|
+
assert.deepEqual(P.projectDetailPatch({ team_shape: '' }), { set: {}, clear: [] },
|
|
343
|
+
'only null clears; an empty string is just another value the vocabulary refuses');
|
|
344
|
+
assert.deepEqual(P.projectDetailPatch({ favourite_colour: null }), { set: {}, clear: [] },
|
|
345
|
+
'an unknown question has no answer to clear');
|
|
346
|
+
// `{}` stays a no-op, which is RFC 7396's own rule and what a skipped step sends
|
|
347
|
+
for (const nothing of [{}, undefined, null, 'solo', ['solo'], 7]) {
|
|
348
|
+
assert.deepEqual(P.projectDetailPatch(nothing), { set: {}, clear: [] }, 'an empty patch changes nothing');
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
|
|
332
352
|
t('projectDetail: a stored value that has since LEFT the vocabulary never leaks out of the read surface', () => {
|
|
333
353
|
assert.deepEqual(P.projectDetail({ detail: { team_shape: 'solo' } }), { team_shape: 'solo' });
|
|
334
354
|
assert.deepEqual(P.projectDetail({ detail: { team_shape: 'guild' } }), {});
|
|
@@ -408,9 +428,16 @@ await ta('updateInstanceDetail: detail MERGES, description REPLACES, and neither
|
|
|
408
428
|
const db = { query: async (q, p) => { sql = q; params = p; return { rows: [{ id: 18 }] }; } };
|
|
409
429
|
|
|
410
430
|
await P.updateInstanceDetail(db, 18, { detail: { team_shape: 'small-team' } });
|
|
411
|
-
assert.ok(sql.includes('detail = detail || $2::jsonb'), 'one answer does not erase the others');
|
|
431
|
+
assert.ok(sql.includes('detail = (detail || $2::jsonb) - $3::text[]'), 'one answer does not erase the others');
|
|
412
432
|
assert.ok(!sql.includes('description ='), 'an unsent description is left alone, never nulled');
|
|
413
|
-
assert.deepEqual(params, [18, JSON.stringify({ team_shape: 'small-team' })]
|
|
433
|
+
assert.deepEqual(params, [18, JSON.stringify({ team_shape: 'small-team' }), []],
|
|
434
|
+
'nothing to clear, so the key-drop list is empty');
|
|
435
|
+
|
|
436
|
+
// The way back (task 1003577): an explicit null UN-ANSWERS the question, which a
|
|
437
|
+
// merging write alone could never do — `{}` merges to nothing and an off-vocabulary
|
|
438
|
+
// sentinel is dropped, so before this the first answer was permanent.
|
|
439
|
+
await P.updateInstanceDetail(db, 18, { detail: { team_shape: null } });
|
|
440
|
+
assert.deepEqual(params, [18, '{}', ['team_shape']], 'the key is DROPPED from the jsonb, not stored as null');
|
|
414
441
|
|
|
415
442
|
await P.updateInstanceDetail(db, 18, { description: 'Filled in later.' });
|
|
416
443
|
assert.ok(sql.includes('description = $2'), 'the way back for a skipped step');
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.538",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.538",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.538",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/src/module-api.js
CHANGED
|
@@ -49,7 +49,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
49
49
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
50
50
|
// the entry to that file. Look for a version's history there, not here.
|
|
51
51
|
// ---------------------------------------------------------------------------
|
|
52
|
-
const CORE_VERSION = '1.19.
|
|
52
|
+
const CORE_VERSION = '1.19.538'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
53
53
|
|
|
54
54
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
55
55
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -211,3 +211,25 @@ test('a catalog hiccup can never turn a saved change into a failure', async () =
|
|
|
211
211
|
catalogBridge.publishInstanceToCatalog = saved;
|
|
212
212
|
}
|
|
213
213
|
});
|
|
214
|
+
|
|
215
|
+
test('mounting the routes registers the `provisioning.projectOwnedBy` port (task 1003580)', async () => {
|
|
216
|
+
// The OTHER direction of the same seam: the catalog write travels provisioning → hub, and
|
|
217
|
+
// this ownership read travels hub → provisioning, so the hub's invite door can own-scope a
|
|
218
|
+
// project without importing this module (ADR 0083) and without treating the hub's own
|
|
219
|
+
// `owner_github_id` columns as authority (ADR 0238). Registered here at mount, so a hub
|
|
220
|
+
// running with provisioning enabled always has it.
|
|
221
|
+
assert.equal(api.hasProvider('provisioning.projectOwnedBy'), true);
|
|
222
|
+
const ownedBy = api.resolve('provisioning.projectOwnedBy');
|
|
223
|
+
assert.equal(typeof ownedBy, 'function');
|
|
224
|
+
// It reaches the module's own read, bound to the module's pool — the caller never supplies
|
|
225
|
+
// a database handle, so no consumer can point this question at another connection.
|
|
226
|
+
const seen = [];
|
|
227
|
+
const saved = provisioning.projectOwnedBy;
|
|
228
|
+
provisioning.projectOwnedBy = async (_db, target) => { seen.push(target); return true; };
|
|
229
|
+
try {
|
|
230
|
+
assert.equal(await ownedBy({ slug: 'spike-harbor', host: 'spike-harbor.cloudbongos.test', builderId: 7 }), true);
|
|
231
|
+
assert.deepEqual(seen, [{ slug: 'spike-harbor', host: 'spike-harbor.cloudbongos.test', builderId: 7 }]);
|
|
232
|
+
} finally {
|
|
233
|
+
provisioning.projectOwnedBy = saved;
|
|
234
|
+
}
|
|
235
|
+
});
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
// tests/project_invite_ownership.mjs — A PROJECT'S OWNER MAY INVITE INTO THEIR OWN PROJECT
|
|
2
|
+
// (task 1003580, GAP 2; the ADR 0210 owner-or-rank shape applied to POST /projects/invite).
|
|
3
|
+
//
|
|
4
|
+
// Task 1003576 / ADR 0246 gave every control-plane project a catalog row, so the invite route
|
|
5
|
+
// stopped 404ing for a wizard-made project. What was left is WHO may call it: the route is
|
|
6
|
+
// gated `project.curate`, floor metic, and the wizard's audience is any signed-in GitHub user
|
|
7
|
+
// — typically a `xenos` who gets 403 on their OWN project. This file is the proof of the arm
|
|
8
|
+
// that fixes it, and of the four things the arm must NOT do.
|
|
9
|
+
//
|
|
10
|
+
// THE OWNERSHIP PROOF IS THE PROVISIONING ROW. `provisioning_instances.owner_builder_id` is
|
|
11
|
+
// the authority the manage view already trusts; the hub's `owner_github_id` columns are
|
|
12
|
+
// attribution, never authority (ADR 0238). platform-identity may not import provisioning
|
|
13
|
+
// (ADR 0083), so the fact travels on the `provisioning.projectOwnedBy` port — resolved
|
|
14
|
+
// OPTIONALLY, because the hub must boot with no provisioning module at all.
|
|
15
|
+
//
|
|
16
|
+
// DB-free: the real router driven over loopback, data access stubbed on the cached module
|
|
17
|
+
// objects and the port stubbed on the doorway (the catalog_only_client.mjs pattern).
|
|
18
|
+
//
|
|
19
|
+
// Run: node --test --test-reporter=tap tests/project_invite_ownership.mjs
|
|
20
|
+
|
|
21
|
+
import assert from 'node:assert/strict';
|
|
22
|
+
import { test, after, beforeEach } from 'node:test';
|
|
23
|
+
import { createRequire } from 'node:module';
|
|
24
|
+
import http from 'node:http';
|
|
25
|
+
|
|
26
|
+
const require = createRequire(import.meta.url);
|
|
27
|
+
const pi = require('../modules/platform-identity/platform-identity.js');
|
|
28
|
+
const api = require('../src/module-api.js');
|
|
29
|
+
|
|
30
|
+
// ── the world ────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
// A wizard-made standalone project: catalog-only client (no secret), owned by builder 7 whose
|
|
33
|
+
// provisioning row is slug `spike-harbor` at spike-harbor.cloudbongos.test.
|
|
34
|
+
const OWNED = Object.freeze({
|
|
35
|
+
client_id: 'spike-harbor', secret_hash: '', name: 'spike-harbor',
|
|
36
|
+
redirect_uris: [], origin: 'https://spike-harbor.cloudbongos.test',
|
|
37
|
+
owner_github_id: 9001, status: 'active',
|
|
38
|
+
});
|
|
39
|
+
// Someone ELSE's project, and a STEALTH one: the refusal a stranger gets must not tell them
|
|
40
|
+
// this row exists (ADR 0182 D2a — a stealth project is on the map anonymously or not at all).
|
|
41
|
+
const STEALTH = Object.freeze({
|
|
42
|
+
...OWNED, client_id: 'quiet-cove', name: 'quiet-cove', origin: 'https://quiet-cove.cloudbongos.test',
|
|
43
|
+
owner_github_id: 9002,
|
|
44
|
+
});
|
|
45
|
+
// A client the control plane never created — the hand-seeded catalog row shape (ADR 0246's
|
|
46
|
+
// "unclaimable" case). Its slug is squattable in provisioning, which is why the port is asked
|
|
47
|
+
// about the ORIGIN as well as the slug.
|
|
48
|
+
const HAND_SEEDED = Object.freeze({
|
|
49
|
+
...OWNED, client_id: 'legacy-port', name: 'legacy-port', origin: 'https://legacy-port.example',
|
|
50
|
+
secret_hash: 'a'.repeat(64), owner_github_id: 9003,
|
|
51
|
+
});
|
|
52
|
+
const CLIENTS = new Map([OWNED, STEALTH, HAND_SEEDED].map((c) => [c.client_id, c]));
|
|
53
|
+
|
|
54
|
+
// The provisioning rows the port answers from: (slug, host) → owner builder id.
|
|
55
|
+
const INSTANCES = [
|
|
56
|
+
{ slug: 'spike-harbor', host: 'spike-harbor.cloudbongos.test', owner: 7 },
|
|
57
|
+
{ slug: 'quiet-cove', host: 'quiet-cove.cloudbongos.test', owner: 9 },
|
|
58
|
+
// The SQUAT: builder 42 registered an instance named after the hand-seeded client, at their
|
|
59
|
+
// own address. Same slug, different origin — so it is not that project.
|
|
60
|
+
{ slug: 'legacy-port', host: 'legacy-port.squatter.test', owner: 42 },
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
const OWNER = Object.freeze({ id: 7, github_id: 9001, github_login: 'owner', rank: 'xenos', curate: false });
|
|
64
|
+
const STRANGER = Object.freeze({ id: 42, github_id: 4242, github_login: 'stranger', rank: 'xenos', curate: false });
|
|
65
|
+
const CURATOR = Object.freeze({ id: 3, github_id: 3003, github_login: 'curator', rank: 'metic', curate: true });
|
|
66
|
+
|
|
67
|
+
let caller = OWNER;
|
|
68
|
+
// 'ok' | 'throws' | 'absent' — the port's health, so the fail-closed paths are executed.
|
|
69
|
+
let portMode = 'ok';
|
|
70
|
+
let invites = [];
|
|
71
|
+
let portCalls = [];
|
|
72
|
+
let clientReads = [];
|
|
73
|
+
|
|
74
|
+
// ── the doorway, stubbed before the router is required ───────────────────────
|
|
75
|
+
|
|
76
|
+
// Destructured at route-module load, so these must be in place first.
|
|
77
|
+
api.requireBuilder = (req, _res, next) => { req.builder = caller; next(); };
|
|
78
|
+
// A faithful stand-in for auth.requirePermission: it admits a holder and otherwise sends the
|
|
79
|
+
// SAME 403 body every caller of this route has always received. The arm under test delegates
|
|
80
|
+
// to this untouched middleware, so "the refusal is unchanged" is true by construction — what
|
|
81
|
+
// these tests pin is that the delegation happens, and that it happens identically whether or
|
|
82
|
+
// not the project exists.
|
|
83
|
+
api.requirePermission = (...required) => (req, res, next) => {
|
|
84
|
+
if (req.builder && req.builder.curate) return next();
|
|
85
|
+
return res.fail('permission_forbidden', {
|
|
86
|
+
status: 403,
|
|
87
|
+
message: `missing permission${required.length === 1 ? '' : 's'}: ${required.join(', ')} (floor: metic)`,
|
|
88
|
+
details: { required, held: [], floors: { [required[0]]: 'metic' } },
|
|
89
|
+
});
|
|
90
|
+
};
|
|
91
|
+
// Silenced: the fallback path logs, and a pino line on stdout would land inside the TAP stream.
|
|
92
|
+
api.logger = () => ({ error: () => {}, warn: () => {}, info: () => {} });
|
|
93
|
+
|
|
94
|
+
const realResolveOptional = api.resolveOptional;
|
|
95
|
+
api.resolveOptional = (name) => {
|
|
96
|
+
if (name !== 'provisioning.projectOwnedBy') return realResolveOptional(name);
|
|
97
|
+
if (portMode === 'absent') return null;
|
|
98
|
+
return async ({ slug, host, builderId }) => {
|
|
99
|
+
portCalls.push({ slug, host, builderId });
|
|
100
|
+
if (portMode === 'throws') throw new Error('provisioning is down');
|
|
101
|
+
return INSTANCES.some((i) => i.slug === slug && i.host === host && String(i.owner) === String(builderId));
|
|
102
|
+
};
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
pi.getClient = async (id) => { clientReads.push(id); return CLIENTS.get(id) || null; };
|
|
106
|
+
pi.getAccountByLogin = async (login) => (login === 'ghost' ? null : { github_id: 5150, github_login: login });
|
|
107
|
+
pi.inviteToProject = async ({ clientId, githubId }) => {
|
|
108
|
+
invites.push({ clientId, githubId });
|
|
109
|
+
return { invited: true, clientId, githubId };
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const express = require('express');
|
|
113
|
+
const projectsRoutes = require('../modules/platform-identity/routes/projects.js');
|
|
114
|
+
|
|
115
|
+
const app = express();
|
|
116
|
+
app.use(express.json());
|
|
117
|
+
app.use((_req, res, next) => {
|
|
118
|
+
res.fail = (code, opts, details) => {
|
|
119
|
+
const status = typeof opts === 'number' ? opts : (opts && opts.status) || 400;
|
|
120
|
+
res.status(status).json({ error: { code, ...(typeof opts === 'object' ? opts : {}), ...(details || {}) } });
|
|
121
|
+
};
|
|
122
|
+
next();
|
|
123
|
+
});
|
|
124
|
+
app.use('/api/bongos', projectsRoutes());
|
|
125
|
+
|
|
126
|
+
const server = app.listen(0);
|
|
127
|
+
await new Promise((r) => server.once('listening', r));
|
|
128
|
+
const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
|
|
129
|
+
after(() => server.close());
|
|
130
|
+
|
|
131
|
+
function invite(body) {
|
|
132
|
+
return new Promise((resolve, reject) => {
|
|
133
|
+
const payload = JSON.stringify(body === undefined ? {} : body);
|
|
134
|
+
const req = http.request(`${base}/projects/invite`, {
|
|
135
|
+
method: 'POST',
|
|
136
|
+
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
|
|
137
|
+
}, (res) => {
|
|
138
|
+
let d = '';
|
|
139
|
+
res.on('data', (c) => { d += c; });
|
|
140
|
+
res.on('end', () => { let j = null; try { j = JSON.parse(d); } catch { /* non-JSON */ } resolve({ status: res.statusCode, body: j }); });
|
|
141
|
+
});
|
|
142
|
+
req.on('error', reject);
|
|
143
|
+
req.write(payload);
|
|
144
|
+
req.end();
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
beforeEach(() => { caller = OWNER; portMode = 'ok'; invites = []; portCalls = []; clientReads = []; });
|
|
149
|
+
|
|
150
|
+
// ── the arm ──────────────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
test('the OWNER of a wizard-made project invites into it, holding no permission at all', async () => {
|
|
153
|
+
// The whole point of the task: the wizard's audience is any signed-in GitHub user, and a
|
|
154
|
+
// `xenos` owner used to get 403 on their own project before the target was even resolved.
|
|
155
|
+
caller = OWNER;
|
|
156
|
+
const res = await invite({ client_id: 'spike-harbor', github_login: 'someone' });
|
|
157
|
+
assert.equal(res.status, 201);
|
|
158
|
+
assert.equal(res.body.invited, true);
|
|
159
|
+
assert.deepEqual(invites, [{ clientId: 'spike-harbor', githubId: 5150 }]);
|
|
160
|
+
// The ownership question was asked of the provisioning row, keyed on BOTH halves of the
|
|
161
|
+
// catalog projection (ADR 0246: client_id = slug, origin = https://domain).
|
|
162
|
+
assert.deepEqual(portCalls, [{ slug: 'spike-harbor', host: 'spike-harbor.cloudbongos.test', builderId: 7 }]);
|
|
163
|
+
// …and the row the gate resolved to ask it rides forward to the handler: equalising the
|
|
164
|
+
// lookup across callers must not turn into reading the same row twice.
|
|
165
|
+
assert.deepEqual(clientReads, ['spike-harbor'], 'the client row is read exactly once per request');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('a METIC curator still invites into a project they do not own — the old door is untouched', async () => {
|
|
169
|
+
caller = CURATOR;
|
|
170
|
+
const res = await invite({ client_id: 'spike-harbor', github_login: 'someone' });
|
|
171
|
+
assert.equal(res.status, 201);
|
|
172
|
+
assert.equal(res.body.invited, true);
|
|
173
|
+
assert.equal(invites.length, 1);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('a DIFFERENT builder is refused with the unchanged shape, and writes nothing', async () => {
|
|
177
|
+
caller = STRANGER;
|
|
178
|
+
const res = await invite({ client_id: 'spike-harbor', github_login: 'someone' });
|
|
179
|
+
assert.equal(res.status, 403);
|
|
180
|
+
assert.equal(res.body.error.code, 'permission_forbidden');
|
|
181
|
+
assert.deepEqual(res.body.error.details.required, ['project.curate']);
|
|
182
|
+
assert.deepEqual(invites, [], 'a refused invite writes no membership row');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// ── the refusal is not an existence oracle ───────────────────────────────────
|
|
186
|
+
|
|
187
|
+
test('a stranger cannot tell a STEALTH project from one that does not exist', async () => {
|
|
188
|
+
// ADR 0182 D2a: a stealth project is anonymous on the map. Turning this route's 403 into a
|
|
189
|
+
// 404-or-403 split would hand any signed-in stranger a membership oracle over every private
|
|
190
|
+
// project on the platform — the refusal must be byte-identical.
|
|
191
|
+
caller = STRANGER;
|
|
192
|
+
const stealth = await invite({ client_id: 'quiet-cove', github_login: 'someone' });
|
|
193
|
+
const nothing = await invite({ client_id: 'never-provisioned', github_login: 'someone' });
|
|
194
|
+
assert.equal(stealth.status, 403);
|
|
195
|
+
assert.deepEqual(stealth, nothing);
|
|
196
|
+
assert.deepEqual(invites, []);
|
|
197
|
+
// The LATENCY half of the same rule: both requests do the same work. A body-identical
|
|
198
|
+
// refusal that is measurably faster for a client_id nobody has taken is still an oracle, so
|
|
199
|
+
// the unknown client is asked about anyway — with the empty host, which matches nothing.
|
|
200
|
+
assert.deepEqual(clientReads, ['quiet-cove', 'never-provisioned']);
|
|
201
|
+
assert.deepEqual(portCalls, [
|
|
202
|
+
{ slug: 'quiet-cove', host: 'quiet-cove.cloudbongos.test', builderId: 42 },
|
|
203
|
+
{ slug: 'never-provisioned', host: '', builderId: 42 },
|
|
204
|
+
], 'the same two lookups on both paths — the refusal is identical in latency, not only in body');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('a stranger cannot tell a real project from a malformed request either', async () => {
|
|
208
|
+
// The gate runs BEFORE the body validator, exactly as requirePermission always did, so a
|
|
209
|
+
// caller who may not invite never learns whether their body was well-formed.
|
|
210
|
+
caller = STRANGER;
|
|
211
|
+
const good = await invite({ client_id: 'spike-harbor', github_login: 'someone' });
|
|
212
|
+
const empty = await invite({});
|
|
213
|
+
assert.equal(good.status, 403);
|
|
214
|
+
assert.deepEqual(good, empty);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// ── the arm cannot be widened by squatting a name ────────────────────────────
|
|
218
|
+
|
|
219
|
+
test('owning an instance that merely SHARES a client_id grants nothing', async () => {
|
|
220
|
+
// Builder 42 registered `legacy-port` at their own address; the catalog row of that name is
|
|
221
|
+
// a hand-seeded client at a different origin, which ADR 0246's claim rule already refuses to
|
|
222
|
+
// overwrite. The ownership arm answers to the same binding: both halves of the projection
|
|
223
|
+
// (slug AND origin) must be the caller's, or this is simply not their project.
|
|
224
|
+
caller = STRANGER;
|
|
225
|
+
const res = await invite({ client_id: 'legacy-port', github_login: 'someone' });
|
|
226
|
+
assert.equal(res.status, 403);
|
|
227
|
+
assert.equal(res.body.error.code, 'permission_forbidden');
|
|
228
|
+
assert.deepEqual(invites, []);
|
|
229
|
+
assert.deepEqual(portCalls, [{ slug: 'legacy-port', host: 'legacy-port.example', builderId: 42 }],
|
|
230
|
+
'the port is asked about the CATALOG row\'s origin, never the caller\'s own instance');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// ── fail-closed on the ownership question itself ─────────────────────────────
|
|
234
|
+
|
|
235
|
+
test('a missing provisioning module leaves the old door, unchanged', async () => {
|
|
236
|
+
// platform-identity is the hub; provisioning is a separate default-off module. The hub must
|
|
237
|
+
// boot without it, and an unresolvable port must deny rather than admit (ADR 0016).
|
|
238
|
+
portMode = 'absent';
|
|
239
|
+
caller = OWNER;
|
|
240
|
+
const res = await invite({ client_id: 'spike-harbor', github_login: 'someone' });
|
|
241
|
+
assert.equal(res.status, 403);
|
|
242
|
+
assert.deepEqual(invites, []);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test('a THROWING ownership check denies — it never 500s and never admits', async () => {
|
|
246
|
+
portMode = 'throws';
|
|
247
|
+
caller = OWNER;
|
|
248
|
+
const res = await invite({ client_id: 'spike-harbor', github_login: 'someone' });
|
|
249
|
+
assert.equal(res.status, 403);
|
|
250
|
+
assert.equal(res.body.error.code, 'permission_forbidden');
|
|
251
|
+
assert.deepEqual(invites, []);
|
|
252
|
+
// …while the CURATOR still gets through the same broken path: the failure mode of the new
|
|
253
|
+
// door is the old door, not an outage.
|
|
254
|
+
caller = CURATOR;
|
|
255
|
+
const asCurator = await invite({ client_id: 'spike-harbor', github_login: 'someone' });
|
|
256
|
+
assert.equal(asCurator.status, 201);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test('a non-string client_id is not an ownership claim', async () => {
|
|
260
|
+
// The gate runs before the body validator, so it validates its own input rather than
|
|
261
|
+
// trusting it: only a string names a project, and String(['spike-harbor']) must not.
|
|
262
|
+
caller = OWNER;
|
|
263
|
+
for (const client_id of [['spike-harbor'], { toString: () => 'spike-harbor' }, 7, null]) {
|
|
264
|
+
const res = await invite({ client_id, github_login: 'someone' });
|
|
265
|
+
assert.equal(res.status, 403, `client_id ${JSON.stringify(client_id)} must not pass the ownership arm`);
|
|
266
|
+
}
|
|
267
|
+
assert.deepEqual(portCalls, [], 'a non-string never even reaches the port');
|
|
268
|
+
assert.deepEqual(clientReads, [], 'nor the client registry — there is no project named here');
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// ── the resolved target is still the catalog row's job ───────────────────────
|
|
272
|
+
|
|
273
|
+
test('an owner asking for a project that has no catalog row still gets the plain 404', async () => {
|
|
274
|
+
// Ownership admits; it does not conjure a target. `never-provisioned` has no client row, so
|
|
275
|
+
// the handler's own `unknown_project` is what answers — unchanged from today.
|
|
276
|
+
caller = CURATOR;
|
|
277
|
+
const res = await invite({ client_id: 'never-provisioned', github_login: 'someone' });
|
|
278
|
+
assert.equal(res.status, 404);
|
|
279
|
+
assert.equal(res.body.error.code, 'unknown_project');
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test('SUSPENDING the client still shuts the door on its own owner', async () => {
|
|
283
|
+
// The operator's revocation lever (`setClientStatus`) is what stops a rogue or compromised
|
|
284
|
+
// project signing anyone in; a widened door that outlived it would be a widening with no way
|
|
285
|
+
// back. Ownership admits at the gate, and the handler's own status check answers — the same
|
|
286
|
+
// `unknown_project` a stranger would get, so suspension is not disclosed either.
|
|
287
|
+
const suspended = { ...OWNED, status: 'suspended' };
|
|
288
|
+
CLIENTS.set(suspended.client_id, suspended);
|
|
289
|
+
try {
|
|
290
|
+
caller = OWNER;
|
|
291
|
+
const res = await invite({ client_id: 'spike-harbor', github_login: 'someone' });
|
|
292
|
+
assert.equal(res.status, 404);
|
|
293
|
+
assert.equal(res.body.error.code, 'unknown_project');
|
|
294
|
+
assert.deepEqual(invites, []);
|
|
295
|
+
} finally {
|
|
296
|
+
CLIENTS.set(OWNED.client_id, OWNED);
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// ── the authority query itself ───────────────────────────────────────────────
|
|
301
|
+
|
|
302
|
+
test('projectOwnedBy asks the provisioning row for all three facts at once', async () => {
|
|
303
|
+
// The authority half of the seam, on the plane that holds the row: slug AND host AND owner
|
|
304
|
+
// in ONE statement, so there is no read-then-compare window and no consumer ever handles an
|
|
305
|
+
// ownership id it could mis-compare (ADR 0238 — the hub's own columns are attribution).
|
|
306
|
+
const provisioning = require('../modules/provisioning/provisioning.js');
|
|
307
|
+
const seen = [];
|
|
308
|
+
const db = { query: async (sql, params) => { seen.push({ sql, params }); return { rows: [{ '?column?': 1 }] }; } };
|
|
309
|
+
assert.equal(await provisioning.projectOwnedBy(db, {
|
|
310
|
+
slug: 'spike-harbor', host: 'Spike-Harbor.CloudBongos.test', builderId: '7',
|
|
311
|
+
}), true, 'a bigint id arriving as a string still matches, and the host is compared case-folded');
|
|
312
|
+
assert.equal(seen.length, 1);
|
|
313
|
+
assert.match(seen[0].sql, /FROM provisioning_instances/);
|
|
314
|
+
assert.match(seen[0].sql, /slug = \$1/);
|
|
315
|
+
assert.match(seen[0].sql, /lower\(domain\) = \$2/);
|
|
316
|
+
assert.match(seen[0].sql, /owner_builder_id = \$3/);
|
|
317
|
+
assert.deepEqual(seen[0].params, ['spike-harbor', 'spike-harbor.cloudbongos.test', 7]);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test('projectOwnedBy fails closed before touching the database on anything unidentifiable', async () => {
|
|
321
|
+
const provisioning = require('../modules/provisioning/provisioning.js');
|
|
322
|
+
const db = { query: async () => { throw new Error('the query must not run'); } };
|
|
323
|
+
const bad = [
|
|
324
|
+
undefined,
|
|
325
|
+
{ slug: '', host: 'h.test', builderId: 7 },
|
|
326
|
+
{ slug: ['s'], host: 'h.test', builderId: 7 },
|
|
327
|
+
{ slug: 's', host: 'h.test', builderId: 'not-a-number' },
|
|
328
|
+
{ slug: 's', host: 'h.test', builderId: 0 },
|
|
329
|
+
{ slug: 's', host: 'h.test', builderId: -1 },
|
|
330
|
+
{ slug: 's', host: 'h.test', builderId: null },
|
|
331
|
+
];
|
|
332
|
+
for (const target of bad) {
|
|
333
|
+
assert.equal(await provisioning.projectOwnedBy(db, target), false, JSON.stringify(target));
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test('projectOwnedBy QUERIES an empty host rather than short-circuiting it', async () => {
|
|
338
|
+
// The timing-equalising half, on the plane that runs the statement. An empty host names no
|
|
339
|
+
// address and matches nothing — `domain` is a hostname or NULL, and `lower(NULL) = ''` is
|
|
340
|
+
// NULL — so this is a real round-trip with a guaranteed-empty result, not a dummy delay.
|
|
341
|
+
const provisioning = require('../modules/provisioning/provisioning.js');
|
|
342
|
+
const seen = [];
|
|
343
|
+
const db = { query: async (sql, params) => { seen.push(params); return { rows: [] }; } };
|
|
344
|
+
assert.equal(await provisioning.projectOwnedBy(db, { slug: 's', host: '', builderId: 7 }), false);
|
|
345
|
+
assert.deepEqual(seen, [['s', '', 7]], 'the query still ran — the caller pays the same cost either way');
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test('projectOwnedBy answers false, not a row, when nothing matches', async () => {
|
|
349
|
+
const provisioning = require('../modules/provisioning/provisioning.js');
|
|
350
|
+
const db = { query: async () => ({ rows: [] }) };
|
|
351
|
+
assert.equal(await provisioning.projectOwnedBy(db, { slug: 's', host: 'h.test', builderId: 7 }), false);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
// ── the seam ─────────────────────────────────────────────────────────────────
|
|
355
|
+
|
|
356
|
+
test('provisioning DECLARES the ownership port, and platform-identity does not require it', async () => {
|
|
357
|
+
// verifyPortsSatisfied reads the manifests at boot, not the registry: an undeclared provider
|
|
358
|
+
// is invisible to it, and a port named in `consumes` the hub would refuse to boot without.
|
|
359
|
+
const read = async (rel) => JSON.parse(await (await import('node:fs/promises'))
|
|
360
|
+
.readFile(new URL(rel, import.meta.url), 'utf8'));
|
|
361
|
+
const provisioning = await read('../modules/provisioning/module.json');
|
|
362
|
+
const identity = await read('../modules/platform-identity/module.json');
|
|
363
|
+
assert.ok(provisioning.provides.includes('provisioning.projectOwnedBy'), 'the port is declared');
|
|
364
|
+
assert.ok(!(identity.consumes || []).includes('provisioning.projectOwnedBy'),
|
|
365
|
+
'optional, not required — the hub must boot with no provisioning module at all');
|
|
366
|
+
});
|