@bongos/core 1.19.101 → 1.19.103
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 +43 -23
- package/bin/bongos.js +3 -0
- package/docs/adr/0169-bongos-serve-server-bring-up.md +52 -0
- package/docs/adr/README.md +1 -0
- package/docs/recipes/instance-deploy.md +1 -0
- package/docs/recipes/local-dev.md +3 -0
- package/docs/recipes/server-bring-up.md +112 -0
- package/modules/platform-identity/project-stats.js +207 -0
- package/modules/platform-identity/routes/projects.js +21 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/assets/cosmos.css +5 -0
- package/public-landing/projects.html +54 -1
- package/scripts/gds/dev-lib.js +209 -3
- package/scripts/gds/dev.js +292 -21
- package/src/module-api.js +5 -1
- package/tests/bongos_cli.mjs +3 -1
- package/tests/dev_lib.mjs +192 -2
- package/tests/project_stats.mjs +206 -0
- package/tests/projects_hub.mjs +53 -10
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// tests/project_stats.mjs — the hub's cross-instance stats fetcher (task 1002771).
|
|
2
|
+
//
|
|
3
|
+
// The hub reads Builders + Runs from each project's OWN public API, because the
|
|
4
|
+
// apex catalog does not know them and the membership tables would report zero for
|
|
5
|
+
// every hand-wired project (task 1002760). The risk in that design is not the
|
|
6
|
+
// happy path — it is the failure modes: a dead project must cost a dash, not the
|
|
7
|
+
// page; a machine account must not be counted as a person; and "we could not ask"
|
|
8
|
+
// must never be rendered as "the answer is none".
|
|
9
|
+
//
|
|
10
|
+
// Run: node --test tests/project_stats.mjs
|
|
11
|
+
//
|
|
12
|
+
// Fixture origins are literal PUBLIC IPs on purpose: net.isIP() short-circuits
|
|
13
|
+
// the SSRF guard's DNS lookup, so these exercise the fetch/cache/filter logic
|
|
14
|
+
// without a resolver in the loop. The guard itself is tested separately below.
|
|
15
|
+
|
|
16
|
+
import test, { beforeEach, afterEach } from 'node:test';
|
|
17
|
+
import assert from 'node:assert/strict';
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
19
|
+
|
|
20
|
+
const require = createRequire(import.meta.url);
|
|
21
|
+
const stats = require('../modules/platform-identity/project-stats.js');
|
|
22
|
+
|
|
23
|
+
const realFetch = globalThis.fetch;
|
|
24
|
+
afterEach(() => { globalThis.fetch = realFetch; });
|
|
25
|
+
beforeEach(() => { stats._reset(); });
|
|
26
|
+
|
|
27
|
+
function json(body) {
|
|
28
|
+
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(body) });
|
|
29
|
+
}
|
|
30
|
+
// route a fake instance: { leaderboard: [...], enabled: {...} }
|
|
31
|
+
function stub(map) {
|
|
32
|
+
globalThis.fetch = (url) => {
|
|
33
|
+
const u = new URL(url);
|
|
34
|
+
const site = map[u.origin];
|
|
35
|
+
if (!site) return Promise.reject(new Error('unreachable'));
|
|
36
|
+
if (u.pathname.endsWith('/leaderboard')) {
|
|
37
|
+
return site.leaderboard ? json({ leaderboard: site.leaderboard }) : Promise.reject(new Error('down'));
|
|
38
|
+
}
|
|
39
|
+
if (u.pathname.endsWith('/modules')) {
|
|
40
|
+
return site.enabled ? json({ enabled: site.enabled }) : Promise.reject(new Error('down'));
|
|
41
|
+
}
|
|
42
|
+
return Promise.reject(new Error('404'));
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
test('system accounts are excluded — they are machines, not builders', async () => {
|
|
47
|
+
// measured live: raw leaderboards read 13/3/3 against real 11/1/1
|
|
48
|
+
stub({
|
|
49
|
+
'https://8.8.8.8': {
|
|
50
|
+
leaderboard: [
|
|
51
|
+
{ github_login: 'ada' }, { github_login: 'grace' },
|
|
52
|
+
{ github_login: 'bfg-system' }, { github_login: 'discord-bot-system' },
|
|
53
|
+
],
|
|
54
|
+
enabled: {},
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
const out = await stats.getProjectStats(['https://8.8.8.8']);
|
|
58
|
+
assert.equal(out['https://8.8.8.8'].builders, 2, 'four rows, two of them machines');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('a tag is a NON-core module — core modules carry no information', async () => {
|
|
62
|
+
// every instance runs the core set, so including it would print the same
|
|
63
|
+
// fifteen tags on every row
|
|
64
|
+
const core = [...stats.coreModules()];
|
|
65
|
+
assert.ok(core.includes('grading'), 'the core set is read from the repo, not hardcoded');
|
|
66
|
+
stub({
|
|
67
|
+
'https://8.8.8.8': {
|
|
68
|
+
leaderboard: [],
|
|
69
|
+
enabled: { grading: true, lifecycle: true, game: true, 'art-pipeline': true },
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
const out = await stats.getProjectStats(['https://8.8.8.8']);
|
|
73
|
+
assert.deepEqual(out['https://8.8.8.8'].tags, ['art-pipeline', 'game']);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('a disabled module is not a tag', async () => {
|
|
77
|
+
stub({ 'https://8.8.8.8': { leaderboard: [], enabled: { game: false, 'art-pipeline': true } } });
|
|
78
|
+
const out = await stats.getProjectStats(['https://8.8.8.8']);
|
|
79
|
+
assert.deepEqual(out['https://8.8.8.8'].tags, ['art-pipeline']);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('an unreachable project yields nulls — never zero, never a throw', async () => {
|
|
83
|
+
// 0 would be the claim "nobody builds here". null is "we could not ask".
|
|
84
|
+
stub({}); // nothing resolves
|
|
85
|
+
const out = await stats.getProjectStats(['https://9.9.9.9']);
|
|
86
|
+
assert.equal(out['https://9.9.9.9'].builders, null);
|
|
87
|
+
assert.equal(out['https://9.9.9.9'].tags, null);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('one dead half does not discard the half that answered', async () => {
|
|
91
|
+
// a project whose leaderboard is down can still report its modules
|
|
92
|
+
stub({ 'https://8.8.8.8': { leaderboard: null, enabled: { game: true } } });
|
|
93
|
+
const out = await stats.getProjectStats(['https://8.8.8.8']);
|
|
94
|
+
assert.equal(out['https://8.8.8.8'].builders, null, 'the dead half is unknown');
|
|
95
|
+
assert.deepEqual(out['https://8.8.8.8'].tags, ['game'], 'the live half still answers');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('a dead project does not take the healthy ones down with it', async () => {
|
|
99
|
+
stub({ 'https://1.1.1.1': { leaderboard: [{ github_login: 'a' }], enabled: {} } });
|
|
100
|
+
const out = await stats.getProjectStats(['https://1.1.1.1', 'https://9.9.9.9']);
|
|
101
|
+
assert.equal(out['https://1.1.1.1'].builders, 1);
|
|
102
|
+
assert.equal(out['https://9.9.9.9'].builders, null);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('results are cached — a warm origin is not re-fetched', async () => {
|
|
106
|
+
let calls = 0;
|
|
107
|
+
const site = { leaderboard: [{ github_login: 'a' }], enabled: {} };
|
|
108
|
+
stub({ 'https://8.8.8.8': site });
|
|
109
|
+
const counting = globalThis.fetch;
|
|
110
|
+
globalThis.fetch = (u) => { calls++; return counting(u); };
|
|
111
|
+
|
|
112
|
+
await stats.getProjectStats(['https://8.8.8.8']);
|
|
113
|
+
const first = calls;
|
|
114
|
+
assert.ok(first > 0, 'the cold call must actually fetch');
|
|
115
|
+
await stats.getProjectStats(['https://8.8.8.8']);
|
|
116
|
+
assert.equal(calls, first, 'the second call must be served from cache');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('a FAILURE is cached too, or one dead project pays its timeout on every request', async () => {
|
|
120
|
+
let calls = 0;
|
|
121
|
+
globalThis.fetch = () => { calls++; return Promise.reject(new Error('down')); };
|
|
122
|
+
await stats.getProjectStats(['https://9.9.9.9']);
|
|
123
|
+
const first = calls;
|
|
124
|
+
await stats.getProjectStats(['https://9.9.9.9']);
|
|
125
|
+
assert.equal(calls, first, 'a known-dead origin must not be retried on the next page load');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('concurrent requests for the same origin collapse into one fetch', async () => {
|
|
129
|
+
let calls = 0;
|
|
130
|
+
globalThis.fetch = (url) => {
|
|
131
|
+
calls++;
|
|
132
|
+
return new Promise((r) => setTimeout(() => r({
|
|
133
|
+
ok: true, status: 200, json: () => Promise.resolve({ leaderboard: [], enabled: {} }),
|
|
134
|
+
}), 10));
|
|
135
|
+
};
|
|
136
|
+
await Promise.all([
|
|
137
|
+
stats.getProjectStats(['https://8.8.8.8']),
|
|
138
|
+
stats.getProjectStats(['https://8.8.8.8']),
|
|
139
|
+
stats.getProjectStats(['https://8.8.8.8']),
|
|
140
|
+
]);
|
|
141
|
+
assert.equal(calls, 2, 'two endpoints for one origin, not six for three callers');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('a non-http origin is refused rather than fetched', async () => {
|
|
145
|
+
globalThis.fetch = () => { throw new Error('must not be called'); };
|
|
146
|
+
const out = await stats.getProjectStats(['file:///etc/passwd']);
|
|
147
|
+
assert.equal(out['file:///etc/passwd'].builders, null);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('the cache TTL is the owner-chosen ten minutes', () => {
|
|
151
|
+
assert.equal(stats.TTL_MS, 10 * 60 * 1000);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
/* ── SSRF guard (grader finding, task 1002771 round 2) ──────────────────────
|
|
155
|
+
This is the one place the server dials an address that came out of the
|
|
156
|
+
database, so it is the one place that must treat the address as hostile.
|
|
157
|
+
"The catalog is Archon-curated" covers the ORIGIN but not a REDIRECT TARGET —
|
|
158
|
+
following redirects hands the final address back to the remote server, which
|
|
159
|
+
is the SSRF primitive itself. ── */
|
|
160
|
+
|
|
161
|
+
test('loopback, private, link-local and metadata addresses are refused', () => {
|
|
162
|
+
const blocked = {
|
|
163
|
+
'127.0.0.1': 'loopback',
|
|
164
|
+
'169.254.169.254': 'cloud metadata', // the one that matters
|
|
165
|
+
'10.0.0.5': 'private',
|
|
166
|
+
'192.168.1.1': 'private',
|
|
167
|
+
'172.16.0.1': 'private',
|
|
168
|
+
'172.31.255.255': 'private (upper bound)',
|
|
169
|
+
'100.64.0.1': 'CGNAT',
|
|
170
|
+
'0.0.0.0': 'unspecified',
|
|
171
|
+
'::1': 'IPv6 loopback',
|
|
172
|
+
'fd00::1': 'IPv6 unique-local',
|
|
173
|
+
'fe80::1': 'IPv6 link-local',
|
|
174
|
+
'::ffff:127.0.0.1': 'IPv4-mapped loopback',
|
|
175
|
+
};
|
|
176
|
+
for (const [addr, why] of Object.entries(blocked)) {
|
|
177
|
+
assert.ok(stats.blockedReason(addr), `${addr} (${why}) must be refused`);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('ordinary public addresses are allowed', () => {
|
|
182
|
+
for (const addr of ['8.8.8.8', '104.21.64.15', '172.15.0.1', '172.32.0.1', '2606:4700::1']) {
|
|
183
|
+
assert.equal(stats.blockedReason(addr), null, `${addr} is public and must be allowed`);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('a literal private IP as the origin host is refused before any fetch', async () => {
|
|
188
|
+
globalThis.fetch = () => { throw new Error('fetch must not be reached'); };
|
|
189
|
+
const out = await stats.getProjectStats(['http://169.254.169.254']);
|
|
190
|
+
assert.equal(out['http://169.254.169.254'].builders, null);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('redirects are refused, never followed', async () => {
|
|
194
|
+
// following would let a curated origin choose our final address
|
|
195
|
+
const src = await import('node:fs').then((fs) =>
|
|
196
|
+
fs.readFileSync(new URL('../modules/platform-identity/project-stats.js', import.meta.url), 'utf8'));
|
|
197
|
+
assert.match(src, /redirect:\s*'error'/, 'the remote must not choose where we end up');
|
|
198
|
+
assert.doesNotMatch(src, /redirect:\s*'follow'/);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('an origin named __proto__ cannot retarget the result prototype', async () => {
|
|
202
|
+
globalThis.fetch = () => Promise.reject(new Error('down'));
|
|
203
|
+
const out = await stats.getProjectStats(['__proto__']);
|
|
204
|
+
assert.equal(Object.getPrototypeOf(out), null, 'the result map is null-prototype');
|
|
205
|
+
assert.equal(({}).builders, undefined, 'Object.prototype is untouched');
|
|
206
|
+
});
|
package/tests/projects_hub.mjs
CHANGED
|
@@ -379,10 +379,14 @@ test('the row keeps its own Open link working underneath the card', () => {
|
|
|
379
379
|
|
|
380
380
|
test('the table declares no column it has no source for', () => {
|
|
381
381
|
const thead = (HUB.match(/<thead>([\s\S]*?)<\/thead>/) || [, ''])[1];
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
382
|
+
// Type is still absent: the owner's call is that a project DECLARES its type
|
|
383
|
+
// at creation, so until that field exists the hub must not guess one.
|
|
384
|
+
assert.doesNotMatch(thead, />Type</, 'Type has no source until a project declares it');
|
|
385
|
+
// Builders and Runs came back in task 1002771 WITH a source behind them
|
|
386
|
+
assert.match(thead, />Builders</);
|
|
387
|
+
assert.match(thead, />Runs</);
|
|
388
|
+
assert.match(SCRIPT, /p\.builders/, 'the Builders cell must read real data');
|
|
389
|
+
assert.match(SCRIPT, /tagsHtml\(p\.tags\)/, 'the Runs cell must read real data');
|
|
386
390
|
});
|
|
387
391
|
|
|
388
392
|
test('header and body agree on the column count', () => {
|
|
@@ -395,9 +399,9 @@ test('header and body agree on the column count', () => {
|
|
|
395
399
|
});
|
|
396
400
|
|
|
397
401
|
test('no sort control is offered for a column that cannot sort', () => {
|
|
398
|
-
// `data-sort="builders"` was declared sortable while the comparator
|
|
399
|
-
// only 'name' — clicking it did nothing at all
|
|
400
|
-
|
|
402
|
+
// `data-sort="builders"` was once declared sortable while the comparator
|
|
403
|
+
// handled only 'name' — clicking it did nothing at all. The column is back
|
|
404
|
+
// (task 1002771) and so is its comparator arm; this keeps the two in step.
|
|
401
405
|
const sorts = [...HUB.matchAll(/data-sort="([^"]+)"/g)].map((m) => m[1]);
|
|
402
406
|
const comparator = (SCRIPT.match(/list\.sort\(function[\s\S]*?\n \}\);/) || [''])[0];
|
|
403
407
|
for (const key of sorts) {
|
|
@@ -405,8 +409,47 @@ test('no sort control is offered for a column that cannot sort', () => {
|
|
|
405
409
|
}
|
|
406
410
|
});
|
|
407
411
|
|
|
408
|
-
test('
|
|
409
|
-
|
|
410
|
-
|
|
412
|
+
test('Type left no styling behind, and the live columns kept theirs', () => {
|
|
413
|
+
assert.ok(!COSMOS.includes('.colType'), '.colType is dead — the column is gone');
|
|
414
|
+
for (const live of ['.tbl .num', '.tbl .tags', '.colTags']) {
|
|
415
|
+
assert.ok(COSMOS.includes(live), `${live} styles a column that now renders real data`);
|
|
411
416
|
}
|
|
412
417
|
});
|
|
418
|
+
|
|
419
|
+
/* ── Real Builders + Runs (task 1002771) ────────────────────────────────────
|
|
420
|
+
The columns came back only once something could fill them: each project's
|
|
421
|
+
OWN public API. The distinction these pin is between "we could not ask"
|
|
422
|
+
(null → em dash) and "we asked, the answer is none" (0 / []). ── */
|
|
423
|
+
|
|
424
|
+
test('unknown and zero are rendered as different things', () => {
|
|
425
|
+
// 0 builders is a real answer — "nobody has joined yet". null is not an
|
|
426
|
+
// answer at all. Collapsing them would restate the lie 1002764 removed.
|
|
427
|
+
assert.match(SCRIPT, /p\.builders == null \? '—'/,
|
|
428
|
+
'only null becomes a dash; 0 must render as 0');
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test('a project that cannot be reached degrades to a dash, not to a wrong number', () => {
|
|
432
|
+
const fn = (SCRIPT.match(/function tagsHtml\(tags\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
|
|
433
|
+
assert.ok(fn, 'tagsHtml() must exist');
|
|
434
|
+
assert.match(fn, /Array\.isArray\(tags\)/, 'a non-array (null) must not be spread into the cell');
|
|
435
|
+
assert.match(fn, /return '—'/);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test('the tag list is capped, and the overflow is counted rather than hidden', () => {
|
|
439
|
+
const fn = (SCRIPT.match(/function tagsHtml\(tags\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
|
|
440
|
+
assert.match(fn, /TAG_CAP/, 'one heavily-moduled project must not blow the column open');
|
|
441
|
+
assert.match(fn, /\+' \+ \(tags\.length - TAG_CAP\)/, 'a silent truncation reads as "that is all of them"');
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test('sorting by builders puts UNKNOWN last, never first', () => {
|
|
445
|
+
const comparator = (SCRIPT.match(/list\.sort\(function[\s\S]*?\n \}\);/) || [''])[0];
|
|
446
|
+
assert.match(comparator, /x\.builders == null\) return 1/,
|
|
447
|
+
'null is not a low number — it must sink in both directions');
|
|
448
|
+
assert.match(comparator, /y\.builders == null\) return -1/);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test('the platform answers for itself by the same rule it applies to others', () => {
|
|
452
|
+
// the self row is synthesized, so the server-side enrichment never sees it
|
|
453
|
+
assert.match(SCRIPT, /function loadSelfStats/);
|
|
454
|
+
assert.match(SCRIPT, /-system\$/, 'machine accounts are excluded here too, or the platform inflates itself');
|
|
455
|
+
});
|