@bongos/core 1.19.1025 → 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 +51 -26
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +3 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +51 -4
- package/docs/api-reference.md +4 -3
- package/docs/copy-inventory.md +3 -3
- package/docs/copy-registry.json +4 -14
- package/docs/module-api-changelog.md +2 -0
- package/modules/hall-ui/public/goal-map.html +1 -1
- package/modules/platform-identity/community-leaderboard.js +235 -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/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/run-unit-tests.js +5 -0
- package/src/module-api.js +1 -1
- package/tests/community_leaderboard_db.mjs +202 -0
- package/tests/community_leaderboard_route.mjs +414 -0
- package/tests/goal_map_page.mjs +8 -0
|
@@ -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
|
+
};
|
|
@@ -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 };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// modules/platform-identity/routes/community-leaderboard.js — GET
|
|
2
|
+
// /community/leaderboard, the PUBLIC windowed leaderboard behind the hub's
|
|
3
|
+
// /community page (D3, docs/specs/bongos-v1-community-tab.md; task 1002978,
|
|
4
|
+
// goal 1000110 CT2). The sibling of routes/community-search.js: same module,
|
|
5
|
+
// same public posture. Who appears and how a window is counted live entirely in
|
|
6
|
+
// ../community-leaderboard.js; this file is the HTTP shell — validation, the
|
|
7
|
+
// per-IP read cap, the response envelope. It never reads a session, so the
|
|
8
|
+
// board is the same for every viewer.
|
|
9
|
+
//
|
|
10
|
+
// Reaches core only through the doorway (ADR 0083).
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const express = require('express');
|
|
14
|
+
const api = require('../../../src/module-api');
|
|
15
|
+
const leaderboard = require('../community-leaderboard');
|
|
16
|
+
const { createReadRateLimit } = require('../read-rate-limit');
|
|
17
|
+
|
|
18
|
+
const { pool, corsPublicGet } = api;
|
|
19
|
+
|
|
20
|
+
const READ_RATE_LIMIT = 60; // per IP per minute — the community-search budget
|
|
21
|
+
const READ_RATE_SCOPE = 'community-leaderboard-read';
|
|
22
|
+
const leaderboardReadRateLimit = createReadRateLimit({ scope: READ_RATE_SCOPE, limit: READ_RATE_LIMIT });
|
|
23
|
+
|
|
24
|
+
module.exports = function communityLeaderboardRoutes() {
|
|
25
|
+
const router = express.Router();
|
|
26
|
+
|
|
27
|
+
router.use('/community/leaderboard', corsPublicGet('/community/leaderboard'));
|
|
28
|
+
|
|
29
|
+
// rank: public — the cross-project leaderboard (?window=7d|30d|365d|all,
|
|
30
|
+
// default all; ?discipline=; ?limit=&offset=). Eligibility is the privacy
|
|
31
|
+
// matrix's numbers predicate (active + public + stats shown); the recruiting
|
|
32
|
+
// opt-out does not apply. no-store: hide must beat the cache (ADR 0171 D5).
|
|
33
|
+
// Rollout note: until a builder's first changed report after snapshot history
|
|
34
|
+
// began, their whole lifetime total counts inside every window (spec D2's
|
|
35
|
+
// missing-baseline-is-zero rule) — expected on day one, and self-correcting.
|
|
36
|
+
router.get('/community/leaderboard', leaderboardReadRateLimit, async (req, res) => {
|
|
37
|
+
const window = req.query.window === undefined || req.query.window === ''
|
|
38
|
+
? leaderboard.DEFAULT_WINDOW
|
|
39
|
+
: req.query.window;
|
|
40
|
+
if (!leaderboard.isWindow(window)) {
|
|
41
|
+
return res.fail('bad_window', { status: 400, message: `window must be one of: ${leaderboard.WINDOWS.join(', ')}` });
|
|
42
|
+
}
|
|
43
|
+
const discipline = (typeof req.query.discipline === 'string' ? req.query.discipline : '').trim();
|
|
44
|
+
if (discipline.length > leaderboard.MAX_DISCIPLINE_CHARS) {
|
|
45
|
+
return res.fail('discipline_too_long', { status: 400, message: `discipline must be at most ${leaderboard.MAX_DISCIPLINE_CHARS} characters` });
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
const board = await leaderboard.listLeaderboard({
|
|
49
|
+
window, discipline, limit: req.query.limit, offset: req.query.offset, pool,
|
|
50
|
+
});
|
|
51
|
+
res.set('Cache-Control', 'no-store');
|
|
52
|
+
res.json({
|
|
53
|
+
window: board.window,
|
|
54
|
+
since: board.since,
|
|
55
|
+
discipline: discipline || null,
|
|
56
|
+
count: board.results.length,
|
|
57
|
+
results: board.results,
|
|
58
|
+
page: { limit: board.limit, offset: board.offset },
|
|
59
|
+
});
|
|
60
|
+
} catch (err) {
|
|
61
|
+
api.logger('platform-identity').error({ err: err && err.message }, 'GET /community/leaderboard failed');
|
|
62
|
+
res.fail('leaderboard_failed', { status: 500, message: 'internal error' });
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
return router;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
module.exports._internals = { READ_RATE_LIMIT, READ_RATE_SCOPE };
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
const express = require('express');
|
|
17
17
|
const api = require('../../../src/module-api');
|
|
18
18
|
const builderSearch = require('../builder-search');
|
|
19
|
+
const { createReadRateLimit } = require('../read-rate-limit');
|
|
19
20
|
|
|
20
21
|
const { pool, corsPublicGet } = api;
|
|
21
22
|
|
|
@@ -33,39 +34,11 @@ const log = {
|
|
|
33
34
|
|
|
34
35
|
// Per-IP read cap for this UNAUTHENTICATED route (the public-profile.js R05
|
|
35
36
|
// posture: the global limiter only meters WRITES, so an uncapped scraper could
|
|
36
|
-
// otherwise run the trigram leg at line rate).
|
|
37
|
-
//
|
|
38
|
-
// own budget rather than reaching into a sibling module's copy.
|
|
37
|
+
// otherwise run the trigram leg at line rate). Its own budget, built by the
|
|
38
|
+
// module's shared factory (task 1002978) rather than a vendored copy.
|
|
39
39
|
const READ_RATE_WINDOW_MS = 60 * 1000;
|
|
40
40
|
const READ_RATE_LIMIT = 60;
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
const sweepTimer = setInterval(() => {
|
|
44
|
-
const cutoff = Date.now() - READ_RATE_WINDOW_MS;
|
|
45
|
-
for (const [k, arr] of buckets) {
|
|
46
|
-
while (arr.length && arr[0] < cutoff) arr.shift();
|
|
47
|
-
if (arr.length === 0) buckets.delete(k);
|
|
48
|
-
}
|
|
49
|
-
}, 5 * 60 * 1000);
|
|
50
|
-
if (typeof sweepTimer.unref === 'function') sweepTimer.unref();
|
|
51
|
-
|
|
52
|
-
function searchReadRateLimit(req, res, next) {
|
|
53
|
-
const now = Date.now();
|
|
54
|
-
const key = req.ip || req.socket?.remoteAddress || 'unknown';
|
|
55
|
-
let bucket = buckets.get(key);
|
|
56
|
-
if (!bucket) { bucket = []; buckets.set(key, bucket); }
|
|
57
|
-
const cutoff = now - READ_RATE_WINDOW_MS;
|
|
58
|
-
while (bucket.length && bucket[0] < cutoff) bucket.shift();
|
|
59
|
-
if (bucket.length >= READ_RATE_LIMIT) {
|
|
60
|
-
const retryAfter = Math.max(1, Math.ceil((bucket[0] + READ_RATE_WINDOW_MS - now) / 1000));
|
|
61
|
-
res.set('Retry-After', String(retryAfter));
|
|
62
|
-
res.set('X-RateLimit-Scope', 'community-search-read');
|
|
63
|
-
res.set('X-RateLimit-Limit', String(READ_RATE_LIMIT));
|
|
64
|
-
return res.fail('rate_limited', 429, { scope: 'community-search-read', retry_after_seconds: retryAfter });
|
|
65
|
-
}
|
|
66
|
-
bucket.push(now);
|
|
67
|
-
return next();
|
|
68
|
-
}
|
|
41
|
+
const searchReadRateLimit = createReadRateLimit({ scope: 'community-search-read', limit: READ_RATE_LIMIT, windowMs: READ_RATE_WINDOW_MS });
|
|
69
42
|
|
|
70
43
|
module.exports = function communitySearchRoutes() {
|
|
71
44
|
const router = express.Router();
|
|
@@ -120,7 +120,7 @@ module.exports = function projectsRoutes() {
|
|
|
120
120
|
// as any-builder — wrong in the dangerous direction (the ADR 0210 rule).
|
|
121
121
|
|
|
122
122
|
// rank: metic+archon — own-or-metic in effect: invite a builder into a project
|
|
123
|
-
// (ADR 0141 §5, task
|
|
123
|
+
// (ADR 0141 §5, task 1002115), called by a `project.curate` holder OR the project's OWN
|
|
124
124
|
// OWNER (task 1003580; the ownership proof is `provisioning_instances.owner_builder_id`,
|
|
125
125
|
// reached through a port — see invite-authz.js). The wizard's audience is any signed-in
|
|
126
126
|
// GitHub user, typically a xenos who used to get 403 on their own project. A caller who is
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.1026",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.1026",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.1026",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
|
@@ -87,6 +87,11 @@ const INTEGRATION = new Set([
|
|
|
87
87
|
// WHERE NOT EXISTS statement) and the upsertActivity wiring against real SQL —
|
|
88
88
|
// a fake pool can prove the query shape but not which reports it inserts.
|
|
89
89
|
'activity_snapshots_db',
|
|
90
|
+
// task 1002978: the windowed leaderboard against real SQL. Which snapshot
|
|
91
|
+
// becomes a window baseline (a LATERAL at-or-before probe), a missing baseline
|
|
92
|
+
// counting as zero, and the privacy matrix filtering are all planner facts the
|
|
93
|
+
// DB-free route test can only assert the shape of.
|
|
94
|
+
'community_leaderboard_db',
|
|
90
95
|
'asset_version', 'auth_flow_slice1', 'backfill_sessions', 'bfg', 'bfg_write',
|
|
91
96
|
'box_connect_e2e', 'box_onboard', 'boxes', 'cf_tunnel', 'cli_token_reissue',
|
|
92
97
|
// consumer_layout_boot builds the core .tgz, `npm install`s it, and boots a live
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.1026'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|