@bongos/core 1.19.644 → 1.19.645

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.bongos-core.json CHANGED
@@ -2,11 +2,11 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.644",
6
- "core_contract": "1.19.644",
7
- "source_commit": "cdc649c80e371715361cb64f295771663d028801",
5
+ "core_version": "1.19.645",
6
+ "core_contract": "1.19.645",
7
+ "source_commit": "14b1d4c19840d818f49d383ed7c50189913860ac",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-10T17:41:14.263Z",
9
+ "built_at": "2026-09-10T18:02:00.629Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 470,
@@ -17,7 +17,7 @@
17
17
  "gate": "passed"
18
18
  },
19
19
  "file_count": 2600,
20
- "tree_sha256": "8c4305075a816844d4abcbe56b413fa30c26237b066800b86dc6c4b348bef90b",
20
+ "tree_sha256": "39c1aaa7c37d7537a2b25d18b8de6e6096cef248e1549d529398fe0d2282e2c2",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/backlog-review/SKILL.md",
@@ -2772,7 +2772,7 @@
2772
2772
  {
2773
2773
  "path": "docs/module-api-changelog.md",
2774
2774
  "mode": "0000644",
2775
- "sha256": "013f4da6dd306f5c4107b1527c38e9a422511b7b81e1ac7b33a2239c3e14c302"
2775
+ "sha256": "7decbd6be1709b8c7fe6173a659d2caa8594bb766d353bece00c88a1e316f57e"
2776
2776
  },
