@bongos/core 1.19.183 → 1.19.185
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 +102 -92
- package/clients/bongos-client/bongos-client.global.js +19 -19
- package/clients/bongos-client/index.cjs +19 -19
- package/clients/bongos-client/index.d.ts +29 -29
- package/clients/bongos-client/index.mjs +19 -19
- package/docs/adr/0157-archon-is-rank-and-identity-only.md +2 -2
- package/docs/api/openapi.json +117 -117
- package/docs/api-reference.md +10 -10
- package/docs/canonical-permissions.md +6 -6
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +19 -19
- package/modules/{governance → government}/catalog.js +8 -8
- package/modules/{governance → government}/config.js +6 -6
- package/modules/{governance → government}/db.js +44 -44
- package/modules/government/migrations/government_001_rename_from_governance.sql +213 -0
- package/modules/government/migrations/government_002_rename_permission_keys.sql +112 -0
- package/modules/government/module.json +22 -0
- package/modules/{governance → government}/protected-surfaces.json +0 -1
- package/modules/{governance/rank-roles.js → government/ranks.js} +18 -18
- package/modules/{governance/routes/governance.js → government/routes/government.js} +109 -84
- package/modules/{governance/routes/role-sync.js → government/routes/rank-sync.js} +7 -7
- package/modules/hall-ui/public/gate.js +1 -1
- package/modules/hall-ui/public/governance.js +38 -38
- package/modules/hall-ui/public/harbor.js +2 -2
- package/modules/hall-ui/public/shell.js +3 -3
- package/modules/hall-ui/public/watch.js +1 -1
- package/modules/lifecycle/db.js +6 -6
- package/modules/lifecycle/routes/gate-approvals.js +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/fitness.js +11 -1
- package/scripts/gds/migration-namespace.js +15 -3
- package/scripts/gds/publish-manifest.js +2 -3
- package/scripts/gds/seed-governance-tasks.js +3 -3
- package/src/bongos/auth.js +16 -16
- package/src/bongos/db.js +6 -6
- package/src/bongos/module-scope-map.js +7 -22
- package/src/bongos/permission-path-check.js +3 -3
- package/src/bongos/route-rank-check.js +15 -21
- package/src/bongos/serve-internal.js +2 -2
- package/src/module-api.js +7 -3
- package/tests/auth_page_gate.mjs +2 -2
- package/tests/criterion_authoring_gate.mjs +1 -1
- package/tests/governance_abuse_matrix.mjs +38 -38
- package/tests/governance_catalog.mjs +4 -4
- package/tests/governance_claim_eligibility.mjs +1 -1
- package/tests/governance_config.mjs +7 -7
- package/tests/governance_fold.mjs +4 -4
- package/tests/governance_fold_bench.mjs +2 -2
- package/tests/governance_live_proof.mjs +6 -6
- package/tests/governance_ownership_scope.mjs +2 -2
- package/tests/governance_page_gates.mjs +10 -10
- package/tests/governance_parity.mjs +3 -3
- package/tests/governance_principals.mjs +3 -3
- package/tests/governance_protected_surfaces.mjs +44 -19
- package/tests/governance_require_permission.mjs +11 -11
- package/tests/governance_resolver.mjs +4 -4
- package/tests/governance_role_sync.mjs +35 -35
- package/tests/governance_routes.mjs +98 -98
- package/tests/governance_seed.mjs +27 -4
- package/tests/governance_session_scope.mjs +5 -5
- package/tests/governance_vanilla_boot.mjs +4 -4
- package/tests/hall_page_gate_map.mjs +3 -3
- package/tests/memory_isolation.mjs +2 -2
- package/tests/module_loader.mjs +1 -1
- package/tests/onboarding_observability.mjs +2 -2
- package/tests/rank_tier_single_source.mjs +2 -2
- package/tests/route_permission_check.mjs +2 -2
- package/modules/governance/module.json +0 -11
- /package/config/{governance.neutral.json → government.neutral.json} +0 -0
- /package/modules/{governance → government}/CLAUDE.md +0 -0
- /package/modules/{governance → government}/migrations/governance_001_tables.sql +0 -0
- /package/modules/{governance → government}/migrations/governance_002_seed_and_backfill.sql +0 -0
- /package/modules/{governance → government}/migrations/governance_003_task_abandon_metic.sql +0 -0
- /package/modules/{governance → government}/migrations/governance_004_criterion_create.sql +0 -0
- /package/modules/{governance → government}/migrations/governance_005_resync_builder_rank_roles.sql +0 -0
- /package/modules/{governance → government}/migrations/governance_006_version_create.sql +0 -0
- /package/modules/{governance → government}/migrations/governance_007_delegate_archon_ops_to_metic.sql +0 -0
- /package/modules/{governance → government}/protected-surfaces.js +0 -0
- /package/modules/{governance → government}/resolver.js +0 -0
- /package/modules/{governance → government}/session-scopes.js +0 -0
- /package/modules/{governance → government}/session-scopes.json +0 -0
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
//
|
|
5
5
|
// 1. Gate classification + pins: route-rank-check's own classifier is run over
|
|
6
6
|
// the real routes source — every /governance route must gate on the
|
|
7
|
-
// `
|
|
7
|
+
// `government.manage` atom through real middleware, and every declared
|
|
8
8
|
// route must be pinned in MODULE_EXPECTED_RANKS. The pin set and the
|
|
9
9
|
// declared set are compared BOTH ways, so adding an unpinned governance
|
|
10
10
|
// route (or orphaning a pin) fails here before the grader sees it.
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
// tests assert that, so a later tightening can't quietly re-close the owner's
|
|
18
18
|
// lever); the ARCHON rank-role and every SUBSTRATE key stay refused; rank-role
|
|
19
19
|
// ASSIGNMENT is still rank-derived (R95b); and every route denies without the
|
|
20
|
-
// `
|
|
20
|
+
// `government.manage` grant.
|
|
21
21
|
//
|
|
22
22
|
// Run: node tests/governance_routes.mjs
|
|
23
23
|
|
|
@@ -33,53 +33,53 @@ const require = createRequire(import.meta.url);
|
|
|
33
33
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
34
34
|
const api = require(path.join(ROOT, 'src', 'module-api.js'));
|
|
35
35
|
const seams = require(path.join(ROOT, 'src', 'module-seams.js'));
|
|
36
|
-
const catalog = require(path.join(ROOT, 'modules', '
|
|
37
|
-
const govDb = require(path.join(ROOT, 'modules', '
|
|
36
|
+
const catalog = require(path.join(ROOT, 'modules', 'government', 'catalog.js'));
|
|
37
|
+
const govDb = require(path.join(ROOT, 'modules', 'government', 'db.js'));
|
|
38
38
|
const rrc = require(path.join(ROOT, 'src', 'bongos', 'route-rank-check.js'));
|
|
39
39
|
const { attachFail } = require(path.join(ROOT, 'src', 'bongos', 'middleware', 'error-envelope.js'));
|
|
40
40
|
const express = require('express');
|
|
41
41
|
|
|
42
|
-
const ROUTES_SRC = path.join(ROOT, 'modules', '
|
|
42
|
+
const ROUTES_SRC = path.join(ROOT, 'modules', 'government', 'routes', 'government.js');
|
|
43
43
|
|
|
44
44
|
// ===========================================================================
|
|
45
45
|
// Layer 1 — gate classification + route-rank-check pins
|
|
46
46
|
// ===========================================================================
|
|
47
47
|
|
|
48
48
|
const classified = rrc.classifyText(fs.readFileSync(ROUTES_SRC, 'utf8'))
|
|
49
|
-
.filter((r) => r.route.startsWith('/
|
|
49
|
+
.filter((r) => r.route.startsWith('/government'));
|
|
50
50
|
|
|
51
51
|
test('the routes file declares the full R110 surface', () => {
|
|
52
52
|
const declared = new Set(classified.map((r) => r.key));
|
|
53
53
|
const wanted = [
|
|
54
|
-
'GET /
|
|
55
|
-
'GET /
|
|
56
|
-
'GET /
|
|
57
|
-
'POST /
|
|
58
|
-
'PUT /
|
|
59
|
-
'DELETE /
|
|
60
|
-
'POST /
|
|
61
|
-
'POST /
|
|
62
|
-
'DELETE /
|
|
54
|
+
'GET /government/ranks',
|
|
55
|
+
'GET /government/permissions',
|
|
56
|
+
'GET /government/assignments',
|
|
57
|
+
'POST /government/ranks',
|
|
58
|
+
'PUT /government/ranks/:rankKey/permissions/:permissionKey',
|
|
59
|
+
'DELETE /government/ranks/:rankKey/permissions/:permissionKey',
|
|
60
|
+
'POST /government/ranks/:rankKey/reset',
|
|
61
|
+
'POST /government/assignments',
|
|
62
|
+
'DELETE /government/assignments/:builderId/:rankKey',
|
|
63
63
|
];
|
|
64
64
|
for (const key of wanted) assert.ok(declared.has(key), `missing route ${key}`);
|
|
65
65
|
});
|
|
66
66
|
|
|
67
|
-
test('every /governance route gates on
|
|
68
|
-
assert.ok(classified.length > 0, 'no /
|
|
67
|
+
test('every /governance route gates on government.manage via REAL middleware', () => {
|
|
68
|
+
assert.ok(classified.length > 0, 'no /government routes found to classify');
|
|
69
69
|
for (const r of classified) {
|
|
70
70
|
assert.equal(r.via, 'middleware', `${r.key}: gate must be middleware, not an annotation`);
|
|
71
|
-
assert.deepEqual(r.perms, ['
|
|
72
|
-
assert.equal(r.rank, 'archon', `${r.key}:
|
|
71
|
+
assert.deepEqual(r.perms, ['government.manage'], `${r.key}: must gate on exactly government.manage`);
|
|
72
|
+
assert.equal(r.rank, 'archon', `${r.key}: government.manage floors at archon`);
|
|
73
73
|
}
|
|
74
74
|
});
|
|
75
75
|
|
|
76
76
|
test('every declared /governance route is pinned, and every pin exists (both directions)', () => {
|
|
77
|
-
const pins = rrc.MODULE_EXPECTED_RANKS.
|
|
78
|
-
assert.ok(pins, 'MODULE_EXPECTED_RANKS has no
|
|
77
|
+
const pins = rrc.MODULE_EXPECTED_RANKS.government;
|
|
78
|
+
assert.ok(pins, 'MODULE_EXPECTED_RANKS has no government block');
|
|
79
79
|
const declared = new Set(classified.map((r) => r.key));
|
|
80
80
|
for (const key of declared) {
|
|
81
|
-
assert.equal(pins[key], `${rrc.PERM_PIN_PREFIX}
|
|
82
|
-
`${key} must be pinned to perm:
|
|
81
|
+
assert.equal(pins[key], `${rrc.PERM_PIN_PREFIX}government.manage`,
|
|
82
|
+
`${key} must be pinned to perm:government.manage`);
|
|
83
83
|
}
|
|
84
84
|
for (const key of Object.keys(pins)) {
|
|
85
85
|
assert.ok(declared.has(key), `pin ${key} names a route the file no longer declares`);
|
|
@@ -88,7 +88,7 @@ test('every declared /governance route is pinned, and every pin exists (both dir
|
|
|
88
88
|
|
|
89
89
|
test('checkRouteRanks passes over the real tree (no governance findings)', () => {
|
|
90
90
|
const result = rrc.checkRouteRanks();
|
|
91
|
-
const ours = result.findings.filter((f) => String(f.file).includes('modules/
|
|
91
|
+
const ours = result.findings.filter((f) => String(f.file).includes('modules/government/'));
|
|
92
92
|
assert.deepEqual(ours, [], `governance findings: ${JSON.stringify(ours, null, 2)}`);
|
|
93
93
|
});
|
|
94
94
|
|
|
@@ -99,18 +99,18 @@ test('checkRouteRanks passes over the real tree (no governance findings)', () =>
|
|
|
99
99
|
// The db stub: every function the routes call, recording args. Reset per test.
|
|
100
100
|
const calls = [];
|
|
101
101
|
const stub = {
|
|
102
|
-
listRolesWithGrants: async () => [{
|
|
103
|
-
listAssignments: async () => [{ builder_id: 7, github_login: 'x', display_name: 'X', rank: 'metic',
|
|
102
|
+
listRolesWithGrants: async () => [{ rank_key: 'metic', label: 'Metic', description: null, is_system: true, seeded_rank: 'metic', created_by: null, created_at: 't', permissions: ['task.create'] }],
|
|
103
|
+
listAssignments: async () => [{ builder_id: 7, github_login: 'x', display_name: 'X', rank: 'metic', rank_key: 'metic', role_label: 'Metic', is_system: true, seeded_rank: 'metic', assigned_by: null, assigned_at: 't' }],
|
|
104
104
|
// Every rank name is a seeded rank-role; anything else is a custom one. Keyed off
|
|
105
105
|
// the catalog's ladder rather than a hand-listed pair, so a rank added to
|
|
106
106
|
// RANK_ORDER is exercised here instead of quietly falling through as "custom".
|
|
107
107
|
getRoleByKey: async (k) => (k === 'missing' ? null : {
|
|
108
|
-
|
|
108
|
+
rank_key: k,
|
|
109
109
|
label: k,
|
|
110
110
|
is_system: catalog.RANK_ORDER.includes(k),
|
|
111
111
|
seeded_rank: catalog.RANK_ORDER.includes(k) ? k : null,
|
|
112
112
|
}),
|
|
113
|
-
createRole: async (a) => ({
|
|
113
|
+
createRole: async (a) => ({ rank_key: a.rankKey, label: a.label, description: a.description ?? null, is_system: false, seeded_rank: null, created_by: a.createdBy, created_at: 't' }),
|
|
114
114
|
grantRolePermission: async () => true,
|
|
115
115
|
revokeRolePermission: async () => true,
|
|
116
116
|
resetRoleGrants: async ({ permissionKeys }) => permissionKeys.length,
|
|
@@ -131,11 +131,11 @@ api.requireBuilder = (req, res, next) => { req.builder = { id: 42, rank: 'archon
|
|
|
131
131
|
|
|
132
132
|
function grant(keys) {
|
|
133
133
|
seams._reset();
|
|
134
|
-
seams.registerProvider('
|
|
134
|
+
seams.registerProvider('government', { resolveBuilderPermissions: async () => keys });
|
|
135
135
|
}
|
|
136
|
-
grant(['
|
|
136
|
+
grant(['government.manage']);
|
|
137
137
|
|
|
138
|
-
const buildGovernanceRouter = require(path.join(ROOT, 'modules', '
|
|
138
|
+
const buildGovernanceRouter = require(path.join(ROOT, 'modules', 'government', 'routes', 'government.js'));
|
|
139
139
|
const app = express();
|
|
140
140
|
app.use(express.json());
|
|
141
141
|
app.use(attachFail);
|
|
@@ -165,41 +165,41 @@ async function call(method, p, body) {
|
|
|
165
165
|
|
|
166
166
|
// --- the deny wall -----------------------------------------------------------
|
|
167
167
|
|
|
168
|
-
test('without
|
|
168
|
+
test('without government.manage EVERY route 403s (reads included)', async () => {
|
|
169
169
|
grant(['idea.triage', 'task.create']);
|
|
170
170
|
for (const [method, p] of [
|
|
171
|
-
['GET', '/
|
|
172
|
-
['GET', '/
|
|
173
|
-
['GET', '/
|
|
174
|
-
['POST', '/
|
|
175
|
-
['PUT', '/
|
|
176
|
-
['DELETE', '/
|
|
177
|
-
['POST', '/
|
|
178
|
-
['DELETE', '/
|
|
171
|
+
['GET', '/government/ranks'],
|
|
172
|
+
['GET', '/government/permissions'],
|
|
173
|
+
['GET', '/government/assignments'],
|
|
174
|
+
['POST', '/government/ranks'],
|
|
175
|
+
['PUT', '/government/ranks/r/permissions/task.create'],
|
|
176
|
+
['DELETE', '/government/ranks/r/permissions/task.create'],
|
|
177
|
+
['POST', '/government/assignments'],
|
|
178
|
+
['DELETE', '/government/assignments/7/r'],
|
|
179
179
|
]) {
|
|
180
180
|
const r = await call(method, p, method === 'GET' ? undefined : {});
|
|
181
181
|
assert.equal(r.status, 403, `${method} ${p} must deny`);
|
|
182
182
|
assert.equal(r.json.error.code, 'permission_forbidden', `${method} ${p}`);
|
|
183
183
|
}
|
|
184
|
-
grant(['
|
|
184
|
+
grant(['government.manage']);
|
|
185
185
|
});
|
|
186
186
|
|
|
187
187
|
// --- reads -------------------------------------------------------------------
|
|
188
188
|
|
|
189
|
-
test('GET /
|
|
190
|
-
const r = await call('GET', '/
|
|
189
|
+
test('GET /government/ranks returns the role list with grants', async () => {
|
|
190
|
+
const r = await call('GET', '/government/ranks');
|
|
191
191
|
assert.equal(r.status, 200);
|
|
192
|
-
assert.equal(r.json.roles[0].
|
|
192
|
+
assert.equal(r.json.roles[0].rank_key, 'metic');
|
|
193
193
|
assert.deepEqual(r.json.roles[0].permissions, ['task.create']);
|
|
194
194
|
});
|
|
195
195
|
|
|
196
|
-
test('GET /
|
|
197
|
-
const r = await call('GET', '/
|
|
196
|
+
test('GET /government/permissions renders the catalog: grantable flags + principals, no DB call', async () => {
|
|
197
|
+
const r = await call('GET', '/government/permissions');
|
|
198
198
|
assert.equal(r.status, 200);
|
|
199
199
|
assert.equal(calls.length, 0, 'the permission catalog is code, not a table');
|
|
200
200
|
const byKey = new Map(r.json.permissions.map((p) => [p.key, p]));
|
|
201
201
|
assert.equal(byKey.size, catalog.permissionKeys().length, 'every catalog key is listed');
|
|
202
|
-
const sys = byKey.get('
|
|
202
|
+
const sys = byKey.get('government.manage');
|
|
203
203
|
assert.equal(sys.system, true);
|
|
204
204
|
assert.equal(sys.grantable, false, 'a system permission renders non-grantable');
|
|
205
205
|
const op = byKey.get('idea.triage');
|
|
@@ -209,56 +209,56 @@ test('GET /governance/permissions renders the catalog: grantable flags + princip
|
|
|
209
209
|
assert.ok(r.json.principals.every((p) => p.grantable === false));
|
|
210
210
|
});
|
|
211
211
|
|
|
212
|
-
test('GET /
|
|
213
|
-
const r = await call('GET', '/
|
|
212
|
+
test('GET /government/assignments returns builder↔role rows', async () => {
|
|
213
|
+
const r = await call('GET', '/government/assignments');
|
|
214
214
|
assert.equal(r.status, 200);
|
|
215
215
|
assert.equal(r.json.assignments[0].builder_id, 7);
|
|
216
|
-
assert.equal(r.json.assignments[0].
|
|
216
|
+
assert.equal(r.json.assignments[0].rank_key, 'metic');
|
|
217
217
|
});
|
|
218
218
|
|
|
219
219
|
// --- create role -------------------------------------------------------------
|
|
220
220
|
|
|
221
|
-
test('POST /
|
|
222
|
-
const r = await call('POST', '/
|
|
221
|
+
test('POST /government/ranks creates a custom role, created_by = the session builder', async () => {
|
|
222
|
+
const r = await call('POST', '/government/ranks', { rank_key: 'triage-helper', label: 'Triage helper' });
|
|
223
223
|
assert.equal(r.status, 201);
|
|
224
|
-
assert.equal(r.json.role.
|
|
224
|
+
assert.equal(r.json.role.rank_key, 'triage-helper');
|
|
225
225
|
assert.equal(r.json.role.is_system, false);
|
|
226
226
|
const c = calls.find(([k]) => k === 'createRole');
|
|
227
227
|
assert.equal(c[1].createdBy, 42, 'created_by must be the session builder, never input');
|
|
228
228
|
});
|
|
229
229
|
|
|
230
|
-
test('POST /
|
|
231
|
-
const missing = await call('POST', '/
|
|
230
|
+
test('POST /government/ranks validates: label required, rank_key must be a slug', async () => {
|
|
231
|
+
const missing = await call('POST', '/government/ranks', { rank_key: 'x-role' });
|
|
232
232
|
assert.equal(missing.status, 400);
|
|
233
233
|
assert.equal(missing.json.error.code, 'validation_failed');
|
|
234
234
|
for (const bad of ['Has Space', 'UPPER', '-lead', 'dots.bad', '']) {
|
|
235
|
-
const r = await call('POST', '/
|
|
236
|
-
assert.equal(r.status, 400, `
|
|
235
|
+
const r = await call('POST', '/government/ranks', { rank_key: bad, label: 'L' });
|
|
236
|
+
assert.equal(r.status, 400, `rank_key "${bad}" must be rejected`);
|
|
237
237
|
}
|
|
238
238
|
assert.equal(calls.filter(([k]) => k === 'createRole').length, 0, 'nothing reached the db');
|
|
239
239
|
});
|
|
240
240
|
|
|
241
|
-
test('POST /
|
|
241
|
+
test('POST /government/ranks maps a unique-violation to rank_exists 409', async () => {
|
|
242
242
|
const prev = stub.createRole;
|
|
243
243
|
stub.createRole = async () => { const e = new Error('dup'); e.code = '23505'; throw e; };
|
|
244
|
-
const r = await call('POST', '/
|
|
244
|
+
const r = await call('POST', '/government/ranks', { rank_key: 'metic-two', label: 'L' });
|
|
245
245
|
stub.createRole = prev;
|
|
246
246
|
assert.equal(r.status, 409);
|
|
247
|
-
assert.equal(r.json.error.code, '
|
|
247
|
+
assert.equal(r.json.error.code, 'rank_exists');
|
|
248
248
|
});
|
|
249
249
|
|
|
250
250
|
// --- toggle a grant ----------------------------------------------------------
|
|
251
251
|
|
|
252
252
|
test('PUT grant: an operational permission lands on a custom role', async () => {
|
|
253
|
-
const r = await call('PUT', '/
|
|
253
|
+
const r = await call('PUT', '/government/ranks/triage-helper/permissions/idea.triage');
|
|
254
254
|
assert.equal(r.status, 200);
|
|
255
255
|
const c = calls.find(([k]) => k === 'grantRolePermission');
|
|
256
|
-
assert.deepEqual(c[1], {
|
|
256
|
+
assert.deepEqual(c[1], { rankKey: 'triage-helper', permissionKey: 'idea.triage' });
|
|
257
257
|
});
|
|
258
258
|
|
|
259
259
|
test('PUT grant: a SYSTEM permission is rejected as non-grantable — the R110 wall', async () => {
|
|
260
|
-
for (const sysKey of ['
|
|
261
|
-
const r = await call('PUT', `/
|
|
260
|
+
for (const sysKey of ['government.manage', 'deploy.push_main', 'builder.rank.set']) {
|
|
261
|
+
const r = await call('PUT', `/government/ranks/triage-helper/permissions/${sysKey}`);
|
|
262
262
|
assert.equal(r.status, 400, `${sysKey} must be refused`);
|
|
263
263
|
assert.equal(r.json.error.code, 'permission_not_grantable');
|
|
264
264
|
assert.equal(calls.filter(([k]) => k === 'grantRolePermission').length, 0, 'the refusal is BEFORE the db');
|
|
@@ -266,29 +266,29 @@ test('PUT grant: a SYSTEM permission is rejected as non-grantable — the R110 w
|
|
|
266
266
|
});
|
|
267
267
|
|
|
268
268
|
test('PUT grant: an unknown permission fails closed', async () => {
|
|
269
|
-
const r = await call('PUT', '/
|
|
269
|
+
const r = await call('PUT', '/government/ranks/triage-helper/permissions/no.such.key');
|
|
270
270
|
assert.equal(r.status, 400);
|
|
271
271
|
assert.equal(r.json.error.code, 'unknown_permission');
|
|
272
272
|
});
|
|
273
273
|
|
|
274
274
|
test('PUT grant: a sub-Archon rank-role is EDITABLE (ADR 0172)', async () => {
|
|
275
|
-
const r = await call('PUT', '/
|
|
275
|
+
const r = await call('PUT', '/government/ranks/metic/permissions/idea.triage');
|
|
276
276
|
assert.equal(r.status, 200);
|
|
277
277
|
const c = calls.find(([k]) => k === 'grantRolePermission');
|
|
278
|
-
assert.deepEqual(c[1], {
|
|
278
|
+
assert.deepEqual(c[1], { rankKey: 'metic', permissionKey: 'idea.triage' });
|
|
279
279
|
});
|
|
280
280
|
|
|
281
281
|
test('PUT grant: an in-app owner power CAN go to a rank-role but never to a custom one', async () => {
|
|
282
|
-
const rank = await call('PUT', '/
|
|
282
|
+
const rank = await call('PUT', '/government/ranks/metic/permissions/builder.rank.set');
|
|
283
283
|
assert.equal(rank.status, 200, 'the owner may delegate an in-app Archon power to a rank');
|
|
284
|
-
const custom = await call('PUT', '/
|
|
284
|
+
const custom = await call('PUT', '/government/ranks/triage-helper/permissions/builder.rank.set');
|
|
285
285
|
assert.equal(custom.status, 400, 'a custom role keeps the R110 wall');
|
|
286
286
|
assert.equal(custom.json.error.code, 'permission_not_grantable');
|
|
287
287
|
});
|
|
288
288
|
|
|
289
289
|
test('PUT grant: a SUBSTRATE key is refused even on a rank-role (it would confer nothing)', async () => {
|
|
290
290
|
for (const key of ['deploy.push_main', 'path.protected.modify', 'infra.ssh']) {
|
|
291
|
-
const r = await call('PUT', `/
|
|
291
|
+
const r = await call('PUT', `/government/ranks/metic/permissions/${key}`);
|
|
292
292
|
assert.equal(r.status, 400, `${key} must be refused`);
|
|
293
293
|
assert.equal(r.json.error.code, 'permission_not_grantable');
|
|
294
294
|
assert.match(r.json.error.details.reason || '', /substrate/i,
|
|
@@ -298,39 +298,39 @@ test('PUT grant: a SUBSTRATE key is refused even on a rank-role (it would confer
|
|
|
298
298
|
});
|
|
299
299
|
|
|
300
300
|
test('PUT grant: the ARCHON rank-role stays immutable (the lockout guard)', async () => {
|
|
301
|
-
const r = await call('PUT', '/
|
|
301
|
+
const r = await call('PUT', '/government/ranks/archon/permissions/idea.triage');
|
|
302
302
|
assert.equal(r.status, 403);
|
|
303
|
-
assert.equal(r.json.error.code, '
|
|
303
|
+
assert.equal(r.json.error.code, 'archon_rank_immutable');
|
|
304
304
|
assert.equal(calls.filter(([k]) => k === 'grantRolePermission').length, 0);
|
|
305
305
|
});
|
|
306
306
|
|
|
307
307
|
test('PUT grant: an unknown role 404s', async () => {
|
|
308
|
-
const r = await call('PUT', '/
|
|
308
|
+
const r = await call('PUT', '/government/ranks/missing/permissions/idea.triage');
|
|
309
309
|
assert.equal(r.status, 404);
|
|
310
|
-
assert.equal(r.json.error.code, '
|
|
310
|
+
assert.equal(r.json.error.code, 'rank_not_found');
|
|
311
311
|
});
|
|
312
312
|
|
|
313
313
|
test('DELETE grant revokes without consulting the catalog (stale keys stay removable)', async () => {
|
|
314
|
-
const r = await call('DELETE', '/
|
|
314
|
+
const r = await call('DELETE', '/government/ranks/triage-helper/permissions/some.retired.key');
|
|
315
315
|
assert.equal(r.status, 200);
|
|
316
316
|
const c = calls.find(([k]) => k === 'revokeRolePermission');
|
|
317
|
-
assert.deepEqual(c[1], {
|
|
317
|
+
assert.deepEqual(c[1], { rankKey: 'triage-helper', permissionKey: 'some.retired.key' });
|
|
318
318
|
});
|
|
319
319
|
|
|
320
320
|
test('DELETE grant: revocable on a sub-Archon rank-role, refused on the Archon one', async () => {
|
|
321
|
-
const metic = await call('DELETE', '/
|
|
321
|
+
const metic = await call('DELETE', '/government/ranks/metic/permissions/idea.triage');
|
|
322
322
|
assert.equal(metic.status, 200, 'taking a permission away from a rank is the other half of editable');
|
|
323
323
|
|
|
324
|
-
const archon = await call('DELETE', '/
|
|
324
|
+
const archon = await call('DELETE', '/government/ranks/archon/permissions/idea.triage');
|
|
325
325
|
assert.equal(archon.status, 403);
|
|
326
|
-
assert.equal(archon.json.error.code, '
|
|
326
|
+
assert.equal(archon.json.error.code, 'archon_rank_immutable');
|
|
327
327
|
assert.equal(calls.filter(([k]) => k === 'revokeRolePermission').length, 0);
|
|
328
328
|
});
|
|
329
329
|
|
|
330
330
|
// --- reset (ADR 0172) --------------------------------------------------------
|
|
331
331
|
|
|
332
332
|
test('POST reset: a rank-role goes back to its floor-derived catalog seed', async () => {
|
|
333
|
-
const r = await call('POST', '/
|
|
333
|
+
const r = await call('POST', '/government/ranks/thetes/reset');
|
|
334
334
|
assert.equal(r.status, 200);
|
|
335
335
|
assert.equal(r.json.seeded_rank, 'thetes');
|
|
336
336
|
const c = calls.find(([k]) => k === 'resetRoleGrants');
|
|
@@ -338,57 +338,57 @@ test('POST reset: a rank-role goes back to its floor-derived catalog seed', asyn
|
|
|
338
338
|
});
|
|
339
339
|
|
|
340
340
|
test('POST reset: refused on a custom role (no catalog default to return to)', async () => {
|
|
341
|
-
const r = await call('POST', '/
|
|
341
|
+
const r = await call('POST', '/government/ranks/triage-helper/reset');
|
|
342
342
|
assert.equal(r.status, 400);
|
|
343
|
-
assert.equal(r.json.error.code, '
|
|
343
|
+
assert.equal(r.json.error.code, 'rank_has_no_default');
|
|
344
344
|
assert.equal(calls.filter(([k]) => k === 'resetRoleGrants').length, 0);
|
|
345
345
|
});
|
|
346
346
|
|
|
347
347
|
// --- assignments -------------------------------------------------------------
|
|
348
348
|
|
|
349
|
-
test('POST /
|
|
350
|
-
const r = await call('POST', '/
|
|
349
|
+
test('POST /government/assignments assigns a custom role, assigned_by = the session builder', async () => {
|
|
350
|
+
const r = await call('POST', '/government/assignments', { builder_id: 7, rank_key: 'triage-helper' });
|
|
351
351
|
assert.equal(r.status, 201);
|
|
352
352
|
const c = calls.find(([k]) => k === 'assignRole');
|
|
353
|
-
assert.deepEqual(c[1], { builderId: 7,
|
|
353
|
+
assert.deepEqual(c[1], { builderId: 7, rankKey: 'triage-helper', assignedBy: 42 });
|
|
354
354
|
});
|
|
355
355
|
|
|
356
|
-
test('POST /
|
|
357
|
-
const r = await call('POST', '/
|
|
356
|
+
test('POST /government/assignments refuses a seeded rank-role (rank is the writer — R95b)', async () => {
|
|
357
|
+
const r = await call('POST', '/government/assignments', { builder_id: 7, rank_key: 'archon' });
|
|
358
358
|
assert.equal(r.status, 403);
|
|
359
|
-
assert.equal(r.json.error.code, '
|
|
359
|
+
assert.equal(r.json.error.code, 'seeded_rank_assignment_forbidden');
|
|
360
360
|
assert.equal(calls.filter(([k]) => k === 'assignRole').length, 0);
|
|
361
361
|
});
|
|
362
362
|
|
|
363
|
-
test('POST /
|
|
363
|
+
test('POST /government/assignments maps a missing builder (FK) to 404', async () => {
|
|
364
364
|
const prev = stub.assignRole;
|
|
365
365
|
stub.assignRole = async () => { const e = new Error('fk'); e.code = '23503'; throw e; };
|
|
366
|
-
const r = await call('POST', '/
|
|
366
|
+
const r = await call('POST', '/government/assignments', { builder_id: 999, rank_key: 'triage-helper' });
|
|
367
367
|
stub.assignRole = prev;
|
|
368
368
|
assert.equal(r.status, 404);
|
|
369
369
|
assert.equal(r.json.error.code, 'builder_not_found');
|
|
370
370
|
});
|
|
371
371
|
|
|
372
|
-
test('POST /
|
|
373
|
-
const r = await call('POST', '/
|
|
372
|
+
test('POST /government/assignments validates builder_id and rank_key', async () => {
|
|
373
|
+
const r = await call('POST', '/government/assignments', { builder_id: 'seven', rank_key: 'triage-helper' });
|
|
374
374
|
assert.equal(r.status, 400);
|
|
375
375
|
assert.equal(r.json.error.code, 'validation_failed');
|
|
376
376
|
});
|
|
377
377
|
|
|
378
|
-
test('DELETE /
|
|
379
|
-
const r = await call('DELETE', '/
|
|
378
|
+
test('DELETE /government/assignments/:builderId/:rankKey unassigns a custom role', async () => {
|
|
379
|
+
const r = await call('DELETE', '/government/assignments/7/triage-helper');
|
|
380
380
|
assert.equal(r.status, 200);
|
|
381
381
|
const c = calls.find(([k]) => k === 'unassignRole');
|
|
382
|
-
assert.deepEqual(c[1], { builderId: 7,
|
|
382
|
+
assert.deepEqual(c[1], { builderId: 7, rankKey: 'triage-helper' });
|
|
383
383
|
});
|
|
384
384
|
|
|
385
385
|
test('DELETE /governance/assignments refuses a seeded rank-role', async () => {
|
|
386
|
-
const r = await call('DELETE', '/
|
|
386
|
+
const r = await call('DELETE', '/government/assignments/7/metic');
|
|
387
387
|
assert.equal(r.status, 403);
|
|
388
|
-
assert.equal(r.json.error.code, '
|
|
388
|
+
assert.equal(r.json.error.code, 'seeded_rank_assignment_forbidden');
|
|
389
389
|
});
|
|
390
390
|
|
|
391
391
|
test('DELETE /governance/assignments validates the builder id param', async () => {
|
|
392
|
-
const r = await call('DELETE', '/
|
|
392
|
+
const r = await call('DELETE', '/government/assignments/not-a-number/triage-helper');
|
|
393
393
|
assert.equal(r.status, 400);
|
|
394
394
|
});
|
|
@@ -15,9 +15,9 @@ import { fileURLToPath } from 'node:url';
|
|
|
15
15
|
|
|
16
16
|
const require = createRequire(import.meta.url);
|
|
17
17
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
18
|
-
const { RANK_ROLE_SEED, RANK_ORDER } = require(path.join(ROOT, 'modules', '
|
|
18
|
+
const { RANK_ROLE_SEED, RANK_ORDER } = require(path.join(ROOT, 'modules', 'government', 'catalog.js'));
|
|
19
19
|
|
|
20
|
-
const MIG_DIR = path.join(ROOT, 'modules', '
|
|
20
|
+
const MIG_DIR = path.join(ROOT, 'modules', 'government', 'migrations');
|
|
21
21
|
const seedSql = fs.readFileSync(path.join(MIG_DIR, 'governance_002_seed_and_backfill.sql'), 'utf8');
|
|
22
22
|
const tablesSql = fs.readFileSync(path.join(MIG_DIR, 'governance_001_tables.sql'), 'utf8');
|
|
23
23
|
|
|
@@ -28,8 +28,31 @@ const tablesSql = fs.readFileSync(path.join(MIG_DIR, 'governance_001_tables.sql'
|
|
|
28
28
|
// ADR 0152), and what must equal RANK_ROLE_SEED is the union of them all, not any one
|
|
29
29
|
// file. Read in filename order; every file is scanned for `SELECT '<role>', unnest(…)`
|
|
30
30
|
// blocks, so a new grant migration needs no change here.
|
|
31
|
+
// Migrations that do NOT grant. Excluded by NAME rather than by "does it
|
|
32
|
+
// INSERT?" so that `a grant migration must INSERT something` below stays a real
|
|
33
|
+
// assertion: a file we believe grants but which inserts nothing is a bug worth
|
|
34
|
+
// failing on, and a content-sniffing filter would quietly excuse it.
|
|
35
|
+
const NON_GRANT_MIGRATIONS = new Set([
|
|
36
|
+
'governance_001_tables.sql', // creates the three tables
|
|
37
|
+
'government_001_rename_from_governance.sql', // ADR 0174 table rename (task 1003004)
|
|
38
|
+
'government_002_rename_permission_keys.sql', // ADR 0174 key rename (task 1003006)
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
// Permission keys RENAMED after the grant migrations that first granted them.
|
|
42
|
+
// The union model below is append-only — it reads INSERTs — so a rename is
|
|
43
|
+
// invisible to it: the historical migration still grants the old spelling while
|
|
44
|
+
// the catalog now declares the new one. Applying this map to the union is what
|
|
45
|
+
// makes the comparison describe the EFFECTIVE grant set after every migration
|
|
46
|
+
// has run, which is what the seed actually is.
|
|
47
|
+
// Each entry must be performed by a real migration; government_002 does these two.
|
|
48
|
+
const RENAMED_KEYS = new Map([
|
|
49
|
+
['governance.manage', 'government.manage'],
|
|
50
|
+
['page.view.governance', 'page.view.government'],
|
|
51
|
+
]);
|
|
52
|
+
const applyRenames = (keys) => keys.map((k) => RENAMED_KEYS.get(k) || k);
|
|
53
|
+
|
|
31
54
|
const grantMigrationFiles = fs.readdirSync(MIG_DIR)
|
|
32
|
-
.filter((f) => f.endsWith('.sql') && f
|
|
55
|
+
.filter((f) => f.endsWith('.sql') && !NON_GRANT_MIGRATIONS.has(f))
|
|
33
56
|
.sort();
|
|
34
57
|
const grantSqlByFile = grantMigrationFiles.map((f) => [f, fs.readFileSync(path.join(MIG_DIR, f), 'utf8')]);
|
|
35
58
|
|
|
@@ -53,7 +76,7 @@ test('the union of every grant migration matches RANK_ROLE_SEED exactly (no drif
|
|
|
53
76
|
for (const b of blocks) {
|
|
54
77
|
assert.equal(b.keys.length, new Set(b.keys).size, `${role}: duplicate permission in the ${b.file} array`);
|
|
55
78
|
}
|
|
56
|
-
const granted = blocks.flatMap((b) => b.keys);
|
|
79
|
+
const granted = applyRenames(blocks.flatMap((b) => b.keys));
|
|
57
80
|
// set-equality (order in SQL is illustrative; the grant set is what matters)
|
|
58
81
|
assert.deepEqual(new Set(granted), new Set(RANK_ROLE_SEED[role]),
|
|
59
82
|
`${role}: grant migrations drift from catalog RANK_ROLE_SEED`);
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// ORACLE DISCIPLINE. A test whose two sides read the same source of truth passes
|
|
19
19
|
// while proving nothing, so nothing load-bearing here is driven by the registry:
|
|
20
20
|
// • the Archon-box assertions name Archon powers LITERALLY (builder.rank.set,
|
|
21
|
-
//
|
|
21
|
+
// government.manage, deploy.push_main …) and cross-check them against `floor`
|
|
22
22
|
// run through auth.rankMeetsThreshold — the REAL production rank gate, a
|
|
23
23
|
// pre-R99 oracle;
|
|
24
24
|
// • the deny-by-default assertions pin a hand-written expected set, so widening
|
|
@@ -38,7 +38,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
38
38
|
|
|
39
39
|
const require = createRequire(import.meta.url);
|
|
40
40
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
41
|
-
const GOV = path.join(ROOT, 'modules', '
|
|
41
|
+
const GOV = path.join(ROOT, 'modules', 'government');
|
|
42
42
|
|
|
43
43
|
// auth.js destructures lookupSession at load, so the stub must be installed before
|
|
44
44
|
// ANYTHING pulls auth.js in (src/module-api does, via the governance route file).
|
|
@@ -66,7 +66,7 @@ const EXPECTED_BOX_SCOPE = ['box.manage.own'];
|
|
|
66
66
|
// Archon powers, named literally. If a box session ever resolves one of these the
|
|
67
67
|
// #919 containment is gone.
|
|
68
68
|
const ARCHON_POWERS = [
|
|
69
|
-
'builder.rank.set', '
|
|
69
|
+
'builder.rank.set', 'government.manage', 'deploy.push_main', 'version.close',
|
|
70
70
|
'task.confirm.grade_bypass', 'path.protected.modify', 'infra.ssh',
|
|
71
71
|
'builder.roster.read', 'claim.release.any', 'memory.read.any', 'audit_log.read',
|
|
72
72
|
];
|
|
@@ -326,7 +326,7 @@ test('F2: an unscoped session reaches every route, declared or not', () => {
|
|
|
326
326
|
assert.equal(auth.sessionScopeAdmits(source, undefined), true);
|
|
327
327
|
assert.equal(auth.sessionScopeAdmits(source, []), true);
|
|
328
328
|
assert.equal(auth.sessionScopeAdmits(source, ['box.manage.own']), true);
|
|
329
|
-
assert.equal(auth.sessionScopeAdmits(source, ['
|
|
329
|
+
assert.equal(auth.sessionScopeAdmits(source, ['government.manage']), true);
|
|
330
330
|
}
|
|
331
331
|
});
|
|
332
332
|
|
|
@@ -419,7 +419,7 @@ test('H3: the resolve is still per-request and frozen — no memoised session au
|
|
|
419
419
|
// ═══ I — the ONE governance port carries the axis ════════════════════════════
|
|
420
420
|
|
|
421
421
|
test('I1: the session-scope axis lands on the single governance port', () => {
|
|
422
|
-
const { governancePort } = require(path.join(GOV, 'routes', '
|
|
422
|
+
const { governancePort } = require(path.join(GOV, 'routes', 'government.js'));
|
|
423
423
|
for (const name of ['resolveSessionPermissions', 'narrowToSessionScope', 'sessionScopeFor', 'listSessionScopes']) {
|
|
424
424
|
assert.equal(typeof governancePort[name], 'function', `the port must expose ${name}`);
|
|
425
425
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// tests/governance_vanilla_boot.mjs — the VANILLA BOOT proof (BV1.R102, ADR 0151).
|
|
2
2
|
//
|
|
3
3
|
// The core/host split only holds if a FRESH instance — one that has never been
|
|
4
|
-
// branded, with no config/
|
|
4
|
+
// branded, with no config/government.json on disk and no rows in
|
|
5
5
|
// governance_builder_roles — comes up sane, anonymous, and powerless. This file is
|
|
6
6
|
// that proof. It is deliberately scoped to what R96's tests/governance_config.mjs
|
|
7
7
|
// does NOT cover: that file pins the config CONTRACT (precedence, per-field merge,
|
|
@@ -37,7 +37,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
37
37
|
|
|
38
38
|
const require = createRequire(import.meta.url);
|
|
39
39
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
40
|
-
const GOV = path.join(ROOT, 'modules', '
|
|
40
|
+
const GOV = path.join(ROOT, 'modules', 'government');
|
|
41
41
|
const catalog = require(path.join(GOV, 'catalog.js'));
|
|
42
42
|
const { loadGovernanceConfig, resolveGovernanceConfig, NEUTRAL_PATH, INSTANCE_PATH } = require(path.join(GOV, 'config.js'));
|
|
43
43
|
const { effectivePermissions, resolveBuilderPermissions } = require(path.join(GOV, 'resolver.js'));
|
|
@@ -127,10 +127,10 @@ test('the identity guard is not a no-op — a planted host brand in the neutral
|
|
|
127
127
|
// trip it, in the exact file this task is about.
|
|
128
128
|
for (const brand of ['Off the Boats', 'amazonprimea.com']) {
|
|
129
129
|
const poisoned = NEUTRAL_TEXT.replace('"roles"', `"instance": "${brand}",\n "roles"`);
|
|
130
|
-
const found = fitness.scanPromptIdentity('config/
|
|
130
|
+
const found = fitness.scanPromptIdentity('config/government.neutral.json', poisoned, '.json');
|
|
131
131
|
assert.ok(found.length >= 1, `a "${brand}" literal in the neutral pack must be caught`);
|
|
132
132
|
}
|
|
133
|
-
assert.deepEqual(fitness.scanPromptIdentity('config/
|
|
133
|
+
assert.deepEqual(fitness.scanPromptIdentity('config/government.neutral.json', NEUTRAL_TEXT, '.json'), [], 'the real pack is clean');
|
|
134
134
|
});
|
|
135
135
|
|
|
136
136
|
// ── (c) the trust boundary on a vanilla boot ────────────────────────────────────
|
|
@@ -32,12 +32,12 @@ const SRC = fs.readFileSync(path.join(ROOT, 'src/bongos/serve-internal.js'), 'ut
|
|
|
32
32
|
// correct for the genuinely public pages and a BUG anywhere else, so every such
|
|
33
33
|
// row is spelled out rather than left to a default.
|
|
34
34
|
//
|
|
35
|
-
// Audiences (the floors live in modules/
|
|
35
|
+
// Audiences (the floors live in modules/government/catalog.js; the gate
|
|
36
36
|
// functions in src/bongos/auth.js):
|
|
37
37
|
// requireBuilderPage page.view.public any signed-in builder (xenos+)
|
|
38
|
-
// requireGovernancePage page.view.
|
|
38
|
+
// requireGovernancePage page.view.government metic+ (ADR 0157)
|
|
39
39
|
// requireProjectCuratePage project.curate metic+
|
|
40
|
-
// requireGovernanceManagePage
|
|
40
|
+
// requireGovernanceManagePage government.manage archon
|
|
41
41
|
const ORACLE = {
|
|
42
42
|
// Any signed-in builder — no rank floor.
|
|
43
43
|
'/builders/work': 'requireBuilderPage',
|
|
@@ -336,8 +336,8 @@ app.use(attachFail);
|
|
|
336
336
|
// currently impersonating, so the cases below exercise the PERMISSION decision rather
|
|
337
337
|
// than the port-missing fallback.
|
|
338
338
|
const seams = require('../src/module-seams.js');
|
|
339
|
-
const catalog = require('../modules/
|
|
340
|
-
seams.registerProvider('
|
|
339
|
+
const catalog = require('../modules/government/catalog.js');
|
|
340
|
+
seams.registerProvider('government', {
|
|
341
341
|
resolveBuilderPermissions: async () => catalog.seedForRank(
|
|
342
342
|
(fixture.session && fixture.session.rank) || 'xenos',
|
|
343
343
|
),
|
package/tests/module_loader.mjs
CHANGED
|
@@ -197,7 +197,7 @@ test('the live tree discovers the extracted modules cleanly (BV1.R43/R48/R47/R49
|
|
|
197
197
|
// That is precisely how the agents regression reached an open PR (task 1002485).
|
|
198
198
|
loader._reset();
|
|
199
199
|
const snap = loader.loaderSnapshot();
|
|
200
|
-
assert.deepEqual(snap.loaded, ['agents', 'autonomy', 'builder-settings', 'dev-box', 'discord', 'economy', '
|
|
200
|
+
assert.deepEqual(snap.loaded, ['agents', 'autonomy', 'builder-settings', 'dev-box', 'discord', 'economy', 'government', 'grading', 'hall-ui', 'ideas', 'lifecycle', 'memory', 'onboarding', 'platform-identity', 'provisioning', 'security', 'sessions', 'status-ui']);
|
|
201
201
|
assert.deepEqual(snap.errors, []);
|
|
202
202
|
assert.deepEqual(snap.skipped, []);
|
|
203
203
|
loader._reset();
|
|
@@ -43,8 +43,8 @@ async function t(name, fn) {
|
|
|
43
43
|
// src/bongos/routes, so we patch the resolved module before requiring the
|
|
44
44
|
// routes.
|
|
45
45
|
const authPath = require.resolve('../src/bongos/auth.js');
|
|
46
|
-
const catalog = require('../modules/
|
|
47
|
-
// Mirrors the canonical ladder in modules/
|
|
46
|
+
const catalog = require('../modules/government/catalog.js');
|
|
47
|
+
// Mirrors the canonical ladder in modules/government/catalog.js — omitting a rung
|
|
48
48
|
// would resolve its floor to Infinity and deny everyone, including archon. Kept at
|
|
49
49
|
// module scope (BV1.R104) so requireRank and requirePermission below share ONE
|
|
50
50
|
// ladder; R105's branch still had the stale 3-rung inline copy that dropped thetes.
|