@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
@@ -65,11 +65,14 @@ let nextId = 100;
65
65
  // comparing them rather than by reading the code and believing it.
66
66
  function seed() {
67
67
  const now = Date.now();
68
+ // `kind`/`created_by` carry core_255's column default on every seeded row:
69
+ // each of these is an application, and cy's is an APPROVED one.
70
+ const app = { kind: 'application', created_by: null };
68
71
  rows = [
69
- { id: 1, github_login: 'ada', display_name: 'Ada', note: 'I build tools', status: 'pending', applicant_github_id: null, vouched_at: null, resolved_by_builder_id: null, resolved_at: null, created_at: now - 3 * HOUR },
70
- { id: 2, github_login: 'bo', display_name: 'Bo', note: 'I build tools', status: 'pending', applicant_github_id: '9002', vouched_at: now - 3 * HOUR, resolved_by_builder_id: null, resolved_at: null, created_at: now - 3 * HOUR },
71
- { id: 3, github_login: 'cy', display_name: 'Cy', note: null, status: 'invited', applicant_github_id: null, vouched_at: null, resolved_by_builder_id: 7, resolved_at: now - 2 * HOUR, created_at: now - 4 * HOUR },
72
- { id: 4, github_login: 'dee', display_name: 'Dee', note: null, status: 'dismissed', applicant_github_id: null, vouched_at: null, resolved_by_builder_id: 7, resolved_at: now - HOUR, created_at: now - 5 * HOUR },
72
+ { id: 1, github_login: 'ada', display_name: 'Ada', note: 'I build tools', status: 'pending', applicant_github_id: null, vouched_at: null, resolved_by_builder_id: null, resolved_at: null, created_at: now - 3 * HOUR, ...app },
73
+ { id: 2, github_login: 'bo', display_name: 'Bo', note: 'I build tools', status: 'pending', applicant_github_id: '9002', vouched_at: now - 3 * HOUR, resolved_by_builder_id: null, resolved_at: null, created_at: now - 3 * HOUR, ...app },
74
+ { id: 3, github_login: 'cy', display_name: 'Cy', note: null, status: 'invited', applicant_github_id: null, vouched_at: null, resolved_by_builder_id: 7, resolved_at: now - 2 * HOUR, created_at: now - 4 * HOUR, ...app },
75
+ { id: 4, github_login: 'dee', display_name: 'Dee', note: null, status: 'dismissed', applicant_github_id: null, vouched_at: null, resolved_by_builder_id: 7, resolved_at: now - HOUR, created_at: now - 5 * HOUR, ...app },
73
76
  ];
74
77
  builders = [{ github_login: 'existing' }];
75
78
  statements = [];
@@ -102,6 +105,15 @@ function predicateFor(rawWhere, params) {
102
105
  where = where.replace(transition[0], ' TRUE ');
103
106
  }
104
107
 
108
+ // The outstanding-invite list's member exclusion (task 1002814): an invitee who
109
+ // has signed in is a member, not an outstanding invite. Stripped as one unit,
110
+ // like the transition guard, so its inner `lower(...)` is not re-read.
111
+ const notMember = /NOT EXISTS \(SELECT 1 FROM builders b WHERE lower\(b\.github_login\) = lower\(ar\.github_login\)\)/.exec(where);
112
+ if (notMember) {
113
+ tests.push((r) => !isBuilder(r.github_login));
114
+ where = where.replace(notMember[0], ' TRUE ');
115
+ }
116
+
105
117
  let m;
106
118
  const eat = () => { where = where.replace(m[0], ' TRUE '); };
107
119
  if ((m = /\bid = \$(\d+)\b/.exec(where))) { const v = p(m[1]); tests.push((r) => String(r.id) === String(v)); eat(); }
@@ -112,6 +124,8 @@ function predicateFor(rawWhere, params) {
112
124
  }
113
125
  if ((m = /\b(?:ar\.)?status = \$(\d+)\b/.exec(where))) { const v = p(m[1]); tests.push((r) => r.status === v); eat(); }
114
126
  if ((m = /\b(?:ar\.)?status = '(\w+)'/.exec(where))) { const v = m[1]; tests.push((r) => r.status === v); eat(); }
127
+ if ((m = /\b(?:ar\.)?kind = '(\w+)'/.exec(where))) { const v = m[1]; tests.push((r) => r.kind === v); eat(); }
128
+ if ((m = /\b(?:ar\.)?created_by = \$(\d+)/.exec(where))) { const v = p(m[1]); tests.push((r) => String(r.created_by) === String(v)); eat(); }
115
129
  if ((m = /\bapplicant_github_id IS NULL\b/.exec(where))) { tests.push((r) => r.applicant_github_id == null); eat(); }
116
130
 
117
131
  for (const ph of rawWhere.matchAll(/\$(\d+)/g)) {
@@ -181,11 +195,47 @@ const pool = {
181
195
  applicant_github_id: applicantGithubId, applicant_handle: applicantHandle, vouched_at: vouchedAt,
182
196
  resolved_by_builder_id: null, resolved_at: null,
183
197
  created_at: Date.now(),
198
+ kind: 'application', created_by: null, // the column defaults — the public write names neither
184
199
  };
185
200
  rows.push(fresh);
186
201
  return { rows: [{ id: fresh.id, github_login: fresh.github_login, status: fresh.status, created_at: fresh.created_at }] };
187
202
  }
188
203
 
204
+ // The invite route's fresh row (task 1002814, ADR 0335 D2). Matched EXACTLY,
205
+ // like the public write above, so `kind` or `created_by` cannot be dropped
206
+ // from the real statement with every test still green.
207
+ if (/^INSERT INTO access_requests \(github_login, note, kind, created_by, status, resolved_by_builder_id, resolved_at\) VALUES \(\$1, \$2, 'invite', \$3, 'invited', \$3, now\(\)\) RETURNING id, github_login, status, kind, resolved_at$/.test(sql)) {
208
+ const [login, note, sender] = params;
209
+ const at = Date.now();
210
+ const fresh = {
211
+ id: nextId++, github_login: login, display_name: null, note, status: 'invited',
212
+ applicant_github_id: null, applicant_handle: null, vouched_at: null,
213
+ resolved_by_builder_id: sender, resolved_at: at, created_at: at,
214
+ kind: 'invite', created_by: sender,
215
+ };
216
+ rows.push(fresh);
217
+ return { rows: [{ id: fresh.id, github_login: login, status: 'invited', kind: 'invite', resolved_at: at }] };
218
+ }
219
+
220
+ // The invite route resolving a PENDING application in place. The SET list is
221
+ // matched exactly: it must never touch kind, created_by or the applicant's note.
222
+ if ((m = /^UPDATE access_requests SET status = 'invited', resolved_by_builder_id = \$1, resolved_at = now\(\) WHERE (.+?) RETURNING id, github_login, status, kind, resolved_at$/.exec(sql))) {
223
+ const hit = rows.filter(predicateFor(m[1], params));
224
+ for (const r of hit) { r.status = 'invited'; r.resolved_by_builder_id = params[0]; r.resolved_at = Date.now(); }
225
+ return { rows: hit.map((r) => ({ id: r.id, github_login: r.github_login, status: r.status, kind: r.kind, resolved_at: r.resolved_at })) };
226
+ }
227
+
228
+ // The outstanding-invite list. Its sender is joined through created_by.
229
+ if ((m = /^SELECT ar\.id, ar\.github_login, ar\.note, ar\.created_at, ib\.github_login AS invited_by_login FROM access_requests ar LEFT JOIN builders ib ON ib\.id = ar\.created_by WHERE (.+?) ORDER BY ar\.created_at DESC LIMIT 200$/.exec(sql))) {
230
+ const hit = rows.filter(predicateFor(m[1], params)).sort((a, b) => b.created_at - a.created_at);
231
+ return {
232
+ rows: hit.map((r) => ({
233
+ id: r.id, github_login: r.github_login, note: r.note, created_at: r.created_at,
234
+ invited_by_login: (builders.find((b) => b.id === r.created_by) || {}).github_login || null,
235
+ })),
236
+ };
237
+ }
238
+
189
239
  // The owner's queue.
190
240
  if ((m = /^SELECT ar\.id, .* FROM access_requests ar .* WHERE (.+?) ORDER BY/.exec(sql))) {
191
241
  const hit = rows.filter(predicateFor(m[1], params)).sort((a, b) => b.created_at - a.created_at);
@@ -197,6 +247,9 @@ const pool = {
197
247
  // Returned only when the real SELECT names it, so the queue test below
198
248
  // proves the route reads the column rather than that this fake invents it.
199
249
  ...(/\bar\.applicant_handle\b/.test(sql) ? { applicant_handle: r.applicant_handle ?? null } : {}),
250
+ ...(/\bar\.kind\b/.test(sql) ? { kind: r.kind } : {}),
251
+ ...(/\bib\.github_login AS invited_by_login\b/.test(sql) && /\bON ib\.id = ar\.created_by\b/.test(sql)
252
+ ? { invited_by_login: (builders.find((b) => b.id === r.created_by) || {}).github_login || null } : {}),
200
253
  vouched: r.applicant_github_id !== null,
201
254
  resolved_by_login: null,
202
255
  })),