2777
2777
  {
2778
2778
  "path": "docs/modules-contract.md",
@@ -5547,12 +5547,12 @@
5547
5547
  {
5548
5548
  "path": "modules/ideas/routes/inbox.js",
5549
5549
  "mode": "0000644",
5550
- "sha256": "5e7bdb2c09e1e62aacb4b40843392e2c1ca1534a69191c00c3103a257f086c31"
5550
+ "sha256": "b3df5f0d4273f44af60a7e09a8bef026a0f02085c7823c7912c70d067e55e127"
5551
5551
  },
5552
5552
  {
5553
5553
  "path": "modules/ideas/routing.js",
5554
5554
  "mode": "0000644",
5555
- "sha256": "ddedb76f43edbe3e7c918578fda94b97cb3822d97074af33393791fb1ec1cbef"
5555
+ "sha256": "da653f475d6c91db8e84bff46c797bb68b61e48d561eacf7bfd228a119ac7266"
5556
5556
  },
5557
5557
  {
5558
5558
  "path": "modules/ideas/templates.js",
@@ -7722,12 +7722,12 @@
7722
7722
  {
7723
7723
  "path": "package-lock.json",
7724
7724
  "mode": "0000644",
7725
- "sha256": "f96527ae9191d470397057ba5d9df620b485cfac53854b04c6b4a9f4b2dd0073"
7725
+ "sha256": "de884c55a00c4f37989b00d9c574fdcde904f617941dc65ee21ece636efd63c8"
7726
7726
  },
7727
7727
  {
7728
7728
  "path": "package.json",
7729
7729
  "mode": "0000644",
7730
- "sha256": "11cc56ca7e914c5f0956f4c65d277cf92ca8098e62d987a907a43033886570ae"
7730
+ "sha256": "a2949e78ffe45c9e0442261dd19892266aa1930607dfaa309ce10754b119921e"
7731
7731
  },
7732
7732
  {
7733
7733
  "path": "public-docs/index.html",
@@ -9217,7 +9217,7 @@
9217
9217
  {
9218
9218
  "path": "src/bongos/auth.js",
9219
9219
  "mode": "0000644",
9220
- "sha256": "8ee4e492d700f44d80c3e2deead51cc468f4c62fe3e473497f7b003a7aec8480"
9220
+ "sha256": "88e734e42d8fd15ba24d75cafcc611de90e16362200e198e9a9bc4ee825bfbaf"
9221
9221
  },
9222
9222
  {
9223
9223
  "path": "src/bongos/cascade-dispatch.js",
@@ -9482,7 +9482,7 @@
9482
9482
  {
9483
9483
  "path": "src/module-api.js",
9484
9484
  "mode": "0000644",
9485
- "sha256": "9784cea15d64b5a9b88797988bfd3e863c9a4d44b45126c77a9502d87ae5a2b0"
9485
+ "sha256": "e5d854562fcdec46abd9212cfdfc5c05bd1b29b43525d00abb9d6acb39dac48c"
9486
9486
  },
9487
9487
  {
9488
9488
  "path": "src/module-loader/catalog.js",
@@ -9742,7 +9742,7 @@
9742
9742
  {
9743
9743
  "path": "tests/auth_page_gate.mjs",
9744
9744
  "mode": "0000644",
9745
- "sha256": "3ed218eada5e36170217ded839fdc80d1cbad4ec71086d9707f00115d6c622d2"
9745
+ "sha256": "8011ca574306df12ae1e046e6a37ca9e779b29ad26d2bfc0508ae8d6b9f5a0b2"
9746
9746
  },
9747
9747
  {
9748
9748
  "path": "tests/auth_resolve_failure.mjs",
@@ -10502,7 +10502,7 @@
10502
10502
  {
10503
10503
  "path": "tests/filing_abuse_matrix.mjs",
10504
10504
  "mode": "0000644",
10505
- "sha256": "2a6de0e7411bb4796fe4f301af77c33f08a99307f2298ed83cd32c38cf059dc5"
10505
+ "sha256": "815f2fa2ffd6e7a721507852d43b0490115a0b15393670f6035626bdf000e99c"
10506
10506
  },
10507
10507
  {
10508
10508
  "path": "tests/first_admin_bootstrap.mjs",
@@ -10902,7 +10902,7 @@
10902
10902
  {
10903
10903
  "path": "tests/government_require_permission.mjs",
10904
10904
  "mode": "0000644",
10905
- "sha256": "eff61f5299bcaaf21e19dfa84eb4ff6b92d480f708568bc243e784953d1db40a"
10905
+ "sha256": "8ae3eaaf0b7d9a0e93d58eda3d877abe4f59bed25ffbcdc4e42a67497398debf"
10906
10906
  },
10907
10907
  {
10908
10908
  "path": "tests/government_resolver.mjs",
@@ -1737,5 +1737,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
1737
1737
  landed since 1.19.642 with no explicit bump. run 34509123612. (task 1002620)
1738
1738
  1.19.644 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1739
1739
  landed since 1.19.643 with no explicit bump. run 34509682128. (task 1002620)
1740
+ 1.19.645 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1741
+ landed since 1.19.644 with no explicit bump. run 34511775606. (task 1002620)
1740
1742
  ---------------------------------------------------------------------------
1741
1743
  ```
@@ -39,6 +39,9 @@ const inbox = require('../inbox');
39
39
  // and the deterministic scorer (R142) — both pure, both walk the declaration.
40
40
  const { validateFieldValues, templateFor, fieldOrder } = require('../templates');
41
41
  const { completenessScore, weakestSectionPayload, scoreFieldValues } = require('../completeness');
42
+ // The trusted tier, spelled once (task 1003197 (D)) — routing.js owns METIC_PLUS
43
+ // and already names 'criterion-proposal' in NEVER_ROUTE_KINDS.
44
+ const { mayProposeCriterion, METIC_PLUS } = require('../routing');
42
45
  // task 1003208: structured logging (pino via the doorway) — was console.*.
43
46
  const log = api.logger('ideas');
44
47
  const { validateOrRespond, LIMITS, parseId } = api;
@@ -93,9 +96,16 @@ module.exports = function buildInboxRouter() {
93
96
  })) return;
94
97
  const body = req.body || {};
95
98
  if (body.kind === 'criterion-proposal') {
96
- const rank = (req.builder?.rank || '').toLowerCase();
97
- if (rank && rank !== 'metic' && rank !== 'archon') {
98
- return res.fail('rank_forbidden', 403, { required: ['metic', 'archon'], actual: rank, reason: "kind='criterion-proposal' is a planning-session write; Metic+ only." });
99
+ // FAIL CLOSED (task 1003197 (D)). This was inline and read
100
+ // `if (rank && rank !== 'metic' && …)`, so an ABSENT rank ADMITTED. The
101
+ // predicate now sits with the one trusted-tier definition (routing.js), pure
102
+ // and denying by default. Still a rank test rather than
103
+ // requirePermission('idea.triage'): the gate is conditional on the request
104
+ // BODY, and requirePermission is route-level middleware that cannot be mounted
105
+ // per-payload — which is why that atom's catalog `guards` note names only
106
+ // PATCH /inbox/:id. Same audience either way; idea.triage floors at metic.
107
+ if (!mayProposeCriterion(req.builder?.rank)) {
108
+ return res.fail('rank_forbidden', 403, { required: [...METIC_PLUS], actual: req.builder?.rank ?? null, reason: "kind='criterion-proposal' is a planning-session write; Metic+ only." });
99
109
  }
100
110
  }
101
111
  // Full Idea capture (task 1002919 / BV1.R143). Only 'full' means anything
@@ -49,6 +49,25 @@ const NEVER_ROUTE_KINDS = new Set(['criterion-proposal', 'decision']);
49
49
  // separate, load-bearing gates (R05), and this decision is subordinate to both.
50
50
  const METIC_PLUS = new Set(['metic', 'archon']);
51
51
 
52
+ // Who may FILE a kind='criterion-proposal' (V3.R87 / #306) — the same trusted tier,
53
+ // read from the one definition above rather than a second copy. A proposal reshapes
54
+ // the next version's scope-truth, so it is a trusted write even though ordinary
55
+ // filing is open to everyone; POST /inbox asks this so a Xenos cannot slip one in by
56
+ // raw curl after the /planning-session skill refuses to run for them.
57
+ //
58
+ // FAIL-CLOSED (task 1003197 (D)). The route held this inline as
59
+ // `if (rank && rank !== 'metic' && …)`, so an ABSENT rank ADMITTED. Unreachable
60
+ // today — builders.rank is NOT NULL behind an enum CHECK and requireBuilder 401s
61
+ // first — but the gate was leaning on a schema invariant it never stated, and an
62
+ // unknown rank is not evidence of authority. Case-insensitive but NOT trimmed:
63
+ // 'METIC ' is a hostile shape, not a rank. Non-strings stringify and refuse rather
64
+ // than throwing on .toLowerCase(). Lives here beside NEVER_ROUTE_KINDS, which
65
+ // already names this same kind, and keeps the trusted tier spelled ONCE
66
+ // (tests/filing_abuse_matrix.mjs holds both lines).
67
+ function mayProposeCriterion(rank) {
68
+ return METIC_PLUS.has(String(rank ?? '').toLowerCase());
69
+ }
70
+
52
71
  // A Full Idea (grade='full') is the Board Room's subject — it is ratified by
53
72
  // peers (goal 1000069), not converted into a task by its own filer. 'spark' and
54
73
  // 'quick' carry no such meaning and route normally.
@@ -292,4 +311,5 @@ module.exports = {
292
311
  NEVER_ROUTE_KINDS,
293
312
  HOMELESS_ROUTING_VECTORS,
294
313
  METIC_PLUS,
314
+ mayProposeCriterion,
295
315
  };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.644",
3
+ "version": "1.19.645",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.644",
9
+ "version": "1.19.645",
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.644",
3
+ "version": "1.19.645",
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",
@@ -117,10 +117,15 @@ async function resolveSession(req) {
117
117
  //
118
118
  // A SESSION-SCOPE CAN ONLY EVER NARROW. Effective authority is (what the builder's
119
119
  // roles confer) ∩ (what this session's scope allows), with the allow-list
120
- // DENY-BY-DEFAULT. The intersection itself lives in the governance resolver
121
- // (resolveSessionPermissions the composed answer the tab and permission gates
122
- // read); THIS file carries its route-reachability projection: "is this route
123
- // inside the session's scope at all". That projection is deliberately the ROLE-FREE
120
+ // DENY-BY-DEFAULT. The governance resolver COMPOSES that intersection
121
+ // (resolveSessionPermissions), but nothing on the request path reads it it is
122
+ // exported on the `government` port and exercised by
123
+ // tests/government_session_scope.mjs, and that is the whole of its live use. The
124
+ // ENFORCEMENT is the kernel's own route-reachability projection below: "is this
125
+ // route inside the session's scope at all", run by requireBuilder and by
126
+ // runPageGate. This comment used to say the permission gates read the resolver's
127
+ // composed answer; they never have, and the next reviewer should not lose an hour
128
+ // to that ghost (task 1003197 (B)). The projection is deliberately the ROLE-FREE
124
129
  // half — folding the builder's roles in here would change who can reach the
125
130
  // bootstrap routes (a builder on a rank with no seeded governance role resolves to
126
131
  // zero permissions by design — see tests/government_parity.mjs Part D), and R99 is
@@ -352,11 +357,15 @@ function signInWithReturn(req) {
352
357
  // unauthorized request is bounced before the file is ever served (curl-proof,
353
358
  // JS-disabled-proof). The client-side link-hiding in the hall is cosmetic UX.
354
359
  //
355
- // Each gate asks TWO questions, in order:
360
+ // Each gate asks THREE questions, in order:
356
361
  // 1. IDENTITY — is there a live session? (token from the gds_session cookie /
357
362
  // legacy pms_session / Bearer, looked up fresh against builder_sessions).
358
363
  // No → 302 into the web sign-in flow.
359
- // 2. AUTHORITY does the builder HOLD the page's catalog permission, resolved
364
+ // 2. SCOPE is a page inside this SESSION's scope at all? A narrowing scope
365
+ // (a box-baked token) reaches no page, whatever its builder's rank, exactly as
366
+ // requireBuilder denies it every route that declared nothing (task 1003197).
367
+ // No → 302 into the web sign-in flow: the remedy is a different session.
368
+ // 3. AUTHORITY — does the builder HOLD the page's catalog permission, resolved
360
369
  // per request through the `governance` port exactly as requirePermission
361
370
  // does? No → 302 to an UNGATED hall page.
362
371
  //
@@ -504,6 +513,24 @@ async function runPageGate(name, req, res, next) {
504
513
  res.redirect(302, signInWithReturn(req));
505
514
  return;
506
515
  }
516
+ // SECURITY (task 1003197 (A)): SESSION-SCOPE, the same deny-by-default check
517
+ // requireBuilder runs above — the two auth paths must not diverge. resolveSession
518
+ // accepts a Bearer token with NO source filter, so before this a box-baked token
519
+ // could fetch a gated page SHELL (/builders, /watch, the tab shells) whenever its
520
+ // builder's rank held the page's permission. Every data endpoint underneath
521
+ // re-gates through requireBuilder and 403s box_scope, so the exposure was shells
522
+ // only — but a shell is still a surface a token living outside the ADR 0016 trust
523
+ // boundary was never meant to reach. Page routes declare no capability, so
524
+ // scopeCovers refuses EVERY narrowing scope here (and an unrecognised source with
525
+ // it), while an unscoped cli/web session passes untouched.
526
+ //
527
+ // Sign-in, not `deniedTo`: unlike the authority branch below, the remedy is a
528
+ // DIFFERENT SESSION — /builder-reauth mints a normal one — not a different page,
529
+ // and a box session has no browser cookie to loop the sign-in flow with.
530
+ if (!scopeCovers(sessionScopeFor(session.source), req.sessionScopeNeeds)) {
531
+ res.redirect(302, signInWithReturn(req));
532
+ return;
533
+ }
507
534
  const held = await resolveHeldPermissions(session.builder_id, session.raw_permission_keys);
508
535
  if (held === null || !held.has(permission)) {
509
536
  // The rank door said no. A page whose data route admits a SECOND audience
@@ -759,6 +786,32 @@ function requirePermission(...perms) {
759
786
  const builderId = req.builder && req.builder.id;
760
787
  if (!builderId) return deny(null);
761
788
  if (required.length === 0) return deny(null);
789
+ // R100 (task 1003197 (C)): an ownership-scoped key has NO unconditional answer.
790
+ // It is parameterized by a concrete row this gate was never handed, so admitting
791
+ // on `held.has(key)` would collapse `.own` into `.any` — precisely the
792
+ // degradation the ownership axis exists to prevent. The resolver's
793
+ // builderHasPermissions refuses these for the same reason (resolver.js); mirror
794
+ // it so the kernel gate can never be the softer of the two doors. ZERO call
795
+ // sites pass an own-scoped key today (full enumeration, task 1003197) — this
796
+ // guards the future mis-authored gate that would otherwise be silently
797
+ // unconditional. The route wants authorizeOwned() in its handler, which cannot
798
+ // be called without naming the owner. LOUD, because this is a wiring bug in the
799
+ // route, not a fact about the caller.
800
+ const governance = seams.resolveOptional('government');
801
+ if (governance && typeof governance.isOwnershipScoped === 'function') {
802
+ const owned = required.filter((key) => {
803
+ try { return governance.isOwnershipScoped(key); } catch { return false; }
804
+ });
805
+ if (owned.length > 0) {
806
+ require('./logger').logger.child({ src: 'auth' })
807
+ .error({ keys: owned }, 'requirePermission: an ownership-scoped key cannot gate a route unconditionally — the handler must call authorizeOwned() with the row\'s owner');
808
+ return res.fail('permission_forbidden', {
809
+ status: 403,
810
+ message: `this route is gated on ownership-scoped permission${owned.length === 1 ? '' : 's'} ${owned.join(', ')}, which cannot be answered without a resource owner — denied fail-closed`,
811
+ details: { required, held: null, floors: null },
812
+ });
813
+ }
814
+ }
762
815
  // Shared with the page gates (R107): null = authority could not be established
763
816
  // (no port / malformed port / a throwing resolver) and is always a denial.
764
817
  // req.rawPermissionKeys rides the fold (task 1002556) when the session query
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.644'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.645'; // 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');
@@ -99,6 +99,64 @@ await t('session lookup error → fails CLOSED (redirect, next() NOT called)', a
99
99
  'must redirect to sign-in on a lookup error (fail closed)');
100
100
  });
101
101
 
102
+ // ---- SESSION-SCOPE on the page path (task 1003197 (A)) ----------------------
103
+ //
104
+ // resolveSession accepts a Bearer token with no source filter, so before this the
105
+ // page gate never asked the question requireBuilder asks: is a page inside this
106
+ // SESSION's scope at all? A box-baked token (which lives on the box, outside the
107
+ // ADR 0016 trust boundary) could therefore fetch a gated page SHELL whenever its
108
+ // builder's rank held the page permission. The stub above resolves the permission
109
+ // for EVERY builder, which is what makes these assertions about scope alone.
110
+
111
+ console.log('\nrequireBuilderPage — session scope:');
112
+
113
+ await t('box-scoped session → 302 to sign-in even though its builder holds the permission', async () => {
114
+ nextSession = { builder_id: 3, github_login: 'boxbot', rank: 'archon', status: 'active', source: 'box' };
115
+ lookupThrows = false;
116
+ const req = { headers: { authorization: 'Bearer boxtoken' } }; // a page route declares no scope
117
+ const res = fakeRes();
118
+ let nexted = false;
119
+ await auth.requireBuilderPage(req, res, () => { nexted = true; });
120
+ assert.equal(nexted, false, 'a box session must not be served a gated page shell');
121
+ assert.ok(res._redirect && res._redirect.loc === '/api/gds/auth/web/start',
122
+ 'the remedy is a different session (/builder-reauth), so sign-in — not deniedTo');
123
+ });
124
+
125
+ await t('a source the registry does not declare → denied, never treated as unscoped', async () => {
126
+ nextSession = { builder_id: 4, github_login: 'mystery', rank: 'archon', status: 'active', source: 'not-a-scope' };
127
+ const req = { headers: { authorization: 'Bearer whoknows' } };
128
+ const res = fakeRes();
129
+ let nexted = false;
130
+ await auth.requireBuilderPage(req, res, () => { nexted = true; });
131
+ assert.equal(nexted, false, 'an unrecognised scope must fail closed, exactly as requireBuilder does');
132
+ assert.ok(res._redirect && res._redirect.loc === '/api/gds/auth/web/start');
133
+ });
134
+
135
+ await t('cli and web sessions are untouched by the scope check', async () => {
136
+ for (const source of ['cli', 'web']) {
137
+ nextSession = { builder_id: 1, github_login: 'someone', rank: 'xenos', status: 'active', source };
138
+ const req = { headers: { cookie: 'gds_session=validtoken' } };
139
+ const res = fakeRes();
140
+ let nexted = false;
141
+ await auth.requireBuilderPage(req, res, () => { nexted = true; });
142
+ assert.equal(nexted, true, `an unscoped ${source} session must still reach the page`);
143
+ assert.equal(res._redirect, null);
144
+ }
145
+ });
146
+
147
+ await t('a legacy session with no source field still reaches the page', async () => {
148
+ // The same invariant tests/box_scope_session.mjs pins for requireBuilder: absent
149
+ // source resolves to the UNSCOPED scope, so this gate can never lock out a real
150
+ // browser session minted before `source` was recorded.
151
+ nextSession = { builder_id: 2, github_login: 'legacy', rank: 'metic', status: 'active' };
152
+ const req = { headers: { cookie: 'gds_session=validtoken' } };
153
+ const res = fakeRes();
154
+ let nexted = false;
155
+ await auth.requireBuilderPage(req, res, () => { nexted = true; });
156
+ assert.equal(nexted, true, 'absent source must never trip the page scope gate');
157
+ assert.equal(res._redirect, null);
158
+ });
159
+
102
160
  seams._reset(); // leave the seam registry as we found it (the governance_* suites' pattern)
103
161
 
104
162
  console.log(`\n${passed} passed, ${failed} failed`);
@@ -36,6 +36,7 @@
36
36
  // F17 get a keyword-guessed 'bug' from #ideas to mint work → cannot: vector not trusted
37
37
  // F18 forge/omit the vector to reach the homeless-bug door → fail-closed, an inbox row
38
38
  // F19 file a homeless NON-bug hoping for a free home → inbox; only bugs get one
39
+ // F20 file a criterion-proposal with no/blank/padded rank → refused, fail-closed
39
40
  // P1 a Metic+ files a bug in an ordinary goal → 'ready' (the C1 grant)
40
41
  // P2 a Metic+ files into protected scope → allowed, floored to metic
41
42
  // P3 a Metic+ files a HOMELESS bug from a trusted vector → 'ready', in the maintenance goal
@@ -76,7 +77,7 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
76
77
  const read = (rel) => readFileSync(path.join(ROOT, rel), 'utf8');
77
78
 
78
79
  const routing = require('../modules/ideas/routing.js');
79
- const { decideLanding, authorizeRoutedLanding, routedPriority, PROTECTED_SCOPE_FLOOR } = routing;
80
+ const { decideLanding, authorizeRoutedLanding, routedPriority, PROTECTED_SCOPE_FLOOR, mayProposeCriterion } = routing;
80
81
  const bugs = require('../modules/discord/discord-bugs.js');
81
82
  const { rateLimitPerHour } = bugs;
82
83
  const { scopeIncludesProtected, isModuleKey } = require('../src/bongos/module-scope-map.js');
@@ -477,6 +478,57 @@ await test('[pin] no OTHER module file mints a task from a filing without a row
477
478
  `a new filing vector must be added to KNOWN_FILING_VECTORS and given a matrix row: ${unaudited.join(', ')}`);
478
479
  });
479
480
 
481
+ // =========================================================================
482
+ // F20 — the criterion-proposal door (task 1003197 (D))
483
+ // =========================================================================
484
+ //
485
+ // Filing is open to everyone, but kind='criterion-proposal' reshapes the next
486
+ // version's scope-truth, so POST /inbox holds a Metic+ door in front of it. That
487
+ // door was written `if (rank && rank !== 'metic' && …)`: an ABSENT rank walked
488
+ // straight through. Unreachable today (builders.rank is NOT NULL behind an enum
489
+ // CHECK, and requireBuilder 401s first) — but it was a gate leaning on a schema
490
+ // invariant it never stated, which is the same shape as every "unreachable" bug
491
+ // that later became reachable. The predicate is now pure and denies by default.
492
+
493
+ await test('[F20] the criterion-proposal door refuses every sub-Metic shape, absent rank included', () => {
494
+ for (const rank of SUB_METIC) {
495
+ assert.equal(mayProposeCriterion(rank), false,
496
+ `rank ${JSON.stringify(rank)} must not be able to file a criterion proposal`);
497
+ }
498
+ // Named explicitly, because these three are the bug: they are the shapes the old
499
+ // `rank &&` guard ADMITTED, and a list-driven loop can lose a case silently.
500
+ for (const falsy of [null, undefined, '']) {
501
+ assert.equal(mayProposeCriterion(falsy), false,
502
+ 'an unknown rank is not evidence of authority — the gate must fail CLOSED');
503
+ }
504
+ });
505
+
506
+ await test('[F20] it still admits Metic and Archon, in any casing', () => {
507
+ for (const rank of TRUSTED) {
508
+ assert.equal(mayProposeCriterion(rank), true,
509
+ `${JSON.stringify(rank)} is a trusted filer and must keep the door`);
510
+ }
511
+ });
512
+
513
+ await test('[F20] a padded rank is a hostile shape, not a rank — no trimming', () => {
514
+ // 'METIC ' appears in SUB_METIC on purpose. Trimming would WIDEN the audience,
515
+ // so the predicate lowercases and nothing else.
516
+ for (const padded of ['metic ', ' metic', 'archon ', '\tarchon']) {
517
+ assert.equal(mayProposeCriterion(padded), false);
518
+ }
519
+ });
520
+
521
+ await test('[F20] the route reads the domain predicate — no second copy of the door', () => {
522
+ const routeSrc = read('modules/ideas/routes/inbox.js');
523
+ const code = routeSrc.split('\n').filter((l) => !l.trim().startsWith('//')).join('\n');
524
+ assert.match(code, /mayProposeCriterion\(req\.builder\?\.rank\)/,
525
+ 'the route must ask routing.js, so the fail-closed posture cannot drift');
526
+ assert.match(code, /require\('\.\.\/routing'\)/,
527
+ 'and reach it by import, never by re-spelling the tier');
528
+ assert.ok(!/rank\s*&&\s*rank\s*!==/.test(code),
529
+ 'the admits-on-falsy guard must not come back');
530
+ });
531
+
480
532
  await test('[pin] there is ONE trusted-tier definition, and #bugs no longer keeps a copy', () => {
481
533
  // This pin used to assert that two spellings of "Metic+" AGREED — routing.js and
482
534
  // discord-bugs.js each carried one. R11 deleted the second, which is the better
@@ -403,3 +403,106 @@ test('every modules/*/routes gate is migrated, bar the one documented holdout',
403
403
  'a modules/*/routes gate still calls requireRank — migrate it to requirePermission, ' +
404
404
  'or add it to RETAINED with a "Still requireRank after BV1.R105" note explaining why');
405
405
  });
406
+
407
+ // ---------------------------------------------------------------------------
408
+ // the OWNERSHIP axis cannot gate a route (task 1003197 (C))
409
+ // ---------------------------------------------------------------------------
410
+ //
411
+ // `held.has('box.manage.own')` is true for anyone whose rank carries the atom, so a
412
+ // gate mounted on an ownership-scoped key would admit UNCONDITIONALLY — `.own`
413
+ // collapsed into `.any`, the exact degradation the axis exists to prevent, because
414
+ // the gate is never handed the row whose owner is the other half of the question.
415
+ // The resolver's builderHasPermissions refuses these already; the kernel gate must
416
+ // not be the softer door. Zero call sites pass one today — this is the guard on the
417
+ // future mis-authored gate.
418
+ //
419
+ // `run` above stubs a port with resolveBuilderPermissions ONLY, so this needs its
420
+ // own runner that also stubs isOwnershipScoped (the live port always carries it).
421
+ async function runOwned(gate, req, { resolve, isOwnershipScoped }) {
422
+ seams._reset();
423
+ seams.registerProvider('government', { resolveBuilderPermissions: resolve, isOwnershipScoped });
424
+ const res = fakeRes();
425
+ let nexted = false;
426
+ await gate(req, res, () => { nexted = true; });
427
+ seams._reset();
428
+ return { res, nexted };
429
+ }
430
+
431
+ const OWNED_KEY = catalog.permissionKeys().find((k) => catalog.isOwnershipScoped(k));
432
+
433
+ test('the catalog still declares at least one ownership-scoped key', () => {
434
+ // If the axis were ever emptied, the assertions below would pass vacuously.
435
+ assert.ok(OWNED_KEY, 'expected an ownership-scoped atom in the catalog (e.g. box.manage.own)');
436
+ });
437
+
438
+ test('an ownership-scoped key 403s even when the builder HOLDS it', async () => {
439
+ const { res, nexted } = await runOwned(
440
+ auth.requirePermission(OWNED_KEY), { builder: BUILDER },
441
+ { resolve: holding(OWNED_KEY), isOwnershipScoped: catalog.isOwnershipScoped },
442
+ );
443
+ assert.equal(nexted, false, 'holding an own-scoped atom is not an unconditional admit');
444
+ assert.equal(res._status, 403);
445
+ assert.equal(res._json.error.code, 'permission_forbidden');
446
+ assert.match(res._json.error.message, /ownership-scoped/,
447
+ 'the denial must name the reason — a route wiring bug, not a missing grant');
448
+ assert.equal(res._json.error.details.held, null,
449
+ 'never echo the held set on this branch: the denial is about the gate, not the caller');
450
+ });
451
+
452
+ test('one own-scoped key in a multi-key gate poisons the whole gate', async () => {
453
+ const { res, nexted } = await runOwned(
454
+ auth.requirePermission('idea.triage', OWNED_KEY), { builder: BUILDER },
455
+ { resolve: holding('idea.triage', OWNED_KEY), isOwnershipScoped: catalog.isOwnershipScoped },
456
+ );
457
+ assert.equal(nexted, false, 'the gate must refuse as a whole — a partial answer is not an answer');
458
+ assert.equal(res._status, 403);
459
+ });
460
+
461
+ test('an ordinary key still passes with isOwnershipScoped present on the port', async () => {
462
+ // The guard must be inert for every real gate in the tree.
463
+ const { res, nexted } = await runOwned(
464
+ auth.requirePermission('idea.triage'), { builder: BUILDER },
465
+ { resolve: holding('idea.triage'), isOwnershipScoped: catalog.isOwnershipScoped },
466
+ );
467
+ assert.equal(nexted, true);
468
+ assert.equal(res._status, null);
469
+ });
470
+
471
+ test('a throwing isOwnershipScoped does not admit and does not crash the gate', async () => {
472
+ // Fall through to the ordinary held check rather than 500 — but a key the caller
473
+ // does not hold must still deny, so the outcome is never an admit-by-error.
474
+ const { res, nexted } = await runOwned(
475
+ auth.requirePermission('government.manage'), { builder: BUILDER },
476
+ { resolve: holding('idea.triage'), isOwnershipScoped: () => { throw new Error('catalog blew up'); } },
477
+ );
478
+ assert.equal(nexted, false);
479
+ assert.equal(res._status, 403);
480
+ });
481
+
482
+ test('no route in the tree is gated on an ownership-scoped key today', () => {
483
+ // The enumeration the fix was written against, kept honest. A new gate mounted on
484
+ // an own-scoped atom would now 403 at runtime; this fails at BUILD time instead,
485
+ // naming the file, so nobody ships a route that can only ever deny.
486
+ const owned = catalog.permissionKeys().filter((k) => catalog.isOwnershipScoped(k));
487
+ const dirs = [path.join(ROOT, 'src', 'bongos', 'routes'), path.join(ROOT, 'modules')];
488
+ const offenders = [];
489
+ const walk = (dir) => {
490
+ if (!fs.existsSync(dir)) return;
491
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
492
+ const full = path.join(dir, entry.name);
493
+ if (entry.isDirectory()) { if (entry.name !== 'node_modules') walk(full); continue; }
494
+ if (!entry.name.endsWith('.js')) continue;
495
+ const text = fs.readFileSync(full, 'utf8');
496
+ for (const key of owned) {
497
+ // A gate is a call: requirePermission('<key>'), in any of its spellings.
498
+ if (new RegExp(`requirePermission\\s*\\([^)]*['"\`]${key.replace(/\./g, '\\.')}['"\`]`).test(text)) {
499
+ offenders.push(`${path.relative(ROOT, full)} :: ${key}`);
500
+ }
501
+ }
502
+ }
503
+ };
504
+ dirs.forEach(walk);
505
+ assert.deepEqual(offenders, [],
506
+ 'requirePermission cannot answer an ownership-scoped key — the handler must call ' +
507
+ 'authorizeOwned() with the row\'s owner instead');
508
+ });