@bongos/core 1.19.754 → 1.19.756

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.754",
6
- "core_contract": "1.19.754",
7
- "source_commit": "8fd1dd9b3ddbc313d9e3bb09ba2a339cb9793e7f",
5
+ "core_version": "1.19.756",
6
+ "core_contract": "1.19.756",
7
+ "source_commit": "701a77495faed74b1cad4941c564f5a8a1a99158",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-16T00:24:38.617Z",
9
+ "built_at": "2026-09-16T02:21:20.653Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 483,
13
13
  "agent_docs_stubbed": 25,
14
- "functional_verbatim": 2111,
14
+ "functional_verbatim": 2114,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2619,
20
- "tree_sha256": "a2f5f1ae36d83e33d7e61bab1264bf62287f10d175f2c9e1f3cabd695bc2b757",
19
+ "file_count": 2622,
20
+ "tree_sha256": "0124e1a9cd241c6125c6a7a1351be80d37c9a40ba36c2f2def3ccb768417105c",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2512,7 +2512,7 @@
2512
2512
  {
2513
2513
  "path": "docs/module-api-changelog.md",
2514
2514
  "mode": "0000644",
2515
- "sha256": "f367478f9031a67c77c1b43521aa215ea65068e9ad1b6b21540c81abd8a22dde"
2515
+ "sha256": "cbc6427898e270a22852c496e1405b927c41e295a1a8808fb260b4f83f6f6429"
2516
2516
  },
