@bongos/core 1.19.1024 → 1.19.1026

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 (42) hide show
  1. package/.bongos-core.json +80 -40
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +4 -0
  4. package/clients/bongos-client/index.cjs +4 -0
  5. package/clients/bongos-client/index.d.ts +7 -1
  6. package/clients/bongos-client/index.mjs +4 -0
  7. package/docs/adr/0213-platform-terms-acceptance-is-the-hubs-own-witness.md +2 -0
  8. package/docs/api/openapi.json +106 -5
  9. package/docs/api-reference.md +7 -5
  10. package/docs/copy-inventory.md +19 -17
  11. package/docs/copy-registry.json +44 -36
  12. package/docs/module-api-changelog.md +5 -1
  13. package/migrations/core_255_access_requests_invite_kind.sql +52 -0
  14. package/modules/government/catalog.js +1 -1
  15. package/modules/hall-ui/public/goal-map.html +1 -1
  16. package/modules/onboarding/routes/access-requests.js +62 -11
  17. package/modules/platform-identity/community-leaderboard.js +235 -0
  18. package/modules/platform-identity/connection-suggestions.js +1 -1
  19. package/modules/platform-identity/migrations/platform_identity_024_terms_grandfathered.sql +68 -0
  20. package/modules/platform-identity/module.json +1 -1
  21. package/modules/platform-identity/read-rate-limit.js +61 -0
  22. package/modules/platform-identity/routes/community-leaderboard.js +69 -0
  23. package/modules/platform-identity/routes/community-search.js +4 -31
  24. package/modules/platform-identity/routes/projects.js +1 -1
  25. package/modules/platform-identity/terms-acceptance.js +34 -14
  26. package/modules/public-landing/public/terms.html +2 -1
  27. package/package-lock.json +2 -2
  28. package/package.json +1 -1
  29. package/scripts/gds/run-unit-tests.js +10 -0
  30. package/src/module-api.js +1 -1
  31. package/tests/access_requests_invite.mjs +81 -4
  32. package/tests/application_lifecycle.mjs +174 -6
  33. package/tests/community_leaderboard_db.mjs +202 -0
  34. package/tests/community_leaderboard_route.mjs +414 -0
  35. package/tests/connection_suggestions.mjs +1 -1
  36. package/tests/connections_api.mjs +1 -1
  37. package/tests/goal_map_page.mjs +8 -0
  38. package/tests/handle_machinery.mjs +2 -0
  39. package/tests/profile_connections_ui.mjs +2 -2
  40. package/tests/profile_rollup_consent.mjs +3 -0
  41. package/tests/terms_acceptance.mjs +264 -14
  42. package/tests/terms_grandfather_db.mjs +144 -0