@@ -280,6 +333,9 @@ const dbKernel = require('../src/bongos/db-kernel.js');
280
333
  // Who is calling, and at what LIVE rank. The rank is resolved through the real
281
334
  // government port on every request — never cached — exactly as production does.
282
335
  let actingRank = 'archon';
336
+ // Which builder is acting — switched only to prove an invite keeps its SENDER
337
+ // when a different archon rescinds it.
338
+ let actingId = 42;
283
339
 
284
340
  require.cache[apiPath].exports = {
285
341
  ...realApi,
@@ -287,7 +343,7 @@ require.cache[apiPath].exports = {
287
343
  joinabilityMode: () => 'apply',
288
344
  projectJoinDoor: () => 'apply',
289
345
  logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
290
- requireBuilder: (req, _res, next) => { req.builder = { id: 42, github_login: 'owner' }; next(); },
346
+ requireBuilder: (req, _res, next) => { req.builder = { id: actingId, github_login: 'owner' }; next(); },
291
347
  // requirePermission is NOT stubbed: realApi's own gate is used, so the denial
292
348
  // below is the production denial.
293
349
  readApplicationVouch: (token, { githubLogin } = {}) =>
@@ -336,7 +392,7 @@ function call(method, pathname, body, extraHeaders = {}) {
336
392
  req.end(payload);
337
393
  });
338
394
  }
339
- const reset = (rank = 'archon') => { seed(); actingRank = rank; };
395
+ const reset = (rank = 'archon') => { seed(); actingRank = rank; actingId = 42; };
340
396
 
341
397
  // ===========================================================================
342
398
  // The interpreter is the instrument, so it is calibrated first
@@ -692,6 +748,118 @@ test('only invited|dismissed are accepted, and a rejected status writes nothing'
692
748
  assert.equal(JSON.stringify(rows), snapshot);
693
749
  });
694
750
 
751
+ // ===========================================================================
752
+ // Invite — a pre-approved row the gate already reads (task 1002814, ADR 0335 D2)
753
+ // ===========================================================================
754
+
755
+ // The two archons the invite tests act as. Only their ids matter: the sender is
756
+ // joined through created_by → builders.id.
757
+ const ARCHONS = [{ id: 42, github_login: 'owner' }, { id: 43, github_login: 'second' }];
758
+
759
+ test('invite: a login that never applied signs straight in, and the row says it is an invite and who sent it', async () => {
760
+ reset();
761
+ builders.push(...ARCHONS);
762
+ assert.equal(await dbKernel.hasInvitedAccessRequest('eve'), false, 'precondition: eve never applied');
763
+ const r = await call('POST', '/api/gds/access-requests/invite', { github_login: 'eve', note: 'Come build the parser' });
764
+ assert.equal(r.status, 201);
765
+ assert.equal(r.json.request.kind, 'invite');
766
+ const eve = rows.find((x) => x.github_login === 'eve');
767
+ assert.equal(eve.kind, 'invite');
768
+ assert.equal(eve.created_by, 42, 'the sender');
769
+ assert.equal(eve.note, 'Come build the parser', 'the inviter’s words, and kind says whose');
770
+ statements = [];
771
+ assert.equal(await dbKernel.hasInvitedAccessRequest('eve'), true, 'the REAL admission read admits her — signing in is the acceptance act');
772
+ assert.ok(statements.length > 0 && statements.every(({ sql }) => !/\bkind\b|\bcreated_by\b/.test(sql)),
773
+ 'and it admitted her without reading either new column (D2.5)');
774
+ });
775
+
776
+ test('rescind: an invite keeps its sender through a rescind by a different archon, and the door re-locks', async () => {
777
+ reset();
778
+ builders.push(...ARCHONS);
779
+ const sent = await call('POST', '/api/gds/access-requests/invite', { github_login: 'eve' });
780
+ actingId = 43;
781
+ const r = await call('PATCH', `/api/gds/access-requests/${sent.json.request.id}`, { status: 'dismissed' });
782
+ assert.equal(r.status, 200);
783
+ const eve = rows.find((x) => x.github_login === 'eve');
784
+ assert.equal(eve.status, 'dismissed');
785
+ assert.equal(eve.resolved_by_builder_id, 43, 'resolved_by records whoever acted last (ADR 0201 D4.2)…');
786
+ assert.equal(eve.created_by, 42, '…so the sender needs its own column, and the rescind does not touch it (D2.2)');
787
+ assert.equal(eve.kind, 'invite', 'a rescinded invite is still an invite in the history');
788
+ assert.equal(await dbKernel.hasInvitedAccessRequest('eve'), false, 'the door is shut again');
789
+ });
790
+
791
+ test('invites list: outstanding only — not an approved application, a rescinded invite, or an invitee who signed in', async () => {
792
+ reset();
793
+ builders.push(...ARCHONS);
794
+ await call('POST', '/api/gds/access-requests/invite', { github_login: 'eve', note: 'hi' });
795
+ actingId = 43;
796
+ await call('POST', '/api/gds/access-requests/invite', { github_login: 'fay' });
797
+ const gus = await call('POST', '/api/gds/access-requests/invite', { github_login: 'gus' });
798
+ await call('PATCH', `/api/gds/access-requests/${gus.json.request.id}`, { status: 'dismissed' });
799
+ await call('POST', '/api/gds/access-requests/invite', { github_login: 'hal' });
800
+ builders.push({ id: 90, github_login: 'HAL' }); // hal accepted: he signed in
801
+ const r = await call('GET', '/api/gds/access-requests/invites');
802
+ assert.equal(r.status, 200);
803
+ const byLogin = Object.fromEntries(r.json.invites.map((x) => [x.github_login, x]));
804
+ assert.deepEqual(Object.keys(byLogin).sort(), ['eve', 'fay'],
805
+ 'cy (an approved application), gus (rescinded) and hal (signed in, case-insensitively) are not outstanding');
806
+ assert.equal(byLogin.eve.invited_by_login, 'owner');
807
+ assert.equal(byLogin.fay.invited_by_login, 'second', 'each names its own sender');
808
+ assert.equal(byLogin.eve.note, 'hi');
809
+
810
+ const mine43 = await call('GET', '/api/gds/access-requests/invites?mine=1');
811
+ assert.deepEqual(mine43.json.invites.map((x) => x.github_login), ['fay'], '?mine=1: the sender lists their own outstanding invites');
812
+ actingId = 42;
813
+ const mine42 = await call('GET', '/api/gds/access-requests/invites?mine=1');
814
+ assert.deepEqual(mine42.json.invites.map((x) => x.github_login), ['eve']);
815
+ });
816
+
817
+ test('the queue history tells an invite from an approved application, and names the sender', async () => {
818
+ reset();
819
+ builders.push(...ARCHONS);
820
+ await call('POST', '/api/gds/access-requests/invite', { github_login: 'eve', note: 'Come build the parser' });
821
+ await call('PATCH', '/api/gds/access-requests/1', { status: 'invited' }); // approve ada's application
822
+ const r = await call('GET', '/api/gds/access-requests?status=invited');
823
+ const byLogin = Object.fromEntries(r.json.requests.map((x) => [x.github_login, x]));
824
+ assert.equal(byLogin.cy.kind, 'application', 'cy applied and was approved');
825
+ assert.equal(byLogin.cy.invited_by_login, null);
826
+ assert.equal(byLogin.eve.kind, 'invite', 'eve never applied — an archon invited her');
827
+ assert.equal(byLogin.eve.invited_by_login, 'owner');
828
+ // `note` on this read has always meant the APPLICANT's words (the hall quotes it
829
+ // as theirs), so an inviter's note must never arrive in it.
830
+ assert.equal(byLogin.eve.note, null, 'an inviter’s note is not the invitee’s words');
831
+ assert.equal(byLogin.eve.invite_note, 'Come build the parser');
832
+ assert.equal(byLogin.ada.note, 'I build tools', 'an applicant’s note stays where it always was');
833
+ assert.equal(byLogin.ada.invite_note, null);
834
+ });
835
+
836
+ test('invite: resolving a pending application in place keeps it an application, with the applicant’s own note', async () => {
837
+ reset();
838
+ builders.push(...ARCHONS);
839
+ const r = await call('POST', '/api/gds/access-requests/invite', { github_login: 'ada', note: 'not their words' });
840
+ assert.equal(r.status, 200);
841
+ assert.equal(r.json.request.kind, 'application');
842
+ assert.equal(row(1).status, 'invited');
843
+ assert.equal(row(1).kind, 'application', 'it was an application, and its history says so (D2.1)');
844
+ assert.equal(row(1).created_by, null, 'created_by is set on invite rows only (D2.2)');
845
+ assert.equal(row(1).note, 'I build tools', 'an inviter never overwrites the applicant’s note');
846
+ assert.equal(rows.filter((x) => x.github_login === 'ada').length, 1, 'resolved, not duplicated');
847
+ });
848
+
849
+ for (const rank of ['thetes', 'metic']) {
850
+ test(`a ${rank} can neither invite nor list outstanding invites — access_request.review until R3 mints project.recruit`, async () => {
851
+ reset(rank);
852
+ const snapshot = JSON.stringify(rows);
853
+ const invited = await call('POST', '/api/gds/access-requests/invite', { github_login: 'eve' });
854
+ assert.equal(invited.status, 403);
855
+ assert.deepEqual(invited.json.error.details.required, ['access_request.review']);
856
+ const listed = await call('GET', '/api/gds/access-requests/invites');
857
+ assert.equal(listed.status, 403, 'the list names logins that are not members yet');
858
+ assert.equal(JSON.stringify(rows), snapshot, 'nothing was written');
859
+ assert.equal(await dbKernel.hasInvitedAccessRequest('eve'), false);
860
+ });
861
+ }
862
+
695
863
  // ===========================================================================
696
864
  // Authority — "a builder cannot approve or decline for a project they don't own"
697
865
  // ===========================================================================
@@ -0,0 +1,202 @@
1
+ // tests/community_leaderboard_db.mjs — task 1002978 (community spec D2/D3; real DB).
2
+ //
3
+ // Proves the windowed leaderboard's arithmetic and eligibility against real
4
+ // SQL. The window math is a LATERAL "last snapshot at-or-before the window
5
+ // start" probe per (project, builder) plus a cross-project sum, and a fake pool
6
+ // can only prove the statement's SHAPE (tests/community_leaderboard_route.mjs);
7
+ // only a real Postgres can prove which snapshot becomes the baseline, that a
8
+ // missing baseline counts as zero, and that the privacy matrix really filters.
9
+ //
10
+ // Every fixture carries a discipline no real builder holds, and every read
11
+ // filters on it, so a shared database's own builders never enter the board
12
+ // under test.
13
+ //
14
+ // Real-DB integration test: self-skips without a reachable Postgres; in the
15
+ // INTEGRATION set (scripts/gds/run-unit-tests.js) so the DB-free lane never runs it.
16
+ //
17
+ // Run: DATABASE_URL=... node tests/community_leaderboard_db.mjs
18
+
19
+ import { createRequire } from 'node:module';
20
+ import { readFileSync } from 'node:fs';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { dirname, join } from 'node:path';
23
+ import { strict as assert } from 'node:assert';
24
+ const require = createRequire(import.meta.url);
25
+ const { Pool } = require('pg');
26
+ const { listLeaderboard, clearTotalsMemo } = require('../modules/platform-identity/community-leaderboard.js');
27
+
28
+ const here = dirname(fileURLToPath(import.meta.url));
29
+ const MIG_DIR = join(here, '..', 'modules', 'platform-identity', 'migrations');
30
+ const migrationSql = (f) => readFileSync(join(MIG_DIR, f), 'utf8');
31
+
32
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
33
+ try {
34
+ await pool.query('SELECT 1');
35
+ } catch (e) {
36
+ console.log(`community_leaderboard_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
37
+ await pool.end().catch(() => {});
38
+ process.exit(0);
39
+ }
40
+
41
+ // High-numbered fixture ids so a shared DB can never collide with real data.
42
+ const A = 90297801, B = 90297802, C = 90297803, D = 90297804, OPTOUT = 90297805;
43
+ const HIDDEN = 90297806, PRIVATE = 90297807, PROV = 90297808, LEGACY = 90297809, BIG = 90297810;
44
+ const IDS = [A, B, C, D, OPTOUT, HIDDEN, PRIVATE, PROV, LEGACY, BIG];
45
+ const P1 = 't1002978-p1', P2 = 't1002978-p2';
46
+ const TAG = 't1002978-board'; // every fixture holds it; every read filters on it
47
+ const TAG_ART = 't1002978-art'; // held by A on P2 only — the union filter
48
+ const TAG_BIG = 't1002978-big'; // BIG's own board, so its totals stay out of the others
49
+ const INT4_MAX = 2147483647;
50
+ const NOW = new Date('2026-06-01T12:00:00.000Z');
51
+ const DAY = 24 * 60 * 60 * 1000;
52
+ const ago = (days, extraMs = 0) => new Date(NOW.getTime() - days * DAY + extraMs);
53
+ const q = (sql, params) => pool.query(sql, params);
54
+
55
+ async function cleanup() {
56
+ await q(`DELETE FROM platform_identity_activity_snapshots WHERE github_id = ANY($1::bigint[])`, [IDS]).catch(() => {});
57
+ await q(`DELETE FROM platform_identity_builder_project_activity WHERE github_id = ANY($1::bigint[])`, [IDS]).catch(() => {});
58
+ await q(`DELETE FROM platform_identity_accounts WHERE github_id = ANY($1::bigint[])`, [IDS]).catch(() => {});
59
+ }
60
+
61
+ async function account(id, handle, { state = 'active', visibility = 'public', hide = false, accepted = true, reach = null } = {}) {
62
+ await q(
63
+ `INSERT INTO platform_identity_accounts
64
+ (github_id, github_login, display_name, handle, profile_state, account_visibility, hide_stats, recruiter_discoverable, terms_accepted_at)
65
+ VALUES ($1, $2, $2, $2, $3, $4, $5, $6, CASE WHEN $7::boolean THEN now() END)`,
66
+ [id, handle, state, visibility, hide, reach, accepted],
67
+ );
68
+ }
69
+ async function activity(id, client, credits, ships, karma, disciplines = [TAG]) {
70
+ await q(
71
+ `INSERT INTO platform_identity_builder_project_activity (client_id, github_id, credits, tasks_shipped, karma, disciplines)
72
+ VALUES ($1, $2, $3, $4, $5, $6)`,
73
+ [client, id, credits, ships, karma, disciplines],
74
+ );
75
+ }
76
+ async function snapshot(id, client, credits, ships, karma, at) {
77
+ await q(
78
+ `INSERT INTO platform_identity_activity_snapshots (client_id, github_id, credits, tasks_shipped, karma, reported_at)
79
+ VALUES ($1, $2, $3, $4, $5, $6)`,
80
+ [client, id, credits, ships, karma, at],
81
+ );
82
+ }
83
+
84
+ const board = async (window, discipline = TAG) =>
85
+ (await listLeaderboard({ window, discipline, limit: 100, now: NOW, pool })).results;
86
+ const row = (results, handle) => results.find((r) => r.handle === handle);
87
+ const totals = (r) => r && [r.credits, r.tasks_shipped, r.karma];
88
+
89
+ let pass = 0;
90
+ const ok = (cond, msg) => { assert.ok(cond, msg); pass++; console.log(` ok - ${msg}`); };
91
+ const same = (actual, expected, msg) => { assert.deepEqual(actual, expected, msg); pass++; console.log(` ok - ${msg}`); };
92
+
93
+ try {
94
+ // The module's own migrations (idempotent) — every column the NUMBERS
95
+ // predicate and the board read: 007 consent + hide, 008 handle, 012 privacy,
96
+ // 019 the terms half of consent, 005/022 the two tables the window reads.
97
+ for (const f of [
98
+ 'platform_identity_001_tables.sql', 'platform_identity_005_builder_activity.sql',
99
+ 'platform_identity_007_profile_consent.sql', 'platform_identity_008_handle.sql',
100
+ 'platform_identity_012_account_privacy.sql', 'platform_identity_019_terms_acceptance.sql',
101
+ 'platform_identity_022_activity_snapshots.sql',
102
+ ]) await q(migrationSql(f));
103
+ await cleanup();
104
+
105
+ await account(A, 't1002978-a');
106
+ await account(B, 't1002978-b');
107
+ await account(C, 't1002978-c');
108
+ await account(D, 't1002978-d');
109
+ await account(OPTOUT, 't1002978-optout', { reach: false });
110
+ await account(HIDDEN, 't1002978-hidden', { hide: true });
111
+ await account(PRIVATE, 't1002978-private', { visibility: 'private' });
112
+ await account(PROV, 't1002978-prov', { state: 'provisional', accepted: false });
113
+ await account(LEGACY, 't1002978-legacy', { accepted: false }); // active, but never accepted the terms
114
+
115
+ // A: two projects. P1 has history; P2 has none (a builder not seen since
116
+ // snapshots began — the day-one case the spec accepts).
117
+ await activity(A, P1, 200, 9, 3);
118
+ await snapshot(A, P1, 100, 5, 2, ago(40));
119
+ await snapshot(A, P1, 150, 7, 3, ago(10));
120
+ await snapshot(A, P1, 200, 9, 3, ago(2));
121
+ await activity(A, P2, 30, 1, 0, [TAG, TAG_ART]);
122
+ // B: unchanged for the last 20 days.
123
+ await activity(B, P1, 500, 10, 1);
124
+ await snapshot(B, P1, 500, 10, 1, ago(20));
125
+ // C: a snapshot EXACTLY at the 7d start, and one a second after it.
126
+ await activity(C, P1, 45, 2, 0);
127
+ await snapshot(C, P1, 40, 2, 0, ago(7));
128
+ await snapshot(C, P1, 42, 2, 0, ago(7, 1000));
129
+ // D: lost credits inside the week (a clawback).
130
+ await activity(D, P1, 90, 3, 5);
131
+ await snapshot(D, P1, 100, 3, 5, ago(10));
132
+ await activity(OPTOUT, P1, 60, 2, 0);
133
+ // Out of the matrix — every one would top the board if it leaked.
134
+ for (const id of [HIDDEN, PRIVATE, PROV, LEGACY]) await activity(id, P1, 9000, 90, 9);
135
+ await account(BIG, 't1002978-big');
136
+ await activity(BIG, P1, INT4_MAX, 1, 0, [TAG_BIG]);
137
+ await activity(BIG, P2, INT4_MAX, 1, 0, [TAG_BIG]);
138
+ await snapshot(BIG, P1, -INT4_MAX, 0, 0, ago(10));
139
+
140
+ // 1 — all-time: the live rows summed across projects, no baseline.
141
+ const all = await board('all');
142
+ same(all.map((r) => r.handle), ['t1002978-b', 't1002978-a', 't1002978-d', 't1002978-optout', 't1002978-c'],
143
+ 'all-time ranks by summed credits');
144
+ same(totals(row(all, 't1002978-a')), [230, 10, 3], 'all-time sums a builder across both projects');
145
+ same(all.map((r) => r.place), [1, 2, 3, 4, 5], 'place numbers the board from 1');
146
+
147
+ // 2 — the privacy matrix, against the real predicate.
148
+ for (const window of ['all', '7d', '30d', '365d']) {
149
+ const handles = (await board(window)).map((r) => r.handle);
150
+ for (const h of ['t1002978-hidden', 't1002978-private', 't1002978-prov', 't1002978-legacy']) {
151
+ assert.ok(!handles.includes(h), `${h} leaked onto the ${window} board`);
152
+ }
153
+ }
154
+ pass++; console.log(' ok - hidden, private, provisional and never-accepted builders are absent from every window');
155
+ ok(row(all, 't1002978-optout'), 'a builder who opted out of RECRUITING still appears (spec D3)');
156
+
157
+ // 3 — 7d: live minus the last snapshot at-or-before the start, summed.
158
+ const week = await board('7d');
159
+ same(totals(row(week, 't1002978-a')), [80, 3, 0],
160
+ '7d: P1 counts from the 10-day-old snapshot, P2 (no history) counts in full');
161
+ same(totals(row(week, 't1002978-c')), [5, 0, 0],
162
+ '7d: a snapshot exactly at the window start IS the baseline; one a second later is not');
163
+ same(totals(row(week, 't1002978-d')), [-10, 0, 0], '7d: a loss inside the window is a negative delta, not hidden');
164
+ ok(!row(week, 't1002978-b'), '7d: a builder with no movement in the window is left off the board');
165
+ same(week.map((r) => r.handle), ['t1002978-a', 't1002978-optout', 't1002978-c', 't1002978-d'],
166
+ '7d ranks by the window delta, a loss last');
167
+
168
+ // 4 — 30d and 365d.
169
+ const month = await board('30d');
170
+ same(totals(row(month, 't1002978-a')), [130, 5, 1], '30d: P1 counts from the 40-day-old snapshot');
171
+ same(totals(row(month, 't1002978-b')), [500, 10, 1],
172
+ '30d: a builder whose first snapshot is inside the window has no baseline, so counts in full');
173
+ const year = await board('365d');
174
+ same(totals(row(year, 't1002978-a')), [230, 10, 3], '365d: no snapshot is old enough, so everything counts');
175
+
176
+ // 5 — the discipline filter is the builder's union across projects.
177
+ const art = await board('all', TAG_ART);
178
+ same(art.map((r) => r.handle), ['t1002978-a'], 'a discipline held on ONE project admits the builder');
179
+ same(totals(art[0]), [230, 10, 3], 'the filter picks builders, not projects — totals stay whole');
180
+ same(art[0].disciplines, [TAG_ART, TAG].sort(), 'disciplines is the de-duplicated union');
181
+
182
+ // 6 — instance-reported int4 totals cannot overflow the board into a 500.
183
+ same(totals((await board('all', TAG_BIG))[0]), [2 * INT4_MAX, 2, 0], 'all-time: two int4-max projects sum past int4');
184
+ same(totals((await board('7d', TAG_BIG))[0]), [3 * INT4_MAX, 2, 0], '7d: int4-max minus a negative baseline does not overflow');
185
+
186
+ // 7 — paging keeps the board's own place numbers.
187
+ const page2 = (await listLeaderboard({ window: 'all', discipline: TAG, limit: 2, offset: 2, now: NOW, pool })).results;
188
+ same(page2.map((r) => [r.place, r.handle]), [[3, 't1002978-d'], [4, 't1002978-optout']], 'offset pages keep place');
189
+
190
+ // 8 — the memoized path (no `now`) against real SQL: hiding beats the memo.
191
+ clearTotalsMemo();
192
+ const memo = async () => (await listLeaderboard({ window: 'all', discipline: TAG, limit: 100, pool })).results;
193
+ ok(row(await memo(), 't1002978-a'), 'the memoized all-time board lists A');
194
+ await q(`UPDATE platform_identity_accounts SET hide_stats = true WHERE github_id = $1`, [A]);
195
+ ok(!row(await memo(), 't1002978-a'), 'A hides inside the memo TTL and is gone on the very next request');
196
+ clearTotalsMemo();
197
+
198
+ console.log(`\ncommunity_leaderboard_db.mjs: ${pass} passed`);
199
+ } finally {
200
+ await cleanup();
201
+ await pool.end().catch(() => {});
202
+ }