@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,52 @@
1
+ -- core_255_access_requests_invite_kind.sql — an invite can be told apart from an
2
+ -- approved application, and keeps who sent it (task 1002814, R2 of the project
3
+ -- admin console wave; ADR 0335 D2).
4
+ --
5
+ -- An archon's invite (POST /access-requests/invite) writes the same
6
+ -- status='invited' row an approved application ends in, because that row is what
7
+ -- the sign-in gate reads. Until now nothing else distinguished the two, and the
8
+ -- only trace of the inviter was resolved_by_builder_id — which a rescind
9
+ -- overwrites with whoever rescinded (ADR 0201 D4.2).
10
+ --
11
+ -- kind 'application' (the default — the public POST names neither
12
+ -- column) or 'invite'. An invite that approves a pending
13
+ -- application in place leaves it 'application': it was one.
14
+ -- created_by the sender, set on invite rows only; a rescind never touches it.
15
+ -- note no new column — on an invite row it holds the inviter's words,
16
+ -- and `kind` says whose.
17
+ --
18
+ -- THE BACKFILL IS EXACT (D2.4). The invite route has always inserted with
19
+ -- resolved_at = now() beside created_at's now() default, so every row it wrote
20
+ -- has created_at = resolved_at, and no approved application can (it is resolved
21
+ -- after it was filed). The one miss is an old invite later rescinded — the
22
+ -- rescind moved resolved_at — which stays 'application' and admits nobody either
23
+ -- way. Guarded on kind, so a replay touches nothing.
24
+ --
25
+ -- NOT AUTHORITY (D2.5, ADR 0016). No admission read looks at either column: an
26
+ -- invite and an approved application admit through the identical
27
+ -- status = 'invited' read. tests/project_admin_console_model.mjs pins that.
28
+ --
29
+ -- Purely additive + idempotent (IF NOT EXISTS skips the column AND its CHECK on a
30
+ -- replay). The previous release never names either column, and its inserts take
31
+ -- the defaults, so a rollback runs unchanged. access_requests is CORE-owned
32
+ -- (060_access_requests), so this is a core_ migration (ADR 0108 §5); migrate.sh
33
+ -- records the stem.
34
+
35
+ BEGIN;
36
+
37
+ ALTER TABLE access_requests
38
+ ADD COLUMN IF NOT EXISTS kind text NOT NULL DEFAULT 'application'
39
+ CONSTRAINT access_requests_kind_check CHECK (kind IN ('application', 'invite')),
40
+ ADD COLUMN IF NOT EXISTS created_by bigint REFERENCES builders(id);
41
+
42
+ UPDATE access_requests
43
+ SET kind = 'invite', created_by = resolved_by_builder_id
44
+ WHERE status = 'invited' AND kind = 'application' AND created_at = resolved_at;
45
+
46
+ COMMENT ON COLUMN access_requests.kind IS
47
+ 'application (the default: filed through the public POST) or invite (sent by a builder via POST /access-requests/invite) — ADR 0335 D2. An invite that approved a pending application in place stays application. DESCRIPTIVE ONLY — no admission path reads it (ADR 0016): status=''invited'' is the whole gate.';
48
+
49
+ COMMENT ON COLUMN access_requests.created_by IS
50
+ 'The builder who sent an invite (kind=invite rows only; NULL on applications). Its own column because a rescind overwrites resolved_by_builder_id (ADR 0201 D4.2, ADR 0335 D2.2). DESCRIPTIVE ONLY — no admission path reads it (ADR 0016).';
51
+
52
+ COMMIT;
@@ -179,7 +179,7 @@ const PERMISSIONS = [
179
179
  // one of them would make this delegation self-reversible.
180
180
  { key: 'builder.status.set', system: false, floor: 'archon', guards: 'PATCH /builders/:id/status — offboard / (de)activate' },
181
181
  { key: 'auth.session.revoke', system: false, floor: 'archon', guards: 'POST /auth/revoke, /auth/revoke-all' },
182
- { key: 'access_request.review', system: false, floor: 'archon', guards: 'GET/PATCH /access-requests/:id, POST /access-requests/invite — admit a builder' },
182
+ { key: 'access_request.review', system: false, floor: 'archon', guards: 'GET/PATCH /access-requests/:id, POST /access-requests/invite, GET /access-requests/invites — admit a builder' },
183
183
 
184
184
  // ── operational — floor metic (the working rank's review/triage/authoring) ────
185
185
  { key: 'idea.triage', system: false, floor: 'metic', guards: 'PATCH /inbox/:id' },
@@ -96,7 +96,7 @@
96
96
  <div class="card glass" id="card" role="tooltip" aria-hidden="true"></div>
97
97
  <div class="menu glass" id="menu" hidden role="menu" aria-label="Chart the routes"></div>
98
98
 
99
- <aside class="room" id="room" hidden aria-label="The list">
99
+ <aside class="room" id="room" hidden aria-label="The ledger">
100
100
  <div class="head"><h2 id="roomTitle">the ledger</h2><span class="lbl" id="roomCount"></span></div>
101
101
  <div class="filters" id="filters" role="group" aria-label="Filter"></div>
102
102
  <div class="rows" id="rows"></div>
@@ -25,6 +25,11 @@
25
25
  // POST /api/bongos/access-requests/invite Archon-only — invite a GitHub
26
26
  // username before any request exists:
27
27
  // the way in on an invite-only project.
28
+ // The row says it is an invite (`kind`)
29
+ // and who sent it (`created_by`).
30
+ // GET /api/bongos/access-requests/invites Archon-only — the outstanding
31
+ // invites: sent, not rescinded, and not
32
+ // yet signed in (ADR 0335 D2).
28
33
  //
29
34
  // Which door a project runs — open / apply / invite_only / locked — is the
30
35
  // COMPOSITION of three owner-set knobs, read through the doorway's
@@ -300,24 +305,34 @@ module.exports = function buildAccessRequestsRouter() {
300
305
  return res.fail('bad_status', 400);
301
306
  }
302
307
  try {
308
+ // `kind` + `invited_by_login` let the invited history tell an invite from
309
+ // an approved application (ADR 0335 D2) — descriptive, like the vouch.
303
310
  const { rows } = await pool.query(
304
311
  `SELECT ar.id, ar.github_login, ar.display_name, ar.note, ar.status,
305
- ar.created_at, ar.resolved_at,
312
+ ar.created_at, ar.resolved_at, ar.kind,
306
313
  ar.applicant_github_id, ar.applicant_handle, ar.vouched_at,
307
314
  (ar.applicant_github_id IS NOT NULL) AS vouched,
308
- rb.github_login AS resolved_by_login
315
+ rb.github_login AS resolved_by_login,
316
+ ib.github_login AS invited_by_login
309
317
  FROM access_requests ar
310
318
  LEFT JOIN builders rb ON rb.id = ar.resolved_by_builder_id
319
+ LEFT JOIN builders ib ON ib.id = ar.created_by
311
320
  WHERE ar.status = $1
312
321
  ORDER BY ar.created_at DESC
313
322
  LIMIT 200`,
314
323
  [status]
315
324
  );
325
+ // On an invite row the stored note is the INVITER's words, but this read's
326
+ // `note` has always meant the applicant's (the hall quotes it as theirs),
327
+ // so an invite's note travels as `invite_note` instead.
328
+ const shaped = rows.map(({ note, ...r }) => (r.kind === 'invite'
329
+ ? { ...r, note: null, invite_note: note }
330
+ : { ...r, note, invite_note: null }));
316
331
  // C4's other half (privacy spec D7, task 1002972): the applicant's own
317
332
  // profile, as their OWN privacy rules would show it, resolved from the hub
318
333
  // at render. See attachApplicantProfiles — it is keyed on the vouch, never
319
334
  // on the login, and every row it cannot resolve carries `profile: null`.
320
- res.json({ requests: await attachApplicantProfiles(rows) });
335
+ res.json({ requests: await attachApplicantProfiles(shaped) });
321
336
  } catch (err) {
322
337
  log.error('[gds] GET /access-requests', err);
323
338
  res.fail('list_failed', { status: 500, message: 'internal error' });
@@ -451,18 +466,25 @@ module.exports = function buildAccessRequestsRouter() {
451
466
  // Archons inviting the same login at once can each insert a row (the unique
452
467
  // index covers pending only): harmless — the gate reads "any invited row" —
453
468
  // and it shows as two lines in the invited history, so no lock is taken.
454
- // body: { github_login }. 201 with a new row; 200 with the row when a pending
455
- // request was resolved; 200 already_invited when an invite already stands;
456
- // 409 already_member when they have an account.
469
+ //
470
+ // A fresh row is marked kind='invite' with created_by = the sender (a rescind
471
+ // overwrites resolved_by) and the optional note as the inviter's words. A pending
472
+ // request resolved in place stays an application with its applicant's own note
473
+ // (ADR 0335 D2.1–D2.3; task 1002814).
474
+ // body: { github_login, note? }. 201 with a new row; 200 with the row when a
475
+ // pending request was resolved; 200 already_invited when an invite already
476
+ // stands; 409 already_member when they have an account.
457
477
  // rank: archon — the same gate as resolving a request.
458
478
  router.post('/access-requests/invite', auth.requireBuilder, auth.requirePermission('access_request.review'), async (req, res) => {
459
479
  if (validateOrRespond(req, res, {
460
480
  github_login: { required: true, type: 'string', minLength: 1, maxLength: 60 },
481
+ note: { type: 'string', maxLength: 500 },
461
482
  })) return;
462
483
  const login = normalizeLogin(req.body.github_login);
463
484
  if (!GITHUB_LOGIN_RE.test(login)) {
464
485
  return res.fail('bad_github_login', { status: 400, message: 'That does not look like a GitHub username.' });
465
486
  }
487
+ const note = (req.body.note || '').trim() || null;
466
488
  try {
467
489
  const member = await pool.query(
468
490
  `SELECT 1 FROM builders WHERE lower(github_login) = lower($1) LIMIT 1`,
@@ -485,17 +507,17 @@ module.exports = function buildAccessRequestsRouter() {
485
507
  resolved_by_builder_id = $1,
486
508
  resolved_at = now()
487
509
  WHERE lower(github_login) = lower($2) AND status = 'pending'
488
- RETURNING id, github_login, status, resolved_at`,
510
+ RETURNING id, github_login, status, kind, resolved_at`,
489
511
  [req.builder.id, login]
490
512
  );
491
513
  if (resolved.rows.length > 0) {
492
514
  return res.json({ ok: true, request: resolved.rows[0] });
493
515
  }
494
516
  const { rows } = await pool.query(
495
- `INSERT INTO access_requests (github_login, status, resolved_by_builder_id, resolved_at)
496
- VALUES ($1, 'invited', $2, now())
497
- RETURNING id, github_login, status, resolved_at`,
498
- [login, req.builder.id]
517
+ `INSERT INTO access_requests (github_login, note, kind, created_by, status, resolved_by_builder_id, resolved_at)
518
+ VALUES ($1, $2, 'invite', $3, 'invited', $3, now())
519
+ RETURNING id, github_login, status, kind, resolved_at`,
520
+ [login, note, req.builder.id]
499
521
  );
500
522
  return res.status(201).json({ ok: true, request: rows[0] });
501
523
  } catch (err) {
@@ -504,5 +526,34 @@ module.exports = function buildAccessRequestsRouter() {
504
526
  }
505
527
  });
506
528
 
529
+ // GET /access-requests/invites[?mine=1] — the outstanding invites: kind='invite',
530
+ // still 'invited' (not rescinded), and no builder row yet (not yet accepted by
531
+ // signing in), each naming its sender; ?mine=1 keeps only the caller's own
532
+ // (ADR 0335 D2; task 1002814). Rescind one with PATCH /access-requests/:id.
533
+ // rank: archon — it names logins that are not members yet.
534
+ router.get('/access-requests/invites', auth.requireBuilder, auth.requirePermission('access_request.review'), async (req, res) => {
535
+ const mine = req.query.mine === '1';
536
+ const mineClause = mine ? 'AND ar.created_by = $1' : '';
537
+ try {
538
+ const { rows } = await pool.query(
539
+ `SELECT ar.id, ar.github_login, ar.note, ar.created_at,
540
+ ib.github_login AS invited_by_login
541
+ FROM access_requests ar
542
+ LEFT JOIN builders ib ON ib.id = ar.created_by
543
+ WHERE ar.kind = 'invite' AND ar.status = 'invited'
544
+ AND NOT EXISTS (SELECT 1 FROM builders b
545
+ WHERE lower(b.github_login) = lower(ar.github_login))
546
+ ${mineClause}
547
+ ORDER BY ar.created_at DESC
548
+ LIMIT 200`,
549
+ mine ? [req.builder.id] : []
550
+ );
551
+ res.json({ invites: rows });
552
+ } catch (err) {
553
+ log.error('[gds] GET /access-requests/invites', err);
554
+ res.fail('list_invites_failed', { status: 500, message: 'internal error' });
555
+ }
556
+ });
557
+
507
558
  return router;
508
559
  };
@@ -0,0 +1,235 @@
1
+ // modules/platform-identity/community-leaderboard.js — the PUBLIC windowed
2
+ // leaderboard (D3, docs/specs/bongos-v1-community-tab.md; task 1002978, goal
3
+ // 1000110 CT2): "who earned the most across every project, this week / month /
4
+ // year / ever", optionally narrowed to one discipline. The read behind
5
+ // GET /community/leaderboard, for the hub's /community page (task 1003363).
6
+ //
7
+ // WHO: rollupVisibleSql — active + public + NOT hide_stats, the privacy matrix's
8
+ // NUMBERS predicate, imported from account-visibility.js and never restated. A
9
+ // leaderboard row IS stats, so hide_stats excludes; private and provisional
10
+ // accounts never reach it. Deliberately NOT scoutingListableSql: the recruiting
11
+ // opt-out governs the recruiting directory only (spec D3). Viewer-independent —
12
+ // nothing here takes a viewer, so every caller sees the same board.
13
+ //
14
+ // WHAT (spec D2): all-time = the live rollup rows summed across projects. A
15
+ // window = per (project, builder), the live row minus the last snapshot
16
+ // at-or-before the window start (activity-snapshots.js), summed across projects;
17
+ // no such snapshot counts as zero. So until a builder's first changed report
18
+ // after snapshots began, their whole lifetime total counts inside every window —
19
+ // the spec's accepted rollout behaviour, self-correcting. Deltas attribute to
20
+ // REPORTING time, not earning time (the spec's stated V1 limitation).
21
+ //
22
+ // TWO HALVES, because the board is public and unauthenticated. The expensive
23
+ // half — every builder's window totals, one snapshot probe per activity row — is
24
+ // memoized per window for TOTALS_TTL_MS, so traffic cannot multiply it. The cheap
25
+ // half — who is eligible, and their name and avatar — is re-read on EVERY
26
+ // request, so a builder who hides or goes private leaves the board on the very
27
+ // next request (hide must beat the cache, ADR 0171 D5). The memo can only lag on
28
+ // numbers, never on who is shown.
29
+ //
30
+ // A builder with nothing to show in the window (credits, ships and karma all
31
+ // unchanged) is left off that window's board rather than listed at zero.
32
+ // github_id never crosses the projection; handle is the address.
33
+ 'use strict';
34
+
35
+ const { pool: defaultPool } = require('../../src/module-api');
36
+ const { rollupVisibleSql } = require('./account-visibility');
37
+ // The sibling public community read's paging + input caps, shared rather than
38
+ // copied so the two routes cannot drift apart.
39
+ const { clampOffset, MAX_OFFSET, MAX_DISCIPLINE_CHARS } = require('./builder-search');
40
+
41
+ // The spec's four windows. null = all-time (no baseline subtracted).
42
+ const WINDOW_DAYS = Object.freeze({ '7d': 7, '30d': 30, '365d': 365, all: null });
43
+ const WINDOWS = Object.freeze(Object.keys(WINDOW_DAYS));
44
+ const DEFAULT_WINDOW = 'all';
45
+ const DAY_MS = 24 * 60 * 60 * 1000;
46
+
47
+ const DEFAULT_LIMIT = 25;
48
+ const MAX_LIMIT = 100;
49
+ // How long one window's totals are reused. Numbers are reporting-time already
50
+ // (they move when an instance reports), so half a minute of lag is invisible;
51
+ // eligibility is never memoized.
52
+ const TOTALS_TTL_MS = 30 * 1000;
53
+
54
+ function isWindow(value) {
55
+ return typeof value === 'string' && Object.prototype.hasOwnProperty.call(WINDOW_DAYS, value);
56
+ }
57
+
58
+ // The instant a window opens, or null for all-time. `now` is injectable so a
59
+ // test can pin the boundary.
60
+ function windowStart(window, now = new Date()) {
61
+ const days = WINDOW_DAYS[window];
62
+ if (days == null) return null;
63
+ return new Date(now.getTime() - days * DAY_MS);
64
+ }
65
+
66
+ function clampLimit(limit) {
67
+ const n = parseInt(limit, 10);
68
+ if (!Number.isFinite(n) || n <= 0) return DEFAULT_LIMIT;
69
+ return Math.min(n, MAX_LIMIT);
70
+ }
71
+
72
+ // Every eligible builder's totals for one window, unsorted and unpaged:
73
+ // [{ githubId, credits, tasks_shipped, karma, disciplines }]. The expensive
74
+ // half. Arithmetic is bigint: the columns are instance-reported int4, and one
75
+ // extreme report must not overflow the whole public board into a 500.
76
+ async function computeWindowTotals(since, pool) {
77
+ const { rows } = await pool.query(
78
+ `WITH act AS (
79
+ SELECT a.client_id, a.github_id, a.credits, a.tasks_shipped, a.karma, a.disciplines
80
+ FROM platform_identity_builder_project_activity a
81
+ JOIN platform_identity_accounts pa ON pa.github_id = a.github_id
82
+ WHERE ${rollupVisibleSql('pa')}
83
+ ), per_project AS (
84
+ -- The baseline probe walks platform_identity_activity_snapshots_builder_time
85
+ -- in index order. All-time binds $1 NULL, so the probe never runs.
86
+ SELECT act.github_id,
87
+ act.credits::bigint - COALESCE(b.credits, 0) AS credits,
88
+ act.tasks_shipped::bigint - COALESCE(b.tasks_shipped, 0) AS tasks_shipped,
89
+ act.karma::bigint - COALESCE(b.karma, 0) AS karma
90
+ FROM act
91
+ LEFT JOIN LATERAL (
92
+ SELECT s.credits, s.tasks_shipped, s.karma
93
+ FROM platform_identity_activity_snapshots s
94
+ WHERE $1::timestamptz IS NOT NULL
95
+ AND s.client_id = act.client_id AND s.github_id = act.github_id
96
+ AND s.reported_at <= $1::timestamptz
97
+ ORDER BY s.reported_at DESC, s.id DESC
98
+ LIMIT 1
99
+ ) b ON true
100
+ ), board AS (
101
+ SELECT github_id,
102
+ sum(credits)::bigint AS credits,
103
+ sum(tasks_shipped)::bigint AS tasks_shipped,
104
+ sum(karma)::bigint AS karma
105
+ FROM per_project
106
+ GROUP BY github_id
107
+ ), disc AS (
108
+ SELECT x.github_id, array_agg(DISTINCT x.d ORDER BY x.d) AS disciplines
109
+ FROM (SELECT act.github_id, unnest(act.disciplines) AS d FROM act) x
110
+ WHERE x.d IS NOT NULL
111
+ GROUP BY x.github_id
112
+ )
113
+ SELECT board.github_id, board.credits, board.tasks_shipped, board.karma, disc.disciplines
114
+ FROM board
115
+ LEFT JOIN disc ON disc.github_id = board.github_id
116
+ WHERE board.credits <> 0 OR board.tasks_shipped <> 0 OR board.karma <> 0`,
117
+ [since],
118
+ );
119
+ // int8 arrives from node-postgres as a string; any real total is far inside
120
+ // Number's exact-integer range.
121
+ return rows.map((r) => ({
122
+ githubId: String(r.github_id),
123
+ credits: Number(r.credits),
124
+ tasks_shipped: Number(r.tasks_shipped),
125
+ karma: Number(r.karma),
126
+ disciplines: Array.isArray(r.disciplines) ? r.disciplines : [],
127
+ }));
128
+ }
129
+
130
+ // window -> { expires, since, promise }. Concurrent requests share one
131
+ // in-flight computation; a failed one is dropped so the next request retries.
132
+ const totalsMemo = new Map();
133
+
134
+ function memoizedWindowTotals(window, pool) {
135
+ const hit = totalsMemo.get(window);
136
+ if (hit && hit.pool === pool && hit.expires > Date.now()) return hit;
137
+ const since = windowStart(window);
138
+ const entry = { pool, since, expires: Date.now() + TOTALS_TTL_MS, promise: computeWindowTotals(since, pool) };
139
+ entry.promise.catch(() => { if (totalsMemo.get(window) === entry) totalsMemo.delete(window); });
140
+ totalsMemo.set(window, entry);
141
+ return entry;
142
+ }
143
+
144
+ function clearTotalsMemo() {
145
+ totalsMemo.clear();
146
+ }
147
+
148
+ // The cheap half, re-read on every request: which of these builders are
149
+ // eligible RIGHT NOW, and how they are shown. Map githubId -> identity.
150
+ async function eligibleIdentities(githubIds, pool) {
151
+ if (githubIds.length === 0) return new Map();
152
+ const { rows } = await pool.query(
153
+ `SELECT pa.github_id, pa.handle, pa.github_login, pa.display_name, pa.avatar_url
154
+ FROM platform_identity_accounts pa
155
+ WHERE pa.github_id = ANY($1::bigint[])
156
+ AND ${rollupVisibleSql('pa')}`,
157
+ [githubIds],
158
+ );
159
+ return new Map(rows.map((r) => [String(r.github_id), r]));
160
+ }
161
+
162
+ // Credits, then ships, then karma, then handle/login so a tie is stable —
163
+ // compared by code unit, so the order never depends on the server's locale.
164
+ const byText = (x, y) => { const a = String(x ?? ''); const b = String(y ?? ''); return a < b ? -1 : a > b ? 1 : 0; };
165
+ function compareRows(a, b) {
166
+ return (b.credits - a.credits) || (b.tasks_shipped - a.tasks_shipped) || (b.karma - a.karma)
167
+ || byText(a.handle, b.handle) || byText(a.github_login, b.github_login);
168
+ }
169
+
170
+ // listLeaderboard — one page of the board. Throws on an unknown window (the
171
+ // route validates and 400s first). Returns { window, since, results, limit,
172
+ // offset }: `since` is the window start the totals were computed from (null
173
+ // for all-time) and limit/offset are the CLAMPED values, so the route echoes
174
+ // rather than re-derives them. `place` is the 1-based position on the whole
175
+ // board — named so it can never be read as a builder's rank. Passing `now`
176
+ // pins the window start and bypasses the memo (tests of the boundary).
177
+ async function listLeaderboard({ window = DEFAULT_WINDOW, discipline, limit, offset, now, pool = defaultPool } = {}) {
178
+ if (!isWindow(window)) throw new Error(`listLeaderboard: unknown window ${JSON.stringify(window)}`);
179
+ const lim = clampLimit(limit);
180
+ const off = clampOffset(offset);
181
+ const disc = typeof discipline === 'string' ? discipline.trim().slice(0, MAX_DISCIPLINE_CHARS) : '';
182
+
183
+ let since;
184
+ let totals;
185
+ if (now !== undefined) {
186
+ since = windowStart(window, now);
187
+ totals = await computeWindowTotals(since, pool);
188
+ } else {
189
+ const entry = memoizedWindowTotals(window, pool);
190
+ since = entry.since;
191
+ totals = await entry.promise;
192
+ }
193
+
194
+ const candidates = disc ? totals.filter((t) => t.disciplines.includes(disc)) : totals;
195
+ const identities = await eligibleIdentities(candidates.map((t) => t.githubId), pool);
196
+ const board = candidates
197
+ .filter((t) => identities.has(t.githubId))
198
+ .map((t) => {
199
+ const who = identities.get(t.githubId);
200
+ return {
201
+ handle: who.handle,
202
+ github_login: who.github_login,
203
+ display_name: who.display_name,
204
+ avatar_url: who.avatar_url,
205
+ credits: t.credits,
206
+ tasks_shipped: t.tasks_shipped,
207
+ karma: t.karma,
208
+ disciplines: t.disciplines,
209
+ };
210
+ })
211
+ .sort(compareRows);
212
+
213
+ return {
214
+ window,
215
+ since: since ? since.toISOString() : null,
216
+ results: board.slice(off, off + lim).map((row, i) => ({ place: off + i + 1, ...row })),
217
+ limit: lim,
218
+ offset: off,
219
+ };
220
+ }
221
+
222
+ module.exports = {
223
+ listLeaderboard,
224
+ clearTotalsMemo,
225
+ windowStart,
226
+ isWindow,
227
+ clampLimit,
228
+ WINDOWS,
229
+ DEFAULT_WINDOW,
230
+ DEFAULT_LIMIT,
231
+ MAX_LIMIT,
232
+ MAX_OFFSET,
233
+ MAX_DISCIPLINE_CHARS,
234
+ TOTALS_TTL_MS,
235
+ };
@@ -137,7 +137,7 @@ async function identityById(githubIds, { pool = defaultPool } = {}) {
137
137
  }
138
138
 
139
139
  function mutualReason(n) {
140
- return `${n} mutual connection${n === 1 ? '' : 's'}`;
140
+ return `${n} connection${n === 1 ? '' : 's'} in common`;
141
141
  }
142
142
 
143
143
  // listConnectionSuggestions({ githubId, pool }) → [{ handle, display_name,
@@ -0,0 +1,68 @@
1
+ -- platform_identity_024_terms_grandfathered.sql — grandfather existing accounts
2
+ -- onto the platform T&C, and record that they were grandfathered rather than
3
+ -- signed in (task 1003360; owner decision on blocker 1000123; ADR 0217 D3,
4
+ -- amending ADR 0213 D4).
5
+ --
6
+ -- WHY. Migration 019 made consent two columns and deliberately backfilled
7
+ -- nothing, so every account the hub had already witnessed ('active') stopped
8
+ -- being published until its owner next signed in. The owner chose instead to
9
+ -- grandfather them — nothing goes dark — having been shown that this records a
10
+ -- consent which did not happen. So the record must not hide it: a grandfathered
11
+ -- acceptance is marked as such, and /terms says so.
12
+ --
13
+ -- WHAT IT DOES, once:
14
+ -- 1. adds terms_accepted_source ('signin' | 'grandfathered'). A named marker,
15
+ -- not a magic timestamp, so any later audit can count the two apart.
16
+ -- 2. marks every acceptance already on record as 'signin' — until now the one
17
+ -- statement that could write an acceptance was the hub sign-in
18
+ -- (upsertAccount's activate branch, ADR 0213 D1). This runs FIRST, or step 3's
19
+ -- rows would be relabelled too.
20
+ -- 3. grandfathers every 'active' account with no acceptance: stamped now(),
21
+ -- with the version in force today, marked 'grandfathered'. profile_state is
22
+ -- not touched — a 'provisional' account was never public and stays
23
+ -- unpublished; this keeps published profiles published, it publishes nobody.
24
+ --
25
+ -- ONE-SHOT, ON PURPOSE. The writes run only in the transaction that creates the
26
+ -- column. Grandfathering is an event at this deploy ("accounts that existed
27
+ -- before these terms"), not a rule: replaying this file (the integration tests
28
+ -- do) must never grandfather an account created since.
29
+ --
30
+ -- The version literal is pinned, not a placeholder: it names the text these
31
+ -- accounts were accepted under, and a later revision must not rewrite that.
32
+ --
33
+ -- AFTER THIS, the grandfather mark gives way to a real acceptance: the owner's
34
+ -- next hub sign-in restamps all three columns as a 'signin'
35
+ -- (terms-acceptance.js acceptanceConflictSet). No runtime code writes
36
+ -- 'grandfathered'; tests/terms_acceptance.mjs fails any that does.
37
+ --
38
+ -- Additive + platform_identity_-namespaced (ADR 0083 §5). Safe to re-run.
39
+
40
+ BEGIN;
41
+
42
+ DO $$
43
+ BEGIN
44
+ IF NOT EXISTS (
45
+ SELECT 1 FROM information_schema.columns
46
+ WHERE table_schema = current_schema()
47
+ AND table_name = 'platform_identity_accounts'
48
+ AND column_name = 'terms_accepted_source'
49
+ ) THEN
50
+ ALTER TABLE platform_identity_accounts
51
+ ADD COLUMN terms_accepted_source text
52
+ CHECK (terms_accepted_source IN ('signin', 'grandfathered'));
53
+
54
+ UPDATE platform_identity_accounts
55
+ SET terms_accepted_source = 'signin'
56
+ WHERE terms_accepted_at IS NOT NULL;
57
+
58
+ UPDATE platform_identity_accounts
59
+ SET terms_accepted_at = now(),
60
+ terms_version = '2026-08-29',
61
+ terms_accepted_source = 'grandfathered'
62
+ WHERE profile_state = 'active'
63
+ AND terms_accepted_at IS NULL;
64
+ END IF;
65
+ END
66
+ $$;
67
+
68
+ COMMIT;
@@ -10,7 +10,7 @@
10
10
  "maintainer": "Cloud Bongos maintainers",
11
11
  "maintenance": { "status": "core-maintained" },
12
12
  "contributes": {
13
- "routes": ["sso", "projects", "my-projects", "scouting", "profile", "public-profile", "connections", "community-search"],
13
+ "routes": ["sso", "projects", "my-projects", "scouting", "profile", "public-profile", "connections", "community-search", "community-leaderboard"],
14
14
  "migrations": true,
15
15
  "pollers": ["code-sweep", "visibility-pull"]
16
16
  },
@@ -0,0 +1,61 @@
1
+ // modules/platform-identity/read-rate-limit.js — a per-IP read cap for an
2
+ // UNAUTHENTICATED public GET in this module. The global limiter meters writes
3
+ // only, and core's per-IP sliding window (src/bongos/middleware/rate-limit.js
4
+ // perIpSlidingWindow) reaches modules only as ready-built instances on the
5
+ // doorway (ADR 0209: an instance per budget), none of which is this module's.
6
+ // This is the module-side twin of that function — same 429 envelope and
7
+ // headers — written once (task 1002978) and mounted by both community reads
8
+ // (routes/community-leaderboard.js, routes/community-search.js).
9
+ // routes/public-profile.js and routes/sso.js still carry older vendored copies.
10
+ 'use strict';
11
+
12
+ const SWEEP_EVERY_MS = 5 * 60 * 1000;
13
+ // Bounds the bucket map between sweeps: rotating source addresses (an IPv6
14
+ // block) must not grow the heap without limit on an unauthenticated route.
15
+ const MAX_TRACKED_IPS = 10000;
16
+
17
+ // createReadRateLimit({ scope, limit, windowMs, maxTrackedIps }) → Express
18
+ // middleware allowing `limit` requests per IP per sliding `windowMs`, then 429
19
+ // rate_limited with Retry-After. `scope` names the budget in the response so a
20
+ // client can tell which cap it hit. Buckets live in this closure: one budget
21
+ // per call. The map is kept in least-recently-seen order, so at the tracking
22
+ // cap the IP evicted is the one idle longest — O(1), no scan on the hot path.
23
+ function createReadRateLimit({ scope, limit, windowMs = 60 * 1000, maxTrackedIps = MAX_TRACKED_IPS }) {
24
+ const buckets = new Map(); // ip -> timestamp[], least recently seen first
25
+
26
+ const sweepTimer = setInterval(() => {
27
+ const cutoff = Date.now() - windowMs;
28
+ for (const [k, arr] of buckets) {
29
+ while (arr.length && arr[0] < cutoff) arr.shift();
30
+ if (arr.length === 0) buckets.delete(k);
31
+ }
32
+ }, SWEEP_EVERY_MS);
33
+ if (typeof sweepTimer.unref === 'function') sweepTimer.unref();
34
+
35
+ return function readRateLimit(req, res, next) {
36
+ const now = Date.now();
37
+ const key = req.ip || req.socket?.remoteAddress || 'unknown';
38
+ let bucket = buckets.get(key);
39
+ if (bucket) {
40
+ buckets.delete(key); // re-inserted below: most recently seen goes last
41
+ } else {
42
+ if (buckets.size >= maxTrackedIps) buckets.delete(buckets.keys().next().value);
43
+ bucket = [];
44
+ }
45
+ buckets.set(key, bucket);
46
+ const cutoff = now - windowMs;
47
+ while (bucket.length && bucket[0] < cutoff) bucket.shift();
48
+ if (bucket.length >= limit) {
49
+ const retryAfter = Math.max(1, Math.ceil((bucket[0] + windowMs - now) / 1000));
50
+ res.set('Retry-After', String(retryAfter));
51
+ res.set('X-RateLimit-Scope', scope);
52
+ res.set('X-RateLimit-Limit', String(limit));
53
+ res.set('X-RateLimit-Window-Seconds', String(windowMs / 1000));
54
+ return res.fail('rate_limited', 429, { scope, retry_after_seconds: retryAfter });
55
+ }
56
+ bucket.push(now);
57
+ return next();
58
+ };
59
+ }
60
+
61
+ module.exports = { createReadRateLimit, MAX_TRACKED_IPS };