@bongos/core 1.19.416 → 1.19.418

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.
Files changed (35) hide show
  1. package/.bongos-core.json +70 -35
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +6 -0
  4. package/clients/bongos-client/index.cjs +6 -0
  5. package/clients/bongos-client/index.d.ts +10 -0
  6. package/clients/bongos-client/index.mjs +6 -0
  7. package/docs/adr/0205-federated-checkin-needs-the-hubs-own-signin-witness.md +79 -0
  8. package/docs/adr/0206-own-scoped-session-revocation-no-absolute-ceiling.md +59 -0
  9. package/docs/adr/README.md +2 -0
  10. package/docs/api/openapi.json +185 -4
  11. package/docs/api-reference.md +5 -2
  12. package/docs/architecture.md +1 -0
  13. package/docs/copy-inventory.md +51 -36
  14. package/docs/copy-registry.json +175 -40
  15. package/docs/file-map.md +1 -0
  16. package/docs/module-api-changelog.md +4 -0
  17. package/modules/dev-box/app/src/vendor/bongos-client.cjs +6 -0
  18. package/modules/hall-ui/public/settings-sessions.js +119 -0
  19. package/modules/hall-ui/public/settings.css +25 -0
  20. package/modules/hall-ui/public/settings.html +22 -0
  21. package/modules/platform-identity/migrations/platform_identity_014_membership_signin_proof.sql +41 -0
  22. package/modules/platform-identity/platform-identity.js +88 -15
  23. package/modules/platform-identity/routes/sso.js +54 -10
  24. package/modules/platform-identity/tests/platform-identity.mjs +25 -6
  25. package/package-lock.json +2 -2
  26. package/package.json +1 -1
  27. package/src/bongos/db.js +46 -0
  28. package/src/bongos/route-rank-check.js +10 -0
  29. package/src/bongos/routes/my-sessions.js +128 -0
  30. package/src/bongos/routes.js +5 -0
  31. package/src/module-api.js +1 -1
  32. package/tests/hub_recent_ships.mjs +4 -1
  33. package/tests/own_scope_guard.mjs +5 -0
  34. package/tests/own_session_revoke.mjs +410 -0
  35. package/tests/sso_checkin_identity_spoof.mjs +348 -0
