@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.
Files changed (36) hide show
  1. package/.bongos-core.json +87 -32
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +22 -0
  4. package/clients/bongos-client/index.cjs +22 -0
  5. package/clients/bongos-client/index.d.ts +33 -0
  6. package/clients/bongos-client/index.mjs +22 -0
  7. package/docs/adr/0206-own-scoped-session-revocation-no-absolute-ceiling.md +59 -0
  8. package/docs/adr/0207-connections-implementation-departures-and-honest-limits.md +90 -0
  9. package/docs/adr/README.md +2 -0
  10. package/docs/api/openapi.json +599 -3
  11. package/docs/api-reference.md +17 -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 +22 -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/account-visibility.js +58 -0
  22. package/modules/platform-identity/connections.js +490 -0
  23. package/modules/platform-identity/migrations/platform_identity_015_connections.sql +78 -0
  24. package/modules/platform-identity/module.json +1 -1
  25. package/modules/platform-identity/routes/connections.js +236 -0
  26. package/package-lock.json +2 -2
  27. package/package.json +1 -1
  28. package/src/bongos/db.js +46 -0
  29. package/src/bongos/route-rank-check.js +10 -0
  30. package/src/bongos/routes/my-sessions.js +128 -0
  31. package/src/bongos/routes.js +5 -0
  32. package/src/module-api.js +1 -1
  33. package/tests/connections_api.mjs +568 -0
  34. package/tests/connections_ownership.mjs +313 -0
  35. package/tests/own_scope_guard.mjs +10 -0
  36. package/tests/own_session_revoke.mjs +410 -0