@@ -0,0 +1,414 @@
1
+ // tests/community_leaderboard_route.mjs
2
+ //
3
+ // Guards GET /community/leaderboard — the PUBLIC windowed leaderboard (D3,
4
+ // docs/specs/bongos-v1-community-tab.md; task 1002978). DB-free. A fake pool
5
+ // asserts that BOTH real statements — the memoized window totals and the
6
+ // per-request eligibility read — carry the shared NUMBERS predicate
7
+ // (rollupVisibleSql('pa'), imported from account-visibility.js) and NOT the
8
+ // recruiting directory's (scoutingListableSql). Which rows the fake returns is
9
+ // a fixture simulation using the same JS twin (isRollupVisible); the window
10
+ // arithmetic and the matrix against real SQL are tests/community_leaderboard_db.mjs.
11
+ //
12
+ // What this pins:
13
+ // 1. the pure helpers — windows, windowStart, the limit clamp.
14
+ // 2. the statements: predicate present in both, recruiting predicate absent,
15
+ // $1 = the window start (null for all-time).
16
+ // 3. the memo: one totals computation per window per TTL however many
17
+ // requests, a builder who hides mid-TTL gone on the very next request, a
18
+ // failed computation never cached.
19
+ // 4. the route: 400s on a bad window or oversized discipline, the default
20
+ // window, ranking + `place` numbering across pages, github_id never
21
+ // crosses, the same body with or without credentials, no-store, the 500
22
+ // path, and the per-IP rate limit.
23
+ //
24
+ // Run: node tests/community_leaderboard_route.mjs
25
+
26
+ import { strict as assert } from 'node:assert';
27
+ import { createRequire } from 'node:module';
28
+ import http from 'node:http';
29
+
30
+ const require = createRequire(import.meta.url);
31
+ const express = require('express');
32
+
33
+ let passed = 0;
34
+ let failed = 0;
35
+ async function t(name, fn) {
36
+ try { await fn(); passed++; console.log(` ok ${name}`); }
37
+ catch (e) { failed++; console.error(` FAIL ${name}\n ${e.message}`); }
38
+ }
39
+
40
+ const {
41
+ isRollupVisible, rollupVisibleSql, scoutingListableSql,
42
+ } = require('../modules/platform-identity/account-visibility.js');
43
+
44
+ // ---- fake engine ------------------------------------------------------------
45
+
46
+ const ACCEPTED_AT = '2026-08-29T00:00:00.000Z';
47
+ const account = (id, handle, extra = {}) => ({
48
+ github_id: id, handle, github_login: handle.replace(/-/g, ''), display_name: handle, avatar_url: null,
49
+ profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'public', hide_stats: false,
50
+ recruiter_discoverable: null, ...extra,
51
+ });
52
+
53
+ // Deliberately NOT in rank order — the module ranks, the fake does not.
54
+ const BOARD = [
55
+ { acct: account('205', 'third-builder'), credits: 50, tasks_shipped: 1, karma: 0, disciplines: ['artist', 'engineer'] },
56
+ { acct: account('201', 'hidden-builder', { hide_stats: true }), credits: 250, tasks_shipped: 8, karma: 1, disciplines: ['artist'] },
57
+ { acct: account('200', 'top-builder'), credits: 300, tasks_shipped: 9, karma: 4, disciplines: ['engineer'] },
58
+ { acct: account('202', 'private-builder', { account_visibility: 'private' }), credits: 240, tasks_shipped: 7, karma: 1, disciplines: ['engineer'] },
59
+ { acct: account('203', 'prov-builder', { profile_state: 'provisional', terms_accepted_at: null }), credits: 230, tasks_shipped: 7, karma: 1, disciplines: ['engineer'] },
60
+ // Opted out of RECRUITING only — spec D3: that opt-out does not touch a leaderboard.
61
+ { acct: account('204', 'optout-builder', { recruiter_discoverable: false }), credits: 120, tasks_shipped: 3, karma: 0, disciplines: ['ui'] },
62
+ // Ties top-builder on every number: the handle decides, so it ranks second.
63
+ { acct: account('206', 'twin-builder'), credits: 300, tasks_shipped: 9, karma: 4, disciplines: ['ui'] },
64
+ ];
65
+ const byId = (id) => BOARD.find((r) => r.acct.github_id === String(id));
66
+
67
+ const isTotals = (sql) => /platform_identity_activity_snapshots/.test(sql);
68
+ const engine = {
69
+ calls: [],
70
+ throwOnQuery: null,
71
+ query: async (text, params = []) => {
72
+ const sql = String(text);
73
+ engine.calls.push({ sql, params });
74
+ if (engine.throwOnQuery) throw engine.throwOnQuery;
75
+ if (isTotals(sql)) {
76
+ // node-postgres hands int8 back as a string — the shape the real sums arrive in.
77
+ return {
78
+ rows: BOARD.filter((r) => isRollupVisible(r.acct)).map((r) => ({
79
+ github_id: r.acct.github_id, credits: String(r.credits), tasks_shipped: String(r.tasks_shipped),
80
+ karma: String(r.karma), disciplines: r.disciplines,
81
+ })),
82
+ };
83
+ }
84
+ assert.match(sql, /github_id = ANY\(\$1::bigint\[\]\)/, 'the only other statement is the eligibility read');
85
+ const [ids] = params;
86
+ return {
87
+ rows: ids.map(byId).filter((r) => r && isRollupVisible(r.acct)).map((r) => ({
88
+ github_id: r.acct.github_id, handle: r.acct.handle, github_login: r.acct.github_login,
89
+ display_name: r.acct.display_name, avatar_url: r.acct.avatar_url,
90
+ })),
91
+ };
92
+ },
93
+ };
94
+ const totalsCalls = () => engine.calls.filter((c) => isTotals(c.sql));
95
+ const lastTotalsCall = () => totalsCalls()[totalsCalls().length - 1];
96
+
97
+ // Pin the module doorway (ADR 0083) BEFORE the modules load.
98
+ const moduleApiPath = require.resolve('../src/module-api.js');
99
+ require.cache[moduleApiPath] = {
100
+ id: moduleApiPath, filename: moduleApiPath, loaded: true,
101
+ exports: {
102
+ pool: engine,
103
+ corsPublicGet: () => (_req, res, next) => { res.set('Access-Control-Allow-Origin', '*'); next(); },
104
+ logger: () => ({ error: () => {}, info: () => {}, warn: () => {} }),
105
+ },
106
+ };
107
+
108
+ const lb = require('../modules/platform-identity/community-leaderboard.js');
109
+ const { createReadRateLimit } = require('../modules/platform-identity/read-rate-limit.js');
110
+ const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
111
+ const buildLeaderboardRoutes = require('../modules/platform-identity/routes/community-leaderboard.js');
112
+
113
+ // ---- pure helpers -------------------------------------------------------------
114
+
115
+ await t('the four spec windows, and nothing else', () => {
116
+ assert.deepEqual([...lb.WINDOWS], ['7d', '30d', '365d', 'all']);
117
+ for (const w of lb.WINDOWS) assert.ok(lb.isWindow(w), w);
118
+ for (const w of ['1d', '7D', '', 'toString', '__proto__', undefined, ['7d']]) assert.ok(!lb.isWindow(w), String(w));
119
+ assert.equal(lb.DEFAULT_WINDOW, 'all');
120
+ });
121
+
122
+ await t('windowStart: exactly N days before now; all-time has no start', () => {
123
+ const now = new Date('2026-09-25T12:00:00.000Z');
124
+ assert.equal(lb.windowStart('7d', now).toISOString(), '2026-09-18T12:00:00.000Z');
125
+ assert.equal(lb.windowStart('30d', now).toISOString(), '2026-08-26T12:00:00.000Z');
126
+ assert.equal(lb.windowStart('365d', now).toISOString(), '2025-09-25T12:00:00.000Z');
127
+ assert.equal(lb.windowStart('all', now), null);
128
+ });
129
+
130
+ await t('clampLimit: default, cap, garbage', () => {
131
+ assert.equal(lb.clampLimit(undefined), lb.DEFAULT_LIMIT);
132
+ assert.equal(lb.clampLimit('x'), lb.DEFAULT_LIMIT);
133
+ assert.equal(lb.clampLimit(0), lb.DEFAULT_LIMIT);
134
+ assert.equal(lb.clampLimit(10), 10);
135
+ assert.equal(lb.clampLimit(99999), lb.MAX_LIMIT);
136
+ });
137
+
138
+ await t('listLeaderboard refuses an unknown window rather than guessing one', async () => {
139
+ await assert.rejects(() => lb.listLeaderboard({ window: '2d', pool: engine }), /unknown window/);
140
+ });
141
+
142
+ // ---- the statements -------------------------------------------------------------
143
+
144
+ await t('both statements join rollupVisibleSql — the NUMBERS predicate — and neither the recruiting one', async () => {
145
+ lb.clearTotalsMemo();
146
+ engine.calls = [];
147
+ await lb.listLeaderboard({ window: 'all', pool: engine });
148
+ assert.equal(engine.calls.length, 2, 'the window totals, then the eligibility read');
149
+ for (const { sql } of engine.calls) {
150
+ assert.ok(sql.includes(rollupVisibleSql('pa')), "must join rollupVisibleSql('pa') — active + public + not hidden");
151
+ assert.ok(!sql.includes(scoutingListableSql('pa')),
152
+ 'must NOT join scoutingListableSql — the recruiting opt-out governs the directory only (spec D3)');
153
+ }
154
+ const { sql } = lastTotalsCall();
155
+ assert.match(sql, /s\.reported_at <= \$1::timestamptz/, 'the baseline is the last snapshot AT-OR-BEFORE the window start');
156
+ assert.match(sql, /COALESCE\(b\.credits, 0\)/, 'a missing baseline counts as zero (spec D2)');
157
+ assert.match(sql, /sum\(credits\)::bigint/, 'sums are bigint, so an extreme report cannot overflow the board');
158
+ });
159
+
160
+ await t('all-time binds no window start; a window binds exactly its start', async () => {
161
+ const now = new Date('2026-09-25T12:00:00.000Z');
162
+ await lb.listLeaderboard({ window: 'all', now, pool: engine });
163
+ assert.equal(lastTotalsCall().params[0], null);
164
+ await lb.listLeaderboard({ window: '7d', now, pool: engine });
165
+ assert.equal(lastTotalsCall().params[0].toISOString(), '2026-09-18T12:00:00.000Z');
166
+ });
167
+
168
+ await t('ranked credits → ships → karma → handle, with a trimmed discipline filter and clamped paging', async () => {
169
+ const all = await lb.listLeaderboard({ window: 'all', pool: engine });
170
+ assert.deepEqual(all.results.map((r) => r.handle), ['top-builder', 'twin-builder', 'optout-builder', 'third-builder']);
171
+ const eng = await lb.listLeaderboard({ window: 'all', discipline: ' engineer ', limit: 5000, offset: 1, pool: engine });
172
+ assert.deepEqual(eng.results.map((r) => [r.place, r.handle]), [[2, 'third-builder']]);
173
+ assert.equal(eng.limit, lb.MAX_LIMIT);
174
+ });
175
+
176
+ // ---- the memo -------------------------------------------------------------------
177
+
178
+ await t('many requests for one window compute its totals once; another window computes its own', async () => {
179
+ lb.clearTotalsMemo();
180
+ engine.calls = [];
181
+ for (let i = 0; i < 5; i++) await lb.listLeaderboard({ window: '30d', pool: engine });
182
+ assert.equal(totalsCalls().length, 1, 'the expensive half ran once for five requests');
183
+ assert.equal(engine.calls.length - totalsCalls().length, 5, 'the eligibility read ran on every request');
184
+ await lb.listLeaderboard({ window: '7d', pool: engine });
185
+ assert.equal(totalsCalls().length, 2);
186
+ });
187
+
188
+ await t('a builder who hides mid-TTL leaves the board on the very next request (hide beats the memo)', async () => {
189
+ lb.clearTotalsMemo();
190
+ const top = byId('200');
191
+ try {
192
+ const before = await lb.listLeaderboard({ window: 'all', pool: engine });
193
+ assert.ok(before.results.some((r) => r.handle === 'top-builder'));
194
+ const computed = totalsCalls().length;
195
+ top.acct.hide_stats = true;
196
+ const after = await lb.listLeaderboard({ window: 'all', pool: engine });
197
+ assert.equal(totalsCalls().length, computed, 'served from the memo — the totals were not recomputed');
198
+ assert.ok(!after.results.some((r) => r.handle === 'top-builder'), 'yet the hidden builder is gone');
199
+ assert.deepEqual(after.results.map((r) => r.place), [1, 2, 3], 'and place closes the gap');
200
+ } finally {
201
+ top.acct.hide_stats = false;
202
+ }
203
+ });
204
+
205
+ await t('a failed totals computation is not cached — the next request recomputes', async () => {
206
+ lb.clearTotalsMemo();
207
+ engine.throwOnQuery = new Error('transient');
208
+ await assert.rejects(() => lb.listLeaderboard({ window: '365d', pool: engine }), /transient/);
209
+ engine.throwOnQuery = null;
210
+ const ok = await lb.listLeaderboard({ window: '365d', pool: engine });
211
+ assert.ok(ok.results.length > 0);
212
+ });
213
+
214
+ // ---- the route ------------------------------------------------------------------
215
+
216
+ function buildApp() {
217
+ const app = express();
218
+ app.use(attachFail);
219
+ app.use('/api/bongos', buildLeaderboardRoutes());
220
+ return app;
221
+ }
222
+
223
+ function get(port, path, headers = {}) {
224
+ return new Promise((resolve, reject) => {
225
+ const req = http.request({ host: '127.0.0.1', port, method: 'GET', path, headers }, (res) => {
226
+ const chunks = [];
227
+ res.on('data', (c) => chunks.push(c));
228
+ res.on('end', () => {
229
+ const text = Buffer.concat(chunks).toString('utf8');
230
+ let json = null; try { json = JSON.parse(text); } catch (_) {}
231
+ resolve({ status: res.statusCode, json, text, headers: res.headers });
232
+ });
233
+ });
234
+ req.on('error', reject);
235
+ req.end();
236
+ });
237
+ }
238
+
239
+ const server = await new Promise((resolve) => { const s = buildApp().listen(0, '127.0.0.1', () => resolve(s)); });
240
+ const port = server.address().port;
241
+ const PATH = '/api/bongos/community/leaderboard';
242
+
243
+ lb.clearTotalsMemo();
244
+
245
+ await t('no window → the all-time board, ranked and numbered by place', async () => {
246
+ const r = await get(port, PATH);
247
+ assert.equal(r.status, 200);
248
+ assert.equal(r.json.window, 'all');
249
+ assert.equal(r.json.since, null);
250
+ assert.deepEqual(r.json.results.map((x) => x.handle), ['top-builder', 'twin-builder', 'optout-builder', 'third-builder']);
251
+ assert.deepEqual(r.json.results.map((x) => x.place), [1, 2, 3, 4]);
252
+ assert.equal(r.json.count, 4);
253
+ assert.deepEqual(Object.keys(r.json.results[0]).sort(),
254
+ ['avatar_url', 'credits', 'disciplines', 'display_name', 'github_login', 'handle', 'karma', 'place', 'tasks_shipped']);
255
+ const top = r.json.results[0];
256
+ assert.deepEqual([top.credits, top.tasks_shipped, top.karma], [300, 9, 4], 'int8 totals serialize as JSON numbers, not strings');
257
+ });
258
+
259
+ await t('hidden, private and provisional builders never appear; a recruiting opt-out does', async () => {
260
+ const r = await get(port, PATH);
261
+ const handles = r.json.results.map((x) => x.handle);
262
+ for (const h of ['hidden-builder', 'private-builder', 'prov-builder']) assert.ok(!handles.includes(h), h);
263
+ assert.ok(handles.includes('optout-builder'), 'recruiter_discoverable=false does not remove a builder from the leaderboard');
264
+ });
265
+
266
+ await t('a window echoes its start', async () => {
267
+ const before = Date.now();
268
+ const r = await get(port, `${PATH}?window=7d`);
269
+ assert.equal(r.status, 200);
270
+ assert.equal(r.json.window, '7d');
271
+ const since = Date.parse(r.json.since);
272
+ assert.ok(Math.abs((before - 7 * 86400000) - since) < 5000, 'since is ~7 days before the request');
273
+ });
274
+
275
+ await t('a discipline filter narrows the board and is echoed', async () => {
276
+ const r = await get(port, `${PATH}?window=30d&discipline=artist`);
277
+ assert.equal(r.status, 200);
278
+ assert.equal(r.json.discipline, 'artist');
279
+ assert.deepEqual(r.json.results.map((x) => x.handle), ['third-builder'],
280
+ 'the hidden builder holds artist too, and still does not appear');
281
+ });
282
+
283
+ await t('place counts from the page offset, and page echoes the clamped values', async () => {
284
+ const r = await get(port, `${PATH}?limit=1&offset=1`);
285
+ assert.equal(r.status, 200);
286
+ assert.deepEqual(r.json.results.map((x) => [x.place, x.handle]), [[2, 'twin-builder']]);
287
+ assert.deepEqual(r.json.page, { limit: 1, offset: 1 });
288
+ const deep = await get(port, `${PATH}?offset=${lb.MAX_OFFSET + 50}&limit=100000`);
289
+ assert.deepEqual(deep.json.page, { limit: lb.MAX_LIMIT, offset: lb.MAX_OFFSET });
290
+ });
291
+
292
+ await t('github_id never crosses the wire', async () => {
293
+ const r = await get(port, PATH);
294
+ assert.ok(!r.text.includes('github_id'), 'the internal join key must not appear');
295
+ assert.ok(!r.text.includes('"200"'));
296
+ });
297
+
298
+ await t('the response is identical for an anonymous and a credentialed viewer', async () => {
299
+ const anon = await get(port, `${PATH}?window=365d`);
300
+ const signedIn = await get(port, `${PATH}?window=365d`, { cookie: 'otb_session=abc', authorization: 'Bearer xyz' });
301
+ assert.equal(signedIn.status, 200);
302
+ const strip = (j) => ({ ...j, since: null });
303
+ assert.deepEqual(strip(signedIn.json), strip(anon.json));
304
+ });
305
+
306
+ await t('an unknown window → 400 bad_window, naming the allowed values', async () => {
307
+ for (const qs of ['window=1d', 'window=7D', 'window=toString', 'window=7d&window=30d']) {
308
+ const r = await get(port, `${PATH}?${qs}`);
309
+ assert.equal(r.status, 400, qs);
310
+ assert.equal(r.json.error.code, 'bad_window');
311
+ assert.match(r.json.error.message, /7d, 30d, 365d, all/);
312
+ }
313
+ });
314
+
315
+ await t('an oversized discipline → 400 discipline_too_long', async () => {
316
+ const r = await get(port, `${PATH}?discipline=${'a'.repeat(lb.MAX_DISCIPLINE_CHARS + 1)}`);
317
+ assert.equal(r.status, 400);
318
+ assert.equal(r.json.error.code, 'discipline_too_long');
319
+ });
320
+
321
+ await t('Cache-Control: no-store (hide must beat the cache, ADR 0171 D5) and CORS-open', async () => {
322
+ const r = await get(port, PATH);
323
+ assert.equal(r.headers['cache-control'], 'no-store');
324
+ assert.equal(r.headers['access-control-allow-origin'], '*');
325
+ });
326
+
327
+ await t('a query failure → 500 leaderboard_failed, with no internal detail in the body', async () => {
328
+ engine.throwOnQuery = new Error('DO-NOT-LEAK connection detail');
329
+ try {
330
+ const r = await get(port, PATH);
331
+ assert.equal(r.status, 500);
332
+ assert.equal(r.json.error.code, 'leaderboard_failed');
333
+ assert.ok(!r.text.includes('DO-NOT-LEAK'));
334
+ } finally {
335
+ engine.throwOnQuery = null;
336
+ }
337
+ });
338
+
339
+ server.close();
340
+
341
+ // ---- the rate limit -------------------------------------------------------------
342
+
343
+ function mkRes() {
344
+ const r = { _status: 200, _json: null, _headers: {} };
345
+ r.set = (k, v) => { r._headers[k] = v; return r; };
346
+ r.status = (s) => { r._status = s; return r; };
347
+ r.json = (j) => { r._json = j; return r; };
348
+ attachFail({}, r, () => {});
349
+ return r;
350
+ }
351
+
352
+ await t('the route mounts a read cap sized by its own constants', () => {
353
+ const { READ_RATE_LIMIT, READ_RATE_SCOPE } = buildLeaderboardRoutes._internals;
354
+ assert.ok(Number.isInteger(READ_RATE_LIMIT) && READ_RATE_LIMIT > 0);
355
+ assert.equal(READ_RATE_SCOPE, 'community-leaderboard-read');
356
+ });
357
+
358
+ await t('createReadRateLimit: allows up to the cap, then 429 with Retry-After and the scope', () => {
359
+ const limit = createReadRateLimit({ scope: 'test-read', limit: 3 });
360
+ const req = { ip: '203.0.113.90' };
361
+ let allowed = 0;
362
+ for (let i = 0; i < 3; i++) limit(req, mkRes(), () => { allowed++; });
363
+ assert.equal(allowed, 3);
364
+ const res = mkRes();
365
+ let nexted = false;
366
+ limit(req, res, () => { nexted = true; });
367
+ assert.equal(nexted, false);
368
+ assert.equal(res._status, 429);
369
+ assert.equal(res._json.error.code, 'rate_limited');
370
+ assert.ok(Number(res._headers['Retry-After']) >= 1);
371
+ assert.equal(res._headers['X-RateLimit-Scope'], 'test-read');
372
+ assert.equal(res._headers['X-RateLimit-Window-Seconds'], '60', 'the core limiter headers, one for one');
373
+ });
374
+
375
+ await t('createReadRateLimit: at the tracking cap the longest-idle IP is forgotten, never an active one', () => {
376
+ const limit = createReadRateLimit({ scope: 'cap', limit: 1, maxTrackedIps: 2 });
377
+ const hit = (ip) => { let passedThrough = false; limit({ ip }, mkRes(), () => { passedThrough = true; }); return passedThrough; };
378
+ hit('198.51.100.1');
379
+ hit('198.51.100.2');
380
+ assert.equal(hit('198.51.100.1'), false, '.1 is capped — and is now the most recently seen');
381
+ hit('198.51.100.3'); // a third IP at the cap evicts the longest idle: .2, not .1
382
+ assert.equal(hit('198.51.100.1'), false, 'the active, capped IP kept its bucket through the flood');
383
+ assert.equal(hit('198.51.100.2'), true, 'the idle IP was the one forgotten');
384
+ });
385
+
386
+ await t('createReadRateLimit keys on IP, and each budget is its own', () => {
387
+ const a = createReadRateLimit({ scope: 'a', limit: 1 });
388
+ const b = createReadRateLimit({ scope: 'b', limit: 1 });
389
+ a({ ip: '203.0.113.91' }, mkRes(), () => {});
390
+ let otherIp = false; a({ ip: '203.0.113.92' }, mkRes(), () => { otherIp = true; });
391
+ assert.ok(otherIp, 'another IP is unaffected');
392
+ let otherBudget = false; b({ ip: '203.0.113.91' }, mkRes(), () => { otherBudget = true; });
393
+ assert.ok(otherBudget, 'another limiter keeps its own buckets');
394
+ });
395
+
396
+ await t('createReadRateLimit: a request older than the window no longer counts', () => {
397
+ const realNow = Date.now;
398
+ try {
399
+ let clock = 1_000_000;
400
+ Date.now = () => clock;
401
+ const limit = createReadRateLimit({ scope: 'w', limit: 1, windowMs: 1000 });
402
+ limit({ ip: '203.0.113.93' }, mkRes(), () => {});
403
+ let blocked = true; limit({ ip: '203.0.113.93' }, mkRes(), () => { blocked = false; });
404
+ assert.ok(blocked, 'second call inside the window is capped');
405
+ clock += 1001;
406
+ let passedAgain = false; limit({ ip: '203.0.113.93' }, mkRes(), () => { passedAgain = true; });
407
+ assert.ok(passedAgain, 'the window slid past the first call');
408
+ } finally {
409
+ Date.now = realNow;
410
+ }
411
+ });
412
+
413
+ console.log(`\n${passed} passed, ${failed} failed`);
414
+ process.exit(failed === 0 ? 0 : 1);
@@ -76,7 +76,7 @@ test('scores mutuals and shared projects per D5\'s weights, caps mutuals at 10,
76
76
  assert.deepEqual(out.map((s) => s.handle), ['ada', 'grace'], '2003 is excluded and never appears; 2001 outranks 2002');
77
77
  assert.deepEqual(Object.keys(out[0]).sort(), ['avatar_url', 'display_name', 'handle', 'reason'],
78
78
  'an explicit whitelist — no score, no github_id');
79
- assert.equal(out[0].reason, '3 mutual connections', 'mutuals is the primary (and only) signal for ada');
79
+ assert.equal(out[0].reason, '3 connections in common', 'mutuals is the primary (and only) signal for ada — worded to match the profile page (task 1004253)');
80
80
  assert.equal(out[1].reason, 'You both build on Acme', 'shared projects outweighs the single mutual, and the project is named');
81
81
  });
