@bongos/core 1.19.102 → 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 +24 -14
- 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/src/module-api.js +3 -1
- package/tests/project_stats.mjs +206 -0
- package/tests/projects_hub.mjs +53 -10
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.103",
|
|
6
|
+
"core_contract": "1.19.103",
|
|
7
|
+
"source_commit": "4c26152f4ffe43100617e89d904852639f51a9dc",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-13T23:
|
|
9
|
+
"built_at": "2026-08-13T23:41:03.246Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 301,
|
|
13
13
|
"agent_docs_stubbed": 21,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1234,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 1556,
|
|
20
|
+
"tree_sha256": "b559bea5c153dfcb5b4007dee090a97532674449005645ee1953761712bcf02a",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -3754,6 +3754,11 @@
|
|
|
3754
3754
|
"mode": "0000644",
|
|
3755
3755
|
"sha256": "b6eea95dbe66a3126fc2c73d93237cf38dd648a803e910eb88fbd5c780ccef62"
|
|
3756
3756
|
},
|
|
3757
|
+
{
|
|
3758
|
+
"path": "modules/platform-identity/project-stats.js",
|
|
3759
|
+
"mode": "0000644",
|
|
3760
|
+
"sha256": "6ce71a9bd387a2b79d4c57a6669f028a807ef88ff205f65c20c55ce3d9889214"
|
|
3761
|
+
},
|
|
3757
3762
|
{
|
|
3758
3763
|
"path": "modules/platform-identity/routes/my-projects.js",
|
|
3759
3764
|
"mode": "0000644",
|
|
@@ -3762,7 +3767,7 @@
|
|
|
3762
3767
|
{
|
|
3763
3768
|
"path": "modules/platform-identity/routes/projects.js",
|
|
3764
3769
|
"mode": "0000644",
|
|
3765
|
-
"sha256": "
|
|
3770
|
+
"sha256": "188583ffb979d305c57a8f0958806ee0bd813007df158d74e45a0d415a3ff6aa"
|
|
3766
3771
|
},
|
|
3767
3772
|
{
|
|
3768
3773
|
"path": "modules/platform-identity/routes/scouting.js",
|
|
@@ -4142,12 +4147,12 @@
|
|
|
4142
4147
|
{
|
|
4143
4148
|
"path": "package-lock.json",
|
|
4144
4149
|
"mode": "0000644",
|
|
4145
|
-
"sha256": "
|
|
4150
|
+
"sha256": "b3854f65fe8ad4aead3d08a87f9f2200d87e2b049aed77f7849d51f9aa6f6e5b"
|
|
4146
4151
|
},
|
|
4147
4152
|
{
|
|
4148
4153
|
"path": "package.json",
|
|
4149
4154
|
"mode": "0000644",
|
|
4150
|
-
"sha256": "
|
|
4155
|
+
"sha256": "7467e0451b4fd162c287c7a5eceee8da746785a3da989bda154830ead5728f3d"
|
|
4151
4156
|
},
|
|
4152
4157
|
{
|
|
4153
4158
|
"path": "public-docs/index.html",
|
|
@@ -4387,7 +4392,7 @@
|
|
|
4387
4392
|
{
|
|
4388
4393
|
"path": "public-landing/assets/cosmos.css",
|
|
4389
4394
|
"mode": "0000644",
|
|
4390
|
-
"sha256": "
|
|
4395
|
+
"sha256": "3ae9d1edc18eef49ebdf90d0c20c00defda76bfb393936c4a615720314a2886e"
|
|
4391
4396
|
},
|
|
4392
4397
|
{
|
|
4393
4398
|
"path": "public-landing/buddha.png",
|
|
@@ -4407,7 +4412,7 @@
|
|
|
4407
4412
|
{
|
|
4408
4413
|
"path": "public-landing/projects.html",
|
|
4409
4414
|
"mode": "0000644",
|
|
4410
|
-
"sha256": "
|
|
4415
|
+
"sha256": "f70211d0d2bfe21fa7000ab82d0adcaa97df1f28763e366965da2b92847b31b6"
|
|
4411
4416
|
},
|
|
4412
4417
|
{
|
|
4413
4418
|
"path": "public-landing/world/assets/clouds/cloud-island-large.png",
|
|
@@ -5717,7 +5722,7 @@
|
|
|
5717
5722
|
{
|
|
5718
5723
|
"path": "src/module-api.js",
|
|
5719
5724
|
"mode": "0000644",
|
|
5720
|
-
"sha256": "
|
|
5725
|
+
"sha256": "18be8d4886ea03a65a0cd5d817c811ca0496ea509df5716f416d985a5d682b84"
|
|
5721
5726
|
},
|
|
5722
5727
|
{
|
|
5723
5728
|
"path": "src/module-loader/catalog.js",
|
|
@@ -7234,10 +7239,15 @@
|
|
|
7234
7239
|
"mode": "0000644",
|
|
7235
7240
|
"sha256": "dc354f082204073409aea90df9b91f149d474c6930b761ed5c9928bb76e1358f"
|
|
7236
7241
|
},
|
|
7242
|
+
{
|
|
7243
|
+
"path": "tests/project_stats.mjs",
|
|
7244
|
+
"mode": "0000644",
|
|
7245
|
+
"sha256": "21bd2a2d0ebb234cd3b9770d126e77ce48f5c630fe7deee2585706d4eb072b53"
|
|
7246
|
+
},
|
|
7237
7247
|
{
|
|
7238
7248
|
"path": "tests/projects_hub.mjs",
|
|
7239
7249
|
"mode": "0000644",
|
|
7240
|
-
"sha256": "
|
|
7250
|
+
"sha256": "d8fd9759b99b2136f8b438727b74b15346db7cbd8f0ef8b67aec3049f059df2b"
|
|
7241
7251
|
},
|
|
7242
7252
|
{
|
|
7243
7253
|
"path": "tests/provision.mjs",
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// modules/platform-identity/project-stats.js — real Builders + Tags for the hub
|
|
2
|
+
// catalog, read from each project's OWN public API (task 1002771).
|
|
3
|
+
//
|
|
4
|
+
// WHY THE DATA IS NOT HERE. The apex catalog stores four fields (origin, name,
|
|
5
|
+
// tagline, art_url) and nothing else; the membership and activity tables that
|
|
6
|
+
// would know a builder count are written only by the federated check-in a hosted
|
|
7
|
+
// instance posts on sign-in, so they hold one row across the whole platform and
|
|
8
|
+
// would report ZERO for every hand-wired project (task 1002760 documents why).
|
|
9
|
+
// Zero is not an absence — it is the claim "nobody builds here" — so task 1002764
|
|
10
|
+
// removed the columns rather than show a wrong number.
|
|
11
|
+
//
|
|
12
|
+
// But every Cloud Bongos instance already serves an unauthenticated
|
|
13
|
+
// /api/bongos/public/* surface, and the hub's server can read it instance-to-
|
|
14
|
+
// instance with no CORS involved. The project itself is the authority on its own
|
|
15
|
+
// builder count, which is a better source than anything the apex could infer.
|
|
16
|
+
//
|
|
17
|
+
// SCALE, recorded so it is not rediscovered: this PULL model is right for a
|
|
18
|
+
// handful and fine for a few dozen behind the cache. It does not scale to
|
|
19
|
+
// hundreds — at that point instances should PUSH these stats on check-in, which
|
|
20
|
+
// is the shape platform_identity_builder_project_activity already has. Swapping
|
|
21
|
+
// pull for push must not change the column, which is why the route depends on
|
|
22
|
+
// getProjectStats() and not on fetch().
|
|
23
|
+
|
|
24
|
+
const fs = require('node:fs');
|
|
25
|
+
const path = require('node:path');
|
|
26
|
+
const dns = require('node:dns').promises;
|
|
27
|
+
const net = require('node:net');
|
|
28
|
+
|
|
29
|
+
const TTL_MS = 10 * 60 * 1000; // owner's choice, 2026-08-13
|
|
30
|
+
const FETCH_TIMEOUT_MS = 2500; // per request
|
|
31
|
+
const COLD_DEADLINE_MS = 3000; // total wait a COLD cache may cost a page load
|
|
32
|
+
|
|
33
|
+
const _cache = new Map(); // origin → { at, stats }
|
|
34
|
+
const _inflight = new Map(); // origin → Promise (collapses concurrent refreshes)
|
|
35
|
+
|
|
36
|
+
// The core's own modules/ directory IS the canonical core set — so a module the
|
|
37
|
+
// core adds later is excluded automatically and nobody maintains a list. Anything
|
|
38
|
+
// an instance runs beyond it is project-specific, and that is what a tag is.
|
|
39
|
+
let _coreModules = null;
|
|
40
|
+
function coreModules() {
|
|
41
|
+
if (_coreModules) return _coreModules;
|
|
42
|
+
try {
|
|
43
|
+
_coreModules = new Set(
|
|
44
|
+
fs.readdirSync(path.join(__dirname, '..'), { withFileTypes: true })
|
|
45
|
+
.filter((d) => d.isDirectory())
|
|
46
|
+
.map((d) => d.name),
|
|
47
|
+
);
|
|
48
|
+
} catch {
|
|
49
|
+
_coreModules = new Set();
|
|
50
|
+
}
|
|
51
|
+
return _coreModules;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Machine identities, not people. Counting them inflates every project: the raw
|
|
55
|
+
// leaderboards read 13/3/3 against real 11/1/1, so this filter is the difference
|
|
56
|
+
// between a true number and a wrong one.
|
|
57
|
+
function isSystemAccount(login) {
|
|
58
|
+
return /-system$/.test(String(login || ''));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── SSRF GUARD ──
|
|
62
|
+
// This is the one place the server makes an outbound request to an address that
|
|
63
|
+
// came out of the database, so it is the one place that has to assume the address
|
|
64
|
+
// is hostile. "The catalog is Archon-curated" is a real mitigation but not a
|
|
65
|
+
// sufficient one, for a specific reason: a curated origin is trusted, and a
|
|
66
|
+
// REDIRECT TARGET is not. Following redirects hands the choice of final address
|
|
67
|
+
// back to the remote server, which is the whole SSRF primitive — a listed project
|
|
68
|
+
// could 302 us at 169.254.169.254 (cloud metadata) or a loopback admin port.
|
|
69
|
+
//
|
|
70
|
+
// So: redirects are refused outright rather than followed (a public JSON endpoint
|
|
71
|
+
// has no reason to redirect), and the hostname must resolve to a PUBLIC address.
|
|
72
|
+
//
|
|
73
|
+
// Known and accepted limit: resolving here and connecting in fetch() is a
|
|
74
|
+
// TOCTOU window — a DNS record could change between the two. Closing it properly
|
|
75
|
+
// means pinning the resolved IP into the connection, which needs a custom agent.
|
|
76
|
+
// Refusing redirects removes the attacker-steerable half, which is the half that
|
|
77
|
+
// matters; the residual requires control of DNS for a curated origin.
|
|
78
|
+
const BLOCKED_V4 = [
|
|
79
|
+
[/^10\./, 'private'], [/^127\./, 'loopback'], [/^0\./, 'unspecified'],
|
|
80
|
+
[/^169\.254\./, 'link-local / cloud metadata'], [/^192\.168\./, 'private'],
|
|
81
|
+
[/^172\.(1[6-9]|2\d|3[01])\./, 'private'], [/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./, 'CGNAT'],
|
|
82
|
+
[/^(22[4-9]|23\d)\./, 'multicast'], [/^(24\d|25[0-5])\./, 'reserved'],
|
|
83
|
+
];
|
|
84
|
+
function blockedReason(addr) {
|
|
85
|
+
const ip = String(addr).replace(/^::ffff:/i, ''); // unwrap IPv4-mapped IPv6
|
|
86
|
+
if (net.isIPv4(ip)) {
|
|
87
|
+
for (const [re, why] of BLOCKED_V4) if (re.test(ip)) return why;
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
const v6 = ip.toLowerCase();
|
|
91
|
+
if (v6 === '::1' || v6 === '::') return 'loopback';
|
|
92
|
+
if (/^f[cd]/.test(v6)) return 'unique-local';
|
|
93
|
+
if (/^fe[89ab]/.test(v6)) return 'link-local';
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function assertPublicHost(hostname) {
|
|
98
|
+
// a literal IP needs no lookup — check it directly
|
|
99
|
+
if (net.isIP(hostname)) {
|
|
100
|
+
const why = blockedReason(hostname);
|
|
101
|
+
if (why) throw new Error(`refused ${hostname} (${why})`);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const addrs = await dns.lookup(hostname, { all: true });
|
|
105
|
+
if (!addrs.length) throw new Error(`no address for ${hostname}`);
|
|
106
|
+
for (const { address } of addrs) {
|
|
107
|
+
const why = blockedReason(address);
|
|
108
|
+
if (why) throw new Error(`refused ${hostname} → ${address} (${why})`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function publicUrl(origin, route) {
|
|
113
|
+
const u = new URL(origin);
|
|
114
|
+
if (u.protocol !== 'https:' && u.protocol !== 'http:') throw new Error('bad scheme');
|
|
115
|
+
return new URL(`/api/bongos/public/${route}`, u).href;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function getJson(url) {
|
|
119
|
+
await assertPublicHost(new URL(url).hostname);
|
|
120
|
+
const res = await fetch(url, {
|
|
121
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
122
|
+
headers: { accept: 'application/json' },
|
|
123
|
+
redirect: 'error', // never let the remote choose our final address
|
|
124
|
+
});
|
|
125
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
126
|
+
return res.json();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// One project's stats. Either half may fail on its own — a project whose
|
|
130
|
+
// leaderboard is down can still report its modules — so they settle independently
|
|
131
|
+
// and a missing half is null (an em dash) rather than a discarded pair.
|
|
132
|
+
async function fetchStats(origin) {
|
|
133
|
+
const [lb, mods] = await Promise.allSettled([
|
|
134
|
+
getJson(publicUrl(origin, 'leaderboard')),
|
|
135
|
+
getJson(publicUrl(origin, 'modules')),
|
|
136
|
+
]);
|
|
137
|
+
|
|
138
|
+
let builders = null;
|
|
139
|
+
if (lb.status === 'fulfilled' && Array.isArray(lb.value?.leaderboard)) {
|
|
140
|
+
builders = lb.value.leaderboard.filter((b) => !isSystemAccount(b?.github_login)).length;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let tags = null;
|
|
144
|
+
if (mods.status === 'fulfilled' && mods.value?.enabled && typeof mods.value.enabled === 'object') {
|
|
145
|
+
const core = coreModules();
|
|
146
|
+
tags = Object.entries(mods.value.enabled)
|
|
147
|
+
.filter(([key, on]) => on === true && !core.has(key))
|
|
148
|
+
.map(([key]) => key)
|
|
149
|
+
.sort();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return { builders, tags };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function refresh(origin) {
|
|
156
|
+
if (_inflight.has(origin)) return _inflight.get(origin);
|
|
157
|
+
const p = fetchStats(origin)
|
|
158
|
+
.then((stats) => {
|
|
159
|
+
_cache.set(origin, { at: Date.now(), stats });
|
|
160
|
+
return stats;
|
|
161
|
+
})
|
|
162
|
+
.catch(() => {
|
|
163
|
+
// Remember the FAILURE for a full TTL too. Without this a permanently
|
|
164
|
+
// unreachable project is re-fetched on every request and pays the timeout
|
|
165
|
+
// every time — the exact way one dead project slows the whole page.
|
|
166
|
+
const stats = { builders: null, tags: null };
|
|
167
|
+
_cache.set(origin, { at: Date.now(), stats });
|
|
168
|
+
return stats;
|
|
169
|
+
})
|
|
170
|
+
.finally(() => _inflight.delete(origin));
|
|
171
|
+
_inflight.set(origin, p);
|
|
172
|
+
return p;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Stale-while-revalidate: a warm cache answers instantly, a STALE cache answers
|
|
176
|
+
// instantly and refreshes behind the request, and only a COLD cache waits — and
|
|
177
|
+
// then only until COLD_DEADLINE_MS, after which whatever has not arrived renders
|
|
178
|
+
// as a dash and fills itself in on the next load. The hub's speed must never
|
|
179
|
+
// become hostage to the slowest project's uptime.
|
|
180
|
+
async function getProjectStats(origins = []) {
|
|
181
|
+
const now = Date.now();
|
|
182
|
+
const cold = [];
|
|
183
|
+
|
|
184
|
+
for (const origin of origins) {
|
|
185
|
+
const hit = _cache.get(origin);
|
|
186
|
+
if (!hit) cold.push(origin);
|
|
187
|
+
else if (now - hit.at > TTL_MS) refresh(origin); // stale: revalidate, do not await
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (cold.length) {
|
|
191
|
+
await Promise.race([
|
|
192
|
+
Promise.allSettled(cold.map((o) => refresh(o))),
|
|
193
|
+
new Promise((r) => setTimeout(r, COLD_DEADLINE_MS)),
|
|
194
|
+
]);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// null-prototype: an origin literally equal to '__proto__' would otherwise
|
|
198
|
+
// retarget the object's prototype instead of adding a key
|
|
199
|
+
const out = Object.create(null);
|
|
200
|
+
for (const origin of origins) out[origin] = _cache.get(origin)?.stats || { builders: null, tags: null };
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// tests
|
|
205
|
+
function _reset() { _cache.clear(); _inflight.clear(); _coreModules = null; }
|
|
206
|
+
|
|
207
|
+
module.exports = { getProjectStats, isSystemAccount, coreModules, blockedReason, assertPublicHost, _reset, TTL_MS };
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
const express = require('express');
|
|
14
14
|
const api = require('../../../src/module-api');
|
|
15
15
|
const pi = require('../platform-identity');
|
|
16
|
+
const projectStats = require('../project-stats');
|
|
16
17
|
|
|
17
18
|
const { pool, requireBuilder, requirePermission, validateOrRespond, parseId, corsPublicGet } = api;
|
|
18
19
|
|
|
@@ -100,7 +101,26 @@ module.exports = function projectsRoutes() {
|
|
|
100
101
|
// rank: public — a curated public showcase; no auth by design.
|
|
101
102
|
router.get('/projects/featured', async (_req, res) => {
|
|
102
103
|
try {
|
|
103
|
-
|
|
104
|
+
const projects = await pi.listFeaturedProjects({ pool });
|
|
105
|
+
// Builders + tags come from each project's OWN public API, because the
|
|
106
|
+
// apex catalog does not know them and the membership tables would report
|
|
107
|
+
// zero for every hand-wired project (task 1002771; see project-stats.js).
|
|
108
|
+
// Enrichment is best-effort by construction: a project that is down gets
|
|
109
|
+
// nulls, which the table renders as an em dash. It must never fail the
|
|
110
|
+
// listing — a broken stats fetch cannot take the catalog down with it.
|
|
111
|
+
let stats = {};
|
|
112
|
+
try {
|
|
113
|
+
stats = await projectStats.getProjectStats(projects.map((p) => p.origin));
|
|
114
|
+
} catch (err) {
|
|
115
|
+
console.error('[platform-identity] project stats enrichment failed (non-fatal):', err.message);
|
|
116
|
+
}
|
|
117
|
+
res.json({
|
|
118
|
+
projects: projects.map((p) => ({
|
|
119
|
+
...p,
|
|
120
|
+
builders: stats[p.origin]?.builders ?? null,
|
|
121
|
+
tags: stats[p.origin]?.tags ?? null,
|
|
122
|
+
})),
|
|
123
|
+
});
|
|
104
124
|
} catch (err) {
|
|
105
125
|
console.error('[platform-identity] GET /projects/featured', err);
|
|
106
126
|
res.fail('explore_failed', { status: 500, message: 'internal error' });
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.103",
|
|
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.103",
|
|
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.103",
|
|
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",
|
|
@@ -540,6 +540,10 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
|
|
|
540
540
|
box-shadow:0 0 0 3px color-mix(in srgb,var(--acc) 22%,transparent);}
|
|
541
541
|
.tbl .ptitle{font-family:var(--font-display);font-weight:700;font-size:15px;color:var(--ink);}
|
|
542
542
|
.tbl .ptag{font-size:12.5px;color:var(--ink-faint);font-weight:600;}
|
|
543
|
+
.tbl .num{font-family:var(--font-mono);font-weight:500;}
|
|
544
|
+
.tbl .tags{display:flex;gap:6px;flex-wrap:wrap;}
|
|
545
|
+
.tbl .tags span{font-family:var(--font-mono);font-size:10.5px;padding:2px 8px;border-radius:999px;
|
|
546
|
+
border:1px solid var(--line);color:var(--ink-faint);white-space:nowrap;}
|
|
543
547
|
.tbl .pill{display:inline-block;font-family:var(--font-mono);font-size:11px;padding:3px 10px;
|
|
544
548
|
border-radius:999px;background:var(--accent-soft);color:var(--accent-deep);}
|
|
545
549
|
:root[data-mode="dark"] .tbl .pill{color:var(--sun-lit);}
|
|
@@ -553,6 +557,7 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
|
|
|
553
557
|
.tbl .colStatus{width:1%;white-space:nowrap;}
|
|
554
558
|
.tbl .colGo{width:1%;white-space:nowrap;padding-left:8px;}
|
|
555
559
|
.tbl .colName{width:auto;}
|
|
560
|
+
@media (max-width:780px){.tbl .colTags{display:none;}}
|
|
556
561
|
|
|
557
562
|
/* ── the project card ──
|
|
558
563
|
An overlay INSIDE the map, not a destination: opening a project must not cost
|
|
@@ -182,6 +182,8 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
|
|
|
182
182
|
<thead>
|
|
183
183
|
<tr>
|
|
184
184
|
<th class="colName" data-sort="name" aria-sort="ascending">Project</th>
|
|
185
|
+
<th class="right" data-sort="builders">Builders</th>
|
|
186
|
+
<th class="colTags">Runs</th>
|
|
185
187
|
<th class="right colStatus">Status</th>
|
|
186
188
|
<th class="colGo"></th>
|
|
187
189
|
</tr>
|
|
@@ -196,6 +198,8 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
|
|
|
196
198
|
<td class="colName"><span class="pname"><span class="pdot" style="--acc:var(--accent)"></span>
|
|
197
199
|
<span><span class="ptitle" data-brand-product-inline>Cloud Bongos</span><span id="selfPill"></span><br>
|
|
198
200
|
<span class="ptag">this platform</span></span></span></td>
|
|
201
|
+
<td class="right num" id="selfBuilders">—</td>
|
|
202
|
+
<td class="colTags" id="selfTags">—</td>
|
|
199
203
|
<td class="right colStatus"><span class="chip calm"><span class="cdot"></span>—</span></td>
|
|
200
204
|
<td class="colGo"><a class="btn small ghost" href="#" data-self-open>Open →</a></td>
|
|
201
205
|
</tr>
|
|
@@ -568,6 +572,18 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
|
|
|
568
572
|
if (!me) return 'none';
|
|
569
573
|
return me.rank === 'archon' ? 'owner' : 'builder';
|
|
570
574
|
}
|
|
575
|
+
/* A tag is a module the project RUNS beyond the core set — a fact it declares
|
|
576
|
+
about itself, not a guess about what it is. Capped so one heavily-moduled
|
|
577
|
+
project cannot blow the column open; the overflow is counted, never hidden
|
|
578
|
+
silently. null = we could not ask (em dash); [] = asked, runs nothing beyond
|
|
579
|
+
core, which is a real answer and still reads as a dash in the cell. */
|
|
580
|
+
var TAG_CAP = 3;
|
|
581
|
+
function tagsHtml(tags) {
|
|
582
|
+
if (!Array.isArray(tags) || !tags.length) return '—';
|
|
583
|
+
var shown = tags.slice(0, TAG_CAP).map(function (t) { return '<span>' + esc(t) + '</span>'; });
|
|
584
|
+
if (tags.length > TAG_CAP) shown.push('<span>+' + (tags.length - TAG_CAP) + '</span>');
|
|
585
|
+
return '<span class="tags">' + shown.join('') + '</span>';
|
|
586
|
+
}
|
|
571
587
|
function matchesMapQuery(p, q) {
|
|
572
588
|
if (!q) return true;
|
|
573
589
|
return (String(p.name || '') + ' ' + String(p.tagline || '')).toLowerCase().indexOf(q) !== -1;
|
|
@@ -622,6 +638,13 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
|
|
|
622
638
|
accentOf(p) + '"></span>' +
|
|
623
639
|
'<span><span class="ptitle">' + esc(p.name) + '</span>' + pill + '<br>' +
|
|
624
640
|
'<span class="ptag">' + esc(p.tagline || '') + '</span></span></span></td>' +
|
|
641
|
+
/* Builders and Runs come from the project's OWN public API, enriched
|
|
642
|
+
server-side (task 1002771). null is unknown — the project was
|
|
643
|
+
unreachable — and an em dash says exactly that. 0 is a real answer and
|
|
644
|
+
renders as 0, because "nobody has joined yet" is a different fact from
|
|
645
|
+
"we could not ask". */
|
|
646
|
+
'<td class="right num">' + (p.builders == null ? '—' : esc(String(p.builders))) + '</td>' +
|
|
647
|
+
'<td class="colTags">' + tagsHtml(p.tags) + '</td>' +
|
|
625
648
|
/* liveness on a catalog row is NOT probed by this surface. A chip that
|
|
626
649
|
said "live" here would claim more than anything verified (C6); the
|
|
627
650
|
verification overlay on owned orbs is its own task. */
|
|
@@ -641,6 +664,15 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
|
|
|
641
664
|
});
|
|
642
665
|
list.sort(function (x, y) {
|
|
643
666
|
if (mapSort.key === 'name') return String(x.name || '').localeCompare(String(y.name || '')) * mapSort.dir;
|
|
667
|
+
/* unknown (null) sorts to the BOTTOM in both directions — it is not a low
|
|
668
|
+
number, and letting it ride the comparator would rank "we could not ask"
|
|
669
|
+
above a project that honestly has one builder */
|
|
670
|
+
if (mapSort.key === 'builders') {
|
|
671
|
+
if (x.builders == null && y.builders == null) return 0;
|
|
672
|
+
if (x.builders == null) return 1;
|
|
673
|
+
if (y.builders == null) return -1;
|
|
674
|
+
return (x.builders - y.builders) * mapSort.dir;
|
|
675
|
+
}
|
|
644
676
|
return 0;
|
|
645
677
|
});
|
|
646
678
|
renderOrbs(list);
|
|
@@ -759,6 +791,25 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
|
|
|
759
791
|
if (ev.key === 'Escape') closeCard();
|
|
760
792
|
});
|
|
761
793
|
|
|
794
|
+
/* The platform's own row is not in the catalog (it is synthesized — task
|
|
795
|
+
1002760), so the server-side enrichment never sees it. It can answer for
|
|
796
|
+
itself though: this is its own origin, so one same-origin call gets the same
|
|
797
|
+
number by the same rule the server applies to everyone else — real builders,
|
|
798
|
+
machine accounts excluded. Its Runs cell stays a dash honestly: every module
|
|
799
|
+
this instance enables is a CORE module, so it declares nothing beyond the
|
|
800
|
+
baseline and there is no tag to show. */
|
|
801
|
+
function loadSelfStats() {
|
|
802
|
+
return fetch(API + '/public/leaderboard', { credentials: 'same-origin' })
|
|
803
|
+
.then(function (res) { return res.ok ? res.json() : null; })
|
|
804
|
+
.then(function (d) {
|
|
805
|
+
var rows = (d && d.leaderboard) || [];
|
|
806
|
+
if (!rows.length) return;
|
|
807
|
+
var real = rows.filter(function (b) { return !/-system$/.test(String(b.github_login || '')); });
|
|
808
|
+
$('selfBuilders').textContent = String(real.length);
|
|
809
|
+
})
|
|
810
|
+
.catch(function () { /* unreachable → the dash already in the markup stands */ });
|
|
811
|
+
}
|
|
812
|
+
|
|
762
813
|
function loadCatalog() {
|
|
763
814
|
fetch(API + '/projects/featured', { credentials: 'same-origin' })
|
|
764
815
|
.then(function (res) { if (!res.ok) throw new Error('catalog'); return res.json(); })
|
|
@@ -1467,7 +1518,9 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
|
|
|
1467
1518
|
})
|
|
1468
1519
|
/* the catalog is PUBLIC, so it loads either way — the map is the view a
|
|
1469
1520
|
signed-out stranger lands on and it must not depend on identity */
|
|
1470
|
-
.then(loadCatalog, loadCatalog)
|
|
1521
|
+
.then(loadCatalog, loadCatalog)
|
|
1522
|
+
/* the platform's own count is public too, so it loads either way */
|
|
1523
|
+
.then(loadSelfStats, loadSelfStats);
|
|
1471
1524
|
})();
|
|
1472
1525
|
</script>
|
|
1473
1526
|
</body>
|
package/src/module-api.js
CHANGED
|
@@ -631,8 +631,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
631
631
|
// landed since 1.19.100 with no explicit bump. run 31752022322. (task 1002620)
|
|
632
632
|
// 1.19.102 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
633
633
|
// landed since 1.19.101 with no explicit bump. run 31753496152. (task 1002620)
|
|
634
|
+
// 1.19.103 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
635
|
+
// landed since 1.19.102 with no explicit bump. run 31754659965. (task 1002620)
|
|
634
636
|
// ---------------------------------------------------------------------------
|
|
635
|
-
const CORE_VERSION = '1.19.
|
|
637
|
+
const CORE_VERSION = '1.19.103'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
636
638
|
|
|
637
639
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
638
640
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -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
|
+
});
|