@bongos/core 1.19.21 → 1.19.22
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 +17 -17
- package/modules/governance/routes/governance.js +3 -0
- package/modules/hall-ui/public/shell.js +31 -0
- package/modules/hall-ui/public/style.css +24 -0
- package/modules/lifecycle/db.js +18 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/ship.js +25 -2
- package/src/bongos/auth.js +34 -2
- package/src/module-api.js +3 -1
- package/tests/governance_page_gates.mjs +17 -6
- package/tests/governance_require_permission.mjs +32 -0
- package/tests/reward_gate.mjs +4 -2
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.22",
|
|
6
|
+
"core_contract": "1.19.22",
|
|
7
|
+
"source_commit": "e089ef90ffdf1b4da4f4a6b7f70d0fb10426bf42",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-
|
|
9
|
+
"built_at": "2026-08-11T01:24:29.274Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 285,
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
19
|
"file_count": 1510,
|
|
20
|
-
"tree_sha256": "
|
|
20
|
+
"tree_sha256": "0a775991e628ae986369cd819f6b384887fa20c1095f7e7263f07f13ad0e8167",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -2797,7 +2797,7 @@
|
|
|
2797
2797
|
{
|
|
2798
2798
|
"path": "modules/governance/routes/governance.js",
|
|
2799
2799
|
"mode": "0000644",
|
|
2800
|
-
"sha256": "
|
|
2800
|
+
"sha256": "2b390c2c51812aa547f85045fe6d36be6c53086f3fa2468ab7c20c4a7d19dc08"
|
|
2801
2801
|
},
|
|
2802
2802
|
{
|
|
2803
2803
|
"path": "modules/governance/routes/role-sync.js",
|
|
@@ -3247,12 +3247,12 @@
|
|
|
3247
3247
|
{
|
|
3248
3248
|
"path": "modules/hall-ui/public/shell.js",
|
|
3249
3249
|
"mode": "0000644",
|
|
3250
|
-
"sha256": "
|
|
3250
|
+
"sha256": "56c1450dd72fcda19841f3a0a98b918b1b875f49971e93d60218fb62e8a21dad"
|
|
3251
3251
|
},
|
|
3252
3252
|
{
|
|
3253
3253
|
"path": "modules/hall-ui/public/style.css",
|
|
3254
3254
|
"mode": "0000644",
|
|
3255
|
-
"sha256": "
|
|
3255
|
+
"sha256": "41fbe46c9bbc6e9a4397733c070cb2d5e7bfd698d629445f786a44c44a35b0c4"
|
|
3256
3256
|
},
|
|
3257
3257
|
{
|
|
3258
3258
|
"path": "modules/hall-ui/public/task-detail.js",
|
|
@@ -3352,7 +3352,7 @@
|
|
|
3352
3352
|
{
|
|
3353
3353
|
"path": "modules/lifecycle/db.js",
|
|
3354
3354
|
"mode": "0000644",
|
|
3355
|
-
"sha256": "
|
|
3355
|
+
"sha256": "7dc50e4e88a204583c753555c76f4103df405a143c4266dbff53793c292803f9"
|
|
3356
3356
|
},
|
|
3357
3357
|
{
|
|
3358
3358
|
"path": "modules/lifecycle/done-when.js",
|
|
@@ -4002,12 +4002,12 @@
|
|
|
4002
4002
|
{
|
|
4003
4003
|
"path": "package-lock.json",
|
|
4004
4004
|
"mode": "0000644",
|
|
4005
|
-
"sha256": "
|
|
4005
|
+
"sha256": "f85ec26a3a5cbefec1aca8407043cb2eb2886120722a79126d364736f1a67584"
|
|
4006
4006
|
},
|
|
4007
4007
|
{
|
|
4008
4008
|
"path": "package.json",
|
|
4009
4009
|
"mode": "0000644",
|
|
4010
|
-
"sha256": "
|
|
4010
|
+
"sha256": "9275da3329d188788a0e69b719231cd23ae38b3499802edbcefebded17317067"
|
|
4011
4011
|
},
|
|
4012
4012
|
{
|
|
4013
4013
|
"path": "public-docs/index.html",
|
|
@@ -5197,7 +5197,7 @@
|
|
|
5197
5197
|
{
|
|
5198
5198
|
"path": "scripts/gds/ship.js",
|
|
5199
5199
|
"mode": "0000644",
|
|
5200
|
-
"sha256": "
|
|
5200
|
+
"sha256": "65af2a0d6fc54ee3dbb3c7faa44796fed2a5817fa48073cebfd9d6a15837fec7"
|
|
5201
5201
|
},
|
|
5202
5202
|
{
|
|
5203
5203
|
"path": "scripts/gds/smoke-batch-claim.sh",
|
|
@@ -5392,7 +5392,7 @@
|
|
|
5392
5392
|
{
|
|
5393
5393
|
"path": "src/bongos/auth.js",
|
|
5394
5394
|
"mode": "0000644",
|
|
5395
|
-
"sha256": "
|
|
5395
|
+
"sha256": "95428c88621510a97a5bfaf89a836508501b1db0f0910f529a260fa2dafce3b5"
|
|
5396
5396
|
},
|
|
5397
5397
|
{
|
|
5398
5398
|
"path": "src/bongos/cascade-dispatch.js",
|
|
@@ -5627,7 +5627,7 @@
|
|
|
5627
5627
|
{
|
|
5628
5628
|
"path": "src/module-api.js",
|
|
5629
5629
|
"mode": "0000644",
|
|
5630
|
-
"sha256": "
|
|
5630
|
+
"sha256": "937021d749587baee02de0c83fa721b99a0e23850c5c12aa5332424673e5d6af"
|
|
5631
5631
|
},
|
|
5632
5632
|
{
|
|
5633
5633
|
"path": "src/module-loader/loader.js",
|
|
@@ -6512,7 +6512,7 @@
|
|
|
6512
6512
|
{
|
|
6513
6513
|
"path": "tests/governance_page_gates.mjs",
|
|
6514
6514
|
"mode": "0000644",
|
|
6515
|
-
"sha256": "
|
|
6515
|
+
"sha256": "be6cc9a4070d9de107cd11bfcb7a38e074f8aeb71198839cd42438d3ea2a0c04"
|
|
6516
6516
|
},
|
|
6517
6517
|
{
|
|
6518
6518
|
"path": "tests/governance_parity.mjs",
|
|
@@ -6532,7 +6532,7 @@
|
|
|
6532
6532
|
{
|
|
6533
6533
|
"path": "tests/governance_require_permission.mjs",
|
|
6534
6534
|
"mode": "0000644",
|
|
6535
|
-
"sha256": "
|
|
6535
|
+
"sha256": "2e8d7f3bb58411efd79727f6edffa448136551ef1c3c6973228fdfa7903f7cb3"
|
|
6536
6536
|
},
|
|
6537
6537
|
{
|
|
6538
6538
|
"path": "tests/governance_resolver.mjs",
|
|
@@ -7167,7 +7167,7 @@
|
|
|
7167
7167
|
{
|
|
7168
7168
|
"path": "tests/reward_gate.mjs",
|
|
7169
7169
|
"mode": "0000644",
|
|
7170
|
-
"sha256": "
|
|
7170
|
+
"sha256": "7ae4d9af4d389c34f0d16f921858cbbf5d0d57f3219f830d10552ebcdc43908f"
|
|
7171
7171
|
},
|
|
7172
7172
|
{
|
|
7173
7173
|
"path": "tests/reward_mode_cost_plus_only.mjs",
|
|
@@ -62,6 +62,9 @@ const governancePort = {
|
|
|
62
62
|
isSystem: catalog.isSystem,
|
|
63
63
|
isGrantable: catalog.isGrantable,
|
|
64
64
|
permissionKeys: catalog.permissionKeys,
|
|
65
|
+
// The seeded floor of one atom, for denial text that names the rank instead of
|
|
66
|
+
// only the atom (task 1002613). Null for principal keys and unknown keys.
|
|
67
|
+
permissionFloor: (key) => (catalog.byKey(key) || {}).floor || null,
|
|
65
68
|
resolvePrincipalPermissions: resolver.resolvePrincipalPermissions,
|
|
66
69
|
principalHasPermission: resolver.principalHasPermission,
|
|
67
70
|
isPrincipalKey: catalog.isPrincipalKey,
|
|
@@ -360,6 +360,36 @@
|
|
|
360
360
|
applyZoom();
|
|
361
361
|
}
|
|
362
362
|
|
|
363
|
+
// task 1002613 (map N-1): a server page-gate denial lands here with
|
|
364
|
+
// ?denied=<permission>[&reason=unavailable]. Render the refusal — "you are
|
|
365
|
+
// signed in; you lack X" — then strip the query so a refresh or a shared link
|
|
366
|
+
// doesn't re-show a stale notice. Cosmetic only; the gate already ran.
|
|
367
|
+
function renderDeniedNotice() {
|
|
368
|
+
// Hand-parsed (no URLSearchParams): shell.js is dependency-free by design,
|
|
369
|
+
// and the hall unit tests boot it in a bare vm context.
|
|
370
|
+
var search = (window.location && window.location.search) || '';
|
|
371
|
+
var m = /[?&]denied=([^&]+)/.exec(search);
|
|
372
|
+
if (!m) return;
|
|
373
|
+
var denied = decodeURIComponent(m[1]);
|
|
374
|
+
if (!/^[a-z0-9_.-]{1,80}$/i.test(denied)) return;
|
|
375
|
+
var unavailable = /[?&]reason=unavailable(?:&|$)/.test(search);
|
|
376
|
+
var topbar = document.getElementById('app-topbar');
|
|
377
|
+
if (!topbar) return;
|
|
378
|
+
var el = document.createElement('div');
|
|
379
|
+
el.className = 'denied-notice';
|
|
380
|
+
el.setAttribute('role', 'status');
|
|
381
|
+
el.innerHTML =
|
|
382
|
+
'<span>' + (unavailable
|
|
383
|
+
? 'You’re signed in, but access to that page couldn’t be verified just now (the permission system didn’t answer), so it stayed closed. Try again in a minute.'
|
|
384
|
+
: 'You’re signed in, but that page needs the <code>' + esc(denied) + '</code> permission — your current role doesn’t hold it.') +
|
|
385
|
+
'</span>' +
|
|
386
|
+
'<button type="button" class="denied-notice__close" aria-label="Dismiss">×</button>';
|
|
387
|
+
el.querySelector('.denied-notice__close').addEventListener('click', function () { el.remove(); });
|
|
388
|
+
topbar.insertAdjacentElement('afterend', el);
|
|
389
|
+
var rest = search.replace(/[?&](?:denied|reason)=[^&]*/g, '').replace(/^&/, '?');
|
|
390
|
+
history.replaceState(null, '', window.location.pathname + rest + window.location.hash);
|
|
391
|
+
}
|
|
392
|
+
|
|
363
393
|
function renderFoot() {
|
|
364
394
|
var el = document.getElementById('app-foot');
|
|
365
395
|
if (!el) return;
|
|
@@ -615,6 +645,7 @@
|
|
|
615
645
|
|
|
616
646
|
renderSidebar();
|
|
617
647
|
renderTopbar();
|
|
648
|
+
renderDeniedNotice();
|
|
618
649
|
renderFoot();
|
|
619
650
|
applyTheme();
|
|
620
651
|
applyZoom();
|
|
@@ -455,6 +455,30 @@ button { font-family: var(--font-body); }
|
|
|
455
455
|
overflow: hidden;
|
|
456
456
|
text-overflow: ellipsis;
|
|
457
457
|
}
|
|
458
|
+
/* Page-gate denial notice (task 1002613, map N-1) — rendered by shell.js from
|
|
459
|
+
the ?denied= query a refused page redirect carries. */
|
|
460
|
+
.denied-notice {
|
|
461
|
+
display: flex;
|
|
462
|
+
align-items: baseline;
|
|
463
|
+
gap: 12px;
|
|
464
|
+
padding: 10px 24px;
|
|
465
|
+
font-size: 13px;
|
|
466
|
+
color: var(--warn);
|
|
467
|
+
background: var(--warn-weak);
|
|
468
|
+
border-bottom: 1px solid var(--border);
|
|
469
|
+
}
|
|
470
|
+
.denied-notice code {
|
|
471
|
+
font-size: 12px;
|
|
472
|
+
}
|
|
473
|
+
.denied-notice__close {
|
|
474
|
+
margin-left: auto;
|
|
475
|
+
border: 0;
|
|
476
|
+
background: none;
|
|
477
|
+
color: inherit;
|
|
478
|
+
font-size: 16px;
|
|
479
|
+
line-height: 1;
|
|
480
|
+
cursor: pointer;
|
|
481
|
+
}
|
|
458
482
|
.topbar-menu {
|
|
459
483
|
display: none;
|
|
460
484
|
align-items: center;
|
package/modules/lifecycle/db.js
CHANGED
|
@@ -42,7 +42,7 @@ const doneWhen = require('./done-when.js');
|
|
|
42
42
|
const { generalGoalTitle } = require('./goal-advisory.js');
|
|
43
43
|
// BV1.R106 (ADR 0151): the permission-expressed QUEUE axis of claim eligibility.
|
|
44
44
|
// Kept deliberately separate from the per-task requires_rank floor below.
|
|
45
|
-
const { claimEligibilityAllows } = require('./claim-eligibility.js');
|
|
45
|
+
const { claimEligibilityAllows, CLAIM_ANY, CLAIM_NEWCOMER } = require('./claim-eligibility.js');
|
|
46
46
|
// Pure kernel-adjacent matchers via the doorway (never the core file).
|
|
47
47
|
const { matchProtected } = api.permissionPathCheck;
|
|
48
48
|
const { touchesOverlap } = api.pathMatch;
|
|
@@ -2572,7 +2572,23 @@ async function claimTask({ taskId, builderId, worktreeName, headSha, creatorSess
|
|
|
2572
2572
|
// task.claim.any / task.claim.newcomer — see ./claim-eligibility.js.
|
|
2573
2573
|
const newcomerFriendly = task.newcomer_friendly;
|
|
2574
2574
|
if (!claimEligibilityAllows({ heldPermissions, newcomerFriendly, rankRuleAllows: xenosClaimAllowed(builderRank, newcomerFriendly) })) {
|
|
2575
|
-
|
|
2575
|
+
// task 1002613 (map §4.4): the bare code reported a PERMISSION decision as
|
|
2576
|
+
// a rank one — no axis, no remedy. Copy INSUFFICIENT_RANK's shape below:
|
|
2577
|
+
// name both sides of the wall and the way forward.
|
|
2578
|
+
const opened = newcomerFriendly === true;
|
|
2579
|
+
throw {
|
|
2580
|
+
code: 'rank_too_low_for_task',
|
|
2581
|
+
legacy_code: 'XENOS_RESTRICTED',
|
|
2582
|
+
required: opened ? [CLAIM_ANY, CLAIM_NEWCOMER] : [CLAIM_ANY],
|
|
2583
|
+
actual: builderRank,
|
|
2584
|
+
newcomer_friendly: opened,
|
|
2585
|
+
message: opened
|
|
2586
|
+
? `claiming this task needs ${CLAIM_ANY} or ${CLAIM_NEWCOMER}, and your role holds neither`
|
|
2587
|
+
: `this task is not opened to newcomers (newcomer_friendly is not set) and your role lacks ${CLAIM_ANY}`,
|
|
2588
|
+
hint: opened
|
|
2589
|
+
? 'your builder appears to hold no claim permission at all — ask for your rank-role to be re-synced'
|
|
2590
|
+
: 'pick a newcomer_friendly task from the claimable feed (/builder-start), or a builder whose role holds task.claim.any can claim this one',
|
|
2591
|
+
};
|
|
2576
2592
|
}
|
|
2577
2593
|
|
|
2578
2594
|
// V3.R82 (#301): the inverse gate — a task can RAISE its minimum required
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.22",
|
|
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.22",
|
|
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.22",
|
|
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/scripts/gds/ship.js
CHANGED
|
@@ -3780,6 +3780,27 @@ async function shipMain() {
|
|
|
3780
3780
|
console.error(`(Your work is committed and the task is at 'completed' — re-running resumes, task 941.)`);
|
|
3781
3781
|
process.exit(1);
|
|
3782
3782
|
}
|
|
3783
|
+
// task 1002613 (map P-1): the one genuine permission wall on the ship path
|
|
3784
|
+
// used to surface as a raw envelope dump — an atom name is not a rank, and
|
|
3785
|
+
// no remedy printed at the one station where self-service exists. Interpret
|
|
3786
|
+
// it: name the wall, the floor (from the server's floors map — never
|
|
3787
|
+
// hardcoded), and both ways forward.
|
|
3788
|
+
const errObj = confirm.data && confirm.data.error;
|
|
3789
|
+
const errCode = typeof errObj === 'string' ? errObj : (errObj && errObj.code);
|
|
3790
|
+
if (confirm.status === 403 && errCode === 'permission_forbidden') {
|
|
3791
|
+
const details = (errObj && typeof errObj === 'object' && errObj.details) || {};
|
|
3792
|
+
const floor = details.floors && details.floors['task.confirm.grade_bypass'];
|
|
3793
|
+
const floorNote = floor ? ` (floor: ${floor})` : '';
|
|
3794
|
+
console.error('');
|
|
3795
|
+
console.error(`✖ Confirming without a passing grade is permission-gated: task.confirm.grade_bypass${floorNote}.`);
|
|
3796
|
+
console.error(' This is an authorization wall, not a verdict on your work.');
|
|
3797
|
+
console.error(' Self-service: re-run WITHOUT --skip-grade to use the subagent grader, and if a');
|
|
3798
|
+
console.error(' grade fails, fix its findings and re-run (unlimited, no attempt cap):');
|
|
3799
|
+
console.error(` node scripts/gds/ship.js ${taskId} --regrade`);
|
|
3800
|
+
console.error(` If the grade genuinely cannot pass, ask a builder whose role holds`);
|
|
3801
|
+
console.error(` task.confirm.grade_bypass${floorNote} to POST /api/gds/tasks/${taskId}/confirm.`);
|
|
3802
|
+
process.exit(1);
|
|
3803
|
+
}
|
|
3783
3804
|
console.error(`manual-confirm failed (${confirm.status}):`, confirm.data);
|
|
3784
3805
|
process.exit(1);
|
|
3785
3806
|
}
|
|
@@ -3817,7 +3838,8 @@ async function shipMain() {
|
|
|
3817
3838
|
console.error('Task stays at completed. Manual options:');
|
|
3818
3839
|
console.error(` - Re-run /builder-ship ${taskId} to try grading again, or`);
|
|
3819
3840
|
console.error(` - node scripts/gds/ship.js ${taskId} --regrade after fixing findings, or`);
|
|
3820
|
-
console.error(` - POST /api/gds/tasks/${taskId}/confirm to manual-confirm
|
|
3841
|
+
console.error(` - POST /api/gds/tasks/${taskId}/confirm to manual-confirm (last resort —`);
|
|
3842
|
+
console.error(' permission-gated on task.confirm.grade_bypass).');
|
|
3821
3843
|
process.exit(1);
|
|
3822
3844
|
}
|
|
3823
3845
|
|
|
@@ -4000,7 +4022,8 @@ async function regradeMain() {
|
|
|
4000
4022
|
});
|
|
4001
4023
|
if (!gradePost.ok) {
|
|
4002
4024
|
console.error(`grade POST failed (${gradePost.status}):`, gradePost.data);
|
|
4003
|
-
console.error('Task stays at completed. Fix the issue and re-run --regrade,
|
|
4025
|
+
console.error('Task stays at completed. Fix the issue and re-run --regrade (unlimited, no attempt cap).');
|
|
4026
|
+
console.error('(POST /confirm is a last resort — it is permission-gated on task.confirm.grade_bypass.)');
|
|
4004
4027
|
process.exit(1);
|
|
4005
4028
|
}
|
|
4006
4029
|
|
package/src/bongos/auth.js
CHANGED
|
@@ -1502,7 +1502,12 @@ async function runPageGate(name, req, res, next) {
|
|
|
1502
1502
|
}
|
|
1503
1503
|
const held = await resolveHeldPermissions(session.builder_id, session.raw_permission_keys);
|
|
1504
1504
|
if (held === null || !held.has(permission)) {
|
|
1505
|
-
|
|
1505
|
+
// N-1 (task 1002613): a bare deniedTo rendered nothing — every denial read as
|
|
1506
|
+
// "I signed in and got dumped at the home page". Name the permission (and an
|
|
1507
|
+
// authority outage, distinctly) in the query so the hall shell can say why.
|
|
1508
|
+
// Same destination, same audience — only the redirect learned to speak.
|
|
1509
|
+
const reason = held === null ? '&reason=unavailable' : '';
|
|
1510
|
+
res.redirect(302, `${deniedTo}?denied=${encodeURIComponent(permission)}${reason}`);
|
|
1506
1511
|
return;
|
|
1507
1512
|
}
|
|
1508
1513
|
next();
|
|
@@ -1693,11 +1698,38 @@ function requireRank(...allowed) {
|
|
|
1693
1698
|
// null` analogue). Reporting only the intersection explains the denial without
|
|
1694
1699
|
// dumping the builder's entire permission set into an error body.
|
|
1695
1700
|
//
|
|
1701
|
+
// The body also carries a `message` and a `floors` map (missing atom → its
|
|
1702
|
+
// catalog floor rank) so the denial names the rank, not just the atom (task
|
|
1703
|
+
// 1002613, choke-point map §4.4 P-1). Floors resolve through the port at deny
|
|
1704
|
+
// time — never hardcoded prose, which goes stale when a floor moves (the
|
|
1705
|
+
// D-16/N-3 failure shape ADR 0157 left behind).
|
|
1706
|
+
//
|
|
1696
1707
|
// Usage: router.post('/roles', auth.requireBuilder, auth.requirePermission('governance.manage'), handler)
|
|
1697
1708
|
function requirePermission(...perms) {
|
|
1698
1709
|
const required = Object.freeze([...perms]);
|
|
1699
1710
|
return async function permissionGate(req, res, next) {
|
|
1700
|
-
const deny = (held) =>
|
|
1711
|
+
const deny = (held) => {
|
|
1712
|
+
const missing = held === null ? [...required] : required.filter((key) => !held.includes(key));
|
|
1713
|
+
const floors = {};
|
|
1714
|
+
const governance = seams.resolveOptional('governance');
|
|
1715
|
+
if (governance && typeof governance.permissionFloor === 'function') {
|
|
1716
|
+
for (const key of missing) {
|
|
1717
|
+
const floor = governance.permissionFloor(key);
|
|
1718
|
+
if (floor) floors[key] = floor;
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
const floorNote = Object.keys(floors).length > 0
|
|
1722
|
+
? ` (floor: ${[...new Set(Object.values(floors))].join(', ')})`
|
|
1723
|
+
: '';
|
|
1724
|
+
const message = held === null
|
|
1725
|
+
? `authority could not be established — denied fail-closed (ADR 0016); this route requires ${required.join(', ') || 'a permission list'}${floorNote}`
|
|
1726
|
+
: `missing permission${missing.length === 1 ? '' : 's'}: ${missing.join(', ')}${floorNote}`;
|
|
1727
|
+
return res.fail('permission_forbidden', {
|
|
1728
|
+
status: 403,
|
|
1729
|
+
message,
|
|
1730
|
+
details: { required, held, floors: Object.keys(floors).length > 0 ? floors : null },
|
|
1731
|
+
});
|
|
1732
|
+
};
|
|
1701
1733
|
const builderId = req.builder && req.builder.id;
|
|
1702
1734
|
if (!builderId) return deny(null);
|
|
1703
1735
|
if (required.length === 0) return deny(null);
|
package/src/module-api.js
CHANGED
|
@@ -469,8 +469,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
469
469
|
// landed since 1.19.19 with no explicit bump. run 31347092915. (task 1002620)
|
|
470
470
|
// 1.19.21 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
471
471
|
// landed since 1.19.20 with no explicit bump. run 31446766597. (task 1002620)
|
|
472
|
+
// 1.19.22 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
473
|
+
// landed since 1.19.21 with no explicit bump. run 31449168428. (task 1002620)
|
|
472
474
|
// ---------------------------------------------------------------------------
|
|
473
|
-
const CORE_VERSION = '1.19.
|
|
475
|
+
const CORE_VERSION = '1.19.22'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
474
476
|
|
|
475
477
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
476
478
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -74,12 +74,22 @@ const PRE_R107_AUDIENCE = {
|
|
|
74
74
|
requireGovernancePage: { xenos: false, thetes: false, metic: true, archon: true },
|
|
75
75
|
};
|
|
76
76
|
// Where a SIGNED-IN builder who fails the gate is sent. Never the sign-in flow —
|
|
77
|
-
// they already have a session, so that would loop.
|
|
77
|
+
// they already have a session, so that would loop. Since task 1002613 (map N-1)
|
|
78
|
+
// the redirect carries ?denied=<permission> — and &reason=unavailable when the
|
|
79
|
+
// authority system itself never answered — so the hall shell can render WHY
|
|
80
|
+
// instead of silently dumping the builder at the home page.
|
|
78
81
|
const DENIED_TO = {
|
|
79
82
|
requireBuilderPage: '/builders',
|
|
80
83
|
requireNonXenosPage: '/builders/not-ready',
|
|
81
84
|
requireGovernancePage: '/builders',
|
|
82
85
|
};
|
|
86
|
+
const GATE_PERMISSION = {
|
|
87
|
+
requireBuilderPage: 'page.view.public',
|
|
88
|
+
requireNonXenosPage: 'page.view.builder',
|
|
89
|
+
requireGovernancePage: 'page.view.governance',
|
|
90
|
+
};
|
|
91
|
+
const deniedLoc = (gate, { unavailable = false } = {}) =>
|
|
92
|
+
`${DENIED_TO[gate]}?denied=${GATE_PERMISSION[gate]}${unavailable ? '&reason=unavailable' : ''}`;
|
|
83
93
|
// The equivalent rank floor, for the independent cross-check below.
|
|
84
94
|
const GATE_FLOOR = {
|
|
85
95
|
requireBuilderPage: 'xenos',
|
|
@@ -166,8 +176,8 @@ test('A2: a denied SIGNED-IN builder is redirected to an ungated page, not JSON-
|
|
|
166
176
|
if (PRE_R107_AUDIENCE[gate][rank]) continue;
|
|
167
177
|
const { res, nexted } = await runGate(gate, { rank });
|
|
168
178
|
assert.equal(nexted, false, `${gate}/${rank} must not admit`);
|
|
169
|
-
assert.deepEqual(res._redirect, { code: 302, loc:
|
|
170
|
-
`${gate}/${rank} must 302 to ${
|
|
179
|
+
assert.deepEqual(res._redirect, { code: 302, loc: deniedLoc(gate) },
|
|
180
|
+
`${gate}/${rank} must 302 to ${deniedLoc(gate)} — naming the permission it lacked`);
|
|
171
181
|
assert.equal(res._status, null, `${gate}/${rank} must not set an HTTP error status`);
|
|
172
182
|
assert.equal(res._json, null, `${gate}/${rank} must not answer a JSON body`);
|
|
173
183
|
assert.notEqual(res._redirect.loc, SIGN_IN,
|
|
@@ -211,7 +221,8 @@ test('B2: an UNREGISTERED governance port denies every gate, it does not admit',
|
|
|
211
221
|
for (const gate of GATES) {
|
|
212
222
|
const { res, nexted } = await runGate(gate, { rank: 'archon', port: false });
|
|
213
223
|
assert.equal(nexted, false, `${gate}: a missing authority system must never open a page gate`);
|
|
214
|
-
assert.equal(res._redirect.loc,
|
|
224
|
+
assert.equal(res._redirect.loc, deniedLoc(gate, { unavailable: true }),
|
|
225
|
+
`${gate}: an authority outage must say so (&reason=unavailable), distinct from a permission denial`);
|
|
215
226
|
assert.equal(res._json, null);
|
|
216
227
|
}
|
|
217
228
|
});
|
|
@@ -223,7 +234,7 @@ test('B3: a throwing resolver denies (a DB blip must not open the page)', async
|
|
|
223
234
|
resolve: async () => { throw new Error('connection terminated'); },
|
|
224
235
|
});
|
|
225
236
|
assert.equal(nexted, false, `${gate} must fail CLOSED on a resolve failure`);
|
|
226
|
-
assert.equal(res._redirect.loc,
|
|
237
|
+
assert.equal(res._redirect.loc, deniedLoc(gate, { unavailable: true }));
|
|
227
238
|
}
|
|
228
239
|
});
|
|
229
240
|
|
|
@@ -247,7 +258,7 @@ test('B4: the page gates resolve per request — no memoised authority (ADR 0016
|
|
|
247
258
|
seams._reset();
|
|
248
259
|
assert.equal(calls, 2, 'the resolver must be consulted on every request');
|
|
249
260
|
assert.equal(admittedAgain, false, 'a revoked role must deny on the very next request');
|
|
250
|
-
assert.equal(second._redirect.loc, '
|
|
261
|
+
assert.equal(second._redirect.loc, deniedLoc('requireGovernancePage'));
|
|
251
262
|
});
|
|
252
263
|
|
|
253
264
|
test('B5: every permission a page gate names is a real catalog key', async () => {
|
|
@@ -83,6 +83,38 @@ test('multiple permissions are AND, not OR — holding one of two still denies',
|
|
|
83
83
|
assert.equal(both.nexted, true, 'holding both must admit');
|
|
84
84
|
});
|
|
85
85
|
|
|
86
|
+
test('the denial names the floor rank, not just the atom (task 1002613, map P-1)', async () => {
|
|
87
|
+
// With the real port shape (permissionFloor present) the body carries a floors
|
|
88
|
+
// map and a message naming the rank. The floor is read from the catalog here so
|
|
89
|
+
// this test moves WITH a floor change instead of pinning stale prose — the
|
|
90
|
+
// D-16/N-3 failure shape the fix exists to avoid.
|
|
91
|
+
seams._reset();
|
|
92
|
+
seams.registerProvider('governance', {
|
|
93
|
+
resolveBuilderPermissions: holding('idea.triage'),
|
|
94
|
+
permissionFloor: (key) => (catalog.byKey(key) || {}).floor || null,
|
|
95
|
+
});
|
|
96
|
+
const res = fakeRes();
|
|
97
|
+
await auth.requirePermission('task.confirm.grade_bypass')({ builder: BUILDER }, res, () => {});
|
|
98
|
+
seams._reset();
|
|
99
|
+
assert.equal(res._status, 403);
|
|
100
|
+
const err = res._json.error;
|
|
101
|
+
const floor = catalog.byKey('task.confirm.grade_bypass').floor;
|
|
102
|
+
assert.deepEqual(err.details.floors, { 'task.confirm.grade_bypass': floor },
|
|
103
|
+
'floors maps each MISSING atom to its seeded floor');
|
|
104
|
+
assert.ok(err.message.includes('task.confirm.grade_bypass'), 'the message names the atom');
|
|
105
|
+
assert.ok(err.message.includes(floor), 'the message names the rank — an atom name is not a rank');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('a port without permissionFloor still denies with a message; floors is null', async () => {
|
|
109
|
+
const { res } = await run(
|
|
110
|
+
auth.requirePermission('governance.manage'), { builder: BUILDER }, holding('idea.triage'),
|
|
111
|
+
);
|
|
112
|
+
assert.equal(res._status, 403);
|
|
113
|
+
assert.equal(res._json.error.details.floors, null);
|
|
114
|
+
assert.ok(res._json.error.message.includes('governance.manage'),
|
|
115
|
+
'the message names the missing atom even when no floor is resolvable');
|
|
116
|
+
});
|
|
117
|
+
|
|
86
118
|
test('no req.builder fails closed (gate mounted without requireBuilder)', async () => {
|
|
87
119
|
const { res, nexted } = await run(auth.requirePermission('idea.triage'), {}, holding('idea.triage'));
|
|
88
120
|
assert.equal(nexted, false);
|
package/tests/reward_gate.mjs
CHANGED
|
@@ -162,8 +162,10 @@ t('claim path AUTO-ASSIGNS inside the FOR UPDATE transaction (never refuses)', (
|
|
|
162
162
|
// The window is a slice heuristic, not a contract: it only has to cover the
|
|
163
163
|
// pre-flight half of claimTask (which spans ~13k chars). Widened 5000 -> 6000 in
|
|
164
164
|
// BV1.R106, when the permission-expressed claim gate pushed the reward-gate block
|
|
165
|
-
// past the old edge
|
|
166
|
-
|
|
165
|
+
// past the old edge; 6000 -> 7000 in task 1002613, when the enriched
|
|
166
|
+
// rank_too_low_for_task denial did the same. Keep it comfortably below the
|
|
167
|
+
// claimTasksBatch boundary (~15.5k chars in).
|
|
168
|
+
const body = dbSrc.slice(start, start + 7000);
|
|
167
169
|
assert.match(body, /rewardGateAssignment\(task\)/, 'claimTask must consult the auto-assign decision');
|
|
168
170
|
assert.match(body, /UPDATE tasks SET credits_reward/, 'claimTask must persist the assigned reward in-tx');
|
|
169
171
|
// it must SELECT the columns the gate needs.
|