@bongos/core 1.19.351 → 1.19.352

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,11 +2,11 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.351",
6
- "core_contract": "1.19.351",
7
- "source_commit": "a1a7446c35da856e17ae920f2d2e2a446320edb6",
5
+ "core_version": "1.19.352",
6
+ "core_contract": "1.19.352",
7
+ "source_commit": "e9a9e6873568180427946951f116e153cdef056f",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-25T12:53:45.200Z",
9
+ "built_at": "2026-08-25T13:00:42.177Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 329,
@@ -17,7 +17,7 @@
17
17
  "gate": "passed"
18
18
  },
19
19
  "file_count": 1857,
20
- "tree_sha256": "fef03d4bf7a70641e028393447ebffd7ca615173e976f25945a0f2846cb9f791",
20
+ "tree_sha256": "0a7c5bcf1b690464bce9877959778b5f611f54fedb8a6c7b9c51459d47ad7a04",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -4787,12 +4787,12 @@
4787
4787
  {
4788
4788
  "path": "package-lock.json",
4789
4789
  "mode": "0000644",
4790
- "sha256": "782cf5fe234b13ce532d23dff2d26511364e0671fc4a706e828bb69a1aa747e5"
4790
+ "sha256": "a82b85a09d4f6e7259d1f11a8468e50b82a94a346f7cada1a1c63e09217d4a81"
4791
4791
  },
4792
4792
  {
4793
4793
  "path": "package.json",
4794
4794
  "mode": "0000644",
4795
- "sha256": "ffa5d64cebc96b2aa32b1e925ec8d128854b565fde7853b1dd6a1a40ae24f1ff"
4795
+ "sha256": "f371019e624fd7befd3ef764ae4cac6d7f98dc8ba3058a2ed552f361e48c5568"
4796
4796
  },