2517
2517
  {
2518
2518
  "path": "docs/modules-contract.md",
@@ -7567,12 +7567,12 @@
7567
7567
  {
7568
7568
  "path": "package-lock.json",
7569
7569
  "mode": "0000644",
7570
- "sha256": "0b25947dcf9f2906d39aec4e4ad10e63dc42831cf593c5404ba77dc51c2be946"
7570
+ "sha256": "cc3fcdaac4eeb204e96790fe0203978514bb550acc078a68d5577cd02d229f7c"
7571
7571
  },
7572
7572
  {
7573
7573
  "path": "package.json",
7574
7574
  "mode": "0000644",
7575
- "sha256": "f340a8707e0abe69d0e7d1028a0a96675b66099ce87af7932004393e02dc2a19"
7575
+ "sha256": "0e01d62b067f43577be10f62f417f36dbdcbadc0ba03d290ba902b9447d9ac34"
7576
7576
  },
7577
7577
  {
7578
7578
  "path": "public-docs/index.html",
@@ -8024,11 +8024,21 @@
8024
8024
  "mode": "0000644",
8025
8025
  "sha256": "b8b1a7ed1d8dd217b24757a3ec9f4b3aa58b26a3e2a7b2cef9de217792e5ea43"
8026
8026
  },
8027
+ {
8028
+ "path": "scripts/gds/fitness-checks-error-envelope.js",
8029
+ "mode": "0000644",
8030
+ "sha256": "ab11c75ae177397fdd507505732450fee3531d42b99ba0b34811d06563ebd21c"
8031
+ },
8027
8032
  {
8028
8033
  "path": "scripts/gds/fitness-checks-identity.js",
8029
8034
  "mode": "0000644",
8030
8035
  "sha256": "583b21bf6527b657058ebfab50aff8c51cec96a0b1baa4d01daea55e90d0ccf7"
8031
8036
  },
8037
+ {
8038
+ "path": "scripts/gds/fitness-checks-packaging.js",
8039
+ "mode": "0000644",
8040
+ "sha256": "1f0611c9b8cb30e20119d50f67bc3a1148d6b5d869337361ae0ac58724aaca12"
8041
+ },
8032
8042
  {
8033
8043
  "path": "scripts/gds/fitness-checks-route-pins.js",
8034
8044
  "mode": "0000644",
@@ -8057,7 +8067,7 @@
8057
8067
  {
8058
8068
  "path": "scripts/gds/fitness.js",
8059
8069
  "mode": "0000644",
8060
- "sha256": "69c24b7d4a273520284cf2e07be151ffd70128e495b6bb12aa6da83b7d62cf71"
8070
+ "sha256": "4856485e5bb5bb03e47cd6953659c25214411d0b63287f6ab2faa4c34e1e27bc"
8061
8071
  },
8062
8072
  {
8063
8073
  "path": "scripts/gds/gate-review.js",
@@ -9342,7 +9352,7 @@
9342
9352
  {
9343
9353
  "path": "src/bongos/serve-internal.js",
9344
9354
  "mode": "0000644",
9345
- "sha256": "61b13299ffdd7f036d5bc3d364135bd8ca4d3470223001b8ff6cc45d858d6107"
9355
+ "sha256": "9edf07966de8d137ba6d987557bb4a9fefeedd19e640f10302dbfd340e0343f9"
9346
9356
  },
9347
9357
  {
9348
9358
  "path": "src/bongos/uptime-poller.js",
@@ -9372,7 +9382,7 @@
9372
9382
  {
9373
9383
  "path": "src/module-api.js",
9374
9384
  "mode": "0000644",
9375
- "sha256": "df078d4bd7a6e0fda01ce242821141c66b6dc0f2d07ebfaf8de1a89ae279499e"
9385
+ "sha256": "a4071a82922c3333e62a1657f6ce674b19831dbf0152ab3c6c270904193d3f2c"
9376
9386
  },
9377
9387
  {
9378
9388
  "path": "src/module-loader/catalog.js",
@@ -11619,6 +11629,11 @@
11619
11629
  "mode": "0000644",
11620
11630
  "sha256": "40c396fda3a618bf9fa2d2ae3d8d30b50e62c4210199d70b5e29cb82a046d037"
11621
11631
  },
11632
+ {
11633
+ "path": "tests/module_asset_subdomain.mjs",
11634
+ "mode": "0000644",
11635
+ "sha256": "420e17095b054f87be386913077966d456ae687b030b476e1bc33b15ae35ed9f"
11636
+ },
11622
11637
  {
11623
11638
  "path": "tests/module_catalog.mjs",
11624
11639
  "mode": "0000644",
@@ -1982,5 +1982,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1982
1982
  landed since 1.19.752 with no explicit bump. run 35036151976. (task 1002620)
1983
1983
  1.19.754 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1984
1984
  landed since 1.19.753 with no explicit bump. run 35039836268. (task 1002620)
1985
+ 1.19.755 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1986
+ landed since 1.19.754 with no explicit bump. run 35047010813. (task 1002620)
1987
+ 1.19.756 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1988
+ landed since 1.19.755 with no explicit bump. run 35047623582. (task 1002620)
1985
1989
  ---------------------------------------------------------------------------
1986
1990
  ```
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.754",
3
+ "version": "1.19.756",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.754",
9
+ "version": "1.19.756",
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.754",
3
+ "version": "1.19.756",
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",
@@ -0,0 +1,134 @@
1
+ // scripts/gds/fitness-checks-error-envelope.js — Check 17: no route hand-rolls a
2
+ // FLAT error (BV1 R03 / task 1990 / ADR 0116).
3
+ //
4
+ // Carved out of scripts/gds/fitness.js by task 1003825 alongside the packaging pair,
5
+ // for the headroom reason documented there. Self-contained: the scan needs only
6
+ // CORE_ROOT / rel / walkJs / walkJsAllRoots from fitness-lib.js, and FLAT_ERROR_BASELINE
7
+ // travels WITH the check rather than staying behind — the baseline and the gate that
8
+ // reads it are one decision, and splitting them is how a ratchet quietly loses its floor.
9
+ //
10
+ // fitness.js requires the four public names straight back (checkErrorEnvelope,
11
+ // scanFlatErrorSites, countFlatErrors, FLAT_ERROR_BASELINE — the last three are read by
12
+ // the R04 sweep and its tests), so nothing outside this file changes shape.
13
+ // stripCommentsForFlat stays PRIVATE here: it was never on fitness.js's exports, and
14
+ // exporting it now to mirror the carve would be a new public name nothing consumes.
15
+
16
+ 'use strict';
17
+
18
+ const fs = require('node:fs');
19
+ const path = require('node:path');
20
+
21
+ const { CORE_ROOT, rel, walkJs, walkJsAllRoots } = require('./fitness-lib.js');
22
+ // ============================================================================
23
+ // Check 17 — no route hand-rolls a FLAT error (BV1 R03 / task 1990 / ADR 0116)
24
+ // ============================================================================
25
+ //
26
+ // The error-envelope contract (ADR 0116) says every failure is the ONE envelope
27
+ // `{error:{code,message,details}}`, emitted via res.fail() or a thrown ApiError
28
+ // (the R02 middleware, task 1989). A hand-rolled flat error —
29
+ // `res.status(n).json({ error: 'x' })` or `.json({ error })` — breaks the
30
+ // contract: the caller gets a bare string where the envelope should be.
31
+ //
32
+ // RATCHET, not big-bang. The R04 sweep (task 1991) migrates the ~562 existing
33
+ // sites; until it finishes they'd all "fail" a strict check, so this HOLDS THE
34
+ // LINE: it fails only when the count rises ABOVE the committed baseline (a NEW
35
+ // hand-rolled flat error), and nudges the baseline DOWN as the sweep shrinks it.
36
+ // When the sweep reaches 0, FLAT_ERROR_BASELINE is 0 and any new flat error
37
+ // hard-fails immediately. Detector is shared with the sweep + its test.
38
+ const FLAT_ERROR_BASELINE = 0; // R04 sweep (task 1991) landed the surface at 0 — the gate is now hard-zero: any new hand-rolled flat error fails the build immediately.
39
+ // A flat error is a `.json({ … })` whose FIRST key is `error` with a value that
40
+ // is NOT an object literal (a string/var/shorthand). The envelope's nested
41
+ // `error: { … }` and `.json(envelope(…))` deliberately do NOT match.
42
+ // The `(?!\s*\{)` lookahead is whitespace-aware: without the inner `\s*` the
43
+ // preceding `\s*` backtracks to zero and the lookahead is tested at the space in
44
+ // `error: {`, so the compliant nested envelope was wrongly flagged (task 2029).
45
+ const FLAT_ERROR_VALUE_RE = /\.json\(\s*\{\s*error\s*:\s*(?!\s*\{)/g; // error: 'x' | error: code
46
+ const FLAT_ERROR_SHORTHAND_RE = /\.json\(\s*\{\s*error\s*[,}]/g; // { error } | { error, … }
47
+
48
+ // Strip // line comments + /* */ block comments AND blank out string/template
49
+ // contents (keeping the delimiters) so neither a DOC example of the anti-pattern
50
+ // — e.g. the res.fail() usage note in middleware/error-envelope.js — nor a
51
+ // pattern quoted inside a string literal is counted as a real flat error once
52
+ // the gate is hard-zero (task 1991). Real flat errors are unaffected: in
53
+ // `res.status(404).json({ error: 'x' })` the `.json({ error:` the detector keys
54
+ // on is CODE (outside the string), so blanking the `'x'` content still matches.
55
+ function stripCommentsForFlat(src) {
56
+ let out = '';
57
+ let i = 0;
58
+ let quote = null;
59
+ while (i < src.length) {
60
+ const c = src[i];
61
+ const d = src[i + 1];
62
+ if (quote) {
63
+ // inside a string/template: drop content, keep only the closing delimiter
64
+ if (c === '\\') { i += 2; continue; }
65
+ if (c === quote) { out += c; quote = null; }
66
+ i++; continue;
67
+ }
68
+ if (c === '"' || c === "'" || c === '`') { quote = c; out += c; i++; continue; }
69
+ if (c === '/' && d === '/') { while (i < src.length && src[i] !== '\n') i++; continue; }
70
+ if (c === '/' && d === '*') { i += 2; while (i < src.length && !(src[i] === '*' && src[i + 1] === '/')) i++; i += 2; continue; }
71
+ out += c; i++;
72
+ }
73
+ return out;
74
+ }
75
+
76
+ function countFlatErrors(text) {
77
+ const code = stripCommentsForFlat(text);
78
+ return (code.match(FLAT_ERROR_VALUE_RE) || []).length
79
+ + (code.match(FLAT_ERROR_SHORTHAND_RE) || []).length;
80
+ }
81
+
82
+ // scanFlatErrorSites() — per-file flat-error counts across the API surface
83
+ // (core src/bongos + every module), skipping tests. Shared by the check below, the
84
+ // R04 codemod, and tests/api_error_envelope_buildcheck.mjs.
85
+ function scanFlatErrorSites() {
86
+ const seen = new Set();
87
+ const byFile = {};
88
+ let total = 0;
89
+ const files = [
90
+ ...walkJs(path.join(CORE_ROOT, 'src', 'bongos')),
91
+ ...walkJsAllRoots('modules'),
92
+ ];
93
+ for (const abs of files) {
94
+ const relFile = rel(abs);
95
+ if (seen.has(relFile)) continue;
96
+ seen.add(relFile);
97
+ if (relFile.includes('/tests/') || relFile.endsWith('.test.js')) continue;
98
+ const n = countFlatErrors(fs.readFileSync(abs, 'utf8'));
99
+ if (n) { byFile[relFile] = n; total += n; }
100
+ }
101
+ return { total, byFile };
102
+ }
103
+
104
+ function checkErrorEnvelope() {
105
+ const { total, byFile } = scanFlatErrorSites();
106
+ const violations = [];
107
+ const warnings = [];
108
+ if (total > FLAT_ERROR_BASELINE) {
109
+ const top = Object.entries(byFile).sort((a, b) => b[1] - a[1]).slice(0, 8)
110
+ .map(([f, n]) => `${f} (${n})`).join(', ');
111
+ violations.push(
112
+ `${total} hand-rolled flat-error sites — ABOVE the ${FLAT_ERROR_BASELINE} baseline. A new route emitted a flat \`res.status(n).json({error:'x'})\` instead of \`res.fail('x', n)\` / \`throw new ApiError('x')\` (ADR 0116). Top files: ${top}. Use the envelope helper (src/bongos/middleware/error-envelope.js).`
113
+ );
114
+ } else if (total < FLAT_ERROR_BASELINE) {
115
+ warnings.push(
116
+ `${total} flat-error sites — BELOW the ${FLAT_ERROR_BASELINE} baseline. The sweep tightened; lower FLAT_ERROR_BASELINE to ${total} in fitness.js so the ratchet can't slip back (BV1 R04 / task 1991).`
117
+ );
118
+ }
119
+ return {
120
+ name: 'no route hand-rolls a flat error (ADR 0116 / R03)',
121
+ ok: violations.length === 0,
122
+ hardFail: violations.length > 0,
123
+ violations,
124
+ warnings,
125
+ note: `≤ ${FLAT_ERROR_BASELINE} flat \`.json({error:'…'})\` sites (ratchet → 0 as the R04 sweep lands); currently ${total}. New failures must use res.fail()/ApiError (the R02 envelope).`,
126
+ };
127
+ }
128
+
129
+ module.exports = {
130
+ checkErrorEnvelope,
131
+ scanFlatErrorSites,
132
+ countFlatErrors,
133
+ FLAT_ERROR_BASELINE,
134
+ };
@@ -0,0 +1,132 @@
1
+ // scripts/gds/fitness-checks-packaging.js — Checks 16 + 16b: core code must not
2
+ // HARD-require a boundary-EXCLUDED config or module.
3
+ //
4
+ // Carved out of scripts/gds/fitness.js by task 1003825, on the task-1003192
5
+ // precedent. That file had returned to the 1,500-line ratchet with ONE line of
6
+ // headroom (1,499 of 1,500, fitness-ratchets.js OVERSIZE_LINES), so the next check
7
+ // registered there would have tipped it over and redded the ratchet for whoever
8
+ // wrote it. These two checks left together because they are one subject — the same
9
+ // question asked of a config and of a module — and because the pair depends only on
10
+ // CORE_ROOT / rel / walkJs / walkJsAllRoots, all already public on fitness-lib.js,
11
+ // so the carve adds no import cycle.
12
+ //
13
+ // fitness.js requires both names straight back, so the CHECKS registry and the
14
+ // module surface the tests read are unchanged.
15
+
16
+ 'use strict';
17
+
18
+ const fs = require('node:fs');
19
+ const path = require('node:path');
20
+
21
+ const { CORE_ROOT, rel, walkJs, walkJsAllRoots } = require('./fitness-lib.js');
22
+ // ============================================================================
23
+ // Check 16 — core code must not HARD-require an instance-excluded config/*.json
24
+ // ============================================================================
25
+ //
26
+ // A vanilla self-host boot ships only the PUBLISHABLE tree (publish-manifest.js). An
27
+ // instance-only config (config/devbox-app.json, config/bongos-cli.json, config/branding.json,
28
+ // config/modules.json, …) is boundary-EXCLUDED, so a bare `require('../../config/<excluded>.json')`
29
+ // in core code throws MODULE_NOT_FOUND and CRASHES the boot on a fresh instance — the exact
30
+ // class that shipped in R88 (two hard-requires in src/bongos/serve-internal.js; task 1824). The
31
+ // cure is to READ the config instead of requiring it, tolerating a missing file
32
+ // (tryLoadInstanceConfig in src/bongos/serve-internal.js / fs.existsSync → fail-to-vanilla), which
33
+ // also drops the require() literal so it no longer matches here. Check 1 (core↔host) scans
34
+ // code→code IMPORTS and misses this code→config class. Only an EXCLUDED config is a violation;
35
+ // a publishable neutral.*/schema config (which ships with the core) is fine.
36
+ const CONFIG_REQUIRE_RE = /require\(\s*['"]([^'"]*config\/[^'"]*\.json)['"]\s*\)/g;
37
+
38
+ function checkCoreRequiresExcludedConfig() {
39
+ const { isPublishable } = require('./publish-manifest');
40
+ const violations = [];
41
+ // src/ + scripts/ are CORE; modules ship from BOTH roots (ADR 0108 §Consequences #4). Dedup by
42
+ // repo-relative path so a shared checkout scans each file once.
43
+ const seen = new Set();
44
+ const allAbs = [
45
+ ...['src', 'scripts'].flatMap((d) => walkJs(path.join(CORE_ROOT, d))),
46
+ ...walkJsAllRoots('modules'),
47
+ ];
48
+ for (const abs of allAbs) {
49
+ const relFile = rel(abs);
50
+ if (seen.has(relFile)) continue;
51
+ seen.add(relFile);
52
+ if (!isPublishable(relFile)) continue; // only CORE (publishable) code can crash a vanilla boot
53
+ for (const raw of fs.readFileSync(abs, 'utf8').split('\n')) {
54
+ const trimmed = raw.trim();
55
+ if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) continue;
56
+ let m;
57
+ CONFIG_REQUIRE_RE.lastIndex = 0;
58
+ while ((m = CONFIG_REQUIRE_RE.exec(raw))) {
59
+ const relCfg = rel(path.resolve(path.dirname(abs), m[1]));
60
+ if (!isPublishable(relCfg)) {
61
+ violations.push(`${relFile} → require('${m[1]}') hard-requires an instance-excluded config (${relCfg}) — guard the read (tryLoadInstanceConfig in src/bongos/serve-internal.js / fs.existsSync, fail-to-vanilla) so a fresh core boots (task 1824).`);
62
+ }
63
+ }
64
+ }
65
+ }
66
+ return {
67
+ name: 'core never hard-requires an excluded config (task 1824)',
68
+ ok: violations.length === 0,
69
+ hardFail: violations.length > 0,
70
+ violations,
71
+ warnings: [],
72
+ note: "a publishable src/·scripts/·modules/ .js must not require('…config/X.json') when X.json is boundary-excluded — guard the read so a vanilla core boots.",
73
+ };
74
+ }
75
+
76
+ // ============================================================================
77
+ // Check 16b — core code must not require an instance-EXCLUDED module (task 2135)
78
+ // ============================================================================
79
+ //
80
+ // Sibling of Check 16 for the code→MODULE class. A publishable core file that
81
+ // require()s a boundary-EXCLUDED module (modules/game, modules/art-pipeline,
82
+ // modules/character-anim, …) throws MODULE_NOT_FOUND once that module lives in the
83
+ // consumer's instance repo rather than the core package (R89 split). The cure is
84
+ // either to reclassify the module INTO core (if it belongs there — e.g. provisioning,
85
+ // sibling of dev-box) or to exclude the requiring file with the feature it serves
86
+ // (art tooling). Catches BOTH require forms: a direct literal
87
+ // require('../modules/<key>/…') AND a require(path.join(<root>, 'modules/<key>/…')).
88
+ // Only an EXCLUDED module required from a PUBLISHABLE file is a violation; requiring a
89
+ // publishable (core) module is fine, and the loader's DYNAMIC dispatch
90
+ // (path.join(dir,'routes',rk) — no literal modules/<key>/ string) is invisible here.
91
+ const MODULE_PATH_IN_STRING_RE = /['"](?:[^'"]*\/)?modules\/([a-z0-9][a-z0-9-]*)\/[^'"]*['"]/g;
92
+
93
+ function checkCoreRequiresExcludedModule() {
94
+ const { isPublishable } = require('./publish-manifest');
95
+ const violations = [];
96
+ const seen = new Set();
97
+ const allAbs = [
98
+ ...['src', 'scripts'].flatMap((d) => walkJs(path.join(CORE_ROOT, d))),
99
+ ...walkJsAllRoots('modules'),
100
+ ];
101
+ for (const abs of allAbs) {
102
+ const relFile = rel(abs);
103
+ if (seen.has(relFile)) continue;
104
+ seen.add(relFile);
105
+ if (!isPublishable(relFile)) continue; // only CORE (publishable) code can crash a vanilla boot
106
+ for (const raw of fs.readFileSync(abs, 'utf8').split('\n')) {
107
+ const trimmed = raw.trim();
108
+ if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) continue;
109
+ if (!/require\s*\(/.test(raw)) continue; // only lines that actually require()
110
+ let m;
111
+ MODULE_PATH_IN_STRING_RE.lastIndex = 0;
112
+ while ((m = MODULE_PATH_IN_STRING_RE.exec(raw))) {
113
+ const key = m[1];
114
+ if (isPublishable(`modules/${key}/module.json`)) continue; // requiring a CORE module is fine
115
+ violations.push(`${relFile} → require(...'modules/${key}/…') pulls in an instance-EXCLUDED module — reclassify it into core (if it belongs there) or exclude ${relFile} with the feature it serves, so a fresh core boots (task 2135).`);
116
+ }
117
+ }
118
+ }
119
+ return {
120
+ name: 'core never requires an excluded module (task 2135)',
121
+ ok: violations.length === 0,
122
+ hardFail: violations.length > 0,
123
+ violations,
124
+ warnings: [],
125
+ note: "a publishable src/·scripts/·modules/ .js must not require('…modules/<key>/…') when that module is boundary-excluded — MODULE_NOT_FOUND post-split (R89).",
126
+ };
127
+ }
128
+
129
+ module.exports = {
130
+ checkCoreRequiresExcludedConfig,
131
+ checkCoreRequiresExcludedModule,
132
+ };
@@ -37,6 +37,8 @@ const { CORE_ROOT, INSTANCE_ROOT, MODULE_ROOTS, REPO_ROOT, rel, walkJs, walkJsAl
37
37
  const { IDENTITY_SCAN_ALLOWED, IDENTITY_SCAN_EXCLUDE, IDENTITY_SCAN_EXTRA_FILES, IDENTITY_SCAN_EXTS, IDENTITY_SCAN_ROOTS, IDENTITY_SCAN_SKIP_DIRS, PROMPT_IDENTITY_LITERALS, checkDesignContract, checkKernelPromptsNoHardcodedIdentity, checkNeutralConfigsNoHostIdentity, collectIdentityFiles, scanPromptIdentity, stripCommentsForIdentity } = require('./fitness-checks-identity.js');
38
38
  const { WRITE_VALIDATION_BASELINE, WRITE_VALIDATION_EXEMPT, checkWriteRoutesValidated, scanUnvalidatedWriteRoutes } = require('./fitness-checks-write-validation.js');
39
39
  const { GOAL_VOCAB_CITATION, GOAL_VOCAB_LINE_SCOPED, GOAL_VOCAB_SURFACE, LIFECYCLE_DB_FILES, PRELAUNCH_SURFACE, RENAME_IDENTITY_RE, VOCAB_ALLOW, VOCAB_BANNED, VOCAB_CITATION, VOCAB_EXTS, VOCAB_HISTORICAL_REF, VOCAB_SURFACE, checkGoalMembershipVocabulary, checkGovernmentRenameIdentity, checkPrelaunchVocabulary, checkVocabularyWall, vocabFilesToScan } = require('./fitness-checks-vocabulary.js');
40
+ const { checkCoreRequiresExcludedConfig, checkCoreRequiresExcludedModule } = require('./fitness-checks-packaging.js'); // Checks 16 + 16b — carved out by task 1003825 (the 1,500-line ratchet)
41
+ const { FLAT_ERROR_BASELINE, checkErrorEnvelope, countFlatErrors, scanFlatErrorSites } = require('./fitness-checks-error-envelope.js'); // Check 17 — carved out by task 1003825
40
42
  // ============================================================================
41
43
  // Check 1 — core ↔ host/game import boundary (ADR 0062 §1 cut-line)
42
44
  // ============================================================================
@@ -1084,219 +1086,6 @@ function checkHallSectionRegistry() {
1084
1086
  };
1085
1087
  }
1086
1088
 
1087
- // ============================================================================
1088
- // Check 16 — core code must not HARD-require an instance-excluded config/*.json
1089
- // ============================================================================
1090
- //
1091
- // A vanilla self-host boot ships only the PUBLISHABLE tree (publish-manifest.js). An
1092
- // instance-only config (config/devbox-app.json, config/bongos-cli.json, config/branding.json,
1093
- // config/modules.json, …) is boundary-EXCLUDED, so a bare `require('../../config/<excluded>.json')`
1094
- // in core code throws MODULE_NOT_FOUND and CRASHES the boot on a fresh instance — the exact
1095
- // class that shipped in R88 (two hard-requires in src/bongos/serve-internal.js; task 1824). The
1096
- // cure is to READ the config instead of requiring it, tolerating a missing file
1097
- // (tryLoadInstanceConfig in src/bongos/serve-internal.js / fs.existsSync → fail-to-vanilla), which
1098
- // also drops the require() literal so it no longer matches here. Check 1 (core↔host) scans
1099
- // code→code IMPORTS and misses this code→config class. Only an EXCLUDED config is a violation;
1100
- // a publishable neutral.*/schema config (which ships with the core) is fine.
1101
- const CONFIG_REQUIRE_RE = /require\(\s*['"]([^'"]*config\/[^'"]*\.json)['"]\s*\)/g;
1102
-
1103
- function checkCoreRequiresExcludedConfig() {
1104
- const { isPublishable } = require('./publish-manifest');
1105
- const violations = [];
1106
- // src/ + scripts/ are CORE; modules ship from BOTH roots (ADR 0108 §Consequences #4). Dedup by
1107
- // repo-relative path so a shared checkout scans each file once.
1108
- const seen = new Set();
1109
- const allAbs = [
1110
- ...['src', 'scripts'].flatMap((d) => walkJs(path.join(CORE_ROOT, d))),
1111
- ...walkJsAllRoots('modules'),
1112
- ];
1113
- for (const abs of allAbs) {
1114
- const relFile = rel(abs);
1115
- if (seen.has(relFile)) continue;
1116
- seen.add(relFile);
1117
- if (!isPublishable(relFile)) continue; // only CORE (publishable) code can crash a vanilla boot
1118
- for (const raw of fs.readFileSync(abs, 'utf8').split('\n')) {
1119
- const trimmed = raw.trim();
1120
- if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) continue;
1121
- let m;
1122
- CONFIG_REQUIRE_RE.lastIndex = 0;
1123
- while ((m = CONFIG_REQUIRE_RE.exec(raw))) {
1124
- const relCfg = rel(path.resolve(path.dirname(abs), m[1]));
1125
- if (!isPublishable(relCfg)) {
1126
- violations.push(`${relFile} → require('${m[1]}') hard-requires an instance-excluded config (${relCfg}) — guard the read (tryLoadInstanceConfig in src/bongos/serve-internal.js / fs.existsSync, fail-to-vanilla) so a fresh core boots (task 1824).`);
1127
- }
1128
- }
1129
- }
1130
- }
1131
- return {
1132
- name: 'core never hard-requires an excluded config (task 1824)',
1133
- ok: violations.length === 0,
1134
- hardFail: violations.length > 0,
1135
- violations,
1136
- warnings: [],
1137
- note: "a publishable src/·scripts/·modules/ .js must not require('…config/X.json') when X.json is boundary-excluded — guard the read so a vanilla core boots.",
1138
- };
1139
- }
1140
-
1141
- // ============================================================================
1142
- // Check 16b — core code must not require an instance-EXCLUDED module (task 2135)
1143
- // ============================================================================
1144
- //
1145
- // Sibling of Check 16 for the code→MODULE class. A publishable core file that
1146
- // require()s a boundary-EXCLUDED module (modules/game, modules/art-pipeline,
1147
- // modules/character-anim, …) throws MODULE_NOT_FOUND once that module lives in the
1148
- // consumer's instance repo rather than the core package (R89 split). The cure is
1149
- // either to reclassify the module INTO core (if it belongs there — e.g. provisioning,
1150
- // sibling of dev-box) or to exclude the requiring file with the feature it serves
1151
- // (art tooling). Catches BOTH require forms: a direct literal
1152
- // require('../modules/<key>/…') AND a require(path.join(<root>, 'modules/<key>/…')).
1153
- // Only an EXCLUDED module required from a PUBLISHABLE file is a violation; requiring a
1154
- // publishable (core) module is fine, and the loader's DYNAMIC dispatch
1155
- // (path.join(dir,'routes',rk) — no literal modules/<key>/ string) is invisible here.
1156
- const MODULE_PATH_IN_STRING_RE = /['"](?:[^'"]*\/)?modules\/([a-z0-9][a-z0-9-]*)\/[^'"]*['"]/g;
1157
-
1158
- function checkCoreRequiresExcludedModule() {
1159
- const { isPublishable } = require('./publish-manifest');
1160
- const violations = [];
1161
- const seen = new Set();
1162
- const allAbs = [
1163
- ...['src', 'scripts'].flatMap((d) => walkJs(path.join(CORE_ROOT, d))),
1164
- ...walkJsAllRoots('modules'),
1165
- ];
1166
- for (const abs of allAbs) {
1167
- const relFile = rel(abs);
1168
- if (seen.has(relFile)) continue;
1169
- seen.add(relFile);
1170
- if (!isPublishable(relFile)) continue; // only CORE (publishable) code can crash a vanilla boot
1171
- for (const raw of fs.readFileSync(abs, 'utf8').split('\n')) {
1172
- const trimmed = raw.trim();
1173
- if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) continue;
1174
- if (!/require\s*\(/.test(raw)) continue; // only lines that actually require()
1175
- let m;
1176
- MODULE_PATH_IN_STRING_RE.lastIndex = 0;
1177
- while ((m = MODULE_PATH_IN_STRING_RE.exec(raw))) {
1178
- const key = m[1];
1179
- if (isPublishable(`modules/${key}/module.json`)) continue; // requiring a CORE module is fine
1180
- violations.push(`${relFile} → require(...'modules/${key}/…') pulls in an instance-EXCLUDED module — reclassify it into core (if it belongs there) or exclude ${relFile} with the feature it serves, so a fresh core boots (task 2135).`);
1181
- }
1182
- }
1183
- }
1184
- return {
1185
- name: 'core never requires an excluded module (task 2135)',
1186
- ok: violations.length === 0,
1187
- hardFail: violations.length > 0,
1188
- violations,
1189
- warnings: [],
1190
- note: "a publishable src/·scripts/·modules/ .js must not require('…modules/<key>/…') when that module is boundary-excluded — MODULE_NOT_FOUND post-split (R89).",
1191
- };
1192
- }
1193
-
1194
- // ============================================================================
1195
- // Check 17 — no route hand-rolls a FLAT error (BV1 R03 / task 1990 / ADR 0116)
1196
- // ============================================================================
1197
- //
1198
- // The error-envelope contract (ADR 0116) says every failure is the ONE envelope
1199
- // `{error:{code,message,details}}`, emitted via res.fail() or a thrown ApiError
1200
- // (the R02 middleware, task 1989). A hand-rolled flat error —
1201
- // `res.status(n).json({ error: 'x' })` or `.json({ error })` — breaks the
1202
- // contract: the caller gets a bare string where the envelope should be.
1203
- //
1204
- // RATCHET, not big-bang. The R04 sweep (task 1991) migrates the ~562 existing
1205
- // sites; until it finishes they'd all "fail" a strict check, so this HOLDS THE
1206
- // LINE: it fails only when the count rises ABOVE the committed baseline (a NEW
1207
- // hand-rolled flat error), and nudges the baseline DOWN as the sweep shrinks it.
1208
- // When the sweep reaches 0, FLAT_ERROR_BASELINE is 0 and any new flat error
1209
- // hard-fails immediately. Detector is shared with the sweep + its test.
1210
- const FLAT_ERROR_BASELINE = 0; // R04 sweep (task 1991) landed the surface at 0 — the gate is now hard-zero: any new hand-rolled flat error fails the build immediately.
1211
- // A flat error is a `.json({ … })` whose FIRST key is `error` with a value that
1212
- // is NOT an object literal (a string/var/shorthand). The envelope's nested
1213
- // `error: { … }` and `.json(envelope(…))` deliberately do NOT match.
1214
- // The `(?!\s*\{)` lookahead is whitespace-aware: without the inner `\s*` the
1215
- // preceding `\s*` backtracks to zero and the lookahead is tested at the space in
1216
- // `error: {`, so the compliant nested envelope was wrongly flagged (task 2029).
1217
- const FLAT_ERROR_VALUE_RE = /\.json\(\s*\{\s*error\s*:\s*(?!\s*\{)/g; // error: 'x' | error: code
1218
- const FLAT_ERROR_SHORTHAND_RE = /\.json\(\s*\{\s*error\s*[,}]/g; // { error } | { error, … }
1219
-
1220
- // Strip // line comments + /* */ block comments AND blank out string/template
1221
- // contents (keeping the delimiters) so neither a DOC example of the anti-pattern
1222
- // — e.g. the res.fail() usage note in middleware/error-envelope.js — nor a
1223
- // pattern quoted inside a string literal is counted as a real flat error once
1224
- // the gate is hard-zero (task 1991). Real flat errors are unaffected: in
1225
- // `res.status(404).json({ error: 'x' })` the `.json({ error:` the detector keys
1226
- // on is CODE (outside the string), so blanking the `'x'` content still matches.
1227
- function stripCommentsForFlat(src) {
1228
- let out = '';
1229
- let i = 0;
1230
- let quote = null;
1231
- while (i < src.length) {
1232
- const c = src[i];
1233
- const d = src[i + 1];
1234
- if (quote) {
1235
- // inside a string/template: drop content, keep only the closing delimiter
1236
- if (c === '\\') { i += 2; continue; }
1237
- if (c === quote) { out += c; quote = null; }
1238
- i++; continue;
1239
- }
1240
- if (c === '"' || c === "'" || c === '`') { quote = c; out += c; i++; continue; }
1241
- if (c === '/' && d === '/') { while (i < src.length && src[i] !== '\n') i++; continue; }
1242
- if (c === '/' && d === '*') { i += 2; while (i < src.length && !(src[i] === '*' && src[i + 1] === '/')) i++; i += 2; continue; }
1243
- out += c; i++;
1244
- }
1245
- return out;
1246
- }
1247
-
1248
- function countFlatErrors(text) {
1249
- const code = stripCommentsForFlat(text);
1250
- return (code.match(FLAT_ERROR_VALUE_RE) || []).length
1251
- + (code.match(FLAT_ERROR_SHORTHAND_RE) || []).length;
1252
- }
1253
-
1254
- // scanFlatErrorSites() — per-file flat-error counts across the API surface
1255
- // (core src/bongos + every module), skipping tests. Shared by the check below, the
1256
- // R04 codemod, and tests/api_error_envelope_buildcheck.mjs.
1257
- function scanFlatErrorSites() {
1258
- const seen = new Set();
1259
- const byFile = {};
1260
- let total = 0;
1261
- const files = [
1262
- ...walkJs(path.join(CORE_ROOT, 'src', 'bongos')),
1263
- ...walkJsAllRoots('modules'),
1264
- ];
1265
- for (const abs of files) {
1266
- const relFile = rel(abs);
1267
- if (seen.has(relFile)) continue;
1268
- seen.add(relFile);
1269
- if (relFile.includes('/tests/') || relFile.endsWith('.test.js')) continue;
1270
- const n = countFlatErrors(fs.readFileSync(abs, 'utf8'));
1271
- if (n) { byFile[relFile] = n; total += n; }
1272
- }
1273
- return { total, byFile };
1274
- }
1275
-
1276
- function checkErrorEnvelope() {
1277
- const { total, byFile } = scanFlatErrorSites();
1278
- const violations = [];
1279
- const warnings = [];
1280
- if (total > FLAT_ERROR_BASELINE) {
1281
- const top = Object.entries(byFile).sort((a, b) => b[1] - a[1]).slice(0, 8)
1282
- .map(([f, n]) => `${f} (${n})`).join(', ');
1283
- violations.push(
1284
- `${total} hand-rolled flat-error sites — ABOVE the ${FLAT_ERROR_BASELINE} baseline. A new route emitted a flat \`res.status(n).json({error:'x'})\` instead of \`res.fail('x', n)\` / \`throw new ApiError('x')\` (ADR 0116). Top files: ${top}. Use the envelope helper (src/bongos/middleware/error-envelope.js).`
1285
- );
1286
- } else if (total < FLAT_ERROR_BASELINE) {
1287
- warnings.push(
1288
- `${total} flat-error sites — BELOW the ${FLAT_ERROR_BASELINE} baseline. The sweep tightened; lower FLAT_ERROR_BASELINE to ${total} in fitness.js so the ratchet can't slip back (BV1 R04 / task 1991).`
1289
- );
1290
- }
1291
- return {
1292
- name: 'no route hand-rolls a flat error (ADR 0116 / R03)',
1293
- ok: violations.length === 0,
1294
- hardFail: violations.length > 0,
1295
- violations,
1296
- warnings,
1297
- note: `≤ ${FLAT_ERROR_BASELINE} flat \`.json({error:'…'})\` sites (ratchet → 0 as the R04 sweep lands); currently ${total}. New failures must use res.fail()/ApiError (the R02 envelope).`,
1298
- };
1299
- }
1300
1089
 
1301
1090
  // ============================================================================
1302
1091
  // Check 2c — every SKILL.md frontmatter parses the way Claude Code's YAML reader reads it (task 1003548)
@@ -826,6 +826,20 @@ function mountInternalSurfaces(app) {
826
826
  app.use((req, res, next) => {
827
827
  if (!isBuildersHost(req)) return next();
828
828
  if (req.path.startsWith('/api/') || req.path === '/healthz') return next();
829
+ // Module browser assets are addressed at the ROOT on every host — the URL the
830
+ // loader's hallWidgetScripts projection emits is an absolute /modules/<key>/…
831
+ // and injectModuleHallWidgets puts that same tag in the hall index on BOTH
832
+ // hosts (task 1002848). Without this line the shim rewrites it to
833
+ // /builders/modules/… before the MODULE_ASSET_RE handler below can match, so
834
+ // the tag is injected on the subdomain and the asset 404s there — injection
835
+ // works on both hosts, serving works only on the apex. The handler keeps all
836
+ // its own defences (the isModuleEnabled gate, the traversal check); this only
837
+ // lets the request reach it.
838
+ //
839
+ // The general hazard: ANY apex-rooted route registered BELOW this shim is
840
+ // eaten the same way. Today that is this one and the /docs family (idea 529
841
+ // → task 1002861); /downloads/* dodges it by being registered ABOVE instead.
842
+ if (req.path.startsWith('/modules/')) return next();
829
843
  if (req.path === '/builders' || req.path.startsWith('/builders/')) return next();
830
844
  // Prefix with /builders so the handlers below match. req.url carries the
831
845
  // query string; preserve it. '/' → '/builders/', '/watch' → '/builders/watch'.
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.754'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.756'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,155 @@
1
+ // tests/module_asset_subdomain.mjs — module browser assets must route identically
2
+ // on the apex and on builders.<apex> (task 1002848).
3
+ //
4
+ // THE BUG. A module-contributed hall widget's <script src="/modules/<key>/public/
5
+ // hall-widget.js"> is injected into the hall index on BOTH hosts by
6
+ // injectModuleHallWidgets, but only SERVED on the apex. The builders-subdomain
7
+ // rewrite shim in serve-internal.js runs as app.use middleware, ahead of the
8
+ // MODULE_ASSET_RE route, and rewrites every path on that host onto the /builders/*
9
+ // tree — exempting only /api/, /healthz and paths already under /builders. So
10
+ // /modules/demo/public/hall-widget.js became /builders/modules/... , stopped
11
+ // matching ^/modules/, and fell through to a 404.
12
+ //
13
+ // WHY THIS TEST HAS TO EXIST. The seam is a live no-op today: no module ships a
14
+ // hall-widget.js, so nothing on either host currently requests one, and a
15
+ // regression here would be completely invisible until the first module that does
16
+ // ship one — at which point it would look like a bug in that module. The test
17
+ // therefore drives a module asset that DOES exist (hall-ui's own public/ dir) and
18
+ // asserts the two hosts agree, which is the property the fix is really about.
19
+ //
20
+ // serve-internal is DB-free (the pg pool connects lazily), so we mount it on a
21
+ // throwaway express app and hit it over loopback — same pattern as
22
+ // tests/docs_openapi_route.mjs.
23
+ //
24
+ // USE node:http, NOT fetch. `Host` is a forbidden header name in the fetch spec,
25
+ // so `fetch(url, { headers: { host } })` SILENTLY drops the override and sends the
26
+ // real one (127.0.0.1:<port>). A host-routing test written with fetch therefore
27
+ // compares the apex against itself, passes, and keeps passing with the fix
28
+ // reverted — which is exactly what the first draft of this file did until the
29
+ // mutation probe caught it. node:http honours an explicit Host in options.headers.
30
+ //
31
+ // Run: node --test tests/module_asset_subdomain.mjs
32
+
33
+ import { test } from 'node:test';
34
+ import assert from 'node:assert/strict';
35
+ import { createRequire } from 'node:module';
36
+ import http from 'node:http';
37
+
38
+ const require = createRequire(import.meta.url);
39
+
40
+ // Any host starting with "builders." is the subdomain; anything else is the apex.
41
+ const APEX = 'example.com';
42
+ const SUBDOMAIN = 'builders.example.com';
43
+
44
+ // A file the hall-ui module really ships, so a 200 proves the request reached the
45
+ // MODULE_ASSET_RE handler AND the handler found bytes — not merely that it matched.
46
+ const REAL_ASSET = '/modules/hall-ui/public/atlas.css';
47
+
48
+ async function withServer(fn) {
49
+ const express = require('express');
50
+ const { mountInternalSurfaces } = require('../src/bongos/serve-internal.js');
51
+ const app = express();
52
+ mountInternalSurfaces(app);
53
+ const server = await new Promise((resolve) => {
54
+ const s = app.listen(0, () => resolve(s));
55
+ });
56
+ try {
57
+ await fn(server.address().port);
58
+ } finally {
59
+ await new Promise((r) => server.close(r));
60
+ }
61
+ }
62
+
63
+ // One loopback request with a REAL Host header. Never follows redirects — a 301 is
64
+ // itself a routing decision we want to see.
65
+ function get(port, path, host) {
66
+ return new Promise((resolve, reject) => {
67
+ const req = http.request({ host: '127.0.0.1', port, path, headers: { Host: host } }, (res) => {
68
+ let body = '';
69
+ res.setEncoding('utf8');
70
+ res.on('data', (c) => { body += c; });
71
+ res.on('end', () => resolve({ status: res.statusCode, body, headers: res.headers }));
72
+ });
73
+ req.on('error', reject);
74
+ req.end();
75
+ });
76
+ }
77
+
78
+ // The harness is only meaningful if the Host header actually reaches the app, and
79
+ // the fetch trap above proves that is not self-evident. Assert it directly, so a
80
+ // future change that breaks host propagation fails HERE with a clear reason rather
81
+ // than turning every test below into a silent apex-vs-apex tautology.
82
+ test('the harness really varies the Host — without this the rest is vacuous', async () => {
83
+ await withServer(async (port) => {
84
+ const { isBuildersHost } = require('../src/bongos/serve-internal.js');
85
+ assert.equal(isBuildersHost({ headers: { host: SUBDOMAIN } }), true);
86
+ assert.equal(isBuildersHost({ headers: { host: APEX } }), false);
87
+ // and end-to-end: both hosts 301 /builders/, but to DIFFERENT places — the apex
88
+ // sends you to the subdomain, the subdomain strips the redundant prefix. Equal
89
+ // targets would mean the Host never reached the app.
90
+ const sub = await get(port, '/builders/', SUBDOMAIN);
91
+ const apex = await get(port, '/builders/', APEX);
92
+ assert.notEqual(sub.headers.location, apex.headers.location,
93
+ 'the two hosts must route differently, or the Host header is being dropped');
94
+ });
95
+ });
96
+
97
+ test('a real module asset serves on the apex AND on builders.<apex>', async () => {
98
+ await withServer(async (port) => {
99
+ const apex = await get(port, REAL_ASSET, APEX);
100
+ const sub = await get(port, REAL_ASSET, SUBDOMAIN);
101
+ assert.equal(apex.status, 200, 'precondition: the asset serves on the apex');
102
+ assert.equal(sub.status, 200,
103
+ 'the subdomain must serve it too — the rewrite shim used to turn this into /builders/modules/... and 404');
104
+ });
105
+ });
106
+
107
+ test('the two hosts return the same body, not just the same status', async () => {
108
+ await withServer(async (port) => {
109
+ const apex = await get(port, REAL_ASSET, APEX);
110
+ const sub = await get(port, REAL_ASSET, SUBDOMAIN);
111
+ assert.ok(apex.body.length > 0, 'precondition: the asset is not empty');
112
+ assert.equal(sub.body, apex.body, 'the subdomain must serve the same bytes, not a rewritten page');
113
+ });
114
+ });
115
+
116
+ test('an unknown module 404s on BOTH hosts — the exemption grants no new reach', async () => {
117
+ await withServer(async (port) => {
118
+ const p = '/modules/no-such-module/public/x.css';
119
+ assert.equal((await get(port, p, APEX)).status, 404);
120
+ assert.equal((await get(port, p, SUBDOMAIN)).status, 404,
121
+ 'the shim exemption must not turn an unknown module into anything but a 404');
122
+ });
123
+ });
124
+
125
+ test('a missing file inside a REAL module 404s on both hosts', async () => {
126
+ await withServer(async (port) => {
127
+ const p = '/modules/hall-ui/public/definitely-not-here.css';
128
+ assert.equal((await get(port, p, APEX)).status, 404);
129
+ assert.equal((await get(port, p, SUBDOMAIN)).status, 404);
130
+ });
131
+ });
132
+
133
+ test('path traversal out of the module dir is refused on the subdomain too', async () => {
134
+ await withServer(async (port) => {
135
+ // The handler normalizes and rejects a rel path escaping modules/<key>/public.
136
+ // Worth pinning on the SUBDOMAIN specifically: that request now reaches the
137
+ // handler where it previously never did, so this is newly-exercised ground.
138
+ for (const p of ['/modules/hall-ui/public/../../../package.json',
139
+ '/modules/hall-ui/public/..%2f..%2f..%2fpackage.json']) {
140
+ const r = await get(port, p, SUBDOMAIN);
141
+ assert.notEqual(r.status, 200, `traversal must not serve a file outside the module dir (${p})`);
142
+ assert.ok(!/"name":\s*"@bongos/.test(r.body), 'package.json bytes must never appear in the body');
143
+ }
144
+ });
145
+ });
146
+
147
+ test('the exemption is scoped to /modules/ — a lookalike path is still rewritten', async () => {
148
+ await withServer(async (port) => {
149
+ // If the exemption had been written as a bare `includes('modules')` or without
150
+ // the trailing slash, this path would escape the shim too.
151
+ const r = await get(port, '/moduleslike', SUBDOMAIN);
152
+ assert.notEqual(r.status, 200,
153
+ 'a path merely starting with "modules" but outside /modules/ is not a module asset');
154
+ });
155
+ });