@bongos/core 1.19.624 → 1.19.626

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.624",
6
- "core_contract": "1.19.624",
7
- "source_commit": "40ba55a6d7d145b717b36a0b316bfb4ed91c0fdd",
5
+ "core_version": "1.19.626",
6
+ "core_contract": "1.19.626",
7
+ "source_commit": "15d768a9fe3239e170150c8d2efb98e6de8d98da",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-09T07:52:09.912Z",
9
+ "built_at": "2026-09-09T14:24:41.469Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 464,
13
13
  "agent_docs_stubbed": 24,
14
- "functional_verbatim": 2094,
14
+ "functional_verbatim": 2096,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2582,
20
- "tree_sha256": "19b515bb213f54c2eafb50bebf229ada6ea886fdff0fa68d3ec68e30c3b93cbf",
19
+ "file_count": 2584,
20
+ "tree_sha256": "1fbadd48214869e3a766dde80f634c74c81a7ee96bf480937417526afe50a27b",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/backlog-review/SKILL.md",
@@ -1907,12 +1907,12 @@
1907
1907
  {
1908
1908
  "path": "docs/copy-inventory.md",
1909
1909
  "mode": "0000644",
1910
- "sha256": "69a114c124cf97c86e7449366d4abfc1cc193649c1abdc2b93655f45886c7bfc"
1910
+ "sha256": "da4d2796fa7ecbfb26c308cb7d1c5074c8c7d0d76d98995549166f265c53c656"
1911
1911
  },
1912
1912
  {
1913
1913
  "path": "docs/copy-registry.json",
1914
1914
  "mode": "0000644",
1915
- "sha256": "bfd0e82c97405348f7d2f5bb7cacfa3143adab91f5b9ebfcd7fa8d7a804e76f7"
1915
+ "sha256": "222db57e3effc7706591e17b7ab901b198c79a46212c16b3221d1bb2b31754e2"
1916
1916
  },
