@bongos/core 1.19.417 → 1.19.419
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 +87 -32
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +22 -0
- package/clients/bongos-client/index.cjs +22 -0
- package/clients/bongos-client/index.d.ts +33 -0
- package/clients/bongos-client/index.mjs +22 -0
- package/docs/adr/0206-own-scoped-session-revocation-no-absolute-ceiling.md +59 -0
- package/docs/adr/0207-connections-implementation-departures-and-honest-limits.md +90 -0
- package/docs/adr/README.md +2 -0
- package/docs/api/openapi.json +599 -3
- package/docs/api-reference.md +17 -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 +22 -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/account-visibility.js +58 -0
- package/modules/platform-identity/connections.js +490 -0
- package/modules/platform-identity/migrations/platform_identity_015_connections.sql +78 -0
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/routes/connections.js +236 -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/connections_api.mjs +568 -0
- package/tests/connections_ownership.mjs +313 -0
- package/tests/own_scope_guard.mjs +10 -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>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// modules/platform-identity/account-visibility.js — THE effective-visibility
|
|
2
|
+
// predicate for a platform account, published in both forms so a route and a
|
|
3
|
+
// query cannot drift (ADR 0202 D4; the ADR 0182 `publishableSql` precedent).
|
|
4
|
+
//
|
|
5
|
+
// WHAT IT ANSWERS: "may the world see that this account exists at all?" —
|
|
6
|
+
// profile_state = 'active' (consent: a hub session has touched the account,
|
|
7
|
+
// ADR 0171 D3) AND account_visibility = 'public' (existence: the privacy-spec
|
|
8
|
+
// D1 axis). Both floors, ANDed, in one place.
|
|
9
|
+
//
|
|
10
|
+
// WHY ITS OWN FILE. ADR 0202 D4 requires connections to consume ONE shared
|
|
11
|
+
// reader rather than grow a connections-flavoured variant — "adding a second
|
|
12
|
+
// predicate is the drift ADR 0194 D7 exists to prevent". The reader the ADR
|
|
13
|
+
// names belongs to task 1002967, which has not shipped; platform-identity.js is
|
|
14
|
+
// already at the file-size ratchet. So the definition lands here, alone and
|
|
15
|
+
// tiny, and 1002967 ADOPTS it rather than writing a second one.
|
|
16
|
+
//
|
|
17
|
+
// WHAT IT IS NOT. Not a rollup gate: hide_stats suppresses NUMBERS, not
|
|
18
|
+
// existence, and is applied by the rollup readers (ADR 0171 D2), never here.
|
|
19
|
+
// Not authority: an account being visible confers nothing (ADR 0016).
|
|
20
|
+
//
|
|
21
|
+
// Fail-closed: a missing/absent account row is invisible, never visible
|
|
22
|
+
// (ADR 0192 §6 / ADR 0194 §2).
|
|
23
|
+
'use strict';
|
|
24
|
+
|
|
25
|
+
// The JS twin. `account` is any row carrying profile_state + account_visibility;
|
|
26
|
+
// anything falsy (absent row, unreadable account) answers false.
|
|
27
|
+
function isAccountVisible(account) {
|
|
28
|
+
if (!account) return false;
|
|
29
|
+
return account.profile_state === 'active' && account.account_visibility === 'public';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// The SAME predicate as a SQL fragment, for the reads that filter in the
|
|
33
|
+
// database. `alias` qualifies the columns for a joined query (e.g.
|
|
34
|
+
// accountVisibleSql('pa')); omit it for an unaliased single-table query.
|
|
35
|
+
function accountVisibleSql(alias) {
|
|
36
|
+
// The alias is INTERPOLATED, so it is the one thing here that could carry SQL.
|
|
37
|
+
// Callers pass a literal, but a helper that builds SQL should refuse anything
|
|
38
|
+
// that is not a bare identifier rather than trust every future caller.
|
|
39
|
+
if (alias && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(String(alias))) {
|
|
40
|
+
throw new Error('accountVisibleSql: alias must be a bare SQL identifier');
|
|
41
|
+
}
|
|
42
|
+
const q = alias ? `${alias}.` : '';
|
|
43
|
+
return `(${q}profile_state = 'active' AND ${q}account_visibility = 'public')`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The CONSENT floor on its own — active, without the existence axis. A private
|
|
47
|
+
// account is still a real participant in its OWN scope: ADR 0202 D4 §2 says its
|
|
48
|
+
// connections appear in its own list and in the counterparty's own list, and an
|
|
49
|
+
// incoming request must name who is asking or it cannot be decided. Those
|
|
50
|
+
// own-scope reads floor on this; every PUBLIC read floors on accountVisibleSql.
|
|
51
|
+
function accountActiveSql(alias) {
|
|
52
|
+
if (alias && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(String(alias))) {
|
|
53
|
+
throw new Error('accountActiveSql: alias must be a bare SQL identifier');
|
|
54
|
+
}
|
|
55
|
+
return `(${alias ? `${alias}.` : ''}profile_state = 'active')`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = { isAccountVisible, accountVisibleSql, accountActiveSql };
|