@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.
@@ -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). Upserts the
366
- // account (so a CLI-only builder still appears in My Projects) + the membership
367
- // with a COARSE membership_kind. The client_id is taken from the AUTHENTICATED
368
- // client — the caller cannot report membership for a project it doesn't hold the
369
- // secret for.
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
- // Upsert the account first so a CLI-only builder is known to the hub, then the
403
- // membership — keyed to the AUTHENTICATED client_id, never a caller-named one.
404
- await pi.upsertAccount({ githubId: github_id, githubLogin: github_login, displayName: display_name, avatarUrl: avatar_url }, { pool });
405
- await pi.upsertMembership({ clientId: client_id, githubId: github_id, membershipKind }, { pool });
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 || membership.membership_kind === 'pending') {
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('upsertMembership upserts on (client_id, github_id) with a coarse membership_kind', async () => {
412
- const pool = mockPool([{ rows: [{ id: '1', client_id: 'c1', github_id: '42', membership_kind: 'owner' }] }]);
413
- await P.upsertMembership({ clientId: 'c1', githubId: 42, membershipKind: 'owner' }, { pool });
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('upsertMembership clamps an unknown kind to member (never trusts a caller blindly)', async () => {
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.upsertMembership({ clientId: 'c1', githubId: 42, membershipKind: 'archon' }, { pool });
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.415",
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.415",
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.415",
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.415'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
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
- return { rows: [{ id: 1, client_id: 'c1', github_id: 42, membership_kind: 'member' }] };
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*');