@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.
- package/.bongos-core.json +70 -35
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +10 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/adr/0205-federated-checkin-needs-the-hubs-own-signin-witness.md +79 -0
- package/docs/adr/0206-own-scoped-session-revocation-no-absolute-ceiling.md +59 -0
- package/docs/adr/README.md +2 -0
- package/docs/api/openapi.json +185 -4
- package/docs/api-reference.md +5 -2
- package/docs/architecture.md +1 -0
- package/docs/copy-inventory.md +51 -36
- package/docs/copy-registry.json +175 -40
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +6 -0
- package/modules/hall-ui/public/settings-sessions.js +119 -0
- package/modules/hall-ui/public/settings.css +25 -0
- package/modules/hall-ui/public/settings.html +22 -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/bongos/db.js +46 -0
- package/src/bongos/route-rank-check.js +10 -0
- package/src/bongos/routes/my-sessions.js +128 -0
- package/src/bongos/routes.js +5 -0
- package/src/module-api.js +1 -1
- package/tests/hub_recent_ships.mjs +4 -1
- package/tests/own_scope_guard.mjs +5 -0
- package/tests/own_session_revoke.mjs +410 -0
- 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>
|
package/modules/platform-identity/migrations/platform_identity_014_membership_signin_proof.sql
ADDED
|
@@ -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
|
|
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
|
-
//
|
|
413
|
-
//
|
|
414
|
-
//
|
|
415
|
-
|
|
416
|
-
|
|
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,
|
|
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 =
|
|
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)
|
|
431
|
-
//
|
|
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
|
-
|
|
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).
|
|
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.418",
|
|
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.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.
|
|
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,
|