4797
4797
  {
4798
4798
  "path": "public-docs/index.html",
@@ -6622,7 +6622,7 @@
6622
6622
  {
6623
6623
  "path": "src/bongos/stealth-gate.js",
6624
6624
  "mode": "0000644",
6625
- "sha256": "6ea72c90380663fac7ece7d97cd628792f3d68a2969a502c59addfcb83c2bcb4"
6625
+ "sha256": "a1b771c103e332b93978e80f16760f80f7ab0260157db31c47d4655d69977344"
6626
6626
  },
6627
6627
  {
6628
6628
  "path": "src/bongos/uptime-poller.js",
@@ -6652,7 +6652,7 @@
6652
6652
  {
6653
6653
  "path": "src/module-api.js",
6654
6654
  "mode": "0000644",
6655
- "sha256": "227d48d264188e540dda1fb674d6fb282d8eafd3db3d3d876ef075c0690029fa"
6655
+ "sha256": "7433ae86603ef217160393fd3b36bb847092f0c040473a5bba43a21c14444be1"
6656
6656
  },
6657
6657
  {
6658
6658
  "path": "src/module-loader/catalog.js",
@@ -9112,7 +9112,7 @@
9112
9112
  {
9113
9113
  "path": "tests/stealth_gate.mjs",
9114
9114
  "mode": "0000644",
9115
- "sha256": "c4deafe658457e9f3c17a99f6ab2435e1b1c4d42d74d77304ba792458562e771"
9115
+ "sha256": "bb264253e2a9c233734cf9273928db203f8f441c4e7a53f5f9ae5ea0477e20e3"
9116
9116
  },
9117
9117
  {
9118
9118
  "path": "tests/stop_refcheck.mjs",
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.351",
3
+ "version": "1.19.352",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.351",
9
+ "version": "1.19.352",
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.351",
3
+ "version": "1.19.352",
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",
@@ -107,11 +107,30 @@ function isExempt(path) {
107
107
  return EXEMPT.some((re) => re.test(path));
108
108
  }
109
109
 
110
+ // The path the visitor knocked on, carried through the form so the password
111
+ // lands them where they were going instead of on '/' (task 1003241). RELATIVE
112
+ // PATHS ONLY: one leading slash, no '//' (protocol-relative), no backslash
113
+ // (browsers read '/\evil' as '//evil'), no control bytes, not the gate's own
114
+ // path — anything else is null and the redirect falls back to '/'. The same
115
+ // shape as the relative branch of auth.js's safeReturnTo, kept local because
116
+ // the gate mounts ahead of every router and loads nothing it does not need.
117
+ function relativeReturn(raw) {
118
+ if (typeof raw !== 'string' || !raw.length || raw.length > 512) return null;
119
+ if (!raw.startsWith('/') || raw.startsWith('//') || raw.includes('\\')) return null;
120
+ if (/[\x00-\x1f\x7f]/.test(raw)) return null;
121
+ if (raw === '/' || raw.startsWith(SUBMIT_PATH)) return null;
122
+ return raw;
123
+ }
124
+
125
+ function escAttr(s) {
126
+ return String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
127
+ }
128
+
110
129
  // The form. Self-contained and token-free on purpose: it is served BEFORE the
111
130
  // visitor is allowed to see anything, so it must not pull the branding bundle,
112
131
  // the stylesheet, or any other asset that would leak the design of an
113
132
  // unannounced product to whoever knocks.
114
- function formHtml({ productName, wrong }) {
133
+ function formHtml({ productName, wrong, returnTo }) {
115
134
  const name = String(productName || 'This instance').replace(/[<>&"]/g, '');
116
135
  return `<!doctype html><html lang="en"><head><meta charset="utf-8">
117
136
  <meta name="viewport" content="width=device-width,initial-scale=1">
@@ -144,6 +163,7 @@ button{padding:11px 20px;border:0;border-radius:999px;background:#ce4806;color:#
144
163
  <h1>${name}</h1>
145
164
  <p>Not open to the world yet. Enter the password to have a look.</p>
146
165
  <form method="POST" action="${SUBMIT_PATH}">
166
+ ${returnTo ? `<input type="hidden" name="return" value="${escAttr(returnTo)}">` : ''}
147
167
  <input type="password" name="password" placeholder="Password" aria-label="Password" autofocus required>
148
168
  <button type="submit">Enter</button>
149
169
  </form>
@@ -175,8 +195,10 @@ function stealthGate() {
175
195
  const want = password();
176
196
  const ok = given.length === want.length
177
197
  && crypto.timingSafeEqual(Buffer.from(given), Buffer.from(want));
198
+ // re-validated on the way back: the field is visitor-supplied
199
+ const returnTo = relativeReturn(params.get('return'));
178
200
  if (!ok) {
179
- return res.status(401).type('html').send(formHtml({ productName: productName(), wrong: true }));
201
+ return res.status(401).type('html').send(formHtml({ productName: productName(), wrong: true, returnTo }));
180
202
  }
181
203
  res.cookie(COOKIE, expectedToken(), {
182
204
  httpOnly: true,
@@ -186,7 +208,7 @@ function stealthGate() {
186
208
  ...domainOpts(req),
187
209
  // no maxAge/expires — a SESSION cookie, by the owner's choice
188
210
  });
189
- res.redirect(303, '/');
211
+ res.redirect(303, returnTo || '/');
190
212
  });
191
213
  return undefined;
192
214
  }
@@ -195,8 +217,11 @@ function stealthGate() {
195
217
  if (tokenMatches(readCookie(req, COOKIE))) return next();
196
218
 
197
219
  // 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 }));
220
+ // a crawler that ignores the noindex should still be told so. A GET's own
221
+ // path rides the form so the password lands the visitor back on it; any
222
+ // other method has nothing safe to replay as a GET.
223
+ const returnTo = (req.method === 'GET' || req.method === 'HEAD') ? relativeReturn(req.originalUrl) : null;
224
+ return res.status(401).type('html').send(formHtml({ productName: productName(), wrong: false, returnTo }));
200
225
  };
201
226
  }
202
227
 
package/src/module-api.js CHANGED
@@ -1132,8 +1132,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
1132
1132
  // landed since 1.19.349 with no explicit bump. run 32810944115. (task 1002620)
1133
1133
  // 1.19.351 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1134
1134
  // landed since 1.19.350 with no explicit bump. run 32850120472. (task 1002620)
1135
+ // 1.19.352 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1136
+ // landed since 1.19.351 with no explicit bump. run 32850767673. (task 1002620)
1135
1137
  // ---------------------------------------------------------------------------
1136
- const CORE_VERSION = '1.19.351'; // CI auto-patch carrier (ADR 0161); see changelog above
1138
+ const CORE_VERSION = '1.19.352'; // CI auto-patch carrier (ADR 0161); see changelog above
1137
1139
 
1138
1140
  // A namespaced logger so a module's log lines are attributable + consistent.
1139
1141
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -135,6 +135,71 @@ test('a wrong password is refused and says so', async () => {
135
135
  assert.ok(!String(res.headers['set-cookie'] || '').includes(gate.COOKIE), 'no cookie on failure');
136
136
  });
137
137
 
138
+ /* ── the deep link survives the password (task 1003241) ── */
139
+ const unlock = (fields) => request(gate.SUBMIT_PATH, {
140
+ method: 'POST',
141
+ body: new URLSearchParams({ password: PASSWORD, ...fields }).toString(),
142
+ });
143
+
144
+ test('the form carries the path the visitor knocked on, and the password lands them there', async () => {
145
+ // the pre-UAT walk found a stranger who typed /create landing on the front
146
+ // door after the password — the deep link rides the form, attribute-escaped
147
+ const knock = await request('/projects?view=new&repos=1');
148
+ assert.equal(knock.status, 401);
149
+ assert.match(knock.body, /<input type="hidden" name="return" value="\/projects\?view=new&amp;repos=1">/);
150
+ const res = await unlock({ return: '/projects?view=new&repos=1' });
151
+ assert.equal(res.status, 303);
152
+ assert.equal(res.headers.location, '/projects?view=new&repos=1');
153
+ assert.match(String(res.headers['set-cookie'] || ''), new RegExp(gate.COOKIE), 'the cookie still lands');
154
+ });
155
+
156
+ test('the front door carries no return field, and a bare password still lands on /', async () => {
157
+ const knock = await request('/');
158
+ assert.doesNotMatch(knock.body, /name="return"/);
159
+ const res = await unlock({});
160
+ assert.equal(res.status, 303);
161
+ assert.equal(res.headers.location, '/');
162
+ });
163
+
164
+ test('a wrong password keeps the deep link for the next try', async () => {
165
+ const res = await request(gate.SUBMIT_PATH, {
166
+ method: 'POST',
167
+ body: new URLSearchParams({ password: 'not-it', return: '/create' }).toString(),
168
+ });
169
+ assert.equal(res.status, 401);
170
+ assert.match(res.body, /name="return" value="\/create"/);
171
+ });
172
+
173
+ test('the return target is relative-path-only — never an absolute or off-site redirect', async () => {
174
+ const bad = [
175
+ 'https://evil.example/', // absolute, even https
176
+ '//evil.example', // protocol-relative
177
+ '/\\evil.example', // browsers read the backslash as a slash
178
+ 'javascript:alert(1)',
179
+ '/ok\r\nSet-Cookie: x=1', // header injection
180
+ '/' + 'a'.repeat(600), // oversize
181
+ gate.SUBMIT_PATH + '?again', // the gate's own path would loop
182
+ ];
183
+ for (const r of bad) {
184
+ const res = await unlock({ return: r });
185
+ assert.equal(res.status, 303, `${JSON.stringify(r)} still opens the gate`);
186
+ assert.equal(res.headers.location, '/', `${JSON.stringify(r)} must fall back to /`);
187
+ }
188
+ });
189
+
190
+ test('a knock that is not a GET is not carried back', async () => {
191
+ const res = await request('/projects', { method: 'POST', body: 'x=1' });
192
+ assert.equal(res.status, 401);
193
+ assert.doesNotMatch(res.body, /name="return"/);
194
+ });
195
+
196
+ test('a hostile path is escaped in the form, never echoed raw', async () => {
197
+ const res = await request('/x?q="><script>alert(1)</script>');
198
+ assert.equal(res.status, 401);
199
+ assert.doesNotMatch(res.body, /<script/);
200
+ assert.match(res.body, /value="\/x\?q=&quot;&gt;&lt;script&gt;/);
201
+ });
202
+
138
203
  test('a forged or stale cookie does not open it', async () => {
139
204
  for (const bad of ['x', 'deadbeef', gate.expectedToken().slice(0, -1) + '0']) {
140
205
  const res = await request('/', { cookie: `${gate.COOKIE}=${bad}` });