@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,568 @@
|
|
|
1
|
+
// tests/connections_api.mjs — the connection graph API (R19, task 1002295 /
|
|
2
|
+
// goal 1000045 criterion C6; ADR 0202).
|
|
3
|
+
//
|
|
4
|
+
// WHAT THIS PINS, and why each one is a rule rather than a behaviour:
|
|
5
|
+
//
|
|
6
|
+
// 1. THE LIFECYCLE — request / accept / decline / remove / cancel / list.
|
|
7
|
+
// Including the two answers ADR 0202 D2 requires to be INDISTINGUISHABLE:
|
|
8
|
+
// a request that was written and a request swallowed by the 90-day decline
|
|
9
|
+
// cooldown. If those two responses ever differ by a byte, the silence a
|
|
10
|
+
// decline is supposed to buy is gone.
|
|
11
|
+
//
|
|
12
|
+
// 2. OWNERSHIP IS IN THE WHERE CLAUSE. A builder cannot act on someone
|
|
13
|
+
// else's connection, and the proof is not "a 403 happens" — it is that the
|
|
14
|
+
// acting github_id bound into every write is the SESSION's, never anything
|
|
15
|
+
// the request carried, and that a non-match answers 404 (a 403 would make
|
|
16
|
+
// the id space probeable). A hostile body naming another account changes
|
|
17
|
+
// nothing about the parameters.
|
|
18
|
+
//
|
|
19
|
+
// 3. THE DEGREE QUERY IS BOUNDED — the DoS surface. An unbounded traversal on
|
|
20
|
+
// a social graph is a denial-of-service primitive, so this asserts the caps
|
|
21
|
+
// on the SQL THAT ACTUALLY RAN (captured from the pool), not on a source
|
|
22
|
+
// guess: every level carries ORDER BY … LIMIT $n, the bound values are
|
|
23
|
+
// FANOUT_1/FANOUT_2, nothing is recursive, and the number of queries is
|
|
24
|
+
// constant no matter what the graph returns — the expansion stops at
|
|
25
|
+
// DEGREE_MAX and answers null rather than searching deeper.
|
|
26
|
+
//
|
|
27
|
+
// 4. THE LEAK-BY-SUBTRACTION BAN (ADR 0171 D4 / ADR 0202 D4) — every count in
|
|
28
|
+
// a response is the length of the array beside it, never a separate total.
|
|
29
|
+
//
|
|
30
|
+
// 5. NOTHING HERE IS AUTHORITY (ADR 0016) — no rank, permission or credit
|
|
31
|
+
// symbol appears anywhere in the feature.
|
|
32
|
+
//
|
|
33
|
+
// DB-free by construction: a recording fake pool, no Postgres, no network
|
|
34
|
+
// beyond loopback. Runs in the unit gate that actually executes on a PR — a
|
|
35
|
+
// proof parked in the integration lane would never run (task 1002282).
|
|
36
|
+
//
|
|
37
|
+
// Run: node tests/connections_api.mjs
|
|
38
|
+
|
|
39
|
+
import { strict as assert } from 'node:assert';
|
|
40
|
+
import { test, after } from 'node:test';
|
|
41
|
+
import { createRequire } from 'node:module';
|
|
42
|
+
import http from 'node:http';
|
|
43
|
+
import { readFileSync } from 'node:fs';
|
|
44
|
+
import { fileURLToPath } from 'node:url';
|
|
45
|
+
import { dirname, join } from 'node:path';
|
|
46
|
+
|
|
47
|
+
const require = createRequire(import.meta.url);
|
|
48
|
+
const express = require('express');
|
|
49
|
+
|
|
50
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
51
|
+
const MIGRATION = readFileSync(
|
|
52
|
+
join(ROOT, 'modules/platform-identity/migrations/platform_identity_015_connections.sql'), 'utf8');
|
|
53
|
+
const DOMAIN_SRC = readFileSync(join(ROOT, 'modules/platform-identity/connections.js'), 'utf8');
|
|
54
|
+
const ROUTE_SRC = readFileSync(join(ROOT, 'modules/platform-identity/routes/connections.js'), 'utf8');
|
|
55
|
+
|
|
56
|
+
// ---- the recording fake pool ----------------------------------------------
|
|
57
|
+
//
|
|
58
|
+
// One shared pool object, pinned into module-api below and resolved once at
|
|
59
|
+
// require time, so the route, the domain and each test's queue are the same
|
|
60
|
+
// thing. `calls` is the evidence the bound-parameter assertions read.
|
|
61
|
+
let queued = [];
|
|
62
|
+
let calls = [];
|
|
63
|
+
const pool = {
|
|
64
|
+
query: async (text, params) => {
|
|
65
|
+
calls.push({ text: String(text), params: params || [] });
|
|
66
|
+
const next = queued.shift();
|
|
67
|
+
if (next instanceof Error) throw next;
|
|
68
|
+
return next || { rows: [], rowCount: 0 };
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
function expect(...responses) { queued = responses; calls = []; }
|
|
72
|
+
const result = (rows = [], rowCount = null) => ({ rows, rowCount: rowCount ?? rows.length });
|
|
73
|
+
|
|
74
|
+
// ---- the route over the real domain, with core pinned ----------------------
|
|
75
|
+
|
|
76
|
+
const ME = { github_id: 4242, github_login: 'octo', display_name: 'Octo', avatar_url: 'https://a.example/o.png' };
|
|
77
|
+
const ADA = 9001;
|
|
78
|
+
|
|
79
|
+
// The live session the stub hands every route. Mutable so one test can prove a
|
|
80
|
+
// non-numeric principal (a core `system:*` builder) is refused cleanly.
|
|
81
|
+
let session = { ...ME };
|
|
82
|
+
|
|
83
|
+
const moduleApiPath = require.resolve('../src/module-api.js');
|
|
84
|
+
const helpers = require('../src/bongos/routes/_helpers.js');
|
|
85
|
+
require.cache[moduleApiPath] = {
|
|
86
|
+
id: moduleApiPath, filename: moduleApiPath, loaded: true,
|
|
87
|
+
exports: {
|
|
88
|
+
pool,
|
|
89
|
+
validateOrRespond: helpers.validateOrRespond,
|
|
90
|
+
parsePagination: helpers.parsePagination,
|
|
91
|
+
logger: () => ({ error() {}, warn() {}, info() {} }),
|
|
92
|
+
// The own-scope stub: a live session, exactly as requireBuilder leaves it.
|
|
93
|
+
requireBuilder: (req, _res, next) => { req.builder = { ...session }; next(); },
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
const conn = require('../modules/platform-identity/connections.js');
|
|
97
|
+
const buildConnectionRoutes = require('../modules/platform-identity/routes/connections.js');
|
|
98
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
99
|
+
|
|
100
|
+
const app = express();
|
|
101
|
+
app.use(express.json());
|
|
102
|
+
app.use(attachFail);
|
|
103
|
+
app.use('/api/bongos', buildConnectionRoutes());
|
|
104
|
+
const server = app.listen(0, '127.0.0.1');
|
|
105
|
+
await new Promise((r) => server.once('listening', r));
|
|
106
|
+
const port = server.address().port;
|
|
107
|
+
after(() => server.close());
|
|
108
|
+
|
|
109
|
+
function call(method, path, body) {
|
|
110
|
+
return new Promise((resolve, reject) => {
|
|
111
|
+
const payload = body === undefined ? null : Buffer.from(JSON.stringify(body));
|
|
112
|
+
const req = http.request({
|
|
113
|
+
host: '127.0.0.1', port, method, path,
|
|
114
|
+
headers: payload ? { 'content-type': 'application/json', 'content-length': payload.length } : {},
|
|
115
|
+
}, (res) => {
|
|
116
|
+
const chunks = [];
|
|
117
|
+
res.on('data', (c) => chunks.push(c));
|
|
118
|
+
res.on('end', () => {
|
|
119
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
120
|
+
let json = null; try { json = JSON.parse(text); } catch { /* non-JSON body */ }
|
|
121
|
+
resolve({ status: res.statusCode, json, text });
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
req.on('error', reject);
|
|
125
|
+
req.end(payload);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// The first query every route makes is the caller's own account (the
|
|
130
|
+
// profile_state='active' floor, ADR 0202 D4 §1).
|
|
131
|
+
const meActive = () => result([{ github_id: String(ME.github_id), profile_state: 'active', account_visibility: 'public', handle: 'octo' }]);
|
|
132
|
+
const account = (over = {}) => ({
|
|
133
|
+
github_id: String(ADA), handle: 'ada', display_name: 'Ada', avatar_url: null,
|
|
134
|
+
profile_state: 'active', account_visibility: 'public', ...over,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// ===========================================================================
|
|
138
|
+
// 1. The stored shape — the rules the database itself refuses to break
|
|
139
|
+
// ===========================================================================
|
|
140
|
+
|
|
141
|
+
test('migration 015 makes a duplicate and a reciprocal request DATABASE errors, not route bugs', () => {
|
|
142
|
+
assert.match(MIGRATION, /pair_lo bigint GENERATED ALWAYS AS \(LEAST\(requester_github_id, addressee_github_id\)\) STORED/,
|
|
143
|
+
'the unordered pair is derived, never written by a caller — A→B and B→A must collide');
|
|
144
|
+
assert.match(MIGRATION, /pair_hi bigint GENERATED ALWAYS AS \(GREATEST\(requester_github_id, addressee_github_id\)\) STORED/);
|
|
145
|
+
assert.match(MIGRATION, /CREATE UNIQUE INDEX IF NOT EXISTS uniq_platform_identity_connections_open[\s\S]*?\(pair_lo, pair_hi\)[\s\S]*?WHERE status IN \('pending', 'accepted'\)/,
|
|
146
|
+
'ONE open relationship per pair, and only the OPEN statuses — a resolved row must never block a later ask');
|
|
147
|
+
assert.match(MIGRATION, /CHECK \(requester_github_id <> addressee_github_id\)/, 'no self-connection');
|
|
148
|
+
assert.match(MIGRATION, /CHECK \(status IN \('pending', 'accepted', 'rejected', 'cancelled', 'expired'\)\)/,
|
|
149
|
+
'the five statuses ADR 0202 D2 named — the engine refuses a sixth');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('the note cap is a database constraint, not just a route rule', () => {
|
|
153
|
+
const m = MIGRATION.match(/CHECK \(note IS NULL OR length\(note\) <= (\d+)\)/);
|
|
154
|
+
assert.ok(m, 'requestConnection is an exported domain function — the cap cannot live only in the route');
|
|
155
|
+
assert.equal(Number(m[1]), conn.NOTE_MAX_CHARS, 'the SQL cap and NOTE_MAX_CHARS must not drift');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('the migration CHECK and the exported vocabulary cannot drift', () => {
|
|
159
|
+
const inSql = MIGRATION.match(/CHECK \(status IN \(([^)]*)\)\)/)[1]
|
|
160
|
+
.split(',').map((s) => s.trim().replace(/'/g, ''));
|
|
161
|
+
assert.deepEqual(inSql, conn.CONNECTION_STATUSES);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// ===========================================================================
|
|
165
|
+
// 2. The lifecycle
|
|
166
|
+
// ===========================================================================
|
|
167
|
+
|
|
168
|
+
test('request: a real create answers 201 { status: "pending" } and writes ONE statement', async () => {
|
|
169
|
+
expect(meActive(), result([account()]), result([{ id: '77' }]));
|
|
170
|
+
const res = await call('POST', '/api/bongos/connections', { handle: 'ada' });
|
|
171
|
+
assert.equal(res.status, 201);
|
|
172
|
+
assert.deepEqual(res.json, { status: 'pending' });
|
|
173
|
+
assert.equal(calls.length, 3, 'own account, handle lookup, and ONE write');
|
|
174
|
+
const write = calls[2];
|
|
175
|
+
assert.match(write.text, /INSERT INTO platform_identity_connections/);
|
|
176
|
+
assert.deepEqual(write.params.slice(0, 4),
|
|
177
|
+
[ME.github_id, String(ADA), conn.REQUEST_EXPIRY_DAYS, conn.DECLINE_COOLDOWN_DAYS],
|
|
178
|
+
'the requester is the SESSION; expiry and cooldown are the ADR constants, bound not interpolated');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('request: the write resolves an expired row in the SAME statement, and drains it FIRST', () => {
|
|
182
|
+
expect(); // no queries — this reads the SQL the previous test captured shape-wise
|
|
183
|
+
const sql = conn.requestConnection.toString();
|
|
184
|
+
assert.match(sql, /WITH expired AS \(\s*UPDATE platform_identity_connections/,
|
|
185
|
+
'expiry is resolved in the same statement that inserts (now() cannot live in an index predicate)');
|
|
186
|
+
assert.match(sql, /guard AS \(SELECT count\(\*\)::int AS expired_count FROM expired\)/);
|
|
187
|
+
assert.match(sql, /FROM guard/,
|
|
188
|
+
'the main query MUST reference the modifying CTE: an unreferenced one runs AFTER the insert '
|
|
189
|
+
+ '(ExecPostprocessPlan) and would hit the very unique index the expiry exists to release');
|
|
190
|
+
assert.match(sql, /status = 'rejected'[\s\S]*?resolved_at > now\(\) - make_interval\(days => \$4::int\)/,
|
|
191
|
+
'the cooldown is a NOT EXISTS on the same statement — a swallowed re-ask inserts zero rows');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('request: a re-ask inside the decline cooldown is SWALLOWED and answers IDENTICALLY', async () => {
|
|
195
|
+
expect(meActive(), result([account()]), result([{ id: '77' }]));
|
|
196
|
+
const created = await call('POST', '/api/bongos/connections', { handle: 'ada' });
|
|
197
|
+
|
|
198
|
+
// Same call, but the insert matched zero rows because the cooldown NOT EXISTS
|
|
199
|
+
// held — nothing was written.
|
|
200
|
+
expect(meActive(), result([account()]), result([], 0));
|
|
201
|
+
const swallowed = await call('POST', '/api/bongos/connections', { handle: 'ada' });
|
|
202
|
+
|
|
203
|
+
assert.equal(swallowed.status, created.status);
|
|
204
|
+
assert.equal(swallowed.text, created.text,
|
|
205
|
+
'byte-identical, or the requester can difference the two and learn they were declined — '
|
|
206
|
+
+ 'which is exactly what ADR 0202 D2 §1/§2 keep quiet');
|
|
207
|
+
assert.doesNotMatch(created.text, /\bid\b/, 'a real create returns no id, so a swallowed one has none to fake');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('request: an already-open pair is a 409 — safe to name, since the caller can already read it', async () => {
|
|
211
|
+
const dup = Object.assign(new Error('duplicate key'), { code: '23505' });
|
|
212
|
+
expect(meActive(), result([account()]), dup);
|
|
213
|
+
const res = await call('POST', '/api/bongos/connections', { handle: 'ada' });
|
|
214
|
+
assert.equal(res.status, 409);
|
|
215
|
+
assert.equal(res.json.error.code, 'connection_already_open');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('request: a PRIVATE stranger answers 404, never 403 — a 403 confirms the handle exists', async () => {
|
|
219
|
+
expect(meActive(), result([account({ account_visibility: 'private' })]), result([], 0));
|
|
220
|
+
const res = await call('POST', '/api/bongos/connections', { handle: 'ada' });
|
|
221
|
+
assert.equal(res.status, 404);
|
|
222
|
+
assert.equal(res.json.error.code, 'connection_target_not_found');
|
|
223
|
+
assert.equal(calls.length, 3, 'it never reached the write');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test('request: a provisional account and an unknown handle are the SAME answer as private', async () => {
|
|
227
|
+
expect(meActive(), result([account({ profile_state: 'provisional' })]));
|
|
228
|
+
const provisional = await call('POST', '/api/bongos/connections', { handle: 'ada' });
|
|
229
|
+
expect(meActive(), result([]));
|
|
230
|
+
const unknown = await call('POST', '/api/bongos/connections', { handle: 'nobody' });
|
|
231
|
+
assert.equal(provisional.status, 404);
|
|
232
|
+
assert.equal(unknown.text, provisional.text, 'absent, provisional and private are one indistinguishable 404');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('request: a PRIVATE account already connected to the viewer IS addressable (D4 §2)', async () => {
|
|
236
|
+
// Private, but an accepted edge exists → visibleTarget resolves, so the write runs.
|
|
237
|
+
expect(meActive(), result([account({ account_visibility: 'private' })]), result([{ '?column?': 1 }]), result([{ id: '80' }]));
|
|
238
|
+
const res = await call('POST', '/api/bongos/connections', { handle: 'ada' });
|
|
239
|
+
assert.equal(res.status, 201);
|
|
240
|
+
assert.equal(calls.length, 4);
|
|
241
|
+
assert.match(calls[2].text, /pair_lo = LEAST\(\$1::bigint, \$2::bigint\)/,
|
|
242
|
+
'the "am I connected" probe is the symmetric pair key, not a direction-aware WHERE');
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test('request: a provisional CALLER cannot send at all — the feature floors on active (D4 §1)', async () => {
|
|
246
|
+
expect(result([{ github_id: String(ME.github_id), profile_state: 'provisional' }]));
|
|
247
|
+
const res = await call('POST', '/api/bongos/connections', { handle: 'ada' });
|
|
248
|
+
assert.equal(res.status, 403);
|
|
249
|
+
assert.equal(res.json.error.code, 'profile_provisional');
|
|
250
|
+
assert.equal(calls.length, 1, 'it never looked the target up');
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test('accept: the addressee is the SESSION, bound into the WHERE clause', async () => {
|
|
254
|
+
expect(meActive(), result([{ id: '77' }]));
|
|
255
|
+
const res = await call('POST', '/api/bongos/connections/77/accept');
|
|
256
|
+
assert.equal(res.status, 200);
|
|
257
|
+
assert.deepEqual(res.json, { status: 'accepted' });
|
|
258
|
+
const w = calls[1];
|
|
259
|
+
assert.match(w.text, /SET status = 'accepted'[\s\S]*WHERE id = \$1 AND addressee_github_id = \$2::bigint AND status = 'pending'/);
|
|
260
|
+
assert.match(w.text, /created_at > now\(\) - make_interval\(days => \$3::int\)/, 'an expired request cannot be accepted');
|
|
261
|
+
assert.deepEqual(w.params, ['77', ME.github_id, conn.REQUEST_EXPIRY_DAYS]);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('decline: silent (204), and the row is KEPT because rejected IS the cooldown', async () => {
|
|
265
|
+
expect(meActive(), result([{ id: '77' }]));
|
|
266
|
+
const res = await call('POST', '/api/bongos/connections/77/decline');
|
|
267
|
+
assert.equal(res.status, 204);
|
|
268
|
+
assert.equal(res.text, '', 'nothing is returned — and nothing is emitted to the requester');
|
|
269
|
+
assert.match(calls[1].text, /SET status = 'rejected'/);
|
|
270
|
+
assert.doesNotMatch(calls[1].text, /DELETE/, 'a decline never deletes: the row is the 90-day cooldown');
|
|
271
|
+
assert.match(calls[1].text, /created_at > now\(\) - make_interval\(days => \$3::int\)/,
|
|
272
|
+
'an ALREADY-EXPIRED request must not be turnable into a fresh 90-day cooldown — '
|
|
273
|
+
+ 'accept guards on expiry and decline has to guard identically');
|
|
274
|
+
assert.deepEqual(calls[1].params, ['77', ME.github_id, conn.REQUEST_EXPIRY_DAYS]);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('remove: an accepted row is HARD-DELETED; a pending one I sent is cancelled', async () => {
|
|
278
|
+
expect(meActive(), result([{ outcome: 'removed' }]));
|
|
279
|
+
const removed = await call('DELETE', '/api/bongos/connections/77');
|
|
280
|
+
assert.deepEqual(removed.json, { outcome: 'removed' });
|
|
281
|
+
const sql = calls[1].text;
|
|
282
|
+
assert.match(sql, /DELETE FROM platform_identity_connections[\s\S]*status = 'accepted'[\s\S]*\(requester_github_id = \$2::bigint OR addressee_github_id = \$2::bigint\)/,
|
|
283
|
+
'either side may end a consensual connection — and no tombstone is written');
|
|
284
|
+
assert.match(sql, /SET status = 'cancelled'[\s\S]*status = 'pending' AND requester_github_id = \$2::bigint/,
|
|
285
|
+
'only the REQUESTER may cancel; an addressee ending a pending row must decline, which arms the cooldown');
|
|
286
|
+
assert.deepEqual(calls[1].params, ['77', ME.github_id]);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test('list: my connections, the count IS the array length, and paging is real', async () => {
|
|
290
|
+
const rows = [
|
|
291
|
+
{ id: '1', connected_at: null, handle: 'ada', display_name: 'Ada', avatar_url: null },
|
|
292
|
+
{ id: '2', connected_at: null, handle: 'bob', display_name: 'Bob', avatar_url: null },
|
|
293
|
+
];
|
|
294
|
+
expect(meActive(), result(rows));
|
|
295
|
+
const res = await call('GET', '/api/bongos/connections?limit=2&offset=4');
|
|
296
|
+
assert.equal(res.status, 200);
|
|
297
|
+
assert.equal(res.json.count, res.json.connections.length);
|
|
298
|
+
assert.equal(res.json.count, 2);
|
|
299
|
+
assert.match(calls[1].text, /LIMIT \$2 OFFSET \$3/,
|
|
300
|
+
'a hard cap with no OFFSET would make the 501st connection unreachable while count still read 500');
|
|
301
|
+
assert.deepEqual(calls[1].params, [ME.github_id, 2, 4]);
|
|
302
|
+
assert.match(calls[1].text, /\(pa\.profile_state = 'active'\)/,
|
|
303
|
+
'own scope floors on consent alone — a private account you connected with appears HERE (D4 §2)');
|
|
304
|
+
assert.doesNotMatch(calls[1].text, /account_visibility/,
|
|
305
|
+
"…and applying the PUBLIC predicate here would erase your own connections from your own list");
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test('list: the limit is clamped to FANOUT_1 however large a caller asks', async () => {
|
|
309
|
+
expect(meActive(), result([]));
|
|
310
|
+
await call('GET', '/api/bongos/connections?limit=999999');
|
|
311
|
+
assert.deepEqual(calls[1].params, [ME.github_id, conn.FANOUT_1, 0]);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test('requests: each direction carries its OWN limit, so a busy outbox cannot empty the inbox', async () => {
|
|
315
|
+
expect(meActive(), result([]));
|
|
316
|
+
await call('GET', '/api/bongos/connections/requests');
|
|
317
|
+
const sql = calls[1].text;
|
|
318
|
+
assert.equal((sql.match(/LIMIT \$3/g) || []).length, 2,
|
|
319
|
+
'ONE cap applied before the split would let 500 outgoing requests push every incoming one out '
|
|
320
|
+
+ 'of the response, reporting incoming_count: 0 with nothing saying it was a truncation');
|
|
321
|
+
assert.match(sql, /c\.created_at > now\(\) - make_interval\(days => \$2::int\)/,
|
|
322
|
+
'expiry is read-time — an expired request is absent from both queues, with no sweeper');
|
|
323
|
+
assert.deepEqual(calls[1].params, [ME.github_id, conn.REQUEST_EXPIRY_DAYS, conn.FANOUT_1]);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test('requests: seen_at rides the inbox and is STRIPPED from the outbox', async () => {
|
|
327
|
+
const rows = [
|
|
328
|
+
{ id: '5', note: null, created_at: null, seen_at: null, incoming: true, handle: 'ada', display_name: 'Ada', avatar_url: null },
|
|
329
|
+
{ id: '6', note: 'hi', created_at: null, seen_at: '2026-08-29T00:00:00Z', incoming: false, handle: 'bob', display_name: 'Bob', avatar_url: null },
|
|
330
|
+
];
|
|
331
|
+
expect(meActive(), result(rows), result([], 1));
|
|
332
|
+
const res = await call('GET', '/api/bongos/connections/requests');
|
|
333
|
+
assert.equal(res.json.incoming.length, 1);
|
|
334
|
+
assert.equal(res.json.outgoing.length, 1);
|
|
335
|
+
assert.equal(res.json.incoming_count, res.json.incoming.length);
|
|
336
|
+
assert.equal(res.json.outgoing_count, res.json.outgoing.length);
|
|
337
|
+
assert.ok(!('incoming' in res.json.incoming[0]), 'the discriminator is consumed, not published');
|
|
338
|
+
assert.ok('seen_at' in res.json.incoming[0], 'my own inbox may say what I have opened');
|
|
339
|
+
assert.ok(!('seen_at' in res.json.outgoing[0]),
|
|
340
|
+
'a read receipt on the OUTBOX would tell a requester the addressee is deliberating — '
|
|
341
|
+
+ 'the migration comment and ADR 0202 D2 §1 both promise that never crosses');
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test('requests: the seen_at stamp is scoped to the rows this call actually rendered', async () => {
|
|
345
|
+
const rows = [
|
|
346
|
+
{ id: '5', note: null, created_at: null, seen_at: null, incoming: true, handle: 'ada', display_name: 'Ada', avatar_url: null },
|
|
347
|
+
{ id: '7', note: null, created_at: null, seen_at: '2026-08-01T00:00:00Z', incoming: true, handle: 'cy', display_name: 'Cy', avatar_url: null },
|
|
348
|
+
];
|
|
349
|
+
expect(meActive(), result(rows), result([], 1));
|
|
350
|
+
await call('GET', '/api/bongos/connections/requests');
|
|
351
|
+
assert.equal(calls.length, 3);
|
|
352
|
+
assert.match(calls[2].text, /WHERE id = ANY\(\$1::bigint\[\]\) AND addressee_github_id = \$2::bigint/,
|
|
353
|
+
'an unscoped UPDATE would stamp expired and filtered-out rows too, so seen_at would mean '
|
|
354
|
+
+ '"your inbox was polled" rather than "this row was delivered"');
|
|
355
|
+
assert.deepEqual(calls[2].params, [['5'], ME.github_id], 'only the unseen row that was rendered');
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test('requests: nothing unseen means no write at all — a GET does not have to be a write', async () => {
|
|
359
|
+
const rows = [{ id: '5', note: null, created_at: null, seen_at: '2026-08-01T00:00:00Z', incoming: true, handle: 'ada', display_name: 'Ada', avatar_url: null }];
|
|
360
|
+
expect(meActive(), result(rows));
|
|
361
|
+
await call('GET', '/api/bongos/connections/requests');
|
|
362
|
+
assert.equal(calls.length, 2, 'the stamp is skipped when there is nothing to stamp');
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
// ===========================================================================
|
|
366
|
+
// 3. A builder cannot act on someone else's connection
|
|
367
|
+
// ===========================================================================
|
|
368
|
+
|
|
369
|
+
test('every write binds the SESSION as the acting account — a hostile body changes nothing', async () => {
|
|
370
|
+
const hostile = { github_id: 1, builder_id: 1, owner: 1, requester_github_id: 1, addressee_github_id: 1 };
|
|
371
|
+
for (const [method, path] of [
|
|
372
|
+
['POST', '/api/bongos/connections/77/accept'],
|
|
373
|
+
['POST', '/api/bongos/connections/77/decline'],
|
|
374
|
+
['DELETE', '/api/bongos/connections/77'],
|
|
375
|
+
]) {
|
|
376
|
+
expect(meActive(), result([{ id: '77', outcome: 'removed' }]));
|
|
377
|
+
await call(method, path, hostile);
|
|
378
|
+
const params = calls[1].params;
|
|
379
|
+
assert.equal(params[1], ME.github_id, `${method} ${path} bound the session, not the body`);
|
|
380
|
+
assert.ok(!params.includes(1) && !params.includes('1'),
|
|
381
|
+
`${method} ${path} let a body value reach the query parameters`);
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test("someone else's connection row simply does not match — and the answer is 404, not 403", async () => {
|
|
386
|
+
for (const [method, path, code] of [
|
|
387
|
+
['POST', '/api/bongos/connections/77/accept', 'connection_request_not_found'],
|
|
388
|
+
['POST', '/api/bongos/connections/77/decline', 'connection_request_not_found'],
|
|
389
|
+
['DELETE', '/api/bongos/connections/77', 'connection_not_found'],
|
|
390
|
+
]) {
|
|
391
|
+
expect(meActive(), result([], 0)); // the WHERE clause matched nothing
|
|
392
|
+
const res = await call(method, path);
|
|
393
|
+
assert.equal(res.status, 404, `${method} ${path} must 404, never 403 — a 403 makes the id space probeable`);
|
|
394
|
+
assert.equal(res.json.error.code, code);
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test('an id too large for bigint is 404 too — a 500 there is a working probe', async () => {
|
|
399
|
+
expect(meActive());
|
|
400
|
+
const res = await call('POST', '/api/bongos/connections/99999999999999999999999/accept');
|
|
401
|
+
assert.equal(res.status, 404);
|
|
402
|
+
assert.equal(calls.length, 0, 'it must not reach Postgres and raise 22003 as a 500');
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test('a non-numeric connection id is 404 too, and never reaches the database', async () => {
|
|
406
|
+
expect(meActive());
|
|
407
|
+
const res = await call('POST', '/api/bongos/connections/1%20OR%201=1/accept');
|
|
408
|
+
assert.equal(res.status, 404);
|
|
409
|
+
assert.equal(calls.length, 0, 'refused before any query — ids are bound, and a malformed one is not even bound');
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
test('no route in this surface reads its subject from the request', () => {
|
|
413
|
+
// The structural twin of the above: tests/own_scope_guard.mjs inventories this
|
|
414
|
+
// file, so a future edit that adds ?builder_id= fails there. This asserts the
|
|
415
|
+
// inventory entry exists, so the guard cannot be dodged by deleting it.
|
|
416
|
+
const guard = readFileSync(join(ROOT, 'tests/own_scope_guard.mjs'), 'utf8');
|
|
417
|
+
assert.match(guard, /file: 'modules\/platform-identity\/routes\/connections\.js'/,
|
|
418
|
+
'the connections surface must stay in the own-scope inventory');
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
// ===========================================================================
|
|
422
|
+
// 4. The degree query is BOUNDED — the DoS surface
|
|
423
|
+
// ===========================================================================
|
|
424
|
+
|
|
425
|
+
const degreeCall = (handle = 'ada') => call('GET', `/api/bongos/connections/degree/${handle}`);
|
|
426
|
+
|
|
427
|
+
// Every LIMIT in a traversal query must be a BOUND parameter and must be
|
|
428
|
+
// preceded by an ORDER BY on the frontier key, so a truncation is reproducible
|
|
429
|
+
// rather than a different answer each call (ADR 0202 D3).
|
|
430
|
+
function assertBounded(sql, label) {
|
|
431
|
+
const limits = sql.match(/LIMIT \$\d+/g) || [];
|
|
432
|
+
assert.ok(limits.length >= 2, `${label}: every expansion level must carry a LIMIT (found ${limits.length})`);
|
|
433
|
+
assert.equal((sql.match(/LIMIT\s+\d/g) || []).length, 0, `${label}: a LIMIT must be a bound parameter, never a literal`);
|
|
434
|
+
assert.equal((sql.match(/ORDER BY [a-z]+\.gid LIMIT \$\d+/g) || []).length, limits.length,
|
|
435
|
+
`${label}: every LIMIT must follow ORDER BY <alias>.gid — an unordered truncation answers differently each call`);
|
|
436
|
+
assert.doesNotMatch(sql, /RECURSIVE/, `${label}: no recursive term — a recursive CTE cannot carry a PER-LEVEL cap`);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
test('degree: 1st degree costs ONE bounded query and never expands the frontier', async () => {
|
|
440
|
+
expect(meActive(), result([account()]),
|
|
441
|
+
result([{ is_first: true, v1_count: 3, mutuals: [] }]));
|
|
442
|
+
const res = await degreeCall();
|
|
443
|
+
assert.equal(res.json.degree, 1);
|
|
444
|
+
assert.equal(calls.length, 3, 'the FANOUT_2 expansion must not run when the near query already answered');
|
|
445
|
+
assertBounded(calls[2].text, 'near');
|
|
446
|
+
assert.deepEqual(calls[2].params, [ME.github_id, String(ADA), conn.FANOUT_1]);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
test('degree: 1st degree is an UNBOUNDED pair-index probe, not a scan of the capped frontier', () => {
|
|
450
|
+
// The bug this pins: reading is_first off `v1` (ORDER BY gid LIMIT 500) makes a
|
|
451
|
+
// viewer with 900 connections see "2nd" for someone who is in their own
|
|
452
|
+
// connections list, purely because that github_id sorts past the 500th.
|
|
453
|
+
const src = readFileSync(join(ROOT, 'modules/platform-identity/connections.js'), 'utf8');
|
|
454
|
+
assert.match(src, /EXISTS \(SELECT 1 FROM platform_identity_connections[\s\S]*?pair_lo = LEAST\(\$1::bigint, \$2::bigint\)[\s\S]*?\) AS is_first/,
|
|
455
|
+
'ADR 0202 D3: 1st degree is "one index hit on the accepted-pair index"');
|
|
456
|
+
assert.doesNotMatch(src, /FROM v1 WHERE v1\.gid = \$2::bigint\) AS is_first/,
|
|
457
|
+
'is_first must never be computed against a truncated frontier');
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test('degree: 2nd degree comes from the mutual intersection, still one query', async () => {
|
|
461
|
+
const mutuals = [{ handle: 'bob', display_name: 'Bob', avatar_url: null }];
|
|
462
|
+
expect(meActive(), result([account()]), result([{ is_first: false, v1_count: 9, mutuals }]));
|
|
463
|
+
const res = await degreeCall();
|
|
464
|
+
assert.equal(res.json.degree, 2);
|
|
465
|
+
assert.deepEqual(res.json.mutuals, mutuals);
|
|
466
|
+
assert.equal(calls.length, 3);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
test('degree: 3rd degree runs exactly ONE more query, capped at FANOUT_1 then FANOUT_2', async () => {
|
|
470
|
+
expect(meActive(), result([account()]),
|
|
471
|
+
result([{ is_first: false, v1_count: 9, mutuals: [] }]),
|
|
472
|
+
result([{ is_third: true, v2_count: 400 }]));
|
|
473
|
+
const res = await degreeCall();
|
|
474
|
+
assert.equal(res.json.degree, 3);
|
|
475
|
+
assert.equal(res.json.degree, conn.DEGREE_MAX);
|
|
476
|
+
assert.equal(calls.length, 4, 'two graph queries, always — the expansion is staged, not open-ended');
|
|
477
|
+
const far = calls[3];
|
|
478
|
+
assertBounded(far.text, 'far');
|
|
479
|
+
assert.deepEqual(far.params, [ME.github_id, String(ADA), conn.FANOUT_1, conn.FANOUT_2],
|
|
480
|
+
'the caps are the ADR 0202 D3 constants, bound as parameters');
|
|
481
|
+
assert.match(far.text, /\(pa\.profile_state = 'active' AND pa\.account_visibility = 'public'\)/,
|
|
482
|
+
'a hop BEYOND the viewer\'s own connections must clear the full public predicate');
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
test('degree: past DEGREE_MAX the answer is null — "out of network", not a deeper search', async () => {
|
|
486
|
+
expect(meActive(), result([account()]),
|
|
487
|
+
result([{ is_first: false, v1_count: 500, mutuals: [] }]),
|
|
488
|
+
result([{ is_third: false, v2_count: 5000 }]));
|
|
489
|
+
const res = await degreeCall();
|
|
490
|
+
assert.equal(res.json.degree, null, 'never a number the query did not establish');
|
|
491
|
+
assert.equal(res.json.truncated, true, 'both caps bit, and the response says so honestly');
|
|
492
|
+
assert.equal(calls.length, 4, 'the query count is CONSTANT — the traversal cannot be made to run longer');
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
test('degree: the query count does not grow with the graph — the DoS bound', async () => {
|
|
496
|
+
// The pool hands back the biggest frontier the caps allow. If the expansion
|
|
497
|
+
// were driven by result size (or recursed), this would issue more queries.
|
|
498
|
+
const huge = Array.from({ length: conn.FANOUT_1 }, (_, i) => ({ handle: `m${i}`, display_name: null, avatar_url: null }));
|
|
499
|
+
expect(meActive(), result([account()]), result([{ is_first: false, v1_count: conn.FANOUT_1, mutuals: huge }]));
|
|
500
|
+
const res = await degreeCall();
|
|
501
|
+
assert.equal(calls.length, 3);
|
|
502
|
+
assert.equal(res.json.degree, 2);
|
|
503
|
+
assert.equal(res.json.mutual_count, res.json.mutuals.length,
|
|
504
|
+
'the count is the length of the array the viewer may see — never a total beside a filtered list');
|
|
505
|
+
assert.equal(res.json.mutual_count, conn.FANOUT_1, 'and the array itself is capped by FANOUT_1');
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
test('degree: measuring yourself is 0 and costs no graph query at all', async () => {
|
|
509
|
+
expect(meActive(), result([account({ github_id: String(ME.github_id), handle: 'octo' })]));
|
|
510
|
+
const res = await degreeCall('octo');
|
|
511
|
+
assert.equal(res.json.degree, 0);
|
|
512
|
+
assert.deepEqual(res.json.mutuals, []);
|
|
513
|
+
assert.equal(calls.length, 2);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
test('degree: an invisible target is 404 before any traversal runs', async () => {
|
|
517
|
+
expect(meActive(), result([account({ account_visibility: 'private' })]), result([], 0));
|
|
518
|
+
const res = await degreeCall();
|
|
519
|
+
assert.equal(res.status, 404);
|
|
520
|
+
assert.equal(calls.length, 3, 'the expansion never started');
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
test('the caps are the ADR 0202 D3 numbers, and DEGREE_MAX really is 3', () => {
|
|
524
|
+
assert.equal(conn.DEGREE_MAX, 3);
|
|
525
|
+
assert.equal(conn.FANOUT_1, 500);
|
|
526
|
+
assert.equal(conn.FANOUT_2, 5000);
|
|
527
|
+
assert.equal(conn.REQUEST_EXPIRY_DAYS, 30);
|
|
528
|
+
assert.equal(conn.DECLINE_COOLDOWN_DAYS, 90);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
// ===========================================================================
|
|
532
|
+
// 5. Nothing here is authority (ADR 0016 / ADR 0141)
|
|
533
|
+
// ===========================================================================
|
|
534
|
+
|
|
535
|
+
test('no rank, permission or credit symbol appears anywhere in the feature', () => {
|
|
536
|
+
for (const [name, src] of [['domain', DOMAIN_SRC], ['routes', ROUTE_SRC]]) {
|
|
537
|
+
const code = src.replace(/\/\/[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, '');
|
|
538
|
+
for (const forbidden of [/requireRank/, /requirePermission/, /\bcredits\b/, /\bkarma\b/, /builders\.rank/, /LIVE_RANK_LADDER/]) {
|
|
539
|
+
assert.doesNotMatch(code, forbidden,
|
|
540
|
+
`${name}: a connection must never touch authority — ${forbidden} found (ADR 0016)`);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
assert.match(ROUTE_SRC, /requireBuilder/, '…while still requiring a live session on every route');
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
test('a system:* principal has no platform account and is refused cleanly, not with a cast error', async () => {
|
|
547
|
+
session = { github_id: 'system:bfg', github_login: 'bfg' };
|
|
548
|
+
try {
|
|
549
|
+
expect();
|
|
550
|
+
const res = await call('GET', '/api/bongos/connections');
|
|
551
|
+
assert.equal(res.status, 400);
|
|
552
|
+
assert.equal(res.json.error.code, 'no_platform_identity');
|
|
553
|
+
assert.equal(calls.length, 0, 'a non-numeric id must never be bound against the bigint column');
|
|
554
|
+
} finally {
|
|
555
|
+
session = { ...ME };
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
test('the responses are no-store — a hide or a visibility flip must beat any cache', async () => {
|
|
560
|
+
expect(meActive(), result([]));
|
|
561
|
+
const res = await new Promise((resolve, reject) => {
|
|
562
|
+
http.get({ host: '127.0.0.1', port, path: '/api/bongos/connections' }, (r) => {
|
|
563
|
+
r.resume();
|
|
564
|
+
r.on('end', () => resolve(r.headers));
|
|
565
|
+
}).on('error', reject);
|
|
566
|
+
});
|
|
567
|
+
assert.equal(res['cache-control'], 'no-store');
|
|
568
|
+
});
|