1917
1917
  {
1918
1918
  "path": "docs/design/apex-pass-2-direction.md",
@@ -2757,7 +2757,7 @@
2757
2757
  {
2758
2758
  "path": "docs/module-api-changelog.md",
2759
2759
  "mode": "0000644",
2760
- "sha256": "ddb02e1135abdcdd075c164efb243f1fae2c43d87543cd4466bf24efb7e020e7"
2760
+ "sha256": "19d4d61c3cd024932aeb068335ef1d9d69e37a59a0e7d653a5e6136bb4972c19"
2761
2761
  },
2762
2762
  {
2763
2763
  "path": "docs/modules-contract.md",
@@ -4912,7 +4912,7 @@
4912
4912
  {
4913
4913
  "path": "modules/hall-ui/public/goal-inbox.js",
4914
4914
  "mode": "0000644",
4915
- "sha256": "524a475dd6ab05e805e8db6baf11c90e10a3e6e76d7a423ffe571cafe61c0324"
4915
+ "sha256": "0e6aacc6e720d1865c45f71b2458f23c5bac8f1643082256f4d7e71d97078994"
4916
4916
  },
4917
4917
  {
4918
4918
  "path": "modules/hall-ui/public/goals-lib.js",
@@ -5507,7 +5507,7 @@
5507
5507
  {
5508
5508
  "path": "modules/ideas/routes/inbox.js",
5509
5509
  "mode": "0000644",
5510
- "sha256": "8dec7682099d131ca050487dafe259bf4c9b511103c472cd0cbce54b4b9a898f"
5510
+ "sha256": "5e7bdb2c09e1e62aacb4b40843392e2c1ca1534a69191c00c3103a257f086c31"
5511
5511
  },
5512
5512
  {
5513
5513
  "path": "modules/ideas/routing.js",
@@ -7682,12 +7682,12 @@
7682
7682
  {
7683
7683
  "path": "package-lock.json",
7684
7684
  "mode": "0000644",
7685
- "sha256": "b8c2a8fc2e6548e76390b5898057eb845926c9ab89b325670b94a5cc80f61e6f"
7685
+ "sha256": "008a893c7efba51a8e8b7feff7b0569454c3b85670369ad736c28ba25b331c75"
7686
7686
  },
7687
7687
  {
7688
7688
  "path": "package.json",
7689
7689
  "mode": "0000644",
7690
- "sha256": "33c03cfa6c4529cce4cd599ce51b14bf480f4d6f2d5e7e5d9d7a6033e404840e"
7690
+ "sha256": "a8f88df15fae0f2566f18b0d78985e82d8d6ab5c72ee35cdf1598130ac2e8ce3"
7691
7691
  },
7692
7692
  {
7693
7693
  "path": "public-docs/index.html",
@@ -8142,7 +8142,7 @@
8142
8142
  {
8143
8143
  "path": "scripts/gds/fitness.js",
8144
8144
  "mode": "0000644",
8145
- "sha256": "a93e0d79fe1d557d09711e9b9bb62ffc7687b91ee2981b71eb89176eb0fc2ce6"
8145
+ "sha256": "cd9f61f69ef8d017a39bed129e41a3d3944cf53bfaf6ff26eb868e46611b6dc3"
8146
8146
  },
8147
8147
  {
8148
8148
  "path": "scripts/gds/gate-review.js",
@@ -8187,7 +8187,7 @@
8187
8187
  {
8188
8188
  "path": "scripts/gds/gen-api-docs.js",
8189
8189
  "mode": "0000644",
8190
- "sha256": "03b4263f19c0f7157160011f43a174599e5294f0b62fdaf3a6e943e9bfeec855"
8190
+ "sha256": "f9c0d6a1fb24158109d2753c9c2bcb79060a67198a7396ed7ba85bdeadfdeaca"
8191
8191
  },
8192
8192
  {
8193
8193
  "path": "scripts/gds/gen-atlas.js",
@@ -8574,6 +8574,11 @@
8574
8574
  "mode": "0000644",
8575
8575
  "sha256": "084fa5c4da1e755fa0edde5ec770e5151a9f66a247bca222bc7a2366ccf61748"
8576
8576
  },
8577
+ {
8578
+ "path": "scripts/gds/route-shadow-guard.js",
8579
+ "mode": "0000644",
8580
+ "sha256": "be2196b6f1e8859924d5f6b50c5a0cb476938ea70d37b5f820713dd8f5fd15f7"
8581
+ },
8577
8582
  {
8578
8583
  "path": "scripts/gds/routine-schedule.js",
8579
8584
  "mode": "0000644",
@@ -9282,7 +9287,7 @@
9282
9287
  {
9283
9288
  "path": "src/bongos/route-rank-check.js",
9284
9289
  "mode": "0000644",
9285
- "sha256": "96b884a69b7b709a256d220c97a972f9827d74a36a864c216a7b37db3fdf66be"
9290
+ "sha256": "ad2d07652864d577736f6c0f83e27ff197fe2201796eb06c5577743c16119a5b"
9286
9291
  },
9287
9292
  {
9288
9293
  "path": "src/bongos/routes.js",
@@ -9422,7 +9427,7 @@
9422
9427
  {
9423
9428
  "path": "src/module-api.js",
9424
9429
  "mode": "0000644",
9425
- "sha256": "f324be1b92aa674fa8bc1baff53336ad5ff21137864d9dd237c9ddc85f7ca273"
9430
+ "sha256": "13157fd8a1bf1a5d1b892a3deed40c9fc1a85c7daf5db9bf5283837e261a6081"
9426
9431
  },
9427
9432
  {
9428
9433
  "path": "src/module-loader/catalog.js",
@@ -9542,7 +9547,7 @@
9542
9547
  {
9543
9548
  "path": "tests/api_docs.mjs",
9544
9549
  "mode": "0000644",
9545
- "sha256": "56c093b994b9539ac93fdf206efe00b0c4318e01f65f4fbe377c31981c7c5bd6"
9550
+ "sha256": "b8725188b7385ec26d35365256cd3eab25d62eaeabfeab6a979de0a447b77b04"
9546
9551
  },
9547
9552
  {
9548
9553
  "path": "tests/api_error_envelope_buildcheck.mjs",
@@ -10032,7 +10037,7 @@
10032
10037
  {
10033
10038
  "path": "tests/cli_sessions.mjs",
10034
10039
  "mode": "0000644",
10035
- "sha256": "6ec406bf2bcbae261ab37ecaf820cfbe196c11007b0d8d3ee47cf21b6a3919ca"
10040
+ "sha256": "ab1ee394c03eccadee0958ea38595e1f4e221146b593559e3474c9573d81ecf7"
10036
10041
  },
10037
10042
  {
10038
10043
  "path": "tests/cli_surface.mjs",
@@ -11057,7 +11062,7 @@
11057
11062
  {
11058
11063
  "path": "tests/helpers.mjs",
11059
11064
  "mode": "0000644",
11060
- "sha256": "b3f0a87c51c9e655c712db00a0a8763e2fc08e4de3c6904c6080385e7c7dad14"
11065
+ "sha256": "6a2360675b70ff2b32ca514e2baafa2aecb10e397a2f88630928ee93b351b34b"
11061
11066
  },
11062
11067
  {
11063
11068
  "path": "tests/hierarchy-config.mjs",
@@ -11157,7 +11162,7 @@
11157
11162
  {
11158
11163
  "path": "tests/idea_hall_composer.mjs",
11159
11164
  "mode": "0000644",
11160
- "sha256": "dbe8f91e4f3acc566a36547ce42d30328ba9e651a622fe1f25092d49d4e3a955"
11165
+ "sha256": "84c5bb805688c484ea83d31e371f1b25fba1daaeb380cba41a21594ddb724de1"
11161
11166
  },
11162
11167
  {
11163
11168
  "path": "tests/idea_ideate_full.mjs",
@@ -11182,7 +11187,7 @@
11182
11187
  {
11183
11188
  "path": "tests/idea_routing.mjs",
11184
11189
  "mode": "0000644",
11185
- "sha256": "d4d4e6ce60f1524e821e0c6c9f2844c7ba791b5502072dc00496224404afa990"
11190
+ "sha256": "3a2c5d2e6c3231f69847e1e2aea073e78caf26228b500dbdf612b5bae354d0a2"
11186
11191
  },
11187
11192
  {
11188
11193
  "path": "tests/idea_spark_hall.mjs",
@@ -11197,7 +11202,7 @@
11197
11202
  {
11198
11203
  "path": "tests/idea_spark_queue.mjs",
11199
11204
  "mode": "0000644",
11200
- "sha256": "43f801610e37062c54e631904aecaa10aa2486139aba3aa9eeb820d84fd32368"
11205
+ "sha256": "8bfc36537ffccae702e74e8763534fa535486a2eb8bbf9ee546a2c7368d1dc6b"
11201
11206
  },
11202
11207
  {
11203
11208
  "path": "tests/idea_task_lineage.mjs",
@@ -11772,7 +11777,7 @@
11772
11777
  {
11773
11778
  "path": "tests/profile_activity.mjs",
11774
11779
  "mode": "0000644",
11775
- "sha256": "013df2528c4998588c1d3f89a9a25f8fa1e912b8abc2d8e95c26c250869a386f"
11780
+ "sha256": "a2c62f9c4acf6f7e683766ae29a616c9325465468a86f38c1d512f58efd124df"
11776
11781
  },
11777
11782
  {
11778
11783
  "path": "tests/profile_connections_ui.mjs",
@@ -11902,7 +11907,7 @@
11902
11907
  {
11903
11908
  "path": "tests/prose_edits.mjs",
11904
11909
  "mode": "0000644",
11905
- "sha256": "a613b7cfa675db5231c44319146333d173d2dfa53da092654d5bd11a141483f4"
11910
+ "sha256": "a36acb19e59db1f9eb885ab6497a43d1c8ce0ba6198b0f5cc98f33bf08837fdf"
11906
11911
  },
11907
11912
  {
11908
11913
  "path": "tests/provision.mjs",
@@ -12164,6 +12169,11 @@
12164
12169
  "mode": "0000644",
12165
12170
  "sha256": "bdc58224050392ece492ae8e8f12f62a8934d86e3993e0df5b8cb0aac80602e8"
12166
12171
  },
12172
+ {
12173
+ "path": "tests/route_shadow_guard.mjs",
12174
+ "mode": "0000644",
12175
+ "sha256": "552479822e1349a56bf68c636a35787d44da2abacf6917339ce5ce39223b9f2d"
12176
+ },
12167
12177
  {
12168
12178
  "path": "tests/route_validation.mjs",
12169
12179
  "mode": "0000644",
@@ -597,7 +597,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
597
597
  | `ae84ebe1d735` | certain | Name cannot be blank. | `modules/hall-ui/public/settings.js:831` |
598
598
  | `31e91ee6ce03` | certain | Name must be {…} characters or fewer. | `modules/hall-ui/public/settings.js:836` |
599
599
  | `5e8199266a8a` | certain | Name unvouched | `modules/hall-ui/public/watch.js:350` |
600
- | `7def635421ea` | certain | Needs your attention | `modules/hall-ui/public/goal-inbox.js:385` |
600
+ | `7def635421ea` | certain | Needs your attention | `modules/hall-ui/public/goal-inbox.js:396` |
601
601
  | `87f09d983f6c` | certain | New category label | `modules/hall-ui/public/project-settings.js:138` |
602
602
  | `d427d9dab01c` | certain | New claims are paused: {…} confirmed {…} can't land yet, so {…} reward is pending. Rebase and re-ship, or rele | `modules/hall-ui/public/hall-render.js:618` |
603
603
  | `7fcc2583c372` | certain | New custom rank | `modules/hall-ui/public/government.html:79` |
@@ -6401,7 +6401,7 @@
6401
6401
  "surface": "builders-hall",
6402
6402
  "text": "Needs your attention",
6403
6403
  "file": "modules/hall-ui/public/goal-inbox.js",
6404
- "line": 385,
6404
+ "line": 396,
6405
6405
  "origin": "js-markup",
6406
6406
  "confidence": "certain"
6407
6407
  },
@@ -1697,5 +1697,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1697
1697
  landed since 1.19.622 with no explicit bump. run 34323141703. (task 1002620)
1698
1698
  1.19.624 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1699
1699
  landed since 1.19.623 with no explicit bump. run 34326009063. (task 1002620)
1700
+ 1.19.625 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1701
+ landed since 1.19.624 with no explicit bump. run 34328603214. (task 1002620)
1702
+ 1.19.626 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1703
+ landed since 1.19.625 with no explicit bump. run 34363350286. (task 1002620)
1700
1704
  ---------------------------------------------------------------------------
1701
1705
  ```
@@ -370,7 +370,18 @@
370
370
  function loadRot() {
371
371
  return getJSON(`${API}/inbox/rotting`)
372
372
  .then((data) => renderRot(data))
373
- .catch(() => { /* signed out, older server, or a hiccup: never break the hall */ });
373
+ .catch((err) => {
374
+ // STILL FAIL-OPEN — a rot feed that cannot load must never break the hall,
375
+ // and being signed out or on an older server are both ordinary. But it now
376
+ // SAYS SO (task 1003747). The bare `.catch(() => {})` this replaces is how
377
+ // the feed stayed dead in production for releases: /inbox/rotting was being
378
+ // shadowed by another module's `/inbox/:id` and answering 400 bad_id, and
379
+ // this handler swallowed it into an empty section that looked like "nothing
380
+ // is rotting". A silent catch on the ONLY caller of a route is indistin-
381
+ // guishable from a healthy empty state — which is the whole failure.
382
+ console.warn('[hall] rot feed unavailable — the "Going quiet" section is empty '
383
+ + 'because the request failed, NOT because nothing is rotting:', err);
384
+ });
374
385
  }
375
386
 
376
387
  // task 2169: this widget IS the "Needs your attention" card — ONE capped
@@ -526,13 +526,41 @@ module.exports = function buildInboxRouter() {
526
526
  return res.json({ builder_id: builderId, ideas, total, limit, offset });
527
527
  }, { errorCode: 'idea_trail_failed' }));
528
528
 
529
+ // THE `(\d+)` ON THE TWO BARE-ID ROUTES BELOW IS LOAD-BEARING, NOT DECORATION
530
+ // (task 1003747). An idea id is always a positive integer, and constraining the
531
+ // param to digits is what stops these routes from swallowing a LITERAL
532
+ // `/inbox/<word>` contributed by ANOTHER module. It swallowed exactly that:
533
+ // `GET /inbox/rotting` (the rot feed, modules/lifecycle/routes/rot.js) answered
534
+ // 400 `bad_id` in production, because mountModuleRoutes
535
+ // (src/module-loader/loader.js) mounts modules in discovery order — `ideas`
536
+ // before `lifecycle` — so this matcher saw "rotting", failed to parse it as an
537
+ // id, and ended the request before the rot handler was ever reached. The whole
538
+ // 30-day rot cadence (ADR 0232) was dead and silent for releases.
539
+ //
540
+ // Declaring literals before `:id` (the convention modules/ideas/CLAUDE.md
541
+ // records) only fixes collisions INSIDE this file; it cannot reach a route
542
+ // another module contributes. The digit constraint can, because it makes the
543
+ // non-numeric path fall THROUGH to whatever is mounted later, whatever the
544
+ // order. `scripts/gds/route-shadow-guard.js` fails the build on any remaining
545
+ // pair of this shape, and gen-api-docs strips the constraint so it never
546
+ // reaches the published spec.
547
+ //
548
+ // Trade-off, taken deliberately: `/inbox/abc` now 404s ("no such route")
549
+ // instead of 400 `bad_id`. No test pinned that 400, and 404 is the more honest
550
+ // answer for a path that matches no route.
551
+ //
552
+ // The blank line below is deliberate: commentBlockAbove() in gen-api-docs.js
553
+ // walks upward from the route and stops at it, so THIS rationale stays in the
554
+ // source for the next reader without becoming the route's public API
555
+ // description.
556
+
529
557
  // rank: any-builder — single idea read for the "#NNN" deep-link resolver
530
558
  // (#1047). Mirrors GET /blockers/:id. The hall resolves /#/idea/N by id, but
531
559
  // the /inbox LIST returns only the open-first-50, so a closed/older idea ref
532
560
  // would dead-end at a "no such idea" card. getIdea() reads any idea by id.
533
561
  // Authed: a logged-out resolve hits the hall's softAuth "sign in" card, so no
534
562
  // idea text is exposed publicly (the linked refs are public, the bodies aren't).
535
- router.get('/inbox/:id', api.requireBuilder, async (req, res) => {
563
+ router.get('/inbox/:id(\\d+)', api.requireBuilder, async (req, res) => {
536
564
  const id = parseId(req, res, { code: 'bad_id', positiveInt: true });
537
565
  if (id === null) return;
538
566
  try {
@@ -1025,7 +1053,13 @@ module.exports = function buildInboxRouter() {
1025
1053
  return res.json({ ok: true, idea: result.idea, delta: result.delta, unchanged: !result.delta });
1026
1054
  }, { errorCode: 'idea_body_update_failed' }));
1027
1055
 
1028
- router.patch('/inbox/:id', api.requireBuilder, api.requirePermission('idea.triage'), async (req, res) => {
1056
+ // `(\d+)` for the same reason as the GET above — see that comment. No PATCH
1057
+ // literal collides today; the constraint is here so the pair of bare-id routes
1058
+ // on this path stay symmetric and a future `PATCH /inbox/<word>` cannot be
1059
+ // quietly eaten. Kept above a blank line so it stays out of the published API
1060
+ // description, same as the GET's rationale.
1061
+
1062
+ router.patch('/inbox/:id(\\d+)', api.requireBuilder, api.requirePermission('idea.triage'), async (req, res) => {
1029
1063
  if (validateOrRespond(req, res, {
1030
1064
  // Enum kept light — inbox.updateIdea returns a structured BAD_STATUS
1031
1065
  // error if the value falls outside the accepted set, and the supervisor
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.624",
3
+ "version": "1.19.626",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.624",
9
+ "version": "1.19.626",
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.624",
3
+ "version": "1.19.626",
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",
@@ -1389,6 +1389,7 @@ const CHECKS = [
1389
1389
  require('./routine-schedule.js').checkRoutineCadencesScheduled, // Check 25 — task 1003209: a routine that declares a cadence must have a scheduler firing it AT that period (ADR 0188)
1390
1390
  require('./baseline-staleness.js').checkBaselineFloors, // Check 26 — task 1003210: a shrink-only baseline that never shrinks is unmanaged debt; a floor exempting one must say why
1391
1391
  require('./client-baseurl-guard.js').checkClientBaseUrl,
1392
+ require('./route-shadow-guard.js').checkNoRouteShadowing, // Check 31 — task 1003747: a `:param` route must not swallow a literal route mounted after it (cross-module mount order; the rot feed shipped dead this way). Rationale in that file.
1392
1393
  require('./test-path-guard.js').checkTestsUseFileUrlToPath, // Check 30 — task 1002889: a test builds its repo root with fileURLToPath, never URL.pathname (a %20 or a Windows drive slash makes require() throw at load; CI's spaceless checkout can't see it)
1393
1394
  // Check 29 — task 1003275 / ADR 0189: a work category ORIENTS a human and steers
1394
1395
  // Claude; it authorises nothing. The owner said so twice, and the goal's own prose
@@ -34,6 +34,12 @@ const path = require('node:path');
34
34
 
35
35
  const { resolveCoreRoot, resolveDocsRoot } = require('../../src/instance-config');
36
36
  const rrc = require('../../src/bongos/route-rank-check');
37
+ // Destructured, not reached through `rrc.` — knip tracks a destructured CJS require but
38
+ // not namespace member access, so `rrc.canonicalRoutePath(...)` read as an unused export
39
+ // and tripped the dead-code ratchet (knip_issue_count 224 > baseline 223) even though the
40
+ // call site below is real. The named import is how a new helper on this module gets
41
+ // counted as used (task 1003747).
42
+ const { canonicalRoutePath } = require('../../src/bongos/route-rank-check');
37
43
  const { API_PREFIX, API_VERSION, VERSIONED_API_PREFIX } = require('../../src/bongos/api-prefix');
38
44
  // Task 2050 (ADR 0118): the enriched extractor resolves named constants used in
39
45
  // validate() rules. Both requires are pure (no side effects / no DB / no express):
@@ -122,10 +128,27 @@ function commentBlockAbove(lines, i) {
122
128
  return neutralizeTaskRefs(text);
123
129
  }
124
130
 
125
- // :param {param}; also return the ordered param name list.
131
+ // An Express inline param constraint `:id(\d+)` (task 1003747). It is a ROUTING
132
+ // detail, never part of the URL a caller types, so it is stripped everywhere a
133
+ // route string reaches a reader or the spec. Without this, `/inbox/:id(\d+)`
134
+ // generated the path template `/inbox/{id}(\\d+)` (malformed — it would break the
135
+ // generated client), renamed the operationId `get_inbox_id` → `get_inbox_id_d`
136
+ // (a silent breaking change for every client consumer), and printed the regex in
137
+ // the public API reference.
138
+ // The canonicaliser itself lives in route-rank-check, next to scanDeclarations — the
139
+ // scanner this generator already shares with the auditor — so there is ONE definition
140
+ // rather than a copy per consumer. Declarations therefore arrive canonical already;
141
+ // this wrapper stays because pathParams/routeForDisplay are pure exported helpers that
142
+ // callers (and tests/api_docs.mjs) hand raw strings to directly.
143
+ function routeForDisplay(route) {
144
+ return canonicalRoutePath(String(route || ''));
145
+ }
146
+
147
+ // :param → {param}; also return the ordered param name list. Inline constraints
148
+ // are dropped first so the template stays a valid OpenAPI path.
126
149
  function pathParams(route) {
127
150
  const names = [];
128
- const openapiPath = route.replace(/:([A-Za-z0-9_]+)/g, (_, n) => {
151
+ const openapiPath = routeForDisplay(route).replace(/:([A-Za-z0-9_]+)/g, (_, n) => {
129
152
  names.push(n);
130
153
  return `{${n}}`;
131
154
  });
@@ -732,7 +755,7 @@ function buildOperation(r, stats, schemas) {
732
755
  const op = {
733
756
  operationId: operationId(r.method, r.openapiPath),
734
757
  tags: [r.tag],
735
- summary: `${r.method} ${r.route}`,
758
+ summary: `${r.method} ${routeForDisplay(r.route)}`,
736
759
  description: (r.description ? r.description + '\n\n' : '')
737
760
  + `**Rank:** \`${r.rank}\` — ${RANK_BLURB[r.rank] || RANK_BLURB.unknown}`
738
761
  + (r.perms && r.perms.length ? `\n\n**Permissions:** \`${r.perms.join('`, `')}\` (all required).` : ''),
@@ -908,7 +931,7 @@ function buildReferenceMd(model, spec) {
908
931
  ? (r.body.fields.length ? '`' + r.body.fields.join('`, `') + '`' : 'validated')
909
932
  : '_undocumented_')
910
933
  : '—';
911
- L.push(`| ${r.method} | \`${API_PREFIX}${r.route}\` | \`${r.rank}\` | ${body} | ${shortDesc(r.description)} |`);
934
+ L.push(`| ${r.method} | \`${API_PREFIX}${routeForDisplay(r.route)}\` | \`${r.rank}\` | ${body} | ${shortDesc(r.description)} |`);
912
935
  }
913
936
  L.push('');
914
937
  }
@@ -1053,6 +1076,7 @@ module.exports = {
1053
1076
  validateOpenapi,
1054
1077
  commentBlockAbove,
1055
1078
  pathParams,
1079
+ routeForDisplay,
1056
1080
  captureBalanced,
1057
1081
  topLevelKeys,
1058
1082
  topLevelEntries,
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env node
2
+ // scripts/gds/route-shadow-guard.js — a `:param` route must not swallow a LITERAL
3
+ // route mounted after it (task 1003747).
4
+ //
5
+ // THE BUG THIS EXISTS TO STOP. `GET /inbox/rotting` — the rot feed behind the
6
+ // hall's "Going quiet" section (modules/lifecycle/routes/rot.js) — answered
7
+ // `400 bad_id` in production for releases. Nothing was wrong with the handler: it
8
+ // was never reached. `mountModuleRoutes` (src/module-loader/loader.js) mounts each
9
+ // enabled module's route factories in DISCOVERY order, so `ideas` mounted its
10
+ // `GET /inbox/:id` before `lifecycle` mounted `GET /inbox/rotting`; Express matched
11
+ // the param route first, failed to parse "rotting" as an id, and ended the request.
12
+ // The entire 30-day rot cadence (ADR 0232 — "work must not decay in silence") was
13
+ // dead, and its only caller swallowed the error, so the section rendered empty and
14
+ // looked healthy.
15
+ //
16
+ // WHY A FITNESS CHECK AND NOT A TEST. The collision is a property of the MOUNT
17
+ // ORDER ACROSS MODULES, which no single module's tests can see — each module's
18
+ // routes are correct in isolation, and modules/ideas/CLAUDE.md's "declare literals
19
+ // before :id" convention only governs collisions inside one file. The defect only
20
+ // exists in the assembled router, and it presents as a plausible 400 rather than a
21
+ // crash, so nothing goes red. A static scan at the assembly altitude is the only
22
+ // place this is visible before a user reports an empty page.
23
+ //
24
+ // WHAT COUNTS AS SHADOWING. Same HTTP method, same segment count, every segment
25
+ // equal up to one position where the earlier route has a `:param` and the later
26
+ // route has a literal. Same-arity is required because Express only matches a
27
+ // pattern against a path of equal depth (no wildcards are in play here), and the
28
+ // param must sit where the literal is or the two never compete.
29
+ //
30
+ // THE FIX IT POINTS AT. Constrain the param so a non-numeric segment falls
31
+ // THROUGH — `router.get('/inbox/:id(\\d+)', …)` — which works regardless of mount
32
+ // order. Reordering declarations is NOT a general fix: it cannot reach a route
33
+ // another module contributes.
34
+
35
+ 'use strict';
36
+
37
+ const fs = require('node:fs');
38
+ const path = require('node:path');
39
+
40
+ const REPO_ROOT = path.resolve(__dirname, '..', '..');
41
+ const NAME = 'no `:param` route shadows a literal route mounted after it (task 1003747)';
42
+
43
+ // Route declarations, as written. Matches the `router.<method>('<path>'` form every
44
+ // route factory in this repo uses.
45
+ const ROUTE_DECL = /router\.(get|post|patch|put|delete)\(\s*['"`]([^'"`]+)['"`]/g;
46
+
47
+ // A param segment already constrained by an inline Express pattern — `:id(\d+)` —
48
+ // cannot match a non-matching literal, so it shadows nothing. Recognising this is
49
+ // what makes the check pass once the fix is applied rather than demanding a
50
+ // reorder it cannot verify.
51
+ function isConstrainedParam(seg) {
52
+ return seg.startsWith(':') && seg.includes('(');
53
+ }
54
+
55
+ function isBareParam(seg) {
56
+ return seg.startsWith(':') && !seg.includes('(');
57
+ }
58
+
59
+ // The mount sequence: for each module with a module.json, its contributes.routes in
60
+ // declared order, and within each file its route declarations in source order. This
61
+ // mirrors mountModuleRoutes' iteration — modules in readdir order, route keys in
62
+ // manifest order — so the sequence this returns is the order Express sees.
63
+ function mountSequence({ modulesDir, readdir, readFile } = {}) {
64
+ const md = modulesDir || path.join(REPO_ROOT, 'modules');
65
+ const rd = readdir || ((d) => { try { return fs.readdirSync(d); } catch { return []; } });
66
+ const read = readFile || ((f) => { try { return fs.readFileSync(f, 'utf8'); } catch { return null; } });
67
+ const seq = [];
68
+ for (const key of rd(md)) {
69
+ const manRaw = read(path.join(md, key, 'module.json'));
70
+ if (manRaw == null) continue;
71
+ let man;
72
+ try { man = JSON.parse(manRaw); } catch { continue; }
73
+ for (const rk of ((man.contributes || {}).routes) || []) {
74
+ const rel = path.join('modules', key, 'routes', `${rk}.js`);
75
+ const src = read(path.join(md, key, 'routes', `${rk}.js`));
76
+ if (src == null) continue;
77
+ ROUTE_DECL.lastIndex = 0;
78
+ let m;
79
+ while ((m = ROUTE_DECL.exec(src))) {
80
+ seq.push({ module: key, file: rel, method: m[1], route: m[2] });
81
+ }
82
+ }
83
+ }
84
+ return seq;
85
+ }
86
+
87
+ const segments = (p) => p.split('/').filter(Boolean);
88
+
89
+ // findShadowPairs(seq) — every (param route, later literal route) pair where the
90
+ // param route wins the match. Pure + exported so tests pin the rule without a
91
+ // filesystem.
92
+ function findShadowPairs(seq) {
93
+ const pairs = [];
94
+ for (let i = 0; i < seq.length; i += 1) {
95
+ const early = seq[i];
96
+ const a = segments(early.route);
97
+ if (!a.some(isBareParam)) continue;
98
+ for (let j = i + 1; j < seq.length; j += 1) {
99
+ const later = seq[j];
100
+ const b = segments(later.route);
101
+ if (early.method !== later.method || a.length !== b.length) continue;
102
+ let shadows = true;
103
+ let viaParam = false;
104
+ for (let k = 0; k < a.length; k += 1) {
105
+ if (isBareParam(a[k])) {
106
+ // A param only eats a LITERAL. Two params at the same position are the
107
+ // same route shape, not a shadowing pair.
108
+ if (b[k].startsWith(':')) { shadows = false; break; }
109
+ viaParam = true;
110
+ } else if (isConstrainedParam(a[k])) {
111
+ shadows = false; // constrained: it cannot match an arbitrary literal
112
+ break;
113
+ } else if (a[k] !== b[k]) {
114
+ shadows = false;
115
+ break;
116
+ }
117
+ }
118
+ if (shadows && viaParam) pairs.push({ early, later });
119
+ }
120
+ }
121
+ return pairs;
122
+ }
123
+
124
+ function checkNoRouteShadowing(opts = {}) {
125
+ const seq = opts.sequence || mountSequence(opts);
126
+ if (!seq.length) {
127
+ return {
128
+ name: NAME,
129
+ ok: false,
130
+ hardFail: true,
131
+ violations: ['scan defect — enumerated 0 route declarations across modules/*/routes; '
132
+ + 'a broken enumeration, not a clean result.'],
133
+ warnings: [],
134
+ note: 'static scan of the cross-module route mount order.',
135
+ };
136
+ }
137
+ const pairs = findShadowPairs(seq);
138
+ const violations = pairs.map(({ early, later }) => (
139
+ `${early.method.toUpperCase()} ${early.route} (${early.file}) is mounted BEFORE `
140
+ + `${later.method.toUpperCase()} ${later.route} (${later.file}) and swallows it — `
141
+ + `a request for ${later.route} never reaches its handler. `
142
+ + `Constrain the param so a non-matching segment falls through `
143
+ + `(e.g. '${early.route.replace(/:(\w+)(?![\w(])/, ':$1(\\\\d+)')}'); reordering cannot fix a `
144
+ + 'cross-module pair, because mount order follows module discovery.'
145
+ ));
146
+ return {
147
+ name: NAME,
148
+ ok: violations.length === 0,
149
+ hardFail: violations.length > 0,
150
+ violations,
151
+ warnings: [],
152
+ note: `${seq.length} route declaration(s) scanned in mount order: no bare \`:param\` route may `
153
+ + 'precede a literal route of the same method and depth. The rot feed shipped dead for releases '
154
+ + 'this way (GET /inbox/:id ate GET /inbox/rotting across a module boundary) and presented as a '
155
+ + 'plausible 400, so no test went red.',
156
+ };
157
+ }
158
+
159
+ function main() {
160
+ const r = checkNoRouteShadowing();
161
+ for (const v of r.violations) console.error(`✗ ${v}`);
162
+ console.log(`${r.hardFail ? 'FAIL' : 'PASS'} ${r.note}`);
163
+ process.exit(r.hardFail ? 1 : 0);
164
+ }
165
+
166
+ if (require.main === module) main();
167
+
168
+ module.exports = {
169
+ checkNoRouteShadowing,
170
+ findShadowPairs,
171
+ mountSequence,
172
+ isBareParam,
173
+ isConstrainedParam,
174
+ ROUTE_DECL,
175
+ };
@@ -630,11 +630,36 @@ const ROUTE_PATH_LINE_RE = /^\s*['"]([^'"]+)['"]\s*,/;
630
630
  // in every real case; the slack only tolerates an interleaved comment.
631
631
  const PATH_LOOKAHEAD = 5;
632
632
 
633
+ // An Express inline param constraint — the `(\d+)` in `/inbox/:id(\d+)`. It constrains
634
+ // MATCHING; it is not part of the path a caller types, so every consumer of "the route
635
+ // path" must see the canonical form (task 1003747).
636
+ //
637
+ // WHY THIS LIVES HERE. scanDeclarations is the single scanner the auditor, the published
638
+ // OpenAPI contract and a dozen route-order tests all read (see its comment below). When
639
+ // `GET /inbox/:id` gained a `(\d+)` — to stop it swallowing another module's
640
+ // `GET /inbox/rotting` — the raw string leaked into all of them at once: the OpenAPI path
641
+ // template became the malformed `/inbox/{id}(\\d+)`, the operationId silently renamed
642
+ // `get_inbox_id` → `get_inbox_id_d` (a breaking change for every generated-client
643
+ // consumer), and FIVE test files that look up `/inbox/:id` by exact string stopped finding
644
+ // it — reporting the route as "unreachable" or "not mounted" when nothing was wrong with
645
+ // it. Canonicalising at the scanner fixes all of them in one place; doing it per-consumer
646
+ // is how the contract and the gate list drift apart.
647
+ //
648
+ // The CONSTRAINT ITSELF still matters to one reader: scripts/gds/route-shadow-guard.js
649
+ // reads raw source with its own regex, precisely because a constrained param is the thing
650
+ // that makes a route safe to mount before a literal. That guard must keep seeing it.
651
+ const PARAM_CONSTRAINT_RE = /:([A-Za-z0-9_]+)\([^)]*\)/g;
652
+
653
+ function canonicalRoutePath(p) {
654
+ return p == null ? p : String(p).replace(PARAM_CONSTRAINT_RE, ':$1');
655
+ }
656
+
633
657
  // The literal path a declaration at line `i` names, or null when it is not statically
634
- // resolvable (a template literal, a variable, a computed path).
658
+ // resolvable (a template literal, a variable, a computed path). Inline param constraints
659
+ // are canonicalised away — see canonicalRoutePath.
635
660
  function routePathAt(lines, i) {
636
661
  const same = ROUTE_DECL_SAME_LINE_RE.exec(lines[i]);
637
- if (same) return same[2];
662
+ if (same) return canonicalRoutePath(same[2]);
638
663
  // Multi-line form: nothing but whitespace/comment may follow the '(' …
639
664
  const rest = lines[i].slice(lines[i].indexOf('(') + 1).trim();
640
665
  if (rest !== '' && !rest.startsWith('//')) return null;
@@ -643,7 +668,7 @@ function routePathAt(lines, i) {
643
668
  const t = lines[j].trim();
644
669
  if (t === '' || t.startsWith('//')) continue;
645
670
  const m = ROUTE_PATH_LINE_RE.exec(lines[j]);
646
- return m ? m[1] : null;
671
+ return m ? canonicalRoutePath(m[1]) : null;
647
672
  }
648
673
  return null;
649
674
  }
@@ -939,6 +964,7 @@ module.exports = {
939
964
  classifyRank,
940
965
  classifyText,
941
966
  scanDeclarations,
967
+ canonicalRoutePath,
942
968
  discoverRouteFiles,
943
969
  checkRouteRanks,
944
970
  toWorkerResult,
package/src/module-api.js CHANGED
@@ -55,7 +55,7 @@ const { buildInfo } = require('./build-info');
55
55
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
56
56
  // the entry to that file. Look for a version's history there, not here.
57
57
  // ---------------------------------------------------------------------------
58
- const CORE_VERSION = '1.19.624'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
58
+ const CORE_VERSION = '1.19.626'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
59
59
 
60
60
  // A namespaced logger so a module's log lines are attributable + consistent.
61
61
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -76,6 +76,34 @@ t('pathParams: :id → {id} and names captured', () => {
76
76
  assert.deepEqual(r.names, ['id']);
77
77
  });
78
78
 
79
+ // task 1003747: an Express inline param constraint is a ROUTING detail and must
80
+ // never reach the published contract. Before this, `/inbox/:id(\d+)` produced the
81
+ // malformed path template `/inbox/{id}(\\d+)` and silently renamed the
82
+ // operationId `get_inbox_id` → `get_inbox_id_d` — a breaking change for every
83
+ // generated-client consumer, from a change that altered no URL.
84
+ t('pathParams: an inline param constraint is stripped from the path template', () => {
85
+ const r = gen.pathParams('/inbox/:id(\\d+)');
86
+ assert.equal(r.openapiPath, '/inbox/{id}');
87
+ assert.deepEqual(r.names, ['id']);
88
+ });
89
+
90
+ t('pathParams: a constrained param yields the SAME template as a bare one', () => {
91
+ assert.equal(gen.pathParams('/inbox/:id(\\d+)').openapiPath,
92
+ gen.pathParams('/inbox/:id').openapiPath);
93
+ });
94
+
95
+ t('routeForDisplay: the reader sees /inbox/:id, never the regex', () => {
96
+ assert.equal(gen.routeForDisplay('/inbox/:id(\\d+)'), '/inbox/:id');
97
+ assert.equal(gen.routeForDisplay('/tasks/:id/water'), '/tasks/:id/water');
98
+ assert.equal(gen.routeForDisplay(''), '');
99
+ });
100
+
101
+ t('the published spec contains no leaked param constraint', () => {
102
+ const spec = JSON.parse(fs.readFileSync(path.join(ROOT, 'docs', 'api', 'openapi.json'), 'utf8'));
103
+ const leaked = Object.keys(spec.paths || {}).filter((p) => p.includes('(') || p.includes('\\'));
104
+ assert.deepEqual(leaked, [], `path templates must carry no regex: ${leaked.join(', ')}`);
105
+ });
106
+
79
107
  t('topLevelKeys: depth-1 keys only, ignores nested min/max + inline comments', () => {
80
108
  const lit = `{
81
109
  title: { required: true, type: 'string', maxLength: 200 },
@@ -28,16 +28,79 @@ const ic = require(path.join(REPO_ROOT, 'src', 'instance-config.js'));
28
28
  const lib = require(path.join(REPO_ROOT, 'scripts', 'gds', 'cli-lib.js'));
29
29
 
30
30
  // SESSION_PATH is frozen at module load from os.homedir(), so anything touching the real save path
31
- // runs in a child with its own HOME.
31
+ // runs in a child with its own home directory.
32
+ //
33
+ // SETTING `HOME` ALONE IS NOT ISOLATION, AND THE MISS COST REAL CREDENTIALS (task 1003760).
34
+ // On Windows os.homedir() reads USERPROFILE and never consults HOME, so the original
35
+ // `env: { ...process.env, HOME: home }` isolated nothing there: every child below wrote
36
+ // its fixtures into the developer's ACTUAL ~/.config/<instance>/, overwriting the live
37
+ // CLI session with `token:'TOKEN-A', api_base:'https://a.example.com'` and leaving
38
+ // a.example.com.json / b.example.com.json / broken.json in the real session store. The
39
+ // owner had to re-issue a CLI token to recover, and because a claim is bound to the
40
+ // session that created it, the re-auth then orphaned an in-flight claim too.
41
+ //
42
+ // CI is Linux, where HOME *is* honoured — so this can never go red there. Two layers,
43
+ // because the platform list is not knowable in advance:
44
+ // 1. Override every variable a platform might read (USERPROFILE is the Windows one;
45
+ // HOMEDRIVE/HOMEPATH are the legacy fallback os.homedir() tries next).
46
+ // 2. FAIL CLOSED IN THE CHILD: prove os.homedir() actually landed in the sandbox
47
+ // BEFORE running any body, and abort if it did not. A future platform that reads
48
+ // some third variable then fails this test loudly instead of silently eating the
49
+ // developer's credentials — which is the only outcome that is acceptable for a
50
+ // test whose whole job is writing to a session store.
51
+ // THE AUDIT THIS FIX OWED, RECORDED HERE RATHER THAN IN A SHIP NOTE. `grep -rn "HOME:"
52
+ // tests/ scripts/` at the time of the fix (task 1003760) found the pattern in ten files.
53
+ // Three already override the Windows variable and are correct: tests/memory_pull.mjs:438,
54
+ // tests/redteam_patrol.mjs:123, tests/setup_device_flow_fallback.mjs:130 — so the
55
+ // convention existed and THIS file simply missed it. Two still set HOME alone and can go
56
+ // FALSE-GREEN on Windows, tracked as task 1003763: tests/cli_package.mjs:140+149 (whose own
57
+ // comment says the temp HOME exists "so a real session file on this machine cannot make a
58
+ // verb look healthier than it is" — on Windows it can) and tests/cli_surface.mjs:174 (which
59
+ // points HOME at a nonexistent dir to simulate "no session", and on Windows sees the real
60
+ // one). Neither DESTROYS anything — this file was the only one writing a session store — so
61
+ // they were filed rather than folded in. Two uses are legitimately HOME-only and must stay:
62
+ // tests/scrubber_corpus.mjs:228 uses HOME:'/root' as corpus DATA, and
63
+ // tests/lib_sh_resolution.mjs exercises POSIX shell resolution where HOME is the subject.
64
+ // They are why closing this class needs an allowlist, not a blanket rule (1003763).
65
+ //
66
+ // Also done off-repo when this landed, and unverifiable from a diff, so stated plainly: the
67
+ // three fixture files this bug left in the REAL session store — a.example.com.json,
68
+ // b.example.com.json, broken.json under ~/.config/<instance>/instances/ — were deleted, and
69
+ // the live session + auth were re-verified afterwards.
70
+ //
71
+ // KNOWN GAP, deliberately left: on Windows this suite now asserts no permission-tightness
72
+ // property at all (see the owner-only test below). NTFS has no POSIX triad, so the real
73
+ // property is the per-user profile ACL; an icacls-style assertion would restore the signal
74
+ // and does not exist here.
75
+ function sandboxEnv(home) {
76
+ const { root } = path.parse(home);
77
+ return {
78
+ ...process.env,
79
+ HOME: home,
80
+ USERPROFILE: home, // Windows: what os.homedir() actually reads
81
+ HOMEDRIVE: root.replace(/[\\/]+$/, ''), // legacy Windows fallback pair
82
+ HOMEPATH: home.slice(root.length - 1) || '\\',
83
+ };
84
+ }
85
+
32
86
  function inSandbox(body) {
33
87
  const home = fs.mkdtempSync(path.join(os.tmpdir(), 'bongos-sess-'));
34
88
  try {
35
89
  const code = `const R=${JSON.stringify(REPO_ROOT)};
90
+ const SANDBOX=${JSON.stringify(home)};
91
+ const os=require('node:os');
92
+ // Layer 2: refuse to touch a session store outside the sandbox. Runs before the
93
+ // requires below so not even a module-load side effect can reach the real home.
94
+ if (os.homedir() !== SANDBOX) {
95
+ console.error('ERR sandbox-escape: os.homedir()=' + os.homedir() + ' but the sandbox is ' + SANDBOX
96
+ + ' — this platform does not take the home override, and writing here would clobber the real session store.');
97
+ process.exit(2);
98
+ }
36
99
  const lib=require(R+'/scripts/gds/cli-lib.js');
37
100
  const ic=require(R+'/src/instance-config.js');
38
101
  const fs=require('node:fs'), path=require('node:path');
39
102
  (async()=>{ ${body} })().catch((e)=>{ console.error('ERR '+e.message); process.exit(1); });`;
40
- const r = spawnSync(process.execPath, ['-e', code], { encoding: 'utf8', env: { ...process.env, HOME: home } });
103
+ const r = spawnSync(process.execPath, ['-e', code], { encoding: 'utf8', env: sandboxEnv(home) });
41
104
  return { home, out: `${r.stdout || ''}${r.stderr || ''}`, status: r.status };
42
105
  } finally {
43
106
  fs.rmSync(home, { recursive: true, force: true });
@@ -78,6 +141,36 @@ test('the store anchor is FIXED, not brand-derived — that is the whole fix', (
78
141
  assert.ok(!/configHome|configDirName|safeBrand/.test(fn), `sessionStoreDir must not read the brand:\n${fn}`);
79
142
  });
80
143
 
144
+ // ── 1b. The sandbox is really a sandbox ─────────────────────────────────────────────────────
145
+ //
146
+ // These guard the guard (task 1003760). Every test below writes to a session store, so if
147
+ // the isolation is a no-op they write to the DEVELOPER'S store — which is exactly what
148
+ // happened on Windows, where os.homedir() reads USERPROFILE and the original helper set
149
+ // only HOME. A test suite that can delete credentials must prove it cannot before it runs.
150
+
151
+ test('the sandbox actually relocates os.homedir() — on THIS platform', () => {
152
+ const { out, status, home } = inSandbox(`console.log(JSON.stringify({ h: require('node:os').homedir() }));`);
153
+ assert.equal(status, 0, `sandbox child failed: ${out}`);
154
+ const { h } = JSON.parse(out.trim().split('\n').pop());
155
+ assert.equal(h, home,
156
+ 'os.homedir() inside the sandbox must BE the sandbox — otherwise every test here writes to the real session store');
157
+ });
158
+
159
+ test('a session store path resolved inside the sandbox stays inside it', () => {
160
+ const { out, status, home } = inSandbox(`console.log(JSON.stringify({ p: ic.configPath('gds-session.json'), d: lib.sessionStoreDir() }));`);
161
+ assert.equal(status, 0, `sandbox child failed: ${out}`);
162
+ const { p, d } = JSON.parse(out.trim().split('\n').pop());
163
+ assert.ok(p.startsWith(home), `the active session pointer resolved OUTSIDE the sandbox: ${p}`);
164
+ assert.ok(d.startsWith(home), `the session store dir resolved OUTSIDE the sandbox: ${d}`);
165
+ });
166
+
167
+ test('sandboxEnv overrides every home variable a platform might read', () => {
168
+ const env = sandboxEnv(path.join(os.tmpdir(), 'probe-home'));
169
+ assert.equal(env.HOME, path.join(os.tmpdir(), 'probe-home'));
170
+ assert.equal(env.USERPROFILE, env.HOME, 'USERPROFILE is the one Windows reads — the original miss');
171
+ assert.ok('HOMEDRIVE' in env && 'HOMEPATH' in env, 'the legacy Windows fallback pair must be set too');
172
+ });
173
+
81
174
  // ── 2. A login never destroys another instance's session ────────────────────────────────────
82
175
 
83
176
  test('signing into a second instance PRESERVES the first — including one written before the store existed', () => {
@@ -127,13 +220,25 @@ test('re-saving the SAME instance is idempotent and files nothing extra', () =>
127
220
  assert.equal(r.token, 'T2');
128
221
  });
129
222
 
223
+ // POSIX MODE BITS DO NOT EXIST ON WINDOWS, so this assertion could never pass there
224
+ // (task 1003760). NTFS has no owner/group/other triad: Node reports 0666 for any
225
+ // writable file and 0444 for a read-only one, and chmod(0o600) is silently a no-op —
226
+ // measured on win32, both before and after an explicit chmod. Asserting '600' there
227
+ // made the unit suite permanently red on every Windows checkout, which is the exact
228
+ // harm task 1003754 names: a test that always fails is how a genuinely failing suite
229
+ // stops being read. So the mode claim is asserted where the platform can express it,
230
+ // and on Windows the file's EXISTENCE is still asserted — the security property has
231
+ // to be met by NTFS ACLs (the per-user profile directory), not by a mode integer.
130
232
  test('session files are owner-only', () => {
131
233
  const { out } = inSandbox(`
132
234
  await lib.saveSession({ token:'T', api_base:'https://a.example.com', builder:{github_login:'x'} });
133
- const f = fs.statSync(lib.sessionStorePath('https://a.example.com')).mode & 0o777;
235
+ const p = lib.sessionStorePath('https://a.example.com');
236
+ const f = fs.statSync(p).mode & 0o777;
134
237
  const d = fs.statSync(lib.sessionStoreDir()).mode & 0o777;
135
- console.log(JSON.stringify({ file: f.toString(8), dir: d.toString(8) }));`);
238
+ console.log(JSON.stringify({ file: f.toString(8), dir: d.toString(8), exists: fs.existsSync(p) }));`);
136
239
  const r = JSON.parse(out.trim().split('\n').pop());
240
+ assert.equal(r.exists, true, 'the session file must be written wherever we run');
241
+ if (process.platform === 'win32') return; // no POSIX triad to assert — see above
137
242
  assert.equal(r.file, '600', 'a session file holds a bearer token');
138
243
  assert.equal(r.dir, '700');
139
244
  });
package/tests/helpers.mjs CHANGED
@@ -148,6 +148,34 @@ export function lifecycleDbSource(modulesDir = path.join(ROOT, 'modules')) {
148
148
  (f) => f === 'db.js' || (f.startsWith('db-') && f.endsWith('.js')));
149
149
  }
150
150
 
151
+ // assertCannotSwallow(assert, paths, literal, paramPath, label) — the route-ordering
152
+ // invariant for a literal sharing a prefix with a `:param` route, e.g.
153
+ // `/inbox/awaiting-nod` against `/inbox/:id` (task 1003747).
154
+ //
155
+ // WHY THIS IS NOT JUST AN indexOf. Four test files each asserted
156
+ // `indexOf(literal) < indexOf('/inbox/:id')`, reading the LIVE router's
157
+ // layer.route.path. That was the whole invariant while the param route was bare —
158
+ // Express matches in registration order, so a literal declared after it arrives as
159
+ // `:id='awaiting-nod'` and 400s on parseId: a bug whose only symptom is an empty card.
160
+ //
161
+ // It stopped being the whole invariant when `/inbox/:id` gained the constraint
162
+ // `(\d+)`. A CONSTRAINED param cannot match a non-numeric segment at all, so it cannot
163
+ // swallow the literal REGARDLESS of order — and the four tests, looking for the bare
164
+ // string, instead reported the param route as "not mounted" and failed. Order-only was
165
+ // always the narrower claim; the real property is that the literal is REACHABLE. This
166
+ // asserts that directly, so both spellings pass for the right reason and neither adding
167
+ // nor removing a constraint produces a false failure.
168
+ export function assertCannotSwallow(assert, paths, literal, paramPath, label = paramPath) {
169
+ const iLit = paths.indexOf(literal);
170
+ assert.ok(iLit >= 0, `${literal} is not mounted at all`);
171
+ const bare = paths.indexOf(paramPath);
172
+ const constrained = paths.findIndex((p) => typeof p === 'string' && p.startsWith(`${paramPath}(`));
173
+ assert.ok(bare >= 0 || constrained >= 0, `${label} is not mounted at all`);
174
+ if (constrained >= 0) return; // constrained ⇒ cannot match a non-numeric segment; order is moot
175
+ assert.ok(iLit < bare,
176
+ `${literal} (${iLit}) must precede the unconstrained ${label} (${bare}), or ${label} swallows it`);
177
+ }
178
+
151
179
  // The SHIP CLI: ship.js (facade + entry point) + the ship-*.js phase modules.
152
180
  export function shipCliSource(scriptsDir = path.join(ROOT, 'scripts', 'gds')) {
153
181
  return familySource(scriptsDir,
@@ -25,6 +25,7 @@ import { createRequire } from 'node:module';
25
25
  import fs from 'node:fs';
26
26
  import path from 'node:path';
27
27
  import { fileURLToPath } from 'node:url';
28
+ import { assertCannotSwallow } from './helpers.mjs';
28
29
 
29
30
  const require = createRequire(import.meta.url);
30
31
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -162,10 +163,10 @@ await test('a draft written against another template is dropped, not half-restor
162
163
  // --- GET /inbox/template ------------------------------------------------
163
164
 
164
165
  await test('GET /inbox/template is declared BEFORE GET /inbox/:id', () => {
165
- const iT = mounted.indexOf('GET /inbox/template');
166
- const iId = mounted.indexOf('GET /inbox/:id');
167
- assert.ok(iT >= 0 && iId >= 0);
168
- assert.ok(iT < iId, `with :id first, "/inbox/template" binds id="template" and parseId 400s it (template@${iT}, :id@${iId})`);
166
+ // Declaration order OR a constrained param keeps this reachable — with a bare :id
167
+ // first, "/inbox/template" binds id="template" and parseId 400s it. See
168
+ // assertCannotSwallow in tests/helpers.mjs (task 1003747).
169
+ assertCannotSwallow(assert, mounted, 'GET /inbox/template', 'GET /inbox/:id');
169
170
  });
170
171
 
171
172
  await test('the template read returns the R141 declaration, in fieldOrder order', async () => {
@@ -26,7 +26,7 @@
26
26
 
27
27
  import { strict as assert } from 'node:assert';
28
28
  import { createRequire } from 'node:module';
29
- import { makeRunner, makeSqlAwareClient } from './helpers.mjs';
29
+ import { makeRunner, makeSqlAwareClient, assertCannotSwallow } from './helpers.mjs';
30
30
 
31
31
  process.env.NODE_ENV = 'test';
32
32
 
@@ -989,10 +989,9 @@ await test('GET /inbox/awaiting-nod is registered BEFORE /inbox/:id, or it is un
989
989
  // a bug with no symptom except an empty card.
990
990
  const router = require('../modules/ideas/routes/inbox.js')({});
991
991
  const paths = router.stack.filter((l) => l.route).map((l) => l.route.path);
992
- const iNod = paths.indexOf('/inbox/awaiting-nod');
993
- const iParam = paths.indexOf('/inbox/:id');
994
- assert.ok(iNod >= 0, 'the route exists');
995
- assert.ok(iNod < iParam, `literal segment must precede the wildcard (got ${iNod} vs ${iParam})`);
992
+ // Order OR a constrained param — either makes this route reachable. See
993
+ // assertCannotSwallow in tests/helpers.mjs (task 1003747).
994
+ assertCannotSwallow(assert, paths, '/inbox/awaiting-nod', '/inbox/:id');
996
995
  });
997
996
 
998
997
  // =========================================================================
@@ -20,6 +20,7 @@
20
20
 
21
21
  import { strict as assert } from 'node:assert';
22
22
  import { createRequire } from 'node:module';
23
+ import { assertCannotSwallow } from './helpers.mjs';
23
24
 
24
25
  const require = createRequire(import.meta.url);
25
26
  const api = require('../src/module-api.js');
@@ -81,14 +82,10 @@ async function test(name, fn) {
81
82
  // --- declaration order: the bug that would 400 the whole queue -----------
82
83
 
83
84
  await test('GET /inbox/sparks is declared BEFORE GET /inbox/:id', () => {
84
- const iSparks = mounted.indexOf('GET /inbox/sparks');
85
- const iId = mounted.indexOf('GET /inbox/:id');
86
- assert.ok(iSparks >= 0, 'the spark queue route is mounted');
87
- assert.ok(iId >= 0, 'the by-id read is mounted');
88
- assert.ok(
89
- iSparks < iId,
90
- `declaration order is load-bearing: with :id first, "/inbox/sparks" binds id="sparks" and parseId 400s the queue (sparks@${iSparks}, :id@${iId})`,
91
- );
85
+ // Declaration order OR a constrained param keeps this reachable — with a bare :id
86
+ // first, "/inbox/sparks" binds id="sparks" and parseId 400s the queue. See
87
+ // assertCannotSwallow in tests/helpers.mjs (task 1003747).
88
+ assertCannotSwallow(assert, mounted, 'GET /inbox/sparks', 'GET /inbox/:id');
92
89
  });
93
90
 
94
91
  // --- the predicate: open, undeveloped sparks, newest first ---------------
@@ -35,6 +35,7 @@ import fs from 'node:fs';
35
35
  import path from 'node:path';
36
36
  import vm from 'node:vm';
37
37
  import { fileURLToPath } from 'node:url';
38
+ import { assertCannotSwallow } from './helpers.mjs';
38
39
 
39
40
  const require = createRequire(import.meta.url);
40
41
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -79,11 +80,9 @@ const ideaRouter = ideaRoutesFactory({});
79
80
  const mountedPaths = ideaRouter.stack.filter((l) => l.route).map((l) => l.route.path);
80
81
 
81
82
  await test('GET /inbox/by-builder/:id is declared BEFORE GET /inbox/:id', () => {
82
- const trail = mountedPaths.indexOf('/inbox/by-builder/:id');
83
- const byId = mountedPaths.indexOf('/inbox/:id');
84
- assert.ok(trail > -1, 'the trail route is mounted');
85
- assert.ok(byId > -1, 'the by-id route is mounted');
86
- assert.ok(trail < byId, `/inbox/by-builder/:id (${trail}) must precede /inbox/:id (${byId}) or :id swallows it`);
83
+ // Order OR a constrained param — either makes the trail route reachable. See
84
+ // assertCannotSwallow in tests/helpers.mjs (task 1003747).
85
+ assertCannotSwallow(assert, mountedPaths, '/inbox/by-builder/:id', '/inbox/:id');
87
86
  });
88
87
 
89
88
  // ---------------------------------------------------------------------------
@@ -160,7 +160,12 @@ await test('ROUTE SHAPE: the triage-gated PATCH /inbox/:id was NOT widened to re
160
160
  assert.ok(row, 'the triage route still exists');
161
161
  assert.equal(row.rank, 'metic+archon', 'PATCH /inbox/:id must keep its triage-level gate');
162
162
  const src = readFileSync(new URL('../modules/ideas/routes/inbox.js', import.meta.url), 'utf8');
163
- const start = src.indexOf("router.patch('/inbox/:id',");
163
+ // Tolerate an inline param constraint on the path — `'/inbox/:id(\d+)'` (task
164
+ // 1003747, which added one so this route stops swallowing another module's
165
+ // `/inbox/rotting`). Matching the bare literal made this test report the triage
166
+ // route as missing when only its MATCHER had been narrowed. The route key above
167
+ // is canonicalised for the same reason (src/bongos/route-rank-check.js).
168
+ const start = src.search(/router\.patch\('\/inbox\/:id(?:\([^)]*\))?',/);
164
169
  const body = src.slice(start, src.indexOf('router.', start + 20));
165
170
  // Assert on the VALIDATION SCHEMA, not on any mention of the string: that route
166
171
  // already discusses body_md in a comment (about it not being parseable as a
@@ -0,0 +1,215 @@
1
+ // tests/route_shadow_guard.mjs — the guard that keeps a `:param` route from
2
+ // swallowing a literal route mounted after it (task 1003747).
3
+ //
4
+ // GET /inbox/rotting answered 400 bad_id in production for releases: `ideas`
5
+ // mounts GET /inbox/:id before `lifecycle` mounts GET /inbox/rotting, so the param
6
+ // matched first and ended the request before the rot handler was reached. The
7
+ // whole 30-day rot cadence was dead, and the hall's only caller swallowed the
8
+ // error into an empty section that looked like "nothing is rotting".
9
+ //
10
+ // The guard is tested on SYNTHETIC mount sequences, not only the live tree: a scan
11
+ // that passes because the tree is clean today proves nothing about what it
12
+ // REFUSES. The live-tree case at the bottom is what proves the real fix holds.
13
+ //
14
+ // Run: node tests/route_shadow_guard.mjs
15
+
16
+ import { strict as assert } from 'node:assert';
17
+ import { createRequire } from 'node:module';
18
+
19
+ const require = createRequire(import.meta.url);
20
+ const {
21
+ checkNoRouteShadowing,
22
+ findShadowPairs,
23
+ mountSequence,
24
+ isBareParam,
25
+ isConstrainedParam,
26
+ } = require('../scripts/gds/route-shadow-guard.js');
27
+
28
+ let passed = 0;
29
+ let failed = 0;
30
+ function test(name, fn) {
31
+ try { fn(); passed++; console.log(` ok ${name}`); }
32
+ catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
33
+ }
34
+
35
+ const r = (module_, file, method, route) => ({ module: module_, file, method, route });
36
+
37
+ // --- THE DEFECT, EXACTLY AS IT SHIPPED -------------------------------------
38
+
39
+ test('catches the real pair: GET /inbox/:id mounted before GET /inbox/rotting', () => {
40
+ const pairs = findShadowPairs([
41
+ r('ideas', 'modules/ideas/routes/inbox.js', 'get', '/inbox/:id'),
42
+ r('lifecycle', 'modules/lifecycle/routes/rot.js', 'get', '/inbox/rotting'),
43
+ ]);
44
+ assert.equal(pairs.length, 1);
45
+ assert.equal(pairs[0].early.route, '/inbox/:id');
46
+ assert.equal(pairs[0].later.route, '/inbox/rotting');
47
+ });
48
+
49
+ test('the check hard-fails on that sequence, and names the fall-through fix', () => {
50
+ const out = checkNoRouteShadowing({
51
+ sequence: [
52
+ r('ideas', 'modules/ideas/routes/inbox.js', 'get', '/inbox/:id'),
53
+ r('lifecycle', 'modules/lifecycle/routes/rot.js', 'get', '/inbox/rotting'),
54
+ ],
55
+ });
56
+ assert.equal(out.ok, false);
57
+ assert.equal(out.hardFail, true);
58
+ assert.equal(out.violations.length, 1);
59
+ assert.match(out.violations[0], /\/inbox\/rotting/);
60
+ assert.match(out.violations[0], /never reaches its handler/);
61
+ assert.match(out.violations[0], /\\d\+/, 'points at constraining the param');
62
+ });
63
+
64
+ // --- WHAT THE FIX DOES -----------------------------------------------------
65
+
66
+ test('a CONSTRAINED param shadows nothing — this is what the fix relies on', () => {
67
+ const pairs = findShadowPairs([
68
+ r('ideas', 'modules/ideas/routes/inbox.js', 'get', '/inbox/:id(\\d+)'),
69
+ r('lifecycle', 'modules/lifecycle/routes/rot.js', 'get', '/inbox/rotting'),
70
+ ]);
71
+ assert.deepEqual(pairs, []);
72
+ });
73
+
74
+ test('order matters: a literal mounted BEFORE the param is fine', () => {
75
+ const pairs = findShadowPairs([
76
+ r('ideas', 'modules/ideas/routes/inbox.js', 'get', '/inbox/awaiting-nod'),
77
+ r('ideas', 'modules/ideas/routes/inbox.js', 'get', '/inbox/:id'),
78
+ ]);
79
+ assert.deepEqual(pairs, [], 'declaring literals first is the in-file convention and stays legal');
80
+ });
81
+
82
+ // --- WHAT MUST NOT BE FLAGGED ---------------------------------------------
83
+
84
+ test('different methods never compete', () => {
85
+ assert.deepEqual(findShadowPairs([
86
+ r('a', 'a.js', 'get', '/inbox/:id'),
87
+ r('b', 'b.js', 'patch', '/inbox/rotting'),
88
+ ]), []);
89
+ });
90
+
91
+ test('different depths never compete', () => {
92
+ assert.deepEqual(findShadowPairs([
93
+ r('a', 'a.js', 'get', '/inbox/:id'),
94
+ r('b', 'b.js', 'get', '/inbox/rotting/detail'),
95
+ ]), []);
96
+ });
97
+
98
+ test('two params at the same position are one shape, not a shadow', () => {
99
+ assert.deepEqual(findShadowPairs([
100
+ r('a', 'a.js', 'get', '/inbox/:id'),
101
+ r('b', 'b.js', 'get', '/inbox/:ideaId'),
102
+ ]), []);
103
+ });
104
+
105
+ test('a differing earlier literal segment means the routes never overlap', () => {
106
+ assert.deepEqual(findShadowPairs([
107
+ r('a', 'a.js', 'get', '/inbox/:id'),
108
+ r('b', 'b.js', 'get', '/blockers/rotting'),
109
+ ]), []);
110
+ });
111
+
112
+ test('the param must sit WHERE the literal is', () => {
113
+ assert.deepEqual(findShadowPairs([
114
+ r('a', 'a.js', 'get', '/tasks/:id/water'),
115
+ r('b', 'b.js', 'get', '/tasks/rotting/water'),
116
+ ]).length, 1, 'same position, so it does shadow');
117
+ assert.deepEqual(findShadowPairs([
118
+ r('a', 'a.js', 'get', '/tasks/:id/water'),
119
+ r('b', 'b.js', 'get', '/tasks/:id/rotting'),
120
+ ]), [], 'literal in a position the param does not occupy');
121
+ });
122
+
123
+ // --- SEGMENT CLASSIFIERS ---------------------------------------------------
124
+
125
+ test('bare vs constrained param', () => {
126
+ assert.equal(isBareParam(':id'), true);
127
+ assert.equal(isBareParam(':id(\\d+)'), false);
128
+ assert.equal(isBareParam('rotting'), false);
129
+ assert.equal(isConstrainedParam(':id(\\d+)'), true);
130
+ assert.equal(isConstrainedParam(':id'), false);
131
+ });
132
+
133
+ // --- SCAN INTEGRITY --------------------------------------------------------
134
+
135
+ test('an empty enumeration is a scan DEFECT, never a clean pass', () => {
136
+ const out = checkNoRouteShadowing({ sequence: [] });
137
+ assert.equal(out.ok, false);
138
+ assert.equal(out.hardFail, true);
139
+ assert.match(out.violations[0], /scan defect/);
140
+ });
141
+
142
+ // --- THE LIVE TREE ---------------------------------------------------------
143
+
144
+ test('the real repo enumerates routes and has no shadowing pair', () => {
145
+ const seq = mountSequence();
146
+ assert.ok(seq.length > 100, `expected a real mount sequence, got ${seq.length}`);
147
+ const out = checkNoRouteShadowing();
148
+ assert.equal(out.hardFail, false, `live tree has shadowing pairs:\n${out.violations.join('\n')}`);
149
+ });
150
+
151
+ test('the live tree still declares the rot feed and the constrained idea read', () => {
152
+ const seq = mountSequence();
153
+ const rot = seq.find((x) => x.route === '/inbox/rotting' && x.method === 'get');
154
+ assert.ok(rot, 'GET /inbox/rotting must still be declared');
155
+ const idea = seq.find((x) => x.route.startsWith('/inbox/:id') && x.method === 'get');
156
+ assert.ok(idea, 'GET /inbox/:id must still be declared');
157
+ assert.ok(isConstrainedParam(idea.route.split('/').filter(Boolean)[1]),
158
+ `GET ${idea.route} must constrain its id, or it eats /inbox/rotting again`);
159
+ });
160
+
161
+ // --- THE BEHAVIOUR THE FIX DEPENDS ON -------------------------------------
162
+ //
163
+ // The static guard above proves the DECLARATION is constrained. This proves the
164
+ // constraint actually routes that way in the Express version we ship — and it is
165
+ // not a formality: Express 5 REMOVED inline param regexes, so an upgrade would
166
+ // silently turn `:id(\d+)` back into a path that eats /inbox/rotting, with the
167
+ // static check still passing because the declaration text is unchanged. This is
168
+ // the test that goes red on that upgrade.
169
+
170
+ async function behaviour() {
171
+ const express = require('express');
172
+ const http = require('node:http');
173
+ const app = express();
174
+
175
+ // Mounted in the SAME order the real loader mounts them: `ideas` (the param
176
+ // route) before `lifecycle` (the literal).
177
+ const ideas = express.Router();
178
+ ideas.get('/inbox/:id(\\d+)', (req, res) => res.json({ handler: 'idea', id: req.params.id }));
179
+ const lifecycle = express.Router();
180
+ lifecycle.get('/inbox/rotting', (_req, res) => res.json({ handler: 'rot' }));
181
+ app.use(ideas);
182
+ app.use(lifecycle);
183
+
184
+ const server = http.createServer(app);
185
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
186
+ const base = `http://127.0.0.1:${server.address().port}`;
187
+ try {
188
+ const rot = await fetch(`${base}/inbox/rotting`);
189
+ const rotBody = await rot.json();
190
+ test('GET /inbox/rotting reaches the ROT handler despite being mounted second', () => {
191
+ assert.equal(rot.status, 200);
192
+ assert.equal(rotBody.handler, 'rot');
193
+ });
194
+
195
+ const idea = await fetch(`${base}/inbox/1234`);
196
+ const ideaBody = await idea.json();
197
+ test('a numeric id still reaches the idea handler', () => {
198
+ assert.equal(idea.status, 200);
199
+ assert.equal(ideaBody.handler, 'idea');
200
+ assert.equal(ideaBody.id, '1234');
201
+ });
202
+
203
+ const junk = await fetch(`${base}/inbox/not-a-number`);
204
+ test('an unmatched non-numeric segment 404s instead of 400 — the accepted trade-off', () => {
205
+ assert.equal(junk.status, 404);
206
+ });
207
+ } finally {
208
+ await new Promise((resolve) => server.close(resolve));
209
+ }
210
+ }
211
+
212
+ await behaviour();
213
+
214
+ console.log(`\n${passed} passed, ${failed} failed`);
215
+ if (failed > 0) process.exit(1);