82
82
 
@@ -554,7 +554,7 @@ test('suggestions: returns the ranked list with reasons, and costs the bounded r
554
554
  const res = await call('GET', '/api/bongos/connections/suggestions');
555
555
  assert.equal(res.status, 200);
556
556
  assert.deepEqual(res.json.suggestions.map((s) => s.handle), ['bob']);
557
- assert.equal(res.json.suggestions[0].reason, '2 mutual connections');
557
+ assert.equal(res.json.suggestions[0].reason, '2 connections in common');
558
558
  assert.equal(res.json.count, res.json.suggestions.length, 'the count is the array\'s own length');
559
559
  assert.equal(calls.length, 6, 'own account, then the three candidate reads, then identity + last-active');
560
560
  });
@@ -412,5 +412,13 @@ await test('the page is the sky\'s own surface — the same sheet and the same s
412
412
  assert.equal(/goal-map-lib|goal-map\.js|goal-map\.css/.test(html), false);
413
413
  });
414
414
 
415
+ await test('the ledger drawer accessible name matches its visible heading (docs/voice.md V3, task 1004251)', () => {
416
+ const html = read('goal-map.html');
417
+ const heading = html.match(/<h2 id="roomTitle">([^<]+)<\/h2>/)[1];
418
+ const label = html.match(/<aside class="room" id="room"[^>]*aria-label="([^"]+)"/)[1];
419
+ assert.equal(label.toLowerCase(), heading.toLowerCase(), 'one thing, one name');
420
+ assert.equal(label, 'The ledger');
421
+ });
422
+
415
423
  console.log('');
