@bongos/core 1.19.415 → 1.19.417
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 +40 -20
- package/docs/adr/0205-federated-checkin-needs-the-hubs-own-signin-witness.md +79 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +1 -1
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/modules/platform-identity/migrations/platform_identity_014_membership_signin_proof.sql +41 -0
- package/modules/platform-identity/platform-identity.js +88 -15
- package/modules/platform-identity/routes/sso.js +54 -10
- package/modules/platform-identity/tests/platform-identity.mjs +25 -6
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/hub_recent_ships.mjs +4 -1
- package/tests/memory_isolation.mjs +4 -0
- package/tests/own_scope_guard.mjs +472 -0
- package/tests/sso_checkin_identity_spoof.mjs +348 -0
|
@@ -259,6 +259,15 @@ module.exports = function ssoRoutes() {
|
|
|
259
259
|
if (identity.redirect_uri !== redirect_uri) {
|
|
260
260
|
return res.fail('invalid_grant', 400);
|
|
261
261
|
}
|
|
262
|
+
// THE SIGN-IN WITNESS (ADR 0205, security report 1000027). A redeemed code
|
|
263
|
+
// means a human with a live hub session authorized THIS client for THIS
|
|
264
|
+
// github_id — the strongest binding the hub ever holds, and one no request
|
|
265
|
+
// body can fabricate. Record it here, so the /sso/membership/check-in and
|
|
266
|
+
// /sso/activity/rollup gates have something a caller cannot write. Inside
|
|
267
|
+
// the try on purpose: if the witness cannot be stored the sign-in fails
|
|
268
|
+
// rather than proceeding into a state where the client's own check-in is
|
|
269
|
+
// the only evidence it ever happened.
|
|
270
|
+
await pi.recordFederatedSignin({ clientId: client_id, githubId: identity.github_id }, { pool });
|
|
262
271
|
// IDENTITY ONLY — never a rank, credits, or a session token (ADR 0016 / 0141).
|
|
263
272
|
return res.json({
|
|
264
273
|
github_id: Number(identity.github_id),
|
|
@@ -342,6 +351,10 @@ module.exports = function ssoRoutes() {
|
|
|
342
351
|
displayName: id.display_name,
|
|
343
352
|
avatarUrl: id.avatar_url,
|
|
344
353
|
}, { pool });
|
|
354
|
+
// The CLI leg's sign-in witness (ADR 0205) — the device-flow twin of the
|
|
355
|
+
// /sso/token stamp. GitHub approved this person and the client was BOUND at
|
|
356
|
+
// start from the typed origin, so the pairing is the hub's own finding.
|
|
357
|
+
await pi.recordFederatedSignin({ clientId: out.client_id, githubId: id.github_id }, { pool });
|
|
345
358
|
const assertion = hubKeys.mintHubAssertion({
|
|
346
359
|
clientId: out.client_id, // BOUND at start from the origin — not caller-supplied
|
|
347
360
|
githubId: id.github_id,
|
|
@@ -362,11 +375,23 @@ module.exports = function ssoRoutes() {
|
|
|
362
375
|
|
|
363
376
|
// POST /sso/membership/check-in — a federated instance reports, on every sign-in,
|
|
364
377
|
// that a builder builds on its project (ADR 0141 §3). Server-to-server, authed by
|
|
365
|
-
// client_id + client_secret (same constant-time check as /sso/token).
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
//
|
|
369
|
-
//
|
|
378
|
+
// client_id + client_secret (same constant-time check as /sso/token). Refreshes an
|
|
379
|
+
// EXISTING `platform_identity_project_memberships` row's COARSE membership_kind.
|
|
380
|
+
// The client_id is taken from the AUTHENTICATED client — the caller cannot report
|
|
381
|
+
// membership for a project it doesn't hold the secret for.
|
|
382
|
+
//
|
|
383
|
+
// WHAT THIS CALL MAY NOT DO, AND WHY (ADR 0205, security report 1000027). Client
|
|
384
|
+
// credentials say WHICH PROJECT is calling; they say nothing about WHO the
|
|
385
|
+
// github_id in the body is. So this route may neither create a membership nor
|
|
386
|
+
// rewrite an account's identity: it updates a row the HUB already witnessed a
|
|
387
|
+
// sign-in for (signin_verified_at, stamped only at /sso/token and
|
|
388
|
+
// /sso/device/poll), and it creates the account shell only when absent. Before
|
|
389
|
+
// this, a client holding only its own secret could overwrite anyone's public
|
|
390
|
+
// github_login + avatar_url AND mint the membership that /sso/activity/rollup's
|
|
391
|
+
// membership_required precheck (the ADR 0171 D7 anti-impersonation gate) trusts —
|
|
392
|
+
// so the gate was satisfiable by the very call it was meant to gate. Nothing
|
|
393
|
+
// legitimate is lost: check-in only ever fires after a hub-authenticated sign-in
|
|
394
|
+
// (src/bongos/auth-idp.js), which stamps the witness first.
|
|
370
395
|
//
|
|
371
396
|
// THE FIELD IS `membership_kind`; `role` IS ACCEPTED FOR THE WINDOW (task 1003090,
|
|
372
397
|
// ADR 0177 §"the fifth use"). ADR 0174 reserves `role` for the three crafts, and
|
|
@@ -399,10 +424,23 @@ module.exports = function ssoRoutes() {
|
|
|
399
424
|
if (!client || client.status !== 'active' || !pi.clientSecretValid(client, client_secret)) {
|
|
400
425
|
return res.fail('invalid_client', 401);
|
|
401
426
|
}
|
|
402
|
-
//
|
|
403
|
-
//
|
|
404
|
-
|
|
405
|
-
|
|
427
|
+
// Reject a non-numeric github_id before it reaches the bigint column (the
|
|
428
|
+
// rollup's isNumericGithubId guard, which this route was missing).
|
|
429
|
+
if (!pi.isNumericGithubId(github_id)) {
|
|
430
|
+
return res.fail('invalid_github_id', 400);
|
|
431
|
+
}
|
|
432
|
+
// The membership FIRST, and update-only: no hub-witnessed sign-in for
|
|
433
|
+
// (this client, this github_id) means the caller cannot speak for this
|
|
434
|
+
// person, and nothing at all is written — not the account shell either.
|
|
435
|
+
const membership = await pi.touchMembership({ clientId: client_id, githubId: github_id, membershipKind }, { pool });
|
|
436
|
+
if (!membership) {
|
|
437
|
+
return res.fail('signin_required', 409);
|
|
438
|
+
}
|
|
439
|
+
// Then the account shell, so a CLI-only builder is known to the hub —
|
|
440
|
+
// INSERT-ONLY (ensureAccount), never a re-sync of an existing row's
|
|
441
|
+
// identity. In the normal sequence the sign-in that stamped the witness
|
|
442
|
+
// above already created it, so this is a defensive no-op.
|
|
443
|
+
await pi.ensureAccount({ githubId: github_id, githubLogin: github_login, displayName: display_name, avatarUrl: avatar_url }, { pool });
|
|
406
444
|
res.json({ ok: true });
|
|
407
445
|
} catch (err) {
|
|
408
446
|
log.error('[platform-identity] /sso/membership/check-in', err);
|
|
@@ -446,11 +484,17 @@ module.exports = function ssoRoutes() {
|
|
|
446
484
|
// it), so it does not satisfy the requirement. The instance sequences its
|
|
447
485
|
// check-in BEFORE this rollup (src/bongos/auth.js), so the legit first
|
|
448
486
|
// sign-in always has its membership row by the time this arrives.
|
|
487
|
+
//
|
|
488
|
+
// AND THE ROW MUST CARRY THE HUB'S OWN SIGN-IN WITNESS (ADR 0205, security
|
|
489
|
+
// report 1000027). Row-existence alone was not a gate: check-in minted the
|
|
490
|
+
// row this precheck reads, so a client could satisfy D7 for any github_id
|
|
491
|
+
// by first calling the other endpoint. membershipBacksRollup demands
|
|
492
|
+
// signin_verified_at — a column only /sso/token and /sso/device/poll stamp.
|
|
449
493
|
if (!pi.isNumericGithubId(b.github_id)) {
|
|
450
494
|
return res.fail('invalid_github_id', 400);
|
|
451
495
|
}
|
|
452
496
|
const membership = await pi.getMembership(b.client_id, b.github_id, { pool });
|
|
453
|
-
if (!membership
|
|
497
|
+
if (!pi.membershipBacksRollup(membership)) {
|
|
454
498
|
return res.fail('membership_required', 409);
|
|
455
499
|
}
|
|
456
500
|
await pi.upsertActivity({
|
|
@@ -408,19 +408,37 @@ await ta('registerClient auto-catalogues the project (2nd query) when an origin
|
|
|
408
408
|
|
|
409
409
|
console.log('\nproject memberships (§3 check-in directory):');
|
|
410
410
|
|
|
411
|
-
await ta('
|
|
412
|
-
const pool = mockPool([{ rows: [{ id: '1', client_id: 'c1', github_id: '42', membership_kind: '
|
|
413
|
-
await P.
|
|
411
|
+
await ta('recordFederatedSignin is the ONE path that creates a membership, and stamps the witness', async () => {
|
|
412
|
+
const pool = mockPool([{ rows: [{ id: '1', client_id: 'c1', github_id: '42', membership_kind: 'member' }] }]);
|
|
413
|
+
await P.recordFederatedSignin({ clientId: 'c1', githubId: 42 }, { pool });
|
|
414
414
|
const c = pool.calls[0];
|
|
415
415
|
assert.match(c.text, /INSERT INTO platform_identity_project_memberships/);
|
|
416
416
|
assert.match(c.text, /ON CONFLICT \(client_id, github_id\) DO UPDATE/);
|
|
417
|
+
assert.match(c.text, /signin_verified_at = now\(\)/);
|
|
418
|
+
assert.match(c.text, /membership_kind = CASE WHEN platform_identity_project_memberships\.membership_kind = 'pending'/,
|
|
419
|
+
"signing in accepts a standing invite, and never overwrites a real coarse kind");
|
|
420
|
+
assert.deepEqual(c.params, ['c1', 42], 'no caller-supplied field reaches this statement');
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
await ta('touchMembership UPDATEs only, and only a row the hub witnessed a sign-in for', async () => {
|
|
424
|
+
const pool = mockPool([{ rows: [{ id: '1', client_id: 'c1', github_id: '42', membership_kind: 'owner' }] }]);
|
|
425
|
+
await P.touchMembership({ clientId: 'c1', githubId: 42, membershipKind: 'owner' }, { pool });
|
|
426
|
+
const c = pool.calls[0];
|
|
427
|
+
assert.match(c.text, /UPDATE platform_identity_project_memberships/);
|
|
428
|
+
assert.doesNotMatch(c.text, /INSERT/, 'a check-in must never create a membership (report 1000027)');
|
|
429
|
+
assert.match(c.text, /signin_verified_at IS NOT NULL/);
|
|
417
430
|
assert.match(c.text, /last_active = now\(\)/);
|
|
418
431
|
assert.deepEqual(c.params, ['c1', 42, 'owner']);
|
|
419
432
|
});
|
|
420
433
|
|
|
421
|
-
await ta('
|
|
434
|
+
await ta('touchMembership returns null when nothing matched (no witness → the route refuses)', async () => {
|
|
435
|
+
const pool = mockPool([{ rows: [] }]);
|
|
436
|
+
assert.equal(await P.touchMembership({ clientId: 'c1', githubId: 42 }, { pool }), null);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
await ta('touchMembership clamps an unknown kind to member (never trusts a caller blindly)', async () => {
|
|
422
440
|
const pool = mockPool([{ rows: [{}] }]);
|
|
423
|
-
await P.
|
|
441
|
+
await P.touchMembership({ clientId: 'c1', githubId: 42, membershipKind: 'archon' }, { pool });
|
|
424
442
|
assert.equal(pool.calls[0].params[2], 'member', 'a non-coarse membership_kind is clamped to member');
|
|
425
443
|
});
|
|
426
444
|
|
|
@@ -538,12 +556,13 @@ await ta('upsertActivity keeps a legitimate core rank key', async () => {
|
|
|
538
556
|
});
|
|
539
557
|
|
|
540
558
|
await ta('getMembership reads one (client, builder) row — the rollup membership requirement', async () => {
|
|
541
|
-
const row = { id: '9', client_id: 'c1', github_id: '42', membership_kind: 'member' };
|
|
559
|
+
const row = { id: '9', client_id: 'c1', github_id: '42', membership_kind: 'member', signin_verified_at: 'T' };
|
|
542
560
|
const pool = mockPool([{ rows: [row] }]);
|
|
543
561
|
const out = await P.getMembership('c1', 42, { pool });
|
|
544
562
|
assert.deepEqual(out, row);
|
|
545
563
|
const c = pool.calls[0];
|
|
546
564
|
assert.match(c.text, /FROM platform_identity_project_memberships/);
|
|
565
|
+
assert.match(c.text, /signin_verified_at/, 'the rollup gate reads the witness, not just the row');
|
|
547
566
|
assert.deepEqual(c.params, ['c1', 42]);
|
|
548
567
|
const miss = await P.getMembership('c1', 43, { pool });
|
|
549
568
|
assert.equal(miss, null);
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.417",
|
|
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.417",
|
|
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.417",
|
|
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
|
@@ -33,7 +33,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
33
33
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
34
34
|
// the entry to that file. Look for a version's history there, not here.
|
|
35
35
|
// ---------------------------------------------------------------------------
|
|
36
|
-
const CORE_VERSION = '1.19.
|
|
36
|
+
const CORE_VERSION = '1.19.417'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
37
37
|
|
|
38
38
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
39
39
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -163,7 +163,10 @@ await t('the route passes recent_ships through and takes the origin from the CLI
|
|
|
163
163
|
return { rows: [{ client_id: 'c1', secret_hash: pi.sha256hex(SECRET), status: 'active', origin: ORIGIN }] };
|
|
164
164
|
}
|
|
165
165
|
if (/FROM platform_identity_project_memberships/.test(sql)) {
|
|
166
|
-
|
|
166
|
+
// signin_verified_at is what ADR 0205 made the rollup gate read: a
|
|
167
|
+
// membership row alone was never proof, because the check-in that wrote it
|
|
168
|
+
// is the caller's own unverified claim. A real sign-in stamps it.
|
|
169
|
+
return { rows: [{ id: 1, client_id: 'c1', github_id: 42, membership_kind: 'member', signin_verified_at: '2026-08-29T00:00:00.000Z' }] };
|
|
167
170
|
}
|
|
168
171
|
return { rows: [{ id: 1 }] };
|
|
169
172
|
});
|
|
@@ -212,6 +212,10 @@ await t('production code contains no grader-system references', () => {
|
|
|
212
212
|
assert.ok(!/hacker worker/i.test(rawSrc), 'memory.js route source must not mention grader workers');
|
|
213
213
|
});
|
|
214
214
|
|
|
215
|
+
// GENERALISED (task 1003343): this check now also exists — for memory.js AND for
|
|
216
|
+
// every other own-scoped surface (/me, builder-settings, platform-identity) — in
|
|
217
|
+
// tests/own_scope_guard.mjs, which runs in the DB-free unit lane. This file is in
|
|
218
|
+
// the INTEGRATION set, so the copy below gates nothing in CI; keep them agreeing.
|
|
215
219
|
await t('the SELF read endpoints never derive the owner from request input', () => {
|
|
216
220
|
assert.ok(!/req\.query\.builder/i.test(codeOnly), 'no owner should come from req.query.builder*');
|
|
217
221
|
assert.ok(!/req\.body\.builder/i.test(codeOnly), 'no owner should come from req.body.builder*');
|