@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 +35 -15
- 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/bongos/serve-internal.js +11 -0
- package/src/bongos/stealth-gate.js +203 -0
- package/src/module-api.js +5 -1
- package/tests/project_stats.mjs +206 -0
- package/tests/projects_hub.mjs +53 -10
- package/tests/stealth_gate.mjs +169 -0
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.104",
|
|
6
|
+
"core_contract": "1.19.104",
|
|
7
|
+
"source_commit": "3c2a0481a031c30a0afdd9294b2510373ffe960f",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-
|
|
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":
|
|
14
|
+
"functional_verbatim": 1236,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
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": "
|
|
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": "ef0ed51dd00b72093c6deac7c061dc0fddf88b0135d2d1981c00d37cdb7a9345"
|
|
4146
4151
|
},
|
|
4147
4152
|
{
|
|
4148
4153
|
"path": "package.json",
|
|
4149
4154
|
"mode": "0000644",
|
|
4150
|
-
"sha256": "
|
|
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": "
|
|
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",
|
|
@@ -5687,7 +5692,12 @@
|
|
|
5687
5692
|
{
|
|
5688
5693
|
"path": "src/bongos/serve-internal.js",
|
|
5689
5694
|
"mode": "0000644",
|
|
5690
|
-
"sha256": "
|
|
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": "
|
|
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": "
|
|
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
|
-
|
|
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.104",
|
|
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.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.
|
|
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.
|
|
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
|
+
});
|
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
|
+
});
|
|
@@ -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
|
+
});
|