416
424
  summary();
@@ -70,6 +70,8 @@ try {
70
70
  // handle assertion.
71
71
  await q(migrationSql('platform_identity_012_account_privacy.sql'));
72
72
  await q(migrationSql('platform_identity_019_terms_acceptance.sql'));
73
+ // upsertAccount writes terms_accepted_source (024, task 1003360).
74
+ await q(migrationSql('platform_identity_024_terms_grandfathered.sql'));
73
75
  await cleanup();
74
76
 
75
77
  await q(
@@ -950,12 +950,12 @@ test('the own profile renders "builders you may know" with the server\'s own rea
950
950
  reset();
951
951
  const page = boot({
952
952
  transport: suggestTransport([
953
- { handle: 'grace', display_name: 'Grace H', avatar_url: null, reason: '3 mutual connections' },
953
+ { handle: 'grace', display_name: 'Grace H', avatar_url: null, reason: '3 connections in common' },
954
954
  ]),
955
955
  });
956
956
  await page.render(profileOf('octo', 7), 7);
957
957
  assert.match(page.html(), /Builders you may know/);
958
- assert.match(page.html(), /3 mutual connections/, 'the server picked the reason; the page states it verbatim');
958
+ assert.match(page.html(), /3 connections in common/, 'the server picked the reason; the page states it verbatim');
959
959
  assert.match(page.html(), /data-act="suggest-connect"/);
960
960
  assert.match(page.html(), /data-handle="grace"/);
961
961
  });
@@ -96,6 +96,9 @@ try {
96
96
  await q(migrationSql('platform_identity_012_recent_ships.sql'));
97
97
  await q(migrationSql('platform_identity_019_terms_acceptance.sql'));
98
98
  await q(migrationSql('platform_identity_020_visibility_pull.sql'));
99
+ // upsertAccount writes terms_accepted_source (024, task 1003360). It runs
100
+ // before the fixtures, so its one-shot grandfather never sees LEGACY below.
101
+ await q(migrationSql('platform_identity_024_terms_grandfathered.sql'));
99
102
  await q(migrationSql('platform_identity_022_activity_snapshots.sql'));
100
103
  await cleanup();
101
104