@@ -0,0 +1,119 @@
1
+ // modules/hall-ui/public/settings-sessions.js — the "Active sessions" panel on
2
+ // /builders/settings (task 1003341, ADR 0206).
3
+ //
4
+ // WHY. A session refreshes for another 24 hours every time it is used, so one a
5
+ // builder has forgotten about never expires; before this panel, killing a leaked
6
+ // CLI token meant asking an Archon, because the only revoke endpoints were
7
+ // admin-gated. This is the surface for the own-scoped remedy: see everywhere you
8
+ // are signed in, end one, or end all of them.
9
+ //
10
+ // Its own file rather than more of settings.js — that file sits close to the
11
+ // 1500-line size ratchet, and this panel is self-contained: it shares nothing
12
+ // with the rest of the page but the `window.OTB` toolkit and the generated API
13
+ // client, both of which are globals loaded before it.
14
+
15
+ (() => {
16
+ 'use strict';
17
+
18
+ const API = '/api/bongos';
19
+ const { escapeHtml, $, fmtDate } = window.OTB;
20
+ const toast = (msg) => window.OTB.toast(msg);
21
+ const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
22
+
23
+ const toAuth = () => window.location.assign(`${API}/auth/web/start`);
24
+
25
+ // The words a person recognises, not the column value. `source` is the same
26
+ // three-value set the session-scope registry declares.
27
+ const SOURCE_LABELS = { cli: 'Terminal', web: 'Browser', box: 'Dev box' };
28
+ const sourceLabel = (s) => SOURCE_LABELS[s] || 'Session';
29
+
30
+ // "3 minutes ago" beats a timestamp for the one question this panel exists to
31
+ // answer: is anything using this session that is not me, right now?
32
+ function ago(iso) {
33
+ const then = new Date(iso).getTime();
34
+ if (!Number.isFinite(then)) return 'unknown';
35
+ const mins = Math.max(0, Math.round((Date.now() - then) / 60000));
36
+ if (mins < 1) return 'just now';
37
+ if (mins < 60) return `${mins} minute${mins === 1 ? '' : 's'} ago`;
38
+ const hours = Math.round(mins / 60);
39
+ if (hours < 48) return `${hours} hour${hours === 1 ? '' : 's'} ago`;
40
+ return `${Math.round(hours / 24)} days ago`;
41
+ }
42
+
43
+ function rowHtml(s) {
44
+ const badge = s.current ? '<span class="sessions-row__badge">this session</span>' : '';
45
+ const agent = s.user_agent ? `<span class="sessions-row__agent">${escapeHtml(s.user_agent)}</span>` : '';
46
+ return `<div class="sessions-row" data-id="${escapeHtml(s.id)}">
47
+ <div class="sessions-row__main">
48
+ <strong>${escapeHtml(sourceLabel(s.source))}</strong>${badge}
49
+ ${agent}
50
+ <span class="sessions-row__when">last used ${escapeHtml(ago(s.last_used_at))} · signed in ${escapeHtml(fmtDate(s.created_at))}</span>
51
+ </div>
52
+ <button type="button" class="settings-btn settings-btn--quiet sessions-row__end">End session</button>
53
+ </div>`;
54
+ }
55
+
56
+ async function render() {
57
+ const list = $('#sessions-list');
58
+ const res = await api.request('GET', `${API}/me/sessions`);
59
+ if (res.status === 401) { toAuth(); return; }
60
+ if (!res.ok) throw new Error((res.data && res.data.message) || `HTTP ${res.status}`);
61
+ const sessions = (res.data && res.data.sessions) || [];
62
+ list.innerHTML = sessions.length
63
+ ? sessions.map(rowHtml).join('')
64
+ : '<p class="settings-card__hint">No other sessions.</p>';
65
+ for (const btn of list.querySelectorAll('.sessions-row__end')) {
66
+ btn.addEventListener('click', () => endOne(btn));
67
+ }
68
+ }
69
+
70
+ async function endOne(btn) {
71
+ const row = btn.closest('.sessions-row');
72
+ const id = row.getAttribute('data-id');
73
+ const isCurrent = !!row.querySelector('.sessions-row__badge');
74
+ if (isCurrent && !window.confirm('That is the session you are using right now. Ending it signs you out of this page.')) return;
75
+ btn.disabled = true;
76
+ try {
77
+ const res = await api.request('POST', `${API}/me/sessions/revoke`, { body: { session_id: id } });
78
+ if (res.status === 401) { toAuth(); return; }
79
+ if (!res.ok) throw new Error((res.data && res.data.message) || `HTTP ${res.status}`);
80
+ if (isCurrent) { toAuth(); return; }
81
+ toast('Session ended.');
82
+ await render();
83
+ } catch (err) {
84
+ btn.disabled = false;
85
+ toast(`Could not end that session: ${err.message}`);
86
+ }
87
+ }
88
+
89
+ // Sign out everywhere ends THIS browser session too — that is what "everywhere"
90
+ // means, and the whole point when a token has leaked — so it confirms first and
91
+ // then sends the builder back to sign-in rather than leaving a dead page up.
92
+ async function endAll() {
93
+ const btn = $('#sessions-revoke-all');
94
+ if (!window.confirm('End every session, including this one? You will need to sign in again, and any CLI token will need re-issuing.')) return;
95
+ btn.disabled = true;
96
+ try {
97
+ const res = await api.request('POST', `${API}/me/sessions/revoke-all`, { body: {} });
98
+ if (!res.ok && res.status !== 401) throw new Error((res.data && res.data.message) || `HTTP ${res.status}`);
99
+ toAuth();
100
+ } catch (err) {
101
+ btn.disabled = false;
102
+ toast(`Could not sign out everywhere: ${err.message}`);
103
+ }
104
+ }
105
+
106
+ async function setup() {
107
+ try {
108
+ await render();
109
+ $('#sessions-scroll').hidden = false;
110
+ $('#sessions-revoke-all').addEventListener('click', endAll);
111
+ } catch (err) {
112
+ // Its own try/catch, like every other section: a failing endpoint hides this
113
+ // panel rather than taking the settings page down with it.
114
+ toast(`Could not load your sessions: ${err.message}`);
115
+ }
116
+ }
117
+
118
+ setup();
119
+ })();
@@ -319,6 +319,31 @@ input.display-name-input {
319
319
  margin: 6px 0;
320
320
  }
321
321
 
322
+ /* ---- Active sessions (task 1003341) — one row per live session, the current
323
+ one badged so nobody ends the page they are reading. ---- */
324
+ .sessions-row {
325
+ display: flex;
326
+ flex-wrap: wrap;
327
+ align-items: center;
328
+ justify-content: space-between;
329
+ gap: 10px;
330
+ padding: 10px 0;
331
+ border-bottom: 1px solid var(--border);
332
+ }
333
+ .sessions-row:last-child { border-bottom: 0; }
334
+ .sessions-row__main { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; min-width: 0; }
335
+ .sessions-row__agent,
336
+ .sessions-row__when { color: var(--ink-soft); font-size: 13px; }
337
+ .sessions-row__agent { overflow-wrap: anywhere; }
338
+ .sessions-row__when { flex-basis: 100%; }
339
+ .sessions-row__badge {
340
+ font-size: 12px;
341
+ padding: 1px 7px;
342
+ border: 1px solid var(--border);
343
+ border-radius: var(--radius-sm);
344
+ color: var(--ink-soft);
345
+ }
346
+
322
347
  /* ---- Image key (task 1013) — the "we cover it" / "action needed" banner
323
348
  uses the shared semantic status tokens. ---- */
324
349
  .artkey-banner {
@@ -145,6 +145,25 @@
145
145
  <p class="section-note">Tokens refresh on use; re-issuing never signs you out anywhere else.</p>
146
146
  </section>
147
147
 
148
+ <!-- ACTIVE SESSIONS — see and kill your own sessions without an
149
+ Archon (task 1003341, ADR 0206). Rendered by settings-sessions.js
150
+ against GET /api/bongos/me/sessions. -->
151
+ <section class="scroll" id="sessions-scroll" aria-labelledby="sessions-h" data-group="access" hidden>
152
+ <h2 id="sessions-h" class="scroll__h">Active sessions</h2>
153
+ <p class="scroll__lede">
154
+ Every place you are currently signed in — this browser, your terminal,
155
+ any dev box. A session refreshes for another 24 hours each time it is
156
+ used, so one you have forgotten about can stay alive indefinitely. If a
157
+ token has leaked, end it here.
158
+ </p>
159
+ <div id="sessions-list" class="sessions-list" aria-live="polite"></div>
160
+ <div class="settings-card">
161
+ <button type="button" class="settings-btn" id="sessions-revoke-all">Sign out everywhere</button>
162
+ <span class="settings-card__hint">ends every session including this one — you will be asked to sign in again.</span>
163
+ </div>
164
+ <p class="section-note">Ending a session takes effect immediately; the token stops working on its very next request.</p>
165
+ </section>
166
+
148
167
  <!-- IMAGE KEY — per-builder Gemini key (task 1013 / ADR 0073).
149
168
  GET/PUT/DELETE /api/bongos/me/art-key/own. Write-only (set/replace/
150
169
  remove); the full key is never shown again after save (masked,
@@ -342,5 +361,8 @@
342
361
  <!-- #807: shared dev-box panel — loaded before settings.js so OTBBoxPanel exists. -->
343
362
  <script src="/builders/box-panel.js?v=2026-06-14-domutils"></script>
344
363
  <script src="/builders/settings.js?v=2026-07-10-regroup"></script>
364
+ <!-- task 1003341: the Active sessions panel. Its own file rather than more of
365
+ settings.js, which sits close to the 1500-line size ratchet. -->
366
+ <script src="/builders/settings-sessions.js?v=2026-08-29-sessions"></script>
345
367
  </body>
346
368
  </html>
@@ -0,0 +1,41 @@
1
+ -- platform_identity_014_membership_signin_proof.sql — the hub's OWN witness that a
2
+ -- federated sign-in really happened (security report 1000027, task 1003340, ADR 0205).
3
+ --
4
+ -- WHY. POST /sso/membership/check-in authenticates the CALLER (client_id +
5
+ -- client_secret) and takes the SUBJECT (github_id) from the request body. Client
6
+ -- credentials say "which project is calling", never "which person this is about", so
7
+ -- any registered client could mint a membership row for anyone — and that row is
8
+ -- exactly what the ADR 0171 D7 `membership_required` precheck at POST
9
+ -- /sso/activity/rollup trusts before publishing credits/karma/rank onto a PUBLIC
10
+ -- profile. The precheck was satisfiable by the very call it was meant to gate.
11
+ --
12
+ -- THE COLUMN. `signin_verified_at` is written ONLY by the hub itself, at the two
13
+ -- moments the hub authenticates a human FOR a client and no request body can reach:
14
+ -- • POST /sso/token — redeeming a single-use code minted at /sso/authorize
15
+ -- under a LIVE HUB SESSION, bound to (client_id, github_id);
16
+ -- • POST /sso/device/poll — a GitHub device approval, audience bound from the
17
+ -- registered origin (the CLI never names the client).
18
+ -- Because no caller-supplied field feeds it, the column is proof the caller could not
19
+ -- have written. Check-in may only UPDATE a row that already carries it; the rollup
20
+ -- precheck requires it.
21
+ --
22
+ -- NO BACKFILL, DELIBERATELY. Existing rows carry no proof, and there is no predicate
23
+ -- that separates a legitimate 2026 check-in row from a forged one — grandfathering
24
+ -- them would preserve precisely the rows the report is about. Nothing breaks for a
25
+ -- real builder: every federated sign-in stamps this column BEFORE the check-in and
26
+ -- rollup that follow it (src/bongos/auth-idp.js sequences token/assertion →
27
+ -- check-in → rollup), so the first sign-in after this deploys heals a real membership
28
+ -- with no operator step. Already-published activity rows are untouched — a profile
29
+ -- goes stale until the next sign-in, it never goes blank.
30
+ --
31
+ -- Additive, idempotent, platform_identity_-namespaced (ADR 0083 §5).
32
+
33
+ BEGIN;
34
+
35
+ ALTER TABLE platform_identity_project_memberships
36
+ ADD COLUMN IF NOT EXISTS signin_verified_at timestamptz;
37
+
38
+ INSERT INTO schema_migrations (version) VALUES ('platform_identity_014_membership_signin_proof')
39
+ ON CONFLICT DO NOTHING;
40
+
41
+ COMMIT;
@@ -99,6 +99,37 @@ function redirectUriAllowed(client, redirectUri) {
99
99
  // Accounts
100
100
  // -------------------------------------------------------------------------
101
101
 
102
+ // The avatar a hub profile may render: an absolute **https** URL, else null — http,
103
+ // data:, javascript:, relative and malformed all collapse (ADR 0205 D5). Validated
104
+ // at the ONE write boundary, not at each render: the value is echoed into a public
105
+ // page and an `<img src>` from several surfaces.
106
+ function safeAvatarUrl(avatarUrl) {
107
+ if (avatarUrl == null || avatarUrl === '') return null;
108
+ try {
109
+ return new URL(String(avatarUrl)).protocol === 'https:' ? String(avatarUrl) : null;
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
114
+
115
+ // Create the account shell for a GitHub identity IF ABSENT, never touching an
116
+ // existing row's identity — THE VARIANT A FEDERATED CLIENT GETS (ADR 0205 D3,
117
+ // security report 1000027). upsertAccount's conflict re-sync is right where the
118
+ // HUB authenticated the human and catastrophic for /sso/membership/check-in,
119
+ // whose subject github_id is an unproven body field: a client could rewrite
120
+ // anyone's public login + avatar. Both hub paths still re-sync every sign-in.
121
+ async function ensureAccount({ githubId, githubLogin, displayName, avatarUrl }, { pool = defaultPool } = {}) {
122
+ await pool.query(
123
+ `INSERT INTO platform_identity_accounts (github_id, github_login, display_name, avatar_url, profile_state)
124
+ VALUES ($1, $2, $3, $4, 'provisional')
125
+ ON CONFLICT (github_id) DO NOTHING`,
126
+ [githubId, githubLogin, displayName ?? null, safeAvatarUrl(avatarUrl)],
127
+ );
128
+ const row = await getAccount(githubId, { pool });
129
+ if (row && !row.handle) row.handle = await ensureHandle({ githubId, githubLogin }, { pool });
130
+ return row;
131
+ }
132
+
102
133
  // Upsert the platform account for a GitHub identity (keyed on the stable
103
134
  // github_id). Called at /sso/authorize for the signed-in hub user. Identity only.
104
135
  //
@@ -113,6 +144,9 @@ function redirectUriAllowed(client, redirectUri) {
113
144
  // display_name_source='user'), no check-in/authorize re-sync overwrites it; a
114
145
  // never-renamed account keeps following GitHub. github_login and avatar_url
115
146
  // re-sync unconditionally — neither is user-editable (spec Decision 4).
147
+ //
148
+ // ONLY HUB-AUTHENTICATED CALLERS MAY USE THIS (ADR 0205 D3) — the conflict branch
149
+ // rewrites identity; a federated check-in gets `ensureAccount`.
116
150
  async function upsertAccount({ githubId, githubLogin, displayName, avatarUrl, activate = false }, { pool = defaultPool } = {}) {
117
151
  const { rows } = await pool.query(
118
152
  `INSERT INTO platform_identity_accounts (github_id, github_login, display_name, avatar_url, profile_state)
@@ -126,7 +160,7 @@ async function upsertAccount({ githubId, githubLogin, displayName, avatarUrl, ac
126
160
  profile_state = CASE WHEN $5::boolean THEN 'active' ELSE platform_identity_accounts.profile_state END,
127
161
  updated_at = now()
128
162
  RETURNING id, github_id, github_login, display_name, avatar_url, profile_state, handle`,
129
- [githubId, githubLogin, displayName ?? null, avatarUrl ?? null, !!activate],
163
+ [githubId, githubLogin, displayName ?? null, safeAvatarUrl(avatarUrl), !!activate],
130
164
  );
131
165
  const row = rows[0];
132
166
  // Seed the handle on first sight (spec Decision 3); a no-op once set.
@@ -409,29 +443,57 @@ function legacyRoleFromKind(kind) {
409
443
  return kind === 'member' ? 'builder' : kind;
410
444
  }
411
445
 
412
- // Upsert a builder's membership in a project (keyed by the reporting client + the
413
- // builder's github_id). COARSE membership only — never a rank. Refreshes
414
- // last_active on every check-in. Returns the row.
415
- async function upsertMembership({ clientId, githubId, membershipKind = 'member' }, { pool = defaultPool } = {}) {
416
- const k = MEMBERSHIP_KINDS.includes(membershipKind) ? membershipKind : 'member';
446
+ // Stamp the hub's OWN witness that this person signed in to this project, and
447
+ // create the membership if it is their first time — THE ONLY WAY A MEMBERSHIP ROW
448
+ // IS BORN (ADR 0205 D1). Called from the two routes where the hub itself
449
+ // authenticated the human for the client and no request body reaches the pairing:
450
+ // POST /sso/token (a code minted at /sso/authorize under a live hub session) and
451
+ // POST /sso/device/poll (a GitHub approval, audience bound from the origin) — which
452
+ // is what makes signin_verified_at evidence rather than another self-report.
453
+ // Signing in accepts a standing 'pending' invite; any other coarse kind is left
454
+ // to the reporting instance to say, via touchMembership.
455
+ async function recordFederatedSignin({ clientId, githubId }, { pool = defaultPool } = {}) {
417
456
  const { rows } = await pool.query(
418
- `INSERT INTO platform_identity_project_memberships (client_id, github_id, membership_kind)
419
- VALUES ($1, $2, $3)
457
+ `INSERT INTO platform_identity_project_memberships (client_id, github_id, membership_kind, signin_verified_at)
458
+ VALUES ($1, $2, 'member', now())
420
459
  ON CONFLICT (client_id, github_id) DO UPDATE SET
421
- membership_kind = EXCLUDED.membership_kind,
460
+ membership_kind = CASE WHEN platform_identity_project_memberships.membership_kind = 'pending'
461
+ THEN 'member' ELSE platform_identity_project_memberships.membership_kind END,
462
+ signin_verified_at = now(),
422
463
  last_active = now()
423
- RETURNING id, client_id, github_id, membership_kind, joined_at, last_active`,
464
+ RETURNING id, client_id, github_id, membership_kind, joined_at, last_active, signin_verified_at`,
465
+ [clientId, githubId],
466
+ );
467
+ return rows[0] || null;
468
+ }
469
+
470
+ // Refresh an EXISTING sign-in-backed membership from a federated check-in: the coarse
471
+ // membership_kind the instance reports, plus last_active. Null when there is no such
472
+ // row — which the route turns into a refusal.
473
+ // UPDATE-ONLY AND GATED ON signin_verified_at BY DESIGN (ADR 0205 D2, security
474
+ // report 1000027). Check-in authenticates the calling PROJECT, never the SUBJECT.
475
+ // When this could INSERT, any client could mint a membership for anyone — the very
476
+ // row the ADR 0171 D7 `membership_required` precheck at /sso/activity/rollup trusts
477
+ // before publishing credits/karma/rank onto a public profile.
478
+ async function touchMembership({ clientId, githubId, membershipKind = 'member' }, { pool = defaultPool } = {}) {
479
+ const k = MEMBERSHIP_KINDS.includes(membershipKind) ? membershipKind : 'member';
480
+ const { rows } = await pool.query(
481
+ `UPDATE platform_identity_project_memberships
482
+ SET membership_kind = $3,
483
+ last_active = now()
484
+ WHERE client_id = $1 AND github_id = $2 AND signin_verified_at IS NOT NULL
485
+ RETURNING id, client_id, github_id, membership_kind, joined_at, last_active, signin_verified_at`,
424
486
  [clientId, githubId, k],
425
487
  );
426
- return rows[0];
488
+ return rows[0] || null;
427
489
  }
428
490
 
429
491
  // One membership row for (project, builder), any membership_kind — or null. Backs the
430
- // activity-rollup membership requirement (ADR 0171 D7): a client may only report
431
- // activity for a builder who actually has a membership on that project.
492
+ // activity-rollup membership requirement (ADR 0171 D7), carrying `signin_verified_at`
493
+ // so the caller can demand the hub's own sign-in witness, not the row's mere existence.
432
494
  async function getMembership(clientId, githubId, { pool = defaultPool } = {}) {
433
495
  const { rows } = await pool.query(
434
- `SELECT id, client_id, github_id, membership_kind, joined_at, last_active
496
+ `SELECT id, client_id, github_id, membership_kind, joined_at, last_active, signin_verified_at
435
497
  FROM platform_identity_project_memberships
436
498
  WHERE client_id = $1 AND github_id = $2`,
437
499
  [clientId, githubId],
@@ -439,6 +501,13 @@ async function getMembership(clientId, githubId, { pool = defaultPool } = {}) {
439
501
  return rows[0] || null;
440
502
  }
441
503
 
504
+ // Does a real federated sign-in stand behind this membership — the ONE predicate the
505
+ // rollup publishes on (ADR 0205 D4)? 'pending' is an invite pre-seed, not a
506
+ // membership; an un-witnessed row is a client's own unproven claim.
507
+ function membershipBacksRollup(membership) {
508
+ return !!membership && membership.membership_kind !== 'pending' && !!membership.signin_verified_at;
509
+ }
510
+
442
511
  // All memberships for one account, newest-active first, joined to the project's
443
512
  // display fields (name/origin from sso_clients). Powers "My Projects" (task #1002112).
444
513
  async function listMembershipsForAccount(githubId, { pool = defaultPool } = {}) {
@@ -1349,8 +1418,10 @@ module.exports = {
1349
1418
  clientSecretValid,
1350
1419
  redirectUriAllowed,
1351
1420
  sessionUsableForFederation,
1421
+ safeAvatarUrl,
1352
1422
  // accounts
1353
1423
  upsertAccount,
1424
+ ensureAccount,
1354
1425
  getAccount,
1355
1426
  getAccountByLogin,
1356
1427
  // clients
@@ -1378,8 +1449,10 @@ module.exports = {
1378
1449
  MEMBERSHIP_KINDS,
1379
1450
  membershipKindFromLegacy,
1380
1451
  legacyRoleFromKind,
1381
- upsertMembership,
1452
+ recordFederatedSignin,
1453
+ touchMembership,
1382
1454
  getMembership,
1455
+ membershipBacksRollup,
1383
1456
  listMembershipsForAccount,
1384
1457
  removeMembership,
1385
1458
  // invitations
@@ -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.416",
3
+ "version": "1.19.418",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.416",
9
+ "version": "1.19.418",
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.416",
3
+ "version": "1.19.418",
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/bongos/db.js CHANGED
@@ -733,6 +733,50 @@ async function revokeAllSessionsForBuilder(builderId) {
733
733
  return rowCount;
734
734
  }
735
735
 
736
+ // ---------- own-scoped session self-service (task 1003341) ----------
737
+ //
738
+ // The three helpers below are the account holder's OWN remedy for a leaked
739
+ // bearer, and every one of them carries `builder_id = $caller` in its WHERE
740
+ // clause. That predicate is the isolation: an id belonging to somebody else
741
+ // matches no row, so a caller who guesses a stranger's session id learns
742
+ // nothing and changes nothing. src/bongos/routes/my-sessions.js is the only
743
+ // caller and always passes req.builder.id — never a client-supplied value.
744
+
745
+ // listActiveSessionsForBuilder — the live (unrevoked, unexpired) sessions on one
746
+ // account, newest-used first. Returns only what a person needs to RECOGNISE a
747
+ // session — where it came from, when it was minted, when it was last used, what
748
+ // client held it. The `token` column is deliberately never selected: a list that
749
+ // returned tokens would turn one stolen session into all of them.
750
+
751
+ async function listActiveSessionsForBuilder(builderId) {
752
+ const { rows } = await pool.query(
753
+ `SELECT id, source, user_agent, created_at, last_used_at, expires_at
754
+ FROM builder_sessions
755
+ WHERE builder_id = $1
756
+ AND revoked_at IS NULL
757
+ AND (expires_at IS NULL OR expires_at > now())
758
+ ORDER BY last_used_at DESC, id DESC`,
759
+ [builderId]
760
+ );
761
+ return rows;
762
+ }
763
+
764
+ // revokeSessionForBuilder — kill ONE session the caller owns. The own-scoped twin
765
+ // of revokeSessionById: same effect, but it can only ever reach a row on the
766
+ // caller's own account. Returns 1 when a session was killed, 0 when the id was
767
+ // already revoked, expired, unknown, or somebody else's — the four cases are
768
+ // deliberately indistinguishable to the caller.
769
+
770
+ async function revokeSessionForBuilder(sessionId, builderId) {
771
+ const { rowCount } = await pool.query(
772
+ `UPDATE builder_sessions
773
+ SET revoked_at = now()
774
+ WHERE id = $1 AND builder_id = $2 AND revoked_at IS NULL`,
775
+ [sessionId, builderId]
776
+ );
777
+ return rowCount;
778
+ }
779
+
736
780
  // -------------------------------------------------------------------------
737
781
  // Versions
738
782
  // -------------------------------------------------------------------------
@@ -994,6 +1038,8 @@ module.exports = {
994
1038
  revokeSession,
995
1039
  revokeSessionById,
996
1040
  revokeAllSessionsForBuilder,
1041
+ listActiveSessionsForBuilder,
1042
+ revokeSessionForBuilder,
997
1043
  hasCliSession,
998
1044
  SESSION_TTL_MS,
999
1045
  LIVE_RANK_LADDER,