@bongos/core 1.19.417 → 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 +51 -31
- 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/0206-own-scoped-session-revocation-no-absolute-ceiling.md +59 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +184 -3
- 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 +2 -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/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/own_scope_guard.mjs +5 -0
- package/tests/own_session_revoke.mjs +410 -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/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,
|
|
@@ -197,6 +197,16 @@ const EXPECTED_RANKS = {
|
|
|
197
197
|
// session revocation (kill another builder's / all sessions)
|
|
198
198
|
'POST /auth/revoke': 'perm:auth.session.revoke',
|
|
199
199
|
'POST /auth/revoke-all': 'perm:auth.session.revoke',
|
|
200
|
+
// deliberately any-builder (task 1003341) — the OWN-scoped half of the same
|
|
201
|
+
// capability. These reach only the caller's own sessions (every statement is
|
|
202
|
+
// keyed on req.builder.id), so a rank floor here would just put an Archon back
|
|
203
|
+
// between a person and their own leaked token, which is the gap they close.
|
|
204
|
+
// Pinned in BOTH directions: an accidental rank/permission gate would silently
|
|
205
|
+
// re-break the self-serve remedy, and a widening to `public` would hand an
|
|
206
|
+
// anonymous caller a sign-out button.
|
|
207
|
+
'GET /me/sessions': 'any-builder',
|
|
208
|
+
'POST /me/sessions/revoke': 'any-builder',
|
|
209
|
+
'POST /me/sessions/revoke-all': 'any-builder',
|
|
200
210
|
// forensic surfaces
|
|
201
211
|
'GET /audit-log': 'perm:audit_log.read',
|
|
202
212
|
'GET /builders/roster': 'perm:builder.roster.read',
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// src/bongos/routes/my-sessions.js — a builder's own auth sessions: see them, kill them.
|
|
2
|
+
//
|
|
3
|
+
// WHY. A CLI bearer refreshes to now()+24h on every authenticated call (the
|
|
4
|
+
// refresh-on-use UPDATE in db-kernel.js lookupSession), so a token that keeps
|
|
5
|
+
// being used never expires on its own. Until these routes existed the only
|
|
6
|
+
// remedy for a LEAKED one was POST /auth/revoke-all, gated on the admin
|
|
7
|
+
// `auth.session.revoke` atom — so the victim had to find an Archon to sign them
|
|
8
|
+
// out, and a single-owner self-hosted instance may not have a second Archon at
|
|
9
|
+
// all. That is the gap this file closes: the account holder gets the remedy
|
|
10
|
+
// directly, without anybody's permission but their own.
|
|
11
|
+
//
|
|
12
|
+
// THE INVARIANT, and the only thing that really matters when editing this file:
|
|
13
|
+
// the account is ALWAYS req.builder.id, resolved by requireBuilder from the
|
|
14
|
+
// session the caller actually presented. It is never read from the query
|
|
15
|
+
// string, the request body, or the path. Every statement underneath carries
|
|
16
|
+
// `builder_id = <caller>` in its WHERE clause, so an id belonging to somebody
|
|
17
|
+
// else matches no row: a caller who guesses a stranger's session id gets
|
|
18
|
+
// `revoked: 0` and learns nothing about whether it existed.
|
|
19
|
+
//
|
|
20
|
+
// WHY ITS OWN FILE rather than three more routes in routes/auth.js. The
|
|
21
|
+
// cross-builder admin pair next door legitimately takes its target from the
|
|
22
|
+
// request body — that is what makes it an admin route. Keeping the own-scoped
|
|
23
|
+
// half in a separate file lets tests/own_session_revoke.mjs assert the
|
|
24
|
+
// no-caller-named-id rule over this source WHOLE, with no carve-out for a
|
|
25
|
+
// neighbouring route to hide behind. routes/auth.js is also the largest file in
|
|
26
|
+
// the directory; this work does not belong in it.
|
|
27
|
+
//
|
|
28
|
+
// The token value is never selected, never logged, and never returned — a list
|
|
29
|
+
// that handed back tokens would turn one stolen session into all of them.
|
|
30
|
+
//
|
|
31
|
+
// The 24h sliding refresh itself is deliberately left alone here; ADR 0206
|
|
32
|
+
// records why an absolute ceiling is a separate decision from this remedy.
|
|
33
|
+
|
|
34
|
+
const express = require('express');
|
|
35
|
+
const auth = require('../auth');
|
|
36
|
+
const db = require('../db');
|
|
37
|
+
const { validateOrRespond } = require('./_helpers');
|
|
38
|
+
const log = require('../logger').logger.child({ src: 'my-sessions' });
|
|
39
|
+
|
|
40
|
+
// A bigserial id as the client may spell it. Matched (and then PASSED) as a
|
|
41
|
+
// string rather than coerced through Number: a bigint above 2^53 would lose
|
|
42
|
+
// precision on the way through, and a parameterized comparison against a bigint
|
|
43
|
+
// column takes the text form without complaint.
|
|
44
|
+
const SESSION_ID_RE = /^[0-9]{1,19}$/;
|
|
45
|
+
|
|
46
|
+
// The list shape a person needs to RECOGNISE one of their own sessions: where it
|
|
47
|
+
// came from, when it was minted, when it was last used, what client held it —
|
|
48
|
+
// plus which row is the very request being served, so the UI can warn before
|
|
49
|
+
// somebody signs out the browser they are reading the page in.
|
|
50
|
+
function presentSession(row, currentSessionId) {
|
|
51
|
+
return {
|
|
52
|
+
id: String(row.id),
|
|
53
|
+
source: row.source,
|
|
54
|
+
user_agent: row.user_agent ?? null,
|
|
55
|
+
created_at: row.created_at,
|
|
56
|
+
last_used_at: row.last_used_at,
|
|
57
|
+
expires_at: row.expires_at,
|
|
58
|
+
current: currentSessionId !== null && String(row.id) === currentSessionId,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The id of the session THIS request authenticated with, as a string, or null
|
|
63
|
+
// when the auth layer did not leave one (no live row can then be marked current,
|
|
64
|
+
// which is the safe direction — the UI simply omits the "this is you" badge).
|
|
65
|
+
function currentSessionId(req) {
|
|
66
|
+
const id = req.gdsSession && req.gdsSession.session_id;
|
|
67
|
+
return id === undefined || id === null ? null : String(id);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = function buildMySessionsRouter() {
|
|
71
|
+
const router = express.Router();
|
|
72
|
+
|
|
73
|
+
// GET /api/bongos/me/sessions — every live session on the CALLER's account.
|
|
74
|
+
// rank: any-builder (own identity only) — a builder owns their own sessions;
|
|
75
|
+
// the account is req.builder.id, never a caller-named one, so there is nothing
|
|
76
|
+
// here for a rank gate to protect.
|
|
77
|
+
router.get('/me/sessions', auth.requireBuilder, async (req, res) => {
|
|
78
|
+
try {
|
|
79
|
+
const rows = await db.listActiveSessionsForBuilder(req.builder.id);
|
|
80
|
+
const current = currentSessionId(req);
|
|
81
|
+
res.json({ sessions: rows.map((row) => presentSession(row, current)) });
|
|
82
|
+
} catch (err) {
|
|
83
|
+
log.error({ err: err && err.message }, 'GET /me/sessions failed');
|
|
84
|
+
res.fail('list_failed', { status: 500, message: 'internal error' });
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// POST /api/bongos/me/sessions/revoke — kill ONE of the caller's own sessions.
|
|
89
|
+
// Body: { session_id }. Response: { ok, revoked: 0|1 }. Revoking the session
|
|
90
|
+
// that made the request is allowed and immediate: the response is written from
|
|
91
|
+
// a request that already authenticated, and the next call with that token
|
|
92
|
+
// fails. `revoked: 0` covers "already gone", "never existed" and "not yours"
|
|
93
|
+
// alike — the caller must not be able to probe for other people's session ids.
|
|
94
|
+
// rank: any-builder (own identity only) — the WHERE clause is the boundary.
|
|
95
|
+
router.post('/me/sessions/revoke', auth.requireBuilder, async (req, res) => {
|
|
96
|
+
if (validateOrRespond(req, res, { session_id: {} })) return;
|
|
97
|
+
const raw = req.body && req.body.session_id;
|
|
98
|
+
if (raw === undefined || raw === null || !SESSION_ID_RE.test(String(raw))) {
|
|
99
|
+
return res.fail('bad_session_id', { status: 400, message: 'session_id must be a positive integer id from GET /me/sessions' });
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
const revoked = await db.revokeSessionForBuilder(String(raw), req.builder.id);
|
|
103
|
+
res.json({ ok: true, revoked });
|
|
104
|
+
} catch (err) {
|
|
105
|
+
log.error({ err: err && err.message }, 'POST /me/sessions/revoke failed');
|
|
106
|
+
res.fail('revoke_failed', { status: 500, message: 'internal error' });
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// POST /api/bongos/me/sessions/revoke-all — sign out everywhere. Kills EVERY
|
|
111
|
+
// live session on the caller's account, the one making the request included.
|
|
112
|
+
// That total sweep is the point: this is the button somebody presses when they
|
|
113
|
+
// believe a token has leaked, and a remedy that spared one session would leave
|
|
114
|
+
// them guessing which. They sign in again afterwards. Response: { ok, revoked }.
|
|
115
|
+
// rank: any-builder (own identity only) — the sweep is keyed on req.builder.id.
|
|
116
|
+
router.post('/me/sessions/revoke-all', auth.requireBuilder, async (req, res) => {
|
|
117
|
+
if (validateOrRespond(req, res, {})) return;
|
|
118
|
+
try {
|
|
119
|
+
const revoked = await db.revokeAllSessionsForBuilder(req.builder.id);
|
|
120
|
+
res.json({ ok: true, revoked });
|
|
121
|
+
} catch (err) {
|
|
122
|
+
log.error({ err: err && err.message }, 'POST /me/sessions/revoke-all failed');
|
|
123
|
+
res.fail('revoke_all_failed', { status: 500, message: 'internal error' });
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
return router;
|
|
128
|
+
};
|
package/src/bongos/routes.js
CHANGED
|
@@ -22,6 +22,10 @@ const buildInstanceRouter = require('./routes/instance');
|
|
|
22
22
|
const buildAuthRouter = require('./routes/auth');
|
|
23
23
|
const buildGithubRouter = require('./routes/github');
|
|
24
24
|
const buildMeRouter = require('./routes/me');
|
|
25
|
+
// The own-scoped session self-service (list / revoke one / sign out everywhere,
|
|
26
|
+
// task 1003341). Its own router rather than three more routes on the /me
|
|
27
|
+
// aggregator or the admin pair in routes/auth.js — see that file's header.
|
|
28
|
+
const buildMySessionsRouter = require('./routes/my-sessions');
|
|
25
29
|
const buildBuildersRouter = require('./routes/builders');
|
|
26
30
|
// The LIFECYCLE routes (tasks · claims · versions · done-when · goals ·
|
|
27
31
|
// dependencies · gate-approvals · analytics) carved into modules/lifecycle/
|
|
@@ -124,6 +128,7 @@ function buildGdsRouter() {
|
|
|
124
128
|
router.use(buildAuthRouter());
|
|
125
129
|
router.use(buildGithubRouter());
|
|
126
130
|
router.use(buildMeRouter());
|
|
131
|
+
router.use(buildMySessionsRouter());
|
|
127
132
|
router.use(buildBuildersRouter());
|
|
128
133
|
// tasks · claims · versions · done-when · goals · dependencies · analytics now
|
|
129
134
|
// mount via moduleLoader.mountModuleRoutes below (lifecycle module, BV1.R86) —
|
package/src/module-api.js
CHANGED
|
@@ -33,7 +33,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
33
33
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
34
34
|
// the entry to that file. Look for a version's history there, not here.
|
|
35
35
|
// ---------------------------------------------------------------------------
|
|
36
|
-
const CORE_VERSION = '1.19.
|
|
36
|
+
const CORE_VERSION = '1.19.418'; // 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');
|
|
@@ -79,6 +79,11 @@ const OWN_SCOPED_SURFACES = [
|
|
|
79
79
|
why: 'the /me aggregator: identity, rank, credits, streak, claims and prefs for the CALLER. Every slice keys on req.builder.id.',
|
|
80
80
|
crossOwner: [],
|
|
81
81
|
},
|
|
82
|
+
{
|
|
83
|
+
file: 'src/bongos/routes/my-sessions.js',
|
|
84
|
+
why: "the caller's OWN auth sessions — list, revoke one, sign out everywhere (task 1003341, ADR 0206). Every statement carries builder_id = req.builder.id, so a session id belonging to somebody else matches no row. Kept OUT of routes/auth.js precisely so this whole file can be held to the own-scope rule: the admin cross-builder pair next door takes its target from the body, which is what makes it admin.",
|
|
85
|
+
crossOwner: [],
|
|
86
|
+
},
|
|
82
87
|
{
|
|
83
88
|
file: 'modules/builder-settings/routes/settings.js',
|
|
84
89
|
why: 'the per-builder preference domain (wandering, skill, sound, render, model-allocation, display name) carved out of /me — read and write, all keyed on req.builder.id.',
|