@bongos/core 1.19.103 → 1.19.104

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.103",
6
- "core_contract": "1.19.103",
7
- "source_commit": "4c26152f4ffe43100617e89d904852639f51a9dc",
5
+ "core_version": "1.19.104",
6
+ "core_contract": "1.19.104",
7
+ "source_commit": "3c2a0481a031c30a0afdd9294b2510373ffe960f",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-13T23:41:03.246Z",
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": 1234,
14
+ "functional_verbatim": 1236,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1556,
20
- "tree_sha256": "b559bea5c153dfcb5b4007dee090a97532674449005645ee1953761712bcf02a",
19
+ "file_count": 1558,
20
+ "tree_sha256": "8257863293fa7660b41b1617777db22fadd851349106de278c846a482dd31a66",
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": "b3854f65fe8ad4aead3d08a87f9f2200d87e2b049aed77f7849d51f9aa6f6e5b"
4150
+ "sha256": "ef0ed51dd00b72093c6deac7c061dc0fddf88b0135d2d1981c00d37cdb7a9345"
4151
4151
  },
4152
4152
  {
4153
4153
  "path": "package.json",
4154
4154
  "mode": "0000644",
4155
- "sha256": "7467e0451b4fd162c287c7a5eceee8da746785a3da989bda154830ead5728f3d"
4155
+ "sha256": "64f2fc25cfe4707562314bef85b1a22c3b3027e6b43d74644fd37fbc61daa539"
4156
4156
  },
4157
4157
  {
4158
4158
  "path": "public-docs/index.html",
@@ -5692,7 +5692,12 @@
5692
5692
  {
5693
5693
  "path": "src/bongos/serve-internal.js",
5694
5694
  "mode": "0000644",
5695
- "sha256": "693ef4dfd3619a2d999e6223de0e2d51b697cf4004c482b74f60b8cfb362a566"
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": "18be8d4886ea03a65a0cd5d817c811ca0496ea509df5716f416d985a5d682b84"
5730
+ "sha256": "c8a86a1c119d7da3b4baf912eb2d228f83be18b07fbce6c104858a00027efbda"
5726
5731
  },
5727
5732
  {
5728
5733
  "path": "src/module-loader/catalog.js",
@@ -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.103",
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.103",
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.103",
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",
@@ -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,10 @@ 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)
636
638
  // ---------------------------------------------------------------------------
637
- const CORE_VERSION = '1.19.103'; // CI auto-patch carrier (ADR 0161); see changelog above
639
+ const CORE_VERSION = '1.19.104'; // CI auto-patch carrier (ADR 0161); see changelog above
638
640
 
639
641
  // A namespaced logger so a module's log lines are attributable + consistent.
640
642
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -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
+ });