@bongos/core 1.19.184 → 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 +59 -54
- 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/government/catalog.js +7 -7
- package/modules/government/db.js +8 -8
- package/modules/government/migrations/government_002_rename_permission_keys.sql +112 -0
- package/modules/government/module.json +1 -1
- package/modules/government/protected-surfaces.json +0 -1
- package/modules/government/ranks.js +13 -13
- package/modules/government/routes/{governance.js → government.js} +105 -80
- 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/routes/gate-approvals.js +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/publish-manifest.js +0 -2
- package/scripts/gds/seed-governance-tasks.js +1 -1
- package/src/bongos/auth.js +7 -7
- package/src/bongos/module-scope-map.js +7 -22
- package/src/bongos/route-rank-check.js +12 -18
- package/src/bongos/serve-internal.js +2 -2
- package/src/module-api.js +4 -2
- package/tests/governance_abuse_matrix.mjs +32 -32
- package/tests/governance_catalog.mjs +3 -3
- package/tests/governance_fold.mjs +2 -2
- package/tests/governance_fold_bench.mjs +2 -2
- package/tests/governance_live_proof.mjs +4 -4
- package/tests/governance_ownership_scope.mjs +1 -1
- package/tests/governance_page_gates.mjs +4 -4
- package/tests/governance_principals.mjs +2 -2
- package/tests/governance_protected_surfaces.mjs +41 -16
- package/tests/governance_require_permission.mjs +5 -5
- package/tests/governance_resolver.mjs +2 -2
- package/tests/governance_role_sync.mjs +23 -23
- package/tests/governance_routes.mjs +94 -94
- package/tests/governance_seed.mjs +24 -9
- package/tests/governance_session_scope.mjs +4 -4
- package/tests/hall_page_gate_map.mjs +2 -2
|
@@ -149,7 +149,7 @@ test('resolved principal sets are frozen — a caller cannot widen one', () => {
|
|
|
149
149
|
// ── show, never grant ───────────────────────────────────────────────────────────
|
|
150
150
|
|
|
151
151
|
test('the tab surface is a read-only projection: shown, flagged non-grantable', () => {
|
|
152
|
-
const { governancePort } = require(path.join(GOV, 'routes', '
|
|
152
|
+
const { governancePort } = require(path.join(GOV, 'routes', 'government.js'));
|
|
153
153
|
assert.equal(typeof governancePort.listPrincipals, 'function');
|
|
154
154
|
assert.equal(typeof governancePort.resolvePrincipalPermissions, 'function');
|
|
155
155
|
const listed = governancePort.listPrincipals();
|
|
@@ -162,7 +162,7 @@ test('the tab surface is a read-only projection: shown, flagged non-grantable',
|
|
|
162
162
|
});
|
|
163
163
|
|
|
164
164
|
test('the governance port exposes no way to create, grant, or assign a principal', () => {
|
|
165
|
-
const { governancePort } = require(path.join(GOV, 'routes', '
|
|
165
|
+
const { governancePort } = require(path.join(GOV, 'routes', 'government.js'));
|
|
166
166
|
const mutator = /(create|grant|assign|add|set|write|provision|revoke|delete|update)/i;
|
|
167
167
|
for (const name of Object.keys(governancePort)) {
|
|
168
168
|
if (!/principal/i.test(name)) continue;
|
|
@@ -92,22 +92,34 @@ const EXPECTED_GLOBS_BEFORE_R101 = [
|
|
|
92
92
|
// Each entry is { after, glob, why }: `after` is the glob it is inserted
|
|
93
93
|
// directly below, so the expected list is reconstructed in order rather than
|
|
94
94
|
// re-pasted (a re-paste is how the two lists drift apart).
|
|
95
|
-
const ADDED_SINCE_R101 = [
|
|
95
|
+
const ADDED_SINCE_R101 = [];
|
|
96
|
+
|
|
97
|
+
// Surfaces RENAMED since R101 — still protected, under a new path. Distinct from
|
|
98
|
+
// a removal on purpose: a rename keeps the protection and moves it, a removal
|
|
99
|
+
// ends it. The subset test below accepts a documented rename and rejects a bare
|
|
100
|
+
// disappearance, so "we renamed it" can never be used to quietly unprotect
|
|
101
|
+
// something — the replacement must actually be in the registry.
|
|
102
|
+
const RENAMED_SINCE_R101 = [
|
|
96
103
|
{
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
why: 'goal 1000068 /
|
|
100
|
-
+ '
|
|
101
|
-
+ '
|
|
102
|
-
+ '
|
|
103
|
-
+ '
|
|
104
|
+
from: 'modules/governance/',
|
|
105
|
+
to: 'modules/government/',
|
|
106
|
+
why: 'goal 1000068 / ADR 0174 — the authz core was renamed governance→government. '
|
|
107
|
+
+ 'The folder moved in task 1003004 and the one-release compatibility glob was '
|
|
108
|
+
+ 'dropped in R05 (task 1003006) along with the /governance/* route aliases. '
|
|
109
|
+
+ 'Protection did not lapse at any point: the new glob was registered BEFORE '
|
|
110
|
+
+ 'the folder moved (task 1003003), so both were listed across the window.',
|
|
104
111
|
},
|
|
105
112
|
];
|
|
106
113
|
|
|
107
114
|
// The list the registry must produce TODAY: the pre-R101 set with each
|
|
108
115
|
// documented addition spliced in at its declared position.
|
|
109
116
|
const EXPECTED_GLOBS = (() => {
|
|
110
|
-
|
|
117
|
+
let out = [...EXPECTED_GLOBS_BEFORE_R101];
|
|
118
|
+
for (const r of RENAMED_SINCE_R101) {
|
|
119
|
+
const at = out.indexOf(r.from);
|
|
120
|
+
assert.notEqual(at, -1, `RENAMED_SINCE_R101 renames '${r.from}', which is not in the list`);
|
|
121
|
+
out[at] = r.to;
|
|
122
|
+
}
|
|
111
123
|
for (const a of ADDED_SINCE_R101) {
|
|
112
124
|
const at = out.indexOf(a.after);
|
|
113
125
|
assert.notEqual(at, -1, `ADDED_SINCE_R101 anchors on '${a.after}', which is not in the list`);
|
|
@@ -119,15 +131,28 @@ const EXPECTED_GLOBS = (() => {
|
|
|
119
131
|
// ---------- behavior identity: the same globs, at the same floor ----------
|
|
120
132
|
|
|
121
133
|
test('nothing protected before R101 has been unprotected since', () => {
|
|
122
|
-
// The load-bearing half.
|
|
123
|
-
//
|
|
124
|
-
//
|
|
134
|
+
// The load-bearing half. A surface may be RENAMED (protection moves with it)
|
|
135
|
+
// but never simply dropped. Renames must be declared, and the declared
|
|
136
|
+
// replacement must actually be present — otherwise "it was renamed" becomes a
|
|
137
|
+
// way to unprotect a trust surface with a comment.
|
|
138
|
+
const renamedFrom = new Map(RENAMED_SINCE_R101.map((r) => [r.from, r.to]));
|
|
125
139
|
for (const glob of EXPECTED_GLOBS_BEFORE_R101) {
|
|
126
|
-
|
|
127
|
-
|
|
140
|
+
if (registry.PROTECTED_GLOBS.includes(glob)) continue;
|
|
141
|
+
const to = renamedFrom.get(glob);
|
|
142
|
+
assert.ok(to,
|
|
128
143
|
`'${glob}' was protected before R101 and is no longer in the registry — `
|
|
129
|
-
+ 'a protected surface cannot be dropped without an ADR saying why.'
|
|
130
|
-
|
|
144
|
+
+ 'a protected surface cannot be dropped without an ADR saying why. If it '
|
|
145
|
+
+ 'was renamed, declare it in RENAMED_SINCE_R101.');
|
|
146
|
+
assert.ok(registry.PROTECTED_GLOBS.includes(to),
|
|
147
|
+
`'${glob}' is declared as renamed to '${to}', but '${to}' is not in the `
|
|
148
|
+
+ 'registry — the protection went nowhere.');
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('every rename since R101 carries a documented reason', () => {
|
|
153
|
+
for (const r of RENAMED_SINCE_R101) {
|
|
154
|
+
assert.ok(r.why && r.why.length > 40,
|
|
155
|
+
`${r.from} -> ${r.to} must record WHY the surface moved.`);
|
|
131
156
|
}
|
|
132
157
|
});
|
|
133
158
|
|
|
@@ -63,12 +63,12 @@ test('a builder holding the permission passes the gate', async () => {
|
|
|
63
63
|
|
|
64
64
|
test('a missing grant 403s with permission_forbidden and explains the denial', async () => {
|
|
65
65
|
const { res, nexted } = await run(
|
|
66
|
-
auth.requirePermission('
|
|
66
|
+
auth.requirePermission('government.manage'), { builder: BUILDER }, holding('idea.triage'),
|
|
67
67
|
);
|
|
68
68
|
assert.equal(nexted, false, 'next() must NOT be called');
|
|
69
69
|
assert.equal(res._status, 403);
|
|
70
70
|
assert.equal(res._json.error.code, 'permission_forbidden');
|
|
71
|
-
assert.deepEqual(res._json.error.details.required, ['
|
|
71
|
+
assert.deepEqual(res._json.error.details.required, ['government.manage']);
|
|
72
72
|
assert.deepEqual(res._json.error.details.held, [], 'holds none of the required perms');
|
|
73
73
|
});
|
|
74
74
|
|
|
@@ -107,11 +107,11 @@ test('the denial names the floor rank, not just the atom (task 1002613, map P-1)
|
|
|
107
107
|
|
|
108
108
|
test('a port without permissionFloor still denies with a message; floors is null', async () => {
|
|
109
109
|
const { res } = await run(
|
|
110
|
-
auth.requirePermission('
|
|
110
|
+
auth.requirePermission('government.manage'), { builder: BUILDER }, holding('idea.triage'),
|
|
111
111
|
);
|
|
112
112
|
assert.equal(res._status, 403);
|
|
113
113
|
assert.equal(res._json.error.details.floors, null);
|
|
114
|
-
assert.ok(res._json.error.message.includes('
|
|
114
|
+
assert.ok(res._json.error.message.includes('government.manage'),
|
|
115
115
|
'the message names the missing atom even when no floor is resolvable');
|
|
116
116
|
});
|
|
117
117
|
|
|
@@ -264,7 +264,7 @@ test('over the four seeded ranks, requirePermission admits exactly who requireRa
|
|
|
264
264
|
'security.report.file',
|
|
265
265
|
];
|
|
266
266
|
const cases = [
|
|
267
|
-
['
|
|
267
|
+
['government.manage', ['archon']],
|
|
268
268
|
['task.claim.any', ['thetes', 'metic', 'archon']],
|
|
269
269
|
['cost.log', ['xenos', 'thetes', 'metic', 'archon']],
|
|
270
270
|
// The CORE route gates migrated in BV1.R104, each paired with the requireRank
|
|
@@ -74,11 +74,11 @@ test('the resolver holds no cache — authority is read fresh per request (ADR 0
|
|
|
74
74
|
});
|
|
75
75
|
|
|
76
76
|
test('the kernel port exposes the resolver without core importing the module', () => {
|
|
77
|
-
const { governancePort } = require(path.join(GOV, 'routes', '
|
|
77
|
+
const { governancePort } = require(path.join(GOV, 'routes', 'government.js'));
|
|
78
78
|
assert.equal(typeof governancePort.resolveBuilderPermissions, 'function');
|
|
79
79
|
assert.equal(typeof governancePort.builderHasPermissions, 'function');
|
|
80
80
|
assert.equal(typeof governancePort.isSystem, 'function');
|
|
81
81
|
const manifest = JSON.parse(fs.readFileSync(path.join(GOV, 'module.json'), 'utf8'));
|
|
82
82
|
assert.ok(manifest.provides.includes('government'), 'module.json must declare the government port');
|
|
83
|
-
assert.ok(manifest.contributes.routes.includes('
|
|
83
|
+
assert.ok(manifest.contributes.routes.includes('government'), 'the port registers from the route factory');
|
|
84
84
|
});
|
|
@@ -26,16 +26,16 @@ const { planRankSync, isSeededRank } = require(path.join(GOV, 'ranks.js'));
|
|
|
26
26
|
|
|
27
27
|
// The seeded rank-roles exactly as governance_002 writes them.
|
|
28
28
|
const SEEDED = [
|
|
29
|
-
{
|
|
30
|
-
{
|
|
31
|
-
{
|
|
32
|
-
{
|
|
29
|
+
{ rank_key: 'xenos', seeded_rank: 'xenos', is_system: true },
|
|
30
|
+
{ rank_key: 'thetes', seeded_rank: 'thetes', is_system: true },
|
|
31
|
+
{ rank_key: 'metic', seeded_rank: 'metic', is_system: true },
|
|
32
|
+
{ rank_key: 'archon', seeded_rank: 'archon', is_system: true },
|
|
33
33
|
];
|
|
34
|
-
const
|
|
34
|
+
const seededRank_ = (rank) => ({ rank_key: rank, seeded_rank: rank, is_system: true });
|
|
35
35
|
// An Archon-authored custom role from the R110 tab: no seeded_rank, not a system role.
|
|
36
|
-
const
|
|
36
|
+
const customRank = (key) => ({ rank_key: key, seeded_rank: null, is_system: false });
|
|
37
37
|
|
|
38
|
-
const plan = (rank,
|
|
38
|
+
const plan = (rank, currentRanks) => planRankSync({ rank, currentRanks, seededRanks: SEEDED });
|
|
39
39
|
|
|
40
40
|
// ---------------------------------------------------------------------------
|
|
41
41
|
// registration — a builder with no roles gets the one matching their rank
|
|
@@ -60,7 +60,7 @@ test('registration: the firstAdmin bootstrap (seated straight to archon) assigns
|
|
|
60
60
|
// ---------------------------------------------------------------------------
|
|
61
61
|
|
|
62
62
|
test('promotion xenos→thetes: the new role is added and the old one removed', () => {
|
|
63
|
-
const p = plan('thetes', [
|
|
63
|
+
const p = plan('thetes', [seededRank_('xenos')]);
|
|
64
64
|
assert.equal(p.target, 'thetes');
|
|
65
65
|
assert.deepEqual([...p.add], ['thetes']);
|
|
66
66
|
assert.deepEqual([...p.remove], ['xenos'], 'the superseded rank-role must be removed');
|
|
@@ -69,8 +69,8 @@ test('promotion xenos→thetes: the new role is added and the old one removed',
|
|
|
69
69
|
|
|
70
70
|
test('promotion via the auto-graduation path leaves exactly one seeded role', () => {
|
|
71
71
|
// maybeAutoGraduateToThetes runs the same _applyRankChangeTx, so the same plan.
|
|
72
|
-
const p = plan('thetes', [
|
|
73
|
-
const seededHeld = p.next.filter((k) => SEEDED.some((s) => s.
|
|
72
|
+
const p = plan('thetes', [seededRank_('xenos')]);
|
|
73
|
+
const seededHeld = p.next.filter((k) => SEEDED.some((s) => s.rank_key === k));
|
|
74
74
|
assert.equal(seededHeld.length, 1);
|
|
75
75
|
});
|
|
76
76
|
|
|
@@ -79,7 +79,7 @@ test('promotion via the auto-graduation path leaves exactly one seeded role', ()
|
|
|
79
79
|
// ---------------------------------------------------------------------------
|
|
80
80
|
|
|
81
81
|
test('demotion archon→metic REMOVES the archon role (not merely adds metic)', () => {
|
|
82
|
-
const p = plan('metic', [
|
|
82
|
+
const p = plan('metic', [seededRank_('archon')]);
|
|
83
83
|
assert.deepEqual([...p.remove], ['archon'], 'the higher rank-role must be REMOVED on demotion');
|
|
84
84
|
assert.deepEqual([...p.add], ['metic']);
|
|
85
85
|
// The absence assertion is the one that matters: an additive-only sync would
|
|
@@ -89,7 +89,7 @@ test('demotion archon→metic REMOVES the archon role (not merely adds metic)',
|
|
|
89
89
|
});
|
|
90
90
|
|
|
91
91
|
test('demotion archon→xenos (a multi-rung drop) removes archon just the same', () => {
|
|
92
|
-
const p = plan('xenos', [
|
|
92
|
+
const p = plan('xenos', [seededRank_('archon')]);
|
|
93
93
|
assert.ok(p.remove.includes('archon'));
|
|
94
94
|
assert.ok(!p.next.includes('archon'));
|
|
95
95
|
assert.deepEqual([...p.next], ['xenos']);
|
|
@@ -98,7 +98,7 @@ test('demotion archon→xenos (a multi-rung drop) removes archon just the same',
|
|
|
98
98
|
test('a builder holding several stale seeded roles is reduced to exactly one', () => {
|
|
99
99
|
// Defence in depth: if drift ever left two seeded roles on one builder, the sync
|
|
100
100
|
// must not pick one and leave the other — every non-matching seeded role goes.
|
|
101
|
-
const p = plan('thetes', [
|
|
101
|
+
const p = plan('thetes', [seededRank_('archon'), seededRank_('metic'), seededRank_('xenos')]);
|
|
102
102
|
assert.deepEqual([...p.remove].sort(), ['archon', 'metic', 'xenos']);
|
|
103
103
|
assert.deepEqual([...p.next], ['thetes']);
|
|
104
104
|
});
|
|
@@ -108,16 +108,16 @@ test('a builder holding several stale seeded roles is reduced to exactly one', (
|
|
|
108
108
|
// ---------------------------------------------------------------------------
|
|
109
109
|
|
|
110
110
|
test('an Archon-assigned custom role survives a rank change', () => {
|
|
111
|
-
const p = plan('metic', [
|
|
111
|
+
const p = plan('metic', [seededRank_('archon'), customRank('release-manager')]);
|
|
112
112
|
assert.deepEqual([...p.remove], ['archon'], 'only the seeded rank-role rotates');
|
|
113
113
|
assert.ok(!p.remove.includes('release-manager'), 'a custom role must never be revoked by a rank change');
|
|
114
114
|
assert.deepEqual([...p.next], ['metic', 'release-manager']);
|
|
115
115
|
});
|
|
116
116
|
|
|
117
117
|
test('a custom role is not mistaken for the rank-role even when its key matches a rank name', () => {
|
|
118
|
-
//
|
|
118
|
+
// rank_key is UNIQUE so this cannot happen today, but the predicate must key on
|
|
119
119
|
// is_system + seeded_rank rather than on the key's spelling.
|
|
120
|
-
const impostor = {
|
|
120
|
+
const impostor = { rank_key: 'archon', seeded_rank: null, is_system: false };
|
|
121
121
|
assert.equal(isSeededRank(impostor), false);
|
|
122
122
|
const p = plan('metic', [impostor]);
|
|
123
123
|
assert.deepEqual([...p.remove], [], 'a non-system role is never rotated away');
|
|
@@ -127,7 +127,7 @@ test('a builder on a non-seeded legacy rank resolves to zero seeded roles', () =
|
|
|
127
127
|
// migration 023's CHECK still admits twelve reserved ranks. governance_002's INNER
|
|
128
128
|
// JOIN gives them no row BY DESIGN (ADR 0151's fail-closed divergence) — preserve
|
|
129
129
|
// it, and strip any seeded role they were carrying.
|
|
130
|
-
const p = plan('hippeis', [
|
|
130
|
+
const p = plan('hippeis', [seededRank_('archon')]);
|
|
131
131
|
assert.equal(p.target, null, 'no seeded role exists for a legacy rank — invent none');
|
|
132
132
|
assert.deepEqual([...p.add], []);
|
|
133
133
|
assert.deepEqual([...p.remove], ['archon'], 'a legacy rank must not retain a seeded rank-role');
|
|
@@ -135,7 +135,7 @@ test('a builder on a non-seeded legacy rank resolves to zero seeded roles', () =
|
|
|
135
135
|
});
|
|
136
136
|
|
|
137
137
|
test('a legacy-rank builder still keeps their custom roles', () => {
|
|
138
|
-
const p = plan('hippeis', [
|
|
138
|
+
const p = plan('hippeis', [seededRank_('metic'), customRank('release-manager')]);
|
|
139
139
|
assert.deepEqual([...p.next], ['release-manager']);
|
|
140
140
|
});
|
|
141
141
|
|
|
@@ -144,23 +144,23 @@ test('a legacy-rank builder still keeps their custom roles', () => {
|
|
|
144
144
|
// ---------------------------------------------------------------------------
|
|
145
145
|
|
|
146
146
|
test('the sync is idempotent — running it twice changes nothing the second time', () => {
|
|
147
|
-
const first = plan('metic', [
|
|
147
|
+
const first = plan('metic', [seededRank_('archon')]);
|
|
148
148
|
assert.ok(first.add.length + first.remove.length > 0, 'the first run must do work');
|
|
149
|
-
const after = first.next.map((key) => (SEEDED.find((s) => s.
|
|
150
|
-
const second = planRankSync({ rank: 'metic',
|
|
149
|
+
const after = first.next.map((key) => (SEEDED.find((s) => s.rank_key === key) || customRank(key)));
|
|
150
|
+
const second = planRankSync({ rank: 'metic', currentRanks: after, seededRanks: SEEDED });
|
|
151
151
|
assert.deepEqual([...second.add], []);
|
|
152
152
|
assert.deepEqual([...second.remove], []);
|
|
153
153
|
assert.deepEqual([...second.next], [...first.next]);
|
|
154
154
|
});
|
|
155
155
|
|
|
156
156
|
test('a builder already holding the right role produces no write at all', () => {
|
|
157
|
-
const p = plan('metic', [
|
|
157
|
+
const p = plan('metic', [seededRank_('metic'), customRank('release-manager')]);
|
|
158
158
|
assert.deepEqual([...p.add], []);
|
|
159
159
|
assert.deepEqual([...p.remove], []);
|
|
160
160
|
});
|
|
161
161
|
|
|
162
162
|
test('the plan is frozen — a caller cannot widen it after the decision', () => {
|
|
163
|
-
const p = plan('metic', [
|
|
163
|
+
const p = plan('metic', [seededRank_('archon')]);
|
|
164
164
|
assert.ok(Object.isFrozen(p) && Object.isFrozen(p.add) && Object.isFrozen(p.remove));
|
|
165
165
|
assert.throws(() => p.add.push('archon'), TypeError);
|
|
166
166
|
});
|