@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 +15 -15
- package/docs/module-api-changelog.md +2 -0
- package/modules/ideas/routes/inbox.js +13 -3
- package/modules/ideas/routing.js +20 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/auth.js +59 -6
- package/src/module-api.js +1 -1
- package/tests/auth_page_gate.mjs +58 -0
- package/tests/filing_abuse_matrix.mjs +53 -1
- package/tests/government_require_permission.mjs +103 -0
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.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
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-
|
|
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": "
|
|
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": "
|
|
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": "
|
|
5550
|
+
"sha256": "b3df5f0d4273f44af60a7e09a8bef026a0f02085c7823c7912c70d067e55e127"
|
|
5551
5551
|
},
|
|
5552
5552
|
{
|
|
5553
5553
|
"path": "modules/ideas/routing.js",
|
|
5554
5554
|
"mode": "0000644",
|
|
5555
|
-
"sha256": "
|
|
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": "
|
|
7725
|
+
"sha256": "de884c55a00c4f37989b00d9c574fdcde904f617941dc65ee21ece636efd63c8"
|
|
7726
7726
|
},
|
|
7727
7727
|
{
|
|
7728
7728
|
"path": "package.json",
|
|
7729
7729
|
"mode": "0000644",
|
|
7730
|
-
"sha256": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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
|
-
|
|
97
|
-
if (rank && rank !== 'metic' && rank
|
|
98
|
-
|
|
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
|
package/modules/ideas/routing.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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",
|
package/src/bongos/auth.js
CHANGED
|
@@ -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
|
|
121
|
-
// (resolveSessionPermissions
|
|
122
|
-
//
|
|
123
|
-
//
|
|
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
|
|
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.
|
|
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.
|
|
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');
|
package/tests/auth_page_gate.mjs
CHANGED
|
@@ -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
|
+
});
|