@bongos/core 1.19.537 → 1.19.539

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/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.537'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
52
+ const CORE_VERSION = '1.19.539'; // 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
+ });