@@ -0,0 +1,313 @@
1
+ // tests/connections_ownership.mjs — "a builder cannot act on someone else's
2
+ // connection", EXECUTED rather than asserted against a string (task 1002295,
3
+ // goal 1000045; ADR 0202 D2).
4
+ //
5
+ // Its sibling `connections_api.mjs` proves the SHAPE of every statement — which
6
+ // parameters are bound, which caps are carried. That is necessary and not
7
+ // sufficient: a shape assertion still passes if the WHERE clause is rewritten in
8
+ // a way the regex happens to match. So this file does the other half.
9
+ //
10
+ // THE MECHANISM, copied from tests/own_session_revoke.mjs (task 1003341): the
11
+ // pool below is a real in-memory `platform_identity_connections` table plus a
12
+ // mini-interpreter that builds each statement's row filter FROM THAT STATEMENT'S
13
+ // OWN WHERE CLAUSE. Drop `addressee_github_id = $2` from the accept and the
14
+ // filter genuinely widens, so a stranger's row genuinely changes, and the test
15
+ // genuinely fails. The interpreter REFUSES any statement whose placeholders it
16
+ // did not all consume, so a predicate it cannot read is a loud error rather than
17
+ // a silently weaker filter — the failure mode that makes a fake pool worthless.
18
+ //
19
+ // Everything above the pool is real: the real router, the real domain functions,
20
+ // real HTTP. Only the storage is in memory.
21
+ //
22
+ // DB-free. Runs in the unit gate that executes on a PR.
23
+ //
24
+ // Run: node tests/connections_ownership.mjs
25
+
26
+ import { strict as assert } from 'node:assert';
27
+ import { test, after } from 'node:test';
28
+ import { createRequire } from 'node:module';
29
+ import http from 'node:http';
30
+ import { fileURLToPath } from 'node:url';
31
+ import { dirname, join } from 'node:path';
32
+
33
+ const require = createRequire(import.meta.url);
34
+ const express = require('express');
35
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
36
+
37
+ const DAY = 24 * 60 * 60 * 1000;
38
+
39
+ // Four accounts: the caller and three other people whose rows must be untouchable.
40
+ const ME = 4242;
41
+ const ADA = 9001;
42
+ const BO = 9002;
43
+ const CY = 9003;
44
+
45
+ // ===========================================================================
46
+ // The in-memory table + its statement interpreter
47
+ // ===========================================================================
48
+
49
+ let rows = [];
50
+ function seed() {
51
+ const now = Date.now();
52
+ rows = [
53
+ // 1 — Ada asked ME. Mine to accept or decline.
54
+ { id: 1, requester_github_id: ADA, addressee_github_id: ME, status: 'pending', created_at: now - DAY, resolved_at: null },
55
+ // 2 — Cy asked Bo. None of my business, and I know its id.
56
+ { id: 2, requester_github_id: CY, addressee_github_id: BO, status: 'pending', created_at: now - DAY, resolved_at: null },
57
+ // 3 — ME and Cy are connected. Either of us may end it.
58
+ { id: 3, requester_github_id: ME, addressee_github_id: CY, status: 'accepted', created_at: now - 30 * DAY, resolved_at: now - 29 * DAY },
59
+ // 4 — Bo and Cy are connected. Not mine to end.
60
+ { id: 4, requester_github_id: BO, addressee_github_id: CY, status: 'accepted', created_at: now - 30 * DAY, resolved_at: now - 29 * DAY },
61
+ // 5 — I asked Ada and she has not answered. Mine to cancel.
62
+ { id: 5, requester_github_id: ME, addressee_github_id: ADA, status: 'pending', created_at: now - 2 * DAY, resolved_at: null },
63
+ // 6 — Ada asked me 40 days ago. Expired: gone from both queues, and it must
64
+ // not be resurrectable into an acceptance or a fresh 90-day cooldown.
65
+ { id: 6, requester_github_id: ADA, addressee_github_id: ME, status: 'pending', created_at: now - 40 * DAY, resolved_at: null },
66
+ ];
67
+ }
68
+ const row = (id) => rows.find((r) => r.id === id);
69
+
70
+ /**
71
+ * Build a row filter from a statement's own WHERE text. Every placeholder the
72
+ * clause mentions must be consumed by a fragment below; one that is not throws,
73
+ * so a predicate this cannot read can never degrade into "no constraint".
74
+ */
75
+ function predicateFor(rawWhere, params) {
76
+ let where = rawWhere;
77
+ const tests = [];
78
+ const consumed = new Set();
79
+ const p = (n) => { consumed.add(String(n)); return params[Number(n) - 1]; };
80
+
81
+ // The either-side clause (remove) is matched and STRIPPED first, so the
82
+ // single-column matchers below cannot re-read half of it as a one-sided filter.
83
+ const either = /\(requester_github_id = \$(\d+)::bigint OR addressee_github_id = \$(\d+)::bigint\)/.exec(where);
84
+ if (either) {
85
+ const a = p(either[1]);
86
+ const b = p(either[2]);
87
+ tests.push((r) => String(r.requester_github_id) === String(a) || String(r.addressee_github_id) === String(b));
88
+ where = where.replace(either[0], ' TRUE ');
89
+ }
90
+
91
+ let m;
92
+ if ((m = /\bid = \$(\d+)\b/.exec(where))) { const v = p(m[1]); tests.push((r) => String(r.id) === String(v)); }
93
+ if ((m = /\baddressee_github_id = \$(\d+)::bigint/.exec(where))) { const v = p(m[1]); tests.push((r) => String(r.addressee_github_id) === String(v)); }
94
+ if ((m = /\brequester_github_id = \$(\d+)::bigint/.exec(where))) { const v = p(m[1]); tests.push((r) => String(r.requester_github_id) === String(v)); }
95
+ if ((m = /\bstatus = '(\w+)'/.exec(where))) { const v = m[1]; tests.push((r) => r.status === v); }
96
+ if ((m = /created_at > now\(\) - make_interval\(days => \$(\d+)::int\)/.exec(where))) {
97
+ const days = Number(p(m[1]));
98
+ tests.push((r) => r.created_at > Date.now() - days * DAY);
99
+ }
100
+
101
+ for (const ph of rawWhere.matchAll(/\$(\d+)/g)) {
102
+ if (!consumed.has(ph[1])) {
103
+ throw new Error(`fake pool: unread predicate on $${ph[1]} in "${rawWhere}" — the interpreter must never silently ignore a filter`);
104
+ }
105
+ }
106
+ return (r) => tests.every((fn) => fn(r));
107
+ }
108
+
109
+ const ACCOUNT = {
110
+ id: 1, github_id: String(ME), github_login: 'octo', display_name: 'Octo', avatar_url: null,
111
+ profile_state: 'active', hide_stats: false, account_visibility: 'public',
112
+ recruiter_discoverable: null, handle: 'octo', previous_handle: null,
113
+ };
114
+
115
+ const pool = {
116
+ query: async (text, params = []) => {
117
+ const sql = String(text).replace(/\s+/g, ' ').trim();
118
+
119
+ // The caller's own account (the profile_state floor every route reads first).
120
+ if (/FROM platform_identity_accounts WHERE github_id = \$1$/.test(sql)) {
121
+ return { rows: [{ ...ACCOUNT }], rowCount: 1 };
122
+ }
123
+
124
+ // accept / decline — one UPDATE, one WHERE.
125
+ let m = /^UPDATE platform_identity_connections SET status = '(accepted|rejected)'.*? WHERE (.+?) RETURNING/.exec(sql);
126
+ if (m) {
127
+ const hit = rows.filter(predicateFor(m[2], params));
128
+ for (const r of hit) { r.status = m[1]; r.resolved_at = Date.now(); }
129
+ return { rows: hit.map((r) => ({ id: String(r.id) })), rowCount: hit.length };
130
+ }
131
+
132
+ // remove / cancel — a DELETE branch and an UPDATE branch in one statement.
133
+ if (/^WITH removed AS \( DELETE FROM platform_identity_connections/.test(sql)) {
134
+ const del = /DELETE FROM platform_identity_connections WHERE (.+?) RETURNING/.exec(sql);
135
+ const upd = /UPDATE platform_identity_connections SET status = 'cancelled'.*? WHERE (.+?) RETURNING/.exec(sql);
136
+ assert.ok(del && upd, 'the remove statement lost one of its two branches');
137
+ const out = [];
138
+ const removed = rows.filter(predicateFor(del[1], params));
139
+ for (const r of removed) { rows = rows.filter((x) => x !== r); out.push({ outcome: 'removed' }); }
140
+ const cancelled = rows.filter(predicateFor(upd[1], params));
141
+ for (const r of cancelled) { r.status = 'cancelled'; r.resolved_at = Date.now(); out.push({ outcome: 'cancelled' }); }
142
+ return { rows: out, rowCount: out.length };
143
+ }
144
+
145
+ throw new Error(`fake pool: unsupported statement — ${sql}`);
146
+ },
147
+ };
148
+
149
+ // ===========================================================================
150
+ // The real router over that table
151
+ // ===========================================================================
152
+
153
+ const moduleApiPath = require.resolve('../src/module-api.js');
154
+ const helpers = require('../src/bongos/routes/_helpers.js');
155
+ require.cache[moduleApiPath] = {
156
+ id: moduleApiPath, filename: moduleApiPath, loaded: true,
157
+ exports: {
158
+ pool,
159
+ validateOrRespond: helpers.validateOrRespond,
160
+ parsePagination: helpers.parsePagination,
161
+ logger: () => ({ error() {}, warn() {}, info() {} }),
162
+ requireBuilder: (req, _res, next) => { req.builder = { github_id: ME, github_login: 'octo' }; next(); },
163
+ },
164
+ };
165
+ const buildConnectionRoutes = require('../modules/platform-identity/routes/connections.js');
166
+ const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
167
+
168
+ const app = express();
169
+ app.use(express.json());
170
+ app.use(attachFail);
171
+ app.use('/api/bongos', buildConnectionRoutes());
172
+ const server = app.listen(0, '127.0.0.1');
173
+ await new Promise((r) => server.once('listening', r));
174
+ const port = server.address().port;
175
+ after(() => server.close());
176
+
177
+ function call(method, path, body) {
178
+ return new Promise((resolve, reject) => {
179
+ const payload = body === undefined ? null : Buffer.from(JSON.stringify(body));
180
+ const req = http.request({
181
+ host: '127.0.0.1', port, method, path,
182
+ headers: payload ? { 'content-type': 'application/json', 'content-length': payload.length } : {},
183
+ }, (res) => {
184
+ const chunks = [];
185
+ res.on('data', (c) => chunks.push(c));
186
+ res.on('end', () => {
187
+ const text = Buffer.concat(chunks).toString('utf8');
188
+ let json = null; try { json = JSON.parse(text); } catch { /* 204 */ }
189
+ resolve({ status: res.statusCode, json });
190
+ });
191
+ });
192
+ req.on('error', reject);
193
+ req.end(payload);
194
+ });
195
+ }
196
+
197
+ // ===========================================================================
198
+ // The interpreter is the instrument, so it gets calibrated first
199
+ // ===========================================================================
200
+
201
+ test('the interpreter refuses a predicate it cannot read, rather than ignoring it', () => {
202
+ assert.throws(
203
+ () => predicateFor("id = $1 AND some_new_column = $2", [1, 2]),
204
+ /unread predicate on \$2/,
205
+ 'a filter the fake cannot model must be a loud error — otherwise a rewritten WHERE silently widens'
206
+ );
207
+ });
208
+
209
+ test('the interpreter really does narrow on the owner column', () => {
210
+ seed();
211
+ const mine = rows.filter(predicateFor("id = $1 AND addressee_github_id = $2::bigint AND status = 'pending'", [1, ME]));
212
+ const wide = rows.filter(predicateFor("id = $1 AND status = 'pending'", [2]));
213
+ assert.equal(mine.length, 1);
214
+ assert.equal(wide.length, 1, 'without the owner column the same shape matches a stranger row');
215
+ assert.equal(wide[0].addressee_github_id, BO);
216
+ });
217
+
218
+ // ===========================================================================
219
+ // The proof: another builder's row does not move
220
+ // ===========================================================================
221
+
222
+ test('accept: my own incoming request really is accepted', async () => {
223
+ seed();
224
+ const res = await call('POST', '/api/bongos/connections/1/accept');
225
+ assert.equal(res.status, 200);
226
+ assert.equal(row(1).status, 'accepted');
227
+ });
228
+
229
+ test("accept: a STRANGER'S request is 404 and the row is byte-for-byte untouched", async () => {
230
+ seed();
231
+ const before = { ...row(2) };
232
+ const res = await call('POST', '/api/bongos/connections/2/accept');
233
+ assert.equal(res.status, 404);
234
+ assert.deepEqual(row(2), before, 'Cy asked Bo — nothing I send may move that row');
235
+ });
236
+
237
+ test('accept: an EXPIRED request of mine cannot be accepted', async () => {
238
+ seed();
239
+ const before = { ...row(6) };
240
+ const res = await call('POST', '/api/bongos/connections/6/accept');
241
+ assert.equal(res.status, 404);
242
+ assert.deepEqual(row(6), before);
243
+ });
244
+
245
+ test('decline: mine flips to rejected — the row that IS the 90-day cooldown', async () => {
246
+ seed();
247
+ const res = await call('POST', '/api/bongos/connections/1/decline');
248
+ assert.equal(res.status, 204);
249
+ assert.equal(row(1).status, 'rejected');
250
+ assert.ok(row(1).resolved_at, 'resolved_at is what the cooldown window is measured from');
251
+ });
252
+
253
+ test("decline: a stranger's request is 404 and unmoved", async () => {
254
+ seed();
255
+ const before = { ...row(2) };
256
+ const res = await call('POST', '/api/bongos/connections/2/decline');
257
+ assert.equal(res.status, 404);
258
+ assert.deepEqual(row(2), before);
259
+ });
260
+
261
+ test('decline: an EXPIRED request cannot be turned into a fresh cooldown', async () => {
262
+ seed();
263
+ const before = { ...row(6) };
264
+ const res = await call('POST', '/api/bongos/connections/6/decline');
265
+ assert.equal(res.status, 404);
266
+ assert.deepEqual(row(6), before, 'a request ADR 0202 D2 §3 considers gone must not silently block the requester for 90 days');
267
+ });
268
+
269
+ test('remove: my own accepted connection is HARD-DELETED, leaving no tombstone', async () => {
270
+ seed();
271
+ const res = await call('DELETE', '/api/bongos/connections/3');
272
+ assert.equal(res.status, 200);
273
+ assert.deepEqual(res.json, { outcome: 'removed' });
274
+ assert.equal(row(3), undefined, 'no "ME removed CY" row survives — neither party consented to keep one');
275
+ });
276
+
277
+ test("remove: two strangers' connection is 404 and still there", async () => {
278
+ seed();
279
+ const before = { ...row(4) };
280
+ const res = await call('DELETE', '/api/bongos/connections/4');
281
+ assert.equal(res.status, 404);
282
+ assert.deepEqual(row(4), before, 'Bo and Cy know each other and I am not in that pair');
283
+ });
284
+
285
+ test('remove: a request I SENT is cancelled, not deleted', async () => {
286
+ seed();
287
+ const res = await call('DELETE', '/api/bongos/connections/5');
288
+ assert.equal(res.status, 200);
289
+ assert.deepEqual(res.json, { outcome: 'cancelled' });
290
+ assert.equal(row(5).status, 'cancelled');
291
+ });
292
+
293
+ test('remove: a request SENT TO ME cannot be deleted — that route is decline, which arms the cooldown', async () => {
294
+ seed();
295
+ const before = { ...row(1) };
296
+ const res = await call('DELETE', '/api/bongos/connections/1');
297
+ assert.equal(res.status, 404);
298
+ assert.deepEqual(row(1), before, 'otherwise an addressee dodges the 90-day cooldown by calling a decline a delete');
299
+ });
300
+
301
+ test('an id nobody owns changes nothing at all', async () => {
302
+ seed();
303
+ const snapshot = JSON.stringify(rows);
304
+ for (const [method, path] of [
305
+ ['POST', '/api/bongos/connections/999/accept'],
306
+ ['POST', '/api/bongos/connections/999/decline'],
307
+ ['DELETE', '/api/bongos/connections/999'],
308
+ ]) {
309
+ const res = await call(method, path);
310
+ assert.equal(res.status, 404, `${method} ${path}`);
311
+ }
312
+ assert.equal(JSON.stringify(rows), snapshot, 'the table is bit-identical after three misses');
313
+ });
@@ -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.',
@@ -89,6 +94,11 @@ const OWN_SCOPED_SURFACES = [
89
94
  why: "the builder's OWN cross-project profile controls (hide flag, handle, bio/links) — all keyed on req.builder.github_id.",
90
95
  crossOwner: [],
91
96
  },
97
+ {
98
+ file: 'modules/platform-identity/routes/connections.js',
99
+ why: 'the connection graph (ADR 0202, task 1002295). The ACTING account is always the session; a counterparty is named by public handle or by the connection row id, never by an account identifier — and the degree label is a viewer-relative own-scope read (D4 §4).',
100
+ crossOwner: [],
101
+ },
92
102
  {
93
103
  file: 'modules/memory/routes/memory.js',
94
104
  why: 'per-builder memory (ADR 0024 §4). The self endpoints are own-scoped; the five cross-builder endpoints name their target in the path and are gate-verified below.',