@bongos/core 1.19.103 → 1.19.105
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 +23 -13
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/projects.html +24 -16
- 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/projects_hub.mjs +64 -0
- 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.105",
|
|
6
|
+
"core_contract": "1.19.105",
|
|
7
|
+
"source_commit": "e17befb90a1b0010369b049bfe92c597f46feaa1",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-
|
|
9
|
+
"built_at": "2026-08-14T00:47:52.457Z",
|
|
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": "0bd2c06a8df30c32d74aae5a437dc8e71707e2201560e7275d066136aa1f0a63",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -4147,12 +4147,12 @@
|
|
|
4147
4147
|
{
|
|
4148
4148
|
"path": "package-lock.json",
|
|
4149
4149
|
"mode": "0000644",
|
|
4150
|
-
"sha256": "
|
|
4150
|
+
"sha256": "1180a81b8034f5e25bb1da6370b91e69575b653dc6f459e16d20dc6764c38e71"
|
|
4151
4151
|
},
|
|
4152
4152
|
{
|
|
4153
4153
|
"path": "package.json",
|
|
4154
4154
|
"mode": "0000644",
|
|
4155
|
-
"sha256": "
|
|
4155
|
+
"sha256": "ebcbf2f1c4b4479c82525349cb2f2aacf659c80eab6876112947bff44600b718"
|
|
4156
4156
|
},
|
|
4157
4157
|
{
|
|
4158
4158
|
"path": "public-docs/index.html",
|
|
@@ -4412,7 +4412,7 @@
|
|
|
4412
4412
|
{
|
|
4413
4413
|
"path": "public-landing/projects.html",
|
|
4414
4414
|
"mode": "0000644",
|
|
4415
|
-
"sha256": "
|
|
4415
|
+
"sha256": "9e6d99f0a54a6189f7295b799c3061c4b23b69569d15e0dd1e1d2e6e8c8671fc"
|
|
4416
4416
|
},
|
|
4417
4417
|
{
|
|
4418
4418
|
"path": "public-landing/world/assets/clouds/cloud-island-large.png",
|
|
@@ -5692,7 +5692,12 @@
|
|
|
5692
5692
|
{
|
|
5693
5693
|
"path": "src/bongos/serve-internal.js",
|
|
5694
5694
|
"mode": "0000644",
|
|
5695
|
-
"sha256": "
|
|
5695
|
+
"sha256": "c791369591627f89ff1755ea1d343159716b512e64f92286d27547209da88df7"
|
|
5696
|
+
},
|
|
5697
|
+
{
|
|
5698
|
+
"path": "src/bongos/stealth-gate.js",
|
|
5699
|
+
"mode": "0000644",
|
|
5700
|
+
"sha256": "6ea72c90380663fac7ece7d97cd628792f3d68a2969a502c59addfcb83c2bcb4"
|
|
5696
5701
|
},
|
|
5697
5702
|
{
|
|
5698
5703
|
"path": "src/bongos/uptime-poller.js",
|
|
@@ -5722,7 +5727,7 @@
|
|
|
5722
5727
|
{
|
|
5723
5728
|
"path": "src/module-api.js",
|
|
5724
5729
|
"mode": "0000644",
|
|
5725
|
-
"sha256": "
|
|
5730
|
+
"sha256": "452383073e87173fa0a4f56df5a3a126ab888807dd416df4939ba951a0a0cf43"
|
|
5726
5731
|
},
|
|
5727
5732
|
{
|
|
5728
5733
|
"path": "src/module-loader/catalog.js",
|
|
@@ -7247,7 +7252,7 @@
|
|
|
7247
7252
|
{
|
|
7248
7253
|
"path": "tests/projects_hub.mjs",
|
|
7249
7254
|
"mode": "0000644",
|
|
7250
|
-
"sha256": "
|
|
7255
|
+
"sha256": "d57dbc4d5b06d2302211d73d1d41c7edf025b38fca433b30df37cf8afc898da9"
|
|
7251
7256
|
},
|
|
7252
7257
|
{
|
|
7253
7258
|
"path": "tests/provision.mjs",
|
|
@@ -7689,6 +7694,11 @@
|
|
|
7689
7694
|
"mode": "0000644",
|
|
7690
7695
|
"sha256": "aa1d79bb3f900097a911c8c6b3055debe4bb20bfe2c3d6c8c64cfd8d7688824e"
|
|
7691
7696
|
},
|
|
7697
|
+
{
|
|
7698
|
+
"path": "tests/stealth_gate.mjs",
|
|
7699
|
+
"mode": "0000644",
|
|
7700
|
+
"sha256": "c4deafe658457e9f3c17a99f6ab2435e1b1c4d42d74d77304ba792458562e771"
|
|
7701
|
+
},
|
|
7692
7702
|
{
|
|
7693
7703
|
"path": "tests/stop_refcheck.mjs",
|
|
7694
7704
|
"mode": "0000644",
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.105",
|
|
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.105",
|
|
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.105",
|
|
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",
|
|
@@ -159,22 +159,6 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
|
|
|
159
159
|
</div>
|
|
160
160
|
<div class="hintbar"><span><kbd>click a project</kbd> open it</span></div>
|
|
161
161
|
</div>
|
|
162
|
-
|
|
163
|
-
<!-- The project card. An OVERLAY inside the map, not a fourth view: opening
|
|
164
|
-
a project must not cost you the sky you were reading (direction §9).
|
|
165
|
-
Real markup, never JS-built (C7) — the same reason every other view
|
|
166
|
-
ships as markup — so its copy is in the document and its ids are stable.
|
|
167
|
-
Filled by openCard(); it carries no project identity of its own. -->
|
|
168
|
-
<div class="cardScrim" id="cardScrim" hidden>
|
|
169
|
-
<div class="projCard" id="projCard" role="dialog" aria-modal="true" aria-labelledby="cardName">
|
|
170
|
-
<button type="button" class="cardX" id="cardClose" aria-label="Close">✕</button>
|
|
171
|
-
<span class="cardDot" id="cardDot"></span>
|
|
172
|
-
<h3 id="cardName"></h3>
|
|
173
|
-
<p class="cardTag" id="cardTag"></p>
|
|
174
|
-
<p class="cardRel" id="cardRel" hidden></p>
|
|
175
|
-
<div class="cardActions" id="cardActions"></div>
|
|
176
|
-
</div>
|
|
177
|
-
</div>
|
|
178
162
|
</div>
|
|
179
163
|
|
|
180
164
|
<div class="wrap viewWrap" id="sub-table-body" hidden>
|
|
@@ -208,6 +192,30 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
|
|
|
208
192
|
</table>
|
|
209
193
|
<p class="finePrint" style="text-align:center">Click a column to sort.</p>
|
|
210
194
|
</div>
|
|
195
|
+
|
|
196
|
+
<!-- The project card. An OVERLAY inside the map, not a fourth view: opening
|
|
197
|
+
a project must not cost you the sky you were reading (direction §9).
|
|
198
|
+
Real markup, never JS-built (C7) — the same reason every other view
|
|
199
|
+
ships as markup — so its copy is in the document and its ids are stable.
|
|
200
|
+
Filled by openCard(); it carries no project identity of its own.
|
|
201
|
+
|
|
202
|
+
IT MUST STAY A SIBLING OF THE TWO SUB-VIEWS, never a child of either.
|
|
203
|
+
It shipped inside #sub-sky-body, which the table view sets `hidden` on —
|
|
204
|
+
and `[hidden]{display:none !important}` means every descendant stops
|
|
205
|
+
generating a box, so the card opened at 0x0 in table view and was simply
|
|
206
|
+
never seen. position:fixed does not rescue an element inside a
|
|
207
|
+
display:none ancestor. It worked in sky view, which is the default and
|
|
208
|
+
where it was built, so nothing noticed (task 1002780). -->
|
|
209
|
+
<div class="cardScrim" id="cardScrim" hidden>
|
|
210
|
+
<div class="projCard" id="projCard" role="dialog" aria-modal="true" aria-labelledby="cardName">
|
|
211
|
+
<button type="button" class="cardX" id="cardClose" aria-label="Close">✕</button>
|
|
212
|
+
<span class="cardDot" id="cardDot"></span>
|
|
213
|
+
<h3 id="cardName"></h3>
|
|
214
|
+
<p class="cardTag" id="cardTag"></p>
|
|
215
|
+
<p class="cardRel" id="cardRel" hidden></p>
|
|
216
|
+
<div class="cardActions" id="cardActions"></div>
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
211
219
|
</section>
|
|
212
220
|
|
|
213
221
|
<!-- ══════════════════ VIEW · YOUR PROJECTS ════════════════════════════════ -->
|
|
@@ -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
|
@@ -633,8 +633,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
633
633
|
// landed since 1.19.101 with no explicit bump. run 31753496152. (task 1002620)
|
|
634
634
|
// 1.19.103 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
635
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)
|
|
638
|
+
// 1.19.105 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
639
|
+
// landed since 1.19.104 with no explicit bump. run 31758526405. (task 1002620)
|
|
636
640
|
// ---------------------------------------------------------------------------
|
|
637
|
-
const CORE_VERSION = '1.19.
|
|
641
|
+
const CORE_VERSION = '1.19.105'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
638
642
|
|
|
639
643
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
640
644
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
package/tests/projects_hub.mjs
CHANGED
|
@@ -453,3 +453,67 @@ test('the platform answers for itself by the same rule it applies to others', ()
|
|
|
453
453
|
assert.match(SCRIPT, /function loadSelfStats/);
|
|
454
454
|
assert.match(SCRIPT, /-system\$/, 'machine accounts are excluded here too, or the platform inflates itself');
|
|
455
455
|
});
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
/* ── The card must RENDER in both sub-views (task 1002780) ──────────────────
|
|
459
|
+
It shipped nested inside #sub-sky-body. The table view sets `hidden` on that
|
|
460
|
+
container, and [hidden]{display:none !important} stops every descendant
|
|
461
|
+
generating a box — so the card "opened" at 0x0 and was never seen. Every
|
|
462
|
+
property said open; only the geometry disagreed.
|
|
463
|
+
|
|
464
|
+
The first attempt at this fix deleted #sub-sky-body's closing tag, which
|
|
465
|
+
re-parented everything after it and left the bug in place while LOOKING
|
|
466
|
+
fixed — regex-proximity checks passed on the broken file. These tests walk
|
|
467
|
+
the real tag depth instead. ── */
|
|
468
|
+
|
|
469
|
+
function mapSection(html) {
|
|
470
|
+
const open = html.indexOf('<section id="view-map">');
|
|
471
|
+
assert.ok(open !== -1, 'view-map section must exist');
|
|
472
|
+
return html.slice(open, html.indexOf('<section id="view-mine"'));
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
test('the map section closes every div it opens', () => {
|
|
476
|
+
// an unclosed div is invisible to a regex check and to `hidden`-flag
|
|
477
|
+
// assertions alike, and silently re-parents everything after it
|
|
478
|
+
const map = mapSection(HUB);
|
|
479
|
+
const opens = (map.match(/<div\b/g) || []).length;
|
|
480
|
+
const closes = (map.match(/<\/div>/g) || []).length;
|
|
481
|
+
assert.equal(opens, closes, 'unbalanced divs re-parent the nodes that follow');
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
// Walk the real tag structure and return the ids of every element still OPEN
|
|
485
|
+
// where `needle` appears. Depth-tracking, NOT regex proximity: the first
|
|
486
|
+
// version of this test took the next `</div>` as proof the outer div had
|
|
487
|
+
// closed, accepted an inner one, and passed on the still-broken file.
|
|
488
|
+
function openAncestorsAt(html, needle) {
|
|
489
|
+
const at = html.indexOf(needle);
|
|
490
|
+
assert.ok(at !== -1, needle + ' not found');
|
|
491
|
+
const stack = [];
|
|
492
|
+
const tag = /<(\/?)(div|section)\b([^>]*)>/g;
|
|
493
|
+
let m;
|
|
494
|
+
while ((m = tag.exec(html)) !== null && m.index < at) {
|
|
495
|
+
if (m[1] === '/') stack.pop();
|
|
496
|
+
else if (!m[3].trim().endsWith('/')) {
|
|
497
|
+
const id = (m[3].match(/id="([^"]+)"/) || [, null])[1];
|
|
498
|
+
stack.push(id);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return stack.filter(Boolean);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
test('no container the view router hides may be an ancestor of the card', () => {
|
|
505
|
+
const ancestors = openAncestorsAt(HUB, 'id="cardScrim"');
|
|
506
|
+
for (const hidden of ['sub-sky-body', 'sub-table-body']) {
|
|
507
|
+
assert.ok(!ancestors.includes(hidden),
|
|
508
|
+
'#' + hidden + ' is toggled hidden — the card cannot live inside it. open: ' + ancestors.join(' > '));
|
|
509
|
+
}
|
|
510
|
+
assert.ok(ancestors.includes('view-map'), 'it is still the overlay of the map view');
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
test('the table body is a sibling of the sky wrapper, not nested inside it', () => {
|
|
514
|
+
// the same defect seen from the other side — a missing close tag re-parents
|
|
515
|
+
// the table itself, which is what the first fix attempt actually did
|
|
516
|
+
const ancestors = openAncestorsAt(HUB, 'id="sub-table-body"');
|
|
517
|
+
assert.ok(!ancestors.includes('sub-sky-body'),
|
|
518
|
+
'the table must not sit inside the sky wrapper. open: ' + ancestors.join(' > '));
|
|
519
|
+
});
|
|
@@ -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
|
+
});
|