@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.
- package/.bongos-core.json +80 -40
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +7 -1
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/adr/0213-platform-terms-acceptance-is-the-hubs-own-witness.md +2 -0
- package/docs/api/openapi.json +106 -5
- package/docs/api-reference.md +7 -5
- package/docs/copy-inventory.md +19 -17
- package/docs/copy-registry.json +44 -36
- package/docs/module-api-changelog.md +5 -1
- package/migrations/core_255_access_requests_invite_kind.sql +52 -0
- package/modules/government/catalog.js +1 -1
- package/modules/hall-ui/public/goal-map.html +1 -1
- package/modules/onboarding/routes/access-requests.js +62 -11
- package/modules/platform-identity/community-leaderboard.js +235 -0
- package/modules/platform-identity/connection-suggestions.js +1 -1
- package/modules/platform-identity/migrations/platform_identity_024_terms_grandfathered.sql +68 -0
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/read-rate-limit.js +61 -0
- package/modules/platform-identity/routes/community-leaderboard.js +69 -0
- package/modules/platform-identity/routes/community-search.js +4 -31
- package/modules/platform-identity/routes/projects.js +1 -1
- package/modules/platform-identity/terms-acceptance.js +34 -14
- package/modules/public-landing/public/terms.html +2 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/run-unit-tests.js +10 -0
- package/src/module-api.js +1 -1
- package/tests/access_requests_invite.mjs +81 -4
- package/tests/application_lifecycle.mjs +174 -6
- package/tests/community_leaderboard_db.mjs +202 -0
- package/tests/community_leaderboard_route.mjs +414 -0
- package/tests/connection_suggestions.mjs +1 -1
- package/tests/connections_api.mjs +1 -1
- package/tests/goal_map_page.mjs +8 -0
- package/tests/handle_machinery.mjs +2 -0
- package/tests/profile_connections_ui.mjs +2 -2
- package/tests/profile_rollup_consent.mjs +3 -0
- package/tests/terms_acceptance.mjs +264 -14
- 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
|
|
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(
|
|
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
|
-
//
|
|
455
|
-
//
|
|
456
|
-
//
|
|
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', $
|
|
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}
|
|
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 };
|