@bongos/core 1.19.418 → 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 +58 -23
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +16 -0
- package/clients/bongos-client/index.cjs +16 -0
- package/clients/bongos-client/index.d.ts +23 -0
- package/clients/bongos-client/index.mjs +16 -0
- package/docs/adr/0207-connections-implementation-departures-and-honest-limits.md +90 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +418 -3
- package/docs/api-reference.md +13 -1
- package/docs/module-api-changelog.md +2 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +16 -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/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 +5 -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
|
+
});
|
|
@@ -94,6 +94,11 @@ const OWN_SCOPED_SURFACES = [
|
|
|
94
94
|
why: "the builder's OWN cross-project profile controls (hide flag, handle, bio/links) — all keyed on req.builder.github_id.",
|
|
95
95
|
crossOwner: [],
|
|
96
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
|
+
},
|
|
97
102
|
{
|
|
98
103
|
file: 'modules/memory/routes/memory.js',
|
|
99
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.',
|