@bongos/core 1.19.102 → 1.19.104

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 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.102",
6
- "core_contract": "1.19.102",
7
- "source_commit": "484851c87018d5e1be475e9964f1215eaf50e4b1",
5
+ "core_version": "1.19.104",
6
+ "core_contract": "1.19.104",
7
+ "source_commit": "3c2a0481a031c30a0afdd9294b2510373ffe960f",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-13T23:21:54.630Z",
9
+ "built_at": "2026-08-14T00:14:10.093Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 301,
13
13
  "agent_docs_stubbed": 21,
14
- "functional_verbatim": 1232,
14
+ "functional_verbatim": 1236,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1554,
20
- "tree_sha256": "5858094e2d3e98c087659218b83067785b4a98e0ee29e6f26c06dc354d8eaeea",
19
+ "file_count": 1558,
20
+ "tree_sha256": "8257863293fa7660b41b1617777db22fadd851349106de278c846a482dd31a66",
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": "33acce5b2f0c9ea9aef4a0d38f5245e56be4cd2756bc1d49129d5fadeb56a4a1"
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": "c83bac76021e0d2fae2092fabdb4cd4614af07caf2dab896b07ff1ecbcdd1409"
4150
+ "sha256": "ef0ed51dd00b72093c6deac7c061dc0fddf88b0135d2d1981c00d37cdb7a9345"
4146
4151
  },
4147
4152
  {
4148
4153
  "path": "package.json",
4149
4154
  "mode": "0000644",
4150
- "sha256": "3d78882106612f2950816b2c7849605ae097a8a8b41fda1b6e1dc0e9d056bc96"
4155
+ "sha256": "64f2fc25cfe4707562314bef85b1a22c3b3027e6b43d74644fd37fbc61daa539"
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": "1fdc2c4e3d58ae089a71bade6aa3f5f3259447b243eacdc75b8a91797ea065e7"
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": "081e903fcb943e4facda8e83c9247a31602bf7750d5d7740353c97a4ccbcf1e2"
4415
+ "sha256": "f70211d0d2bfe21fa7000ab82d0adcaa97df1f28763e366965da2b92847b31b6"
4411
4416
  },
4412
4417
  {
4413
4418
  "path": "public-landing/world/assets/clouds/cloud-island-large.png",
@@ -5687,7 +5692,12 @@
5687
5692
  {
5688
5693
  "path": "src/bongos/serve-internal.js",
5689
5694
  "mode": "0000644",
5690
- "sha256": "693ef4dfd3619a2d999e6223de0e2d51b697cf4004c482b74f60b8cfb362a566"
5695
+ "sha256": "c791369591627f89ff1755ea1d343159716b512e64f92286d27547209da88df7"
5696
+ },
5697
+ {
5698
+ "path": "src/bongos/stealth-gate.js",
5699
+ "mode": "0000644",
5700
+ "sha256": "6ea72c90380663fac7ece7d97cd628792f3d68a2969a502c59addfcb83c2bcb4"
5691
5701
  },
5692
5702
  {
5693
5703
  "path": "src/bongos/uptime-poller.js",
@@ -5717,7 +5727,7 @@
5717
5727
  {
5718
5728
  "path": "src/module-api.js",
5719
5729
  "mode": "0000644",
5720
- "sha256": "36d78bc9f5db096873e060769bd9b9fd24b65d5a2141e65e57adfdd30df81342"
5730
+ "sha256": "c8a86a1c119d7da3b4baf912eb2d228f83be18b07fbce6c104858a00027efbda"
5721
5731
  },
5722
5732
  {
5723
5733
  "path": "src/module-loader/catalog.js",
@@ -7234,10 +7244,15 @@
7234
7244
  "mode": "0000644",
7235
7245
  "sha256": "dc354f082204073409aea90df9b91f149d474c6930b761ed5c9928bb76e1358f"
7236
7246
  },
7247
+ {
7248
+ "path": "tests/project_stats.mjs",
7249
+ "mode": "0000644",
7250
+ "sha256": "21bd2a2d0ebb234cd3b9770d126e77ce48f5c630fe7deee2585706d4eb072b53"
7251
+ },
7237
7252
  {
7238
7253
  "path": "tests/projects_hub.mjs",
7239
7254
  "mode": "0000644",
7240
- "sha256": "6a59da0cfbf5d2be741ed26f3350a39d49a64b6f1dffb89f69666af7ba938dd1"
7255
+ "sha256": "d8fd9759b99b2136f8b438727b74b15346db7cbd8f0ef8b67aec3049f059df2b"
7241
7256
  },
7242
7257
  {
7243
7258
  "path": "tests/provision.mjs",
@@ -7679,6 +7694,11 @@
7679
7694
  "mode": "0000644",
7680
7695
  "sha256": "aa1d79bb3f900097a911c8c6b3055debe4bb20bfe2c3d6c8c64cfd8d7688824e"
7681
7696
  },
7697
+ {
7698
+ "path": "tests/stealth_gate.mjs",
7699
+ "mode": "0000644",
7700
+ "sha256": "c4deafe658457e9f3c17a99f6ab2435e1b1c4d42d74d77304ba792458562e771"
7701
+ },
7682
7702
  {
7683
7703
  "path": "tests/stop_refcheck.mjs",
7684
7704
  "mode": "0000644",
@@ -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
- res.json({ projects: await pi.listFeaturedProjects({ pool }) });
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.102",
3
+ "version": "1.19.104",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.102",
9
+ "version": "1.19.104",
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.102",
3
+ "version": "1.19.104",
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>
@@ -45,6 +45,7 @@ const { buildInfo } = require('../build-info');
45
45
  const { buildDevboxDownloads } = require('../devbox-downloads');
46
46
  const { buildBongosDownloads } = require('../bongos-downloads');
47
47
  const { clientBranding, branding } = require('../branding');
48
+ const { stealthGate } = require('./stealth-gate');
48
49
  const { clientModules, isModuleEnabled } = require('../modules');
49
50
  const { hallWidgetScripts, moduleWebSurfaces } = require('../module-loader/loader');
50
51
  const { resolveCoreRoot, resolveDocsRoot, resolveInstanceRoot } = require('../instance-config');
@@ -441,6 +442,16 @@ function mountInternalSurfaces(app) {
441
442
  // game routes too) — it records on res 'finish' regardless of which handler
442
443
  // answers. The /metrics scrape endpoint is access-gated (loopback Prometheus
443
444
  // or Bearer METRICS_TOKEN; see metrics.authorizeScrape).
445
+ // THE STEALTH GATE (task 1002775) mounts before anything it is meant to
446
+ // cover — a surface registered earlier would answer first and the gate would
447
+ // never run. Inert unless STEALTH_PASSWORD is set, so a vanilla instance is
448
+ // unaffected and pays only a closure call per request.
449
+ //
450
+ // It sits ABOVE the metrics middleware deliberately: a refused knock is still
451
+ // a request this instance served, and hiding gate 401s would make the traffic
452
+ // graph lie about how much attention an unannounced instance is getting.
453
+ app.use(stealthGate());
454
+
444
455
  app.use(metrics.httpMiddleware());
445
456
  app.get('/metrics', metrics.metricsHandler());
446
457
 
@@ -0,0 +1,203 @@
1
+ // src/bongos/stealth-gate.js — the pre-launch stealth gate (task 1002775).
2
+ //
3
+ // An instance that is built but not announced needs a door that is shut without
4
+ // being broken. The proxy can do that with basic_auth, and task 1002774 did
5
+ // exactly that to close an exposure quickly — but HTTP basic auth is per-ORIGIN,
6
+ // so the apex, the hall and the status dashboard each prompt separately. Three
7
+ // native browser dialogs, none of them able to say what the site is or why it is
8
+ // asking. The owner asked for one password, entered once, good across the whole
9
+ // domain for the session.
10
+ //
11
+ // So the gate lives in the app, where it can set a cookie the browser will send
12
+ // to every subdomain:
13
+ //
14
+ // - It is INERT unless STEALTH_PASSWORD is set. That is the portable default:
15
+ // a vanilla instance has no gate and needs no config to not have one.
16
+ // - The cookie is a SESSION cookie — no Max-Age, no Expires — because the
17
+ // owner explicitly did not want it to outlive the browser. That is also the
18
+ // safer default: nothing is written to disk to be found later.
19
+ // - It is scoped to the cookie apex, so ONE entry unlocks apex + www +
20
+ // builders + status. The apex and its host set come from the same branding
21
+ // key auth.js reads (domains.cookieDomain), so the gate and the session
22
+ // cookie cannot disagree about what "this domain" means.
23
+ // - The cookie carries an HMAC KEYED BY THE PASSWORD, never the password. No
24
+ // second secret to manage, and rotating the password invalidates every
25
+ // outstanding cookie for free.
26
+ //
27
+ // This is deliberately not an authorization system. It has no accounts, no
28
+ // ranks, and it grants nothing: everything behind it still enforces its own
29
+ // rank checks against the database (ADR 0016). It only decides whether a
30
+ // stranger may SEE the instance at all, which is why one shared password is the
31
+ // right primitive and why it must never be mistaken for sign-in.
32
+
33
+ 'use strict';
34
+
35
+ const crypto = require('node:crypto');
36
+ const { branding } = require('../branding');
37
+
38
+ const COOKIE = 'cb_stealth';
39
+ const SUBMIT_PATH = '/__stealth';
40
+
41
+ // Paths that must answer even to a stranger, each for its own reason:
42
+ // /api/* the GitHub OAuth round trip has to complete, and a gated callback
43
+ // would break sign-in for the very people holding the password
44
+ // /healthz external probes + the upgrade's post-restart health check
45
+ // /metrics Prometheus, already access-gated on its own terms (loopback or
46
+ // bearer token) — double-gating it would break the scrape
47
+ // /version the deploy pin check, a public commit sha
48
+ const EXEMPT = [/^\/api\//, /^\/healthz$/, /^\/metrics$/, /^\/version$/, /^\/__stealth$/];
49
+
50
+ function password() {
51
+ const p = process.env.STEALTH_PASSWORD;
52
+ return typeof p === 'string' && p.length ? p : null;
53
+ }
54
+
55
+ function enabled() { return password() !== null; }
56
+
57
+ // The token a valid cookie must carry. Keyed by the password, so the password
58
+ // itself never leaves the server and a rotation invalidates every cookie.
59
+ function expectedToken() {
60
+ return crypto.createHmac('sha256', password()).update('stealth-v1').digest('hex');
61
+ }
62
+
63
+ function tokenMatches(raw) {
64
+ if (typeof raw !== 'string') return false;
65
+ const want = Buffer.from(expectedToken(), 'utf8');
66
+ const got = Buffer.from(raw, 'utf8');
67
+ // timingSafeEqual throws on a length mismatch, which is itself a comparison —
68
+ // check the length first so both branches cost the same shape
69
+ if (got.length !== want.length) return false;
70
+ return crypto.timingSafeEqual(got, want);
71
+ }
72
+
73
+ function cookieApex() {
74
+ try {
75
+ return String(branding().domains?.cookieDomain || '').replace(/^\./, '').toLowerCase();
76
+ } catch {
77
+ return '';
78
+ }
79
+ }
80
+
81
+ function reqHost(req) {
82
+ const raw = req.headers['x-forwarded-host'] || req.headers['host'] || '';
83
+ return String(raw).toLowerCase().split(',')[0].trim().split(':')[0];
84
+ }
85
+
86
+ // Scope to the apex only on the hosts that actually share it — the same set
87
+ // auth.js uses for the session cookie. Anywhere else (localhost, a preview host)
88
+ // the cookie stays host-only, because a Domain= attribute for a domain you are
89
+ // not on is simply rejected by the browser and would lock the operator out.
90
+ function domainOpts(req) {
91
+ const apex = cookieApex();
92
+ if (!apex) return {};
93
+ const host = reqHost(req);
94
+ const shared = new Set([apex, 'www.' + apex, 'builders.' + apex, 'status.' + apex]);
95
+ return shared.has(host) ? { domain: '.' + apex } : {};
96
+ }
97
+
98
+ function readCookie(req, name) {
99
+ for (const part of String(req.headers.cookie || '').split(';')) {
100
+ const [k, ...v] = part.trim().split('=');
101
+ if (k === name && v.length) return decodeURIComponent(v.join('='));
102
+ }
103
+ return null;
104
+ }
105
+
106
+ function isExempt(path) {
107
+ return EXEMPT.some((re) => re.test(path));
108
+ }
109
+
110
+ // The form. Self-contained and token-free on purpose: it is served BEFORE the
111
+ // visitor is allowed to see anything, so it must not pull the branding bundle,
112
+ // the stylesheet, or any other asset that would leak the design of an
113
+ // unannounced product to whoever knocks.
114
+ function formHtml({ productName, wrong }) {
115
+ const name = String(productName || 'This instance').replace(/[<>&"]/g, '');
116
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8">
117
+ <meta name="viewport" content="width=device-width,initial-scale=1">
118
+ <meta name="robots" content="noindex,nofollow">
119
+ <title>${name}</title><style>
120
+ :root{color-scheme:light dark}
121
+ *{box-sizing:border-box}
122
+ body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px;
123
+ font:500 15px/1.5 ui-rounded,"Quicksand",system-ui,-apple-system,"Segoe UI",sans-serif;
124
+ background:#f9d9a8;color:#2b170e}
125
+ @media(prefers-color-scheme:dark){body{background:#150c06;color:#f4e6cd}}
126
+ .box{width:min(380px,100%);text-align:center}
127
+ .dots{display:flex;gap:10px;justify-content:center;margin-bottom:22px}
128
+ .dots i{display:block;border-radius:50%;background:#ce4806}
129
+ .dots i:first-child{width:38px;height:38px}
130
+ .dots i:last-child{width:28px;height:28px;background:#855b3d;align-self:flex-end}
131
+ h1{font-size:20px;font-weight:700;margin:0 0 6px}
132
+ p{margin:0 0 20px;opacity:.72;font-size:14px}
133
+ form{display:flex;gap:8px}
134
+ input{flex:1;min-width:0;padding:11px 14px;border-radius:999px;border:1.5px solid rgba(206,72,6,.35);
135
+ background:rgba(255,255,255,.72);color:inherit;font:inherit}
136
+ @media(prefers-color-scheme:dark){input{background:rgba(255,255,255,.08)}}
137
+ input:focus{outline:2px solid #ce4806;outline-offset:1px}
138
+ button{padding:11px 20px;border:0;border-radius:999px;background:#ce4806;color:#fff;
139
+ font:700 14px/1 inherit;cursor:pointer}
140
+ .err{margin:14px 0 0;color:#b3261e;font-size:13.5px}
141
+ @media(prefers-color-scheme:dark){.err{color:#ffb4a8}}
142
+ </style></head><body><div class="box">
143
+ <div class="dots"><i></i><i></i></div>
144
+ <h1>${name}</h1>
145
+ <p>Not open to the world yet. Enter the password to have a look.</p>
146
+ <form method="POST" action="${SUBMIT_PATH}">
147
+ <input type="password" name="password" placeholder="Password" aria-label="Password" autofocus required>
148
+ <button type="submit">Enter</button>
149
+ </form>
150
+ ${wrong ? '<p class="err">That password didn’t work.</p>' : ''}
151
+ </div></body></html>`;
152
+ }
153
+
154
+ function productName() {
155
+ try { return branding().identity?.productName || 'This instance'; } catch { return 'This instance'; }
156
+ }
157
+
158
+ // Mounted FIRST, ahead of every other surface — a route registered earlier would
159
+ // answer before the gate ever ran.
160
+ function stealthGate() {
161
+ return function stealth(req, res, next) {
162
+ if (!enabled()) return next();
163
+
164
+ if (req.method === 'POST' && req.path === SUBMIT_PATH) {
165
+ // The form is urlencoded; body parsers mount later (and only on some
166
+ // routers), so read it here rather than depending on middleware order.
167
+ let body = '';
168
+ req.on('data', (c) => {
169
+ body += c;
170
+ if (body.length > 4096) req.destroy(); // a password is not a payload
171
+ });
172
+ req.on('end', () => {
173
+ const params = new URLSearchParams(body);
174
+ const given = params.get('password') || '';
175
+ const want = password();
176
+ const ok = given.length === want.length
177
+ && crypto.timingSafeEqual(Buffer.from(given), Buffer.from(want));
178
+ if (!ok) {
179
+ return res.status(401).type('html').send(formHtml({ productName: productName(), wrong: true }));
180
+ }
181
+ res.cookie(COOKIE, expectedToken(), {
182
+ httpOnly: true,
183
+ secure: req.secure || req.headers['x-forwarded-proto'] === 'https',
184
+ sameSite: 'lax',
185
+ path: '/',
186
+ ...domainOpts(req),
187
+ // no maxAge/expires — a SESSION cookie, by the owner's choice
188
+ });
189
+ res.redirect(303, '/');
190
+ });
191
+ return undefined;
192
+ }
193
+
194
+ if (isExempt(req.path)) return next();
195
+ if (tokenMatches(readCookie(req, COOKIE))) return next();
196
+
197
+ // 401 rather than 200: this is an unauthenticated request being refused, and
198
+ // a crawler that ignores the noindex should still be told so.
199
+ return res.status(401).type('html').send(formHtml({ productName: productName(), wrong: false }));
200
+ };
201
+ }
202
+
203
+ module.exports = { stealthGate, enabled, isExempt, tokenMatches, expectedToken, COOKIE, SUBMIT_PATH };
package/src/module-api.js CHANGED
@@ -631,8 +631,12 @@ 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)
636
+ // 1.19.104 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
637
+ // landed since 1.19.103 with no explicit bump. run 31756625919. (task 1002620)
634
638
  // ---------------------------------------------------------------------------
635
- const CORE_VERSION = '1.19.102'; // CI auto-patch carrier (ADR 0161); see changelog above
639
+ const CORE_VERSION = '1.19.104'; // CI auto-patch carrier (ADR 0161); see changelog above
636
640
 
637
641
  // A namespaced logger so a module's log lines are attributable + consistent.
638
642
  // 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
+ });
@@ -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
- for (const dead of [/>Builders</, />Type</, />Tags</]) {
383
- assert.doesNotMatch(thead, dead, `a permanently-empty column must not be advertised`);
384
- }
385
- assert.match(thead, />Project</, 'the name column is real');
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 handled
399
- // only 'name' — clicking it did nothing at all
400
- assert.doesNotMatch(HUB, /data-sort="builders"/);
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('the CSS for the dropped columns went with them', () => {
409
- for (const orphan of ['.colType', '.colTags', '.tbl .num', '.tbl .tags']) {
410
- assert.ok(!COSMOS.includes(orphan), `${orphan} is dead once its column is gone`);
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
+ });
@@ -0,0 +1,169 @@
1
+ // tests/stealth_gate.mjs — the pre-launch stealth gate (task 1002775).
2
+ //
3
+ // A gate that is wrong in either direction is worse than no gate: too open and
4
+ // the unannounced instance is readable; too closed and it locks out sign-in,
5
+ // health probes, or the operator. These drive the REAL booted app, because the
6
+ // gate's whole job is to sit in front of surfaces registered elsewhere — a unit
7
+ // test of the middleware alone could not prove it was mounted early enough.
8
+ //
9
+ // Run: node --test tests/stealth_gate.mjs
10
+
11
+ import assert from 'node:assert/strict';
12
+ import { test, before, after } from 'node:test';
13
+ import http from 'node:http';
14
+ import { createRequire } from 'node:module';
15
+
16
+ const require = createRequire(import.meta.url);
17
+
18
+ const PASSWORD = 'a-test-password';
19
+ process.env.STEALTH_PASSWORD = PASSWORD; // set BEFORE the app is built
20
+
21
+ const gate = require('../src/bongos/stealth-gate.js');
22
+ const platformMod = require('../src/platform-server.js');
23
+
24
+ let server, port;
25
+
26
+ before(async () => {
27
+ server = http.createServer(platformMod.buildPlatformApp());
28
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
29
+ port = server.address().port;
30
+ });
31
+ after(async () => {
32
+ await new Promise((r) => server.close(r));
33
+ delete process.env.STEALTH_PASSWORD;
34
+ });
35
+
36
+ function request(pathname, { method = 'GET', cookie, body } = {}) {
37
+ return new Promise((resolve, reject) => {
38
+ const req = http.request({
39
+ host: '127.0.0.1', port, path: pathname, method,
40
+ headers: {
41
+ host: '127.0.0.1',
42
+ ...(cookie ? { cookie } : {}),
43
+ ...(body ? { 'content-type': 'application/x-www-form-urlencoded', 'content-length': Buffer.byteLength(body) } : {}),
44
+ },
45
+ }, (res) => {
46
+ let data = '';
47
+ res.on('data', (c) => { data += c; });
48
+ res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: data }));
49
+ });
50
+ req.on('error', reject);
51
+ if (body) req.write(body);
52
+ req.end();
53
+ });
54
+ }
55
+
56
+ const validCookie = () => `${gate.COOKIE}=${gate.expectedToken()}`;
57
+
58
+ test('a stranger gets the form, not the product', async () => {
59
+ const res = await request('/');
60
+ assert.equal(res.status, 401, 'a refused knock is a 401, not a 200');
61
+ assert.match(res.body, /type="password"/, 'the form must be usable');
62
+ assert.match(res.body, /noindex/, 'an unannounced instance must not be indexed');
63
+ });
64
+
65
+ test('the form leaks nothing about the product it is hiding', async () => {
66
+ const res = await request('/');
67
+ // it is served BEFORE the visitor may see anything, so it must not pull the
68
+ // stylesheet, the branding bundle, or any other asset that would show the
69
+ // design of an unannounced product to whoever knocks
70
+ assert.doesNotMatch(res.body, /cosmos\.css/);
71
+ assert.doesNotMatch(res.body, /__BRANDING__/);
72
+ assert.doesNotMatch(res.body, /<script/, 'no script at all on the gate page');
73
+ });
74
+
75
+ test('every human surface is gated, not just the front page', async () => {
76
+ // the old proxy lock covered '/' but not '/projects', so the hub leaked for
77
+ // the whole stealth period — that regression must be impossible here
78
+ for (const p of ['/', '/projects', '/create', '/builders', '/docs']) {
79
+ const res = await request(p);
80
+ assert.equal(res.status, 401, `${p} must be gated`);
81
+ }
82
+ });
83
+
84
+ test('the OAuth round trip and the probes stay open', async () => {
85
+ // gating these would lock out the very people holding the password, and blind
86
+ // the deploy's own health check
87
+ for (const p of ['/healthz', '/version']) {
88
+ const res = await request(p);
89
+ assert.notEqual(res.status, 401, `${p} must answer a stranger`);
90
+ }
91
+ // a DB-free API path: 404 from the router proves the request REACHED the API
92
+ // (i.e. the gate let it through) without needing a database in this suite
93
+ const api = await request('/api/bongos/__not-a-route');
94
+ assert.notEqual(api.status, 401, 'the OAuth/public API surface must not be gated');
95
+ assert.equal(api.status, 404, 'and it should reach the API router itself');
96
+ });
97
+
98
+ test('the right password opens it and sets a SESSION cookie', async () => {
99
+ const res = await request(gate.SUBMIT_PATH, {
100
+ method: 'POST',
101
+ body: new URLSearchParams({ password: PASSWORD }).toString(),
102
+ });
103
+ assert.equal(res.status, 303, 'a successful POST redirects rather than rendering');
104
+ const setCookie = String(res.headers['set-cookie'] || '');
105
+ assert.match(setCookie, new RegExp(gate.COOKIE));
106
+ assert.match(setCookie, /HttpOnly/i, 'script must not be able to read it');
107
+ assert.match(setCookie, /SameSite=Lax/i);
108
+ // the owner asked for session-only — no persistence to disk
109
+ assert.doesNotMatch(setCookie, /Max-Age/i, 'must not outlive the browser session');
110
+ assert.doesNotMatch(setCookie, /Expires/i);
111
+ });
112
+
113
+ test('the cookie never carries the password itself', async () => {
114
+ const res = await request(gate.SUBMIT_PATH, {
115
+ method: 'POST',
116
+ body: new URLSearchParams({ password: PASSWORD }).toString(),
117
+ });
118
+ const setCookie = String(res.headers['set-cookie'] || '');
119
+ assert.ok(!setCookie.includes(PASSWORD), 'the password must never reach the client');
120
+ });
121
+
122
+ test('a valid cookie gets the real page', async () => {
123
+ const res = await request('/', { cookie: validCookie() });
124
+ assert.equal(res.status, 200);
125
+ assert.doesNotMatch(res.body, /type="password"/, 'the gate must be out of the way now');
126
+ });
127
+
128
+ test('a wrong password is refused and says so', async () => {
129
+ const res = await request(gate.SUBMIT_PATH, {
130
+ method: 'POST',
131
+ body: new URLSearchParams({ password: 'not-it' }).toString(),
132
+ });
133
+ assert.equal(res.status, 401);
134
+ assert.match(res.body, /didn/, 'the visitor must be told it failed');
135
+ assert.ok(!String(res.headers['set-cookie'] || '').includes(gate.COOKIE), 'no cookie on failure');
136
+ });
137
+
138
+ test('a forged or stale cookie does not open it', async () => {
139
+ for (const bad of ['x', 'deadbeef', gate.expectedToken().slice(0, -1) + '0']) {
140
+ const res = await request('/', { cookie: `${gate.COOKIE}=${bad}` });
141
+ assert.equal(res.status, 401, `forged cookie ${bad.slice(0, 12)} must be refused`);
142
+ }
143
+ });
144
+
145
+ test('rotating the password invalidates outstanding cookies', async () => {
146
+ const old = validCookie();
147
+ process.env.STEALTH_PASSWORD = 'a-different-password';
148
+ const res = await request('/', { cookie: old });
149
+ process.env.STEALTH_PASSWORD = PASSWORD;
150
+ assert.equal(res.status, 401, 'the token is keyed by the password, so a rotation revokes');
151
+ });
152
+
153
+ test('an oversized POST body is dropped rather than buffered', async () => {
154
+ // a password is not a payload — the handler reads the body itself, so it must
155
+ // bound it or it is a trivial memory sink on an unauthenticated endpoint
156
+ const src = require('node:fs').readFileSync(
157
+ new URL('../src/bongos/stealth-gate.js', import.meta.url), 'utf8');
158
+ assert.match(src, /body\.length > \d+/, 'the body read must be bounded');
159
+ assert.match(src, /req\.destroy\(\)/);
160
+ });
161
+
162
+ test('the gate is inert when no password is configured', () => {
163
+ // the portable default: a vanilla instance has no gate and needs no config
164
+ // to not have one
165
+ delete process.env.STEALTH_PASSWORD;
166
+ assert.equal(gate.enabled(), false);
167
+ process.env.STEALTH_PASSWORD = PASSWORD;
168
+ assert.equal(gate.enabled(), true);
169
+ });