@bongos/core 1.19.158 → 1.19.159
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 +34 -29
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +3 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0172-editable-rank-roles-substrate-axis.md +45 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +77 -5
- package/docs/api-reference.md +4 -3
- package/docs/canonical-permissions.md +16 -2
- package/modules/governance/catalog.js +91 -12
- package/modules/governance/db.js +30 -0
- package/modules/governance/routes/governance.js +106 -24
- package/modules/hall-ui/public/governance.css +3 -0
- package/modules/hall-ui/public/governance.html +1 -0
- package/modules/hall-ui/public/governance.js +110 -31
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/route-rank-check.js +1 -0
- package/src/module-api.js +3 -1
- package/tests/governance_abuse_matrix.mjs +78 -11
- package/tests/governance_catalog.mjs +51 -0
- package/tests/governance_routes.mjs +72 -12
|
@@ -9,14 +9,23 @@
|
|
|
9
9
|
// docs/governance/authority-sweep-2026-07-24.md.
|
|
10
10
|
//
|
|
11
11
|
// Two orthogonal axes per permission:
|
|
12
|
-
// • system — true = trust-boundary
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
// constrains custom roles, not the seed.
|
|
12
|
+
// • system — true = trust-boundary. The governance API (R110) rejects adding it
|
|
13
|
+
// to a CUSTOM role and the tab renders it LOCKED there; it is deploy/
|
|
14
|
+
// protected-path/rank-set/version-close/grade-bypass/cross-tier/
|
|
15
|
+
// principal power, enforced by the out-of-band substrate (ADR 0043)
|
|
16
|
+
// or reserved to Archon. The built-in `archon` rank-role legitimately
|
|
17
|
+
// HOLDS the archon-floored system perms.
|
|
19
18
|
// — false = operational, freely composable into a custom role.
|
|
19
|
+
// `system` is NOT the whole grantability answer any more — see the
|
|
20
|
+
// `substrate` axis below and `isGrantableTo()`, which is per-ROLE.
|
|
21
|
+
// • substrate — true = this key's authority is enforced OUTSIDE the app, by the
|
|
22
|
+
// pre-push hook / main-audit / the rank floor the git and ssh layers
|
|
23
|
+
// read (ADR 0043). A grant row for one is INERT: no route consumes it
|
|
24
|
+
// through requirePermission, so granting it would be a toggle that
|
|
25
|
+
// looks like it worked and changes nothing. Therefore non-grantable to
|
|
26
|
+
// EVERY role, seeded rank-roles included — the one wall ADR 0172's
|
|
27
|
+
// widening deliberately did not move, because moving it would make the
|
|
28
|
+
// tab lie rather than delegate. Implies system:true + floor 'archon'.
|
|
20
29
|
// • floor — the rank that holds this permission TODAY via the compat seed
|
|
21
30
|
// ('xenos' < 'thetes' < 'metic' < 'archon'), or null for a machine
|
|
22
31
|
// PRINCIPAL permission held by NO rank-role (gated on exact
|
|
@@ -84,7 +93,12 @@ const RANK_ORDER = Object.freeze(['xenos', 'thetes', 'metic', 'archon']);
|
|
|
84
93
|
// (the exhaustive 1:1 list lives in the authority sweep). Order: system first, then
|
|
85
94
|
// operational by floor (archon → metic → thetes/own/any), matching the ADR appendix.
|
|
86
95
|
const PERMISSIONS = [
|
|
87
|
-
// ── system: true —
|
|
96
|
+
// ── system: true — trust-boundary (substrate- or Archon-enforced) ─────────────
|
|
97
|
+
//
|
|
98
|
+
// The seven WITHOUT `substrate` are in-app Archon powers: a real route reads them
|
|
99
|
+
// through requirePermission, so granting one to a sub-Archon rank-role genuinely
|
|
100
|
+
// delegates it (ADR 0172, the owner's lever). The three WITH `substrate: true` are
|
|
101
|
+
// enforced out of band and can never be delegated by a row in this database.
|
|
88
102
|
{ key: 'builder.rank.set', system: true, floor: 'archon', guards: 'PATCH /builders/:id/rank — the single rank writer' },
|
|
89
103
|
{ key: 'version.close', system: true, floor: 'archon', guards: 'version close disposition (never delegated below Archon)' },
|
|
90
104
|
{ key: 'task.confirm.grade_bypass', system: true, floor: 'archon', guards: 'POST /tasks/:id/confirm — ratify past a failed grade (env-relaxable, ADR 0041)' },
|
|
@@ -92,9 +106,9 @@ const PERMISSIONS = [
|
|
|
92
106
|
{ key: 'goal.scope.manage', system: true, floor: 'archon', guards: 'cross-tier goal-scope edges + protected-scope admission (ADR 0086)' },
|
|
93
107
|
{ key: 'governance.manage', system: true, floor: 'archon', guards: 'the governance tab routes (roles/perms/assignments CRUD, reads included — R110) — meta-escalation' },
|
|
94
108
|
{ key: 'gate_approval.decide', system: true, floor: 'archon', guards: 'POST /gate-approvals/:pr/approve — CI deploy-gate override' },
|
|
95
|
-
{ key: 'deploy.push_main', system: true, floor: 'archon', guards: 'git push origin main / the deploy — substrate-enforced (pre-push, main-audit)' },
|
|
96
|
-
{ key: 'path.protected.modify', system: true, floor: 'archon', guards: 'modifying a PROTECTED_GLOBS path below the Metic floor — substrate (ADR 0043)' },
|
|
97
|
-
{ key: 'infra.ssh', system: true, floor: 'archon', guards: 'ssh to the droplet / operator surface — substrate (credential placement)' },
|
|
109
|
+
{ key: 'deploy.push_main', system: true, substrate: true, floor: 'archon', guards: 'git push origin main / the deploy — substrate-enforced (pre-push, main-audit)' },
|
|
110
|
+
{ key: 'path.protected.modify', system: true, substrate: true, floor: 'archon', guards: 'modifying a PROTECTED_GLOBS path below the Metic floor — substrate (ADR 0043)' },
|
|
111
|
+
{ key: 'infra.ssh', system: true, substrate: true, floor: 'archon', guards: 'ssh to the droplet / operator surface — substrate (credential placement)' },
|
|
98
112
|
{ key: 'principal.bfg', system: true, floor: null, principal: 'bfg', guards: '/memory/bfg/* — requireBfgPrincipal (exact system_role=bfg; NO rank-role holds it)' },
|
|
99
113
|
{ key: 'principal.discord_bot', system: true, floor: null, principal: 'discord_bot', guards: 'Discord bot routes — requireDiscordBot (exact system_role; dormant)' },
|
|
100
114
|
|
|
@@ -256,6 +270,23 @@ for (const p of PERMISSIONS) {
|
|
|
256
270
|
if (!p.system) throw new Error(`governance catalog: ${p.key} is a principal permission and must be system:true (never grantable to a role)`);
|
|
257
271
|
if (_byPrincipal.has(principal)) throw new Error(`governance catalog: duplicate principal identity "${principal}"`);
|
|
258
272
|
}
|
|
273
|
+
// ── the SUBSTRATE axis (ADR 0172) ─────────────────────────────────────────
|
|
274
|
+
// A substrate key is enforced by the out-of-band layer (pre-push hook, main-audit,
|
|
275
|
+
// the rank floor git/ssh read), so no role row can confer it. Three shape rules,
|
|
276
|
+
// each closing a way the marker could quietly stop meaning that:
|
|
277
|
+
const substrate = p.substrate ?? false;
|
|
278
|
+
if (typeof substrate !== 'boolean') throw new Error(`governance catalog: ${p.key} has a non-boolean 'substrate'`);
|
|
279
|
+
if (substrate) {
|
|
280
|
+
// Substrate power is trust-boundary power by definition — a substrate key that
|
|
281
|
+
// was somehow operational would be freely grantable to a custom role.
|
|
282
|
+
if (!p.system) throw new Error(`governance catalog: ${p.key} is substrate-enforced and must be system:true`);
|
|
283
|
+
// A substrate key belongs to no machine identity: the two are different reasons
|
|
284
|
+
// a key is unreachable, and collapsing them would hide one behind the other.
|
|
285
|
+
if (principal !== null) throw new Error(`governance catalog: ${p.key} cannot be both substrate-enforced and a machine principal`);
|
|
286
|
+
// The out-of-band enforcers all compare against the top of the ladder. A lower
|
|
287
|
+
// floor would seed the key into a rank-role that the substrate then still denies.
|
|
288
|
+
if (p.floor !== 'archon') throw new Error(`governance catalog: ${p.key} is substrate-enforced and must floor at 'archon' (the substrate compares against the top of the ladder)`);
|
|
289
|
+
}
|
|
259
290
|
// ── the OWNERSHIP axis (R100) ─────────────────────────────────────────────
|
|
260
291
|
const scope = p.scope ?? null;
|
|
261
292
|
const resource = p.resource ?? null;
|
|
@@ -283,7 +314,7 @@ for (const p of PERMISSIONS) {
|
|
|
283
314
|
if (scope !== null && p.floor === null) {
|
|
284
315
|
throw new Error(`governance catalog: ${p.key} is ownership-scoped but has no rank floor — the ownership axis narrows a ROLE permission, never an identity one`);
|
|
285
316
|
}
|
|
286
|
-
const frozen = Object.freeze({ ...p, principal, scope, resource });
|
|
317
|
+
const frozen = Object.freeze({ ...p, principal, scope, resource, substrate });
|
|
287
318
|
_byKey.set(p.key, frozen);
|
|
288
319
|
if (principal !== null) _byPrincipal.set(principal, frozen);
|
|
289
320
|
if (resource !== null) {
|
|
@@ -352,6 +383,8 @@ function isSystem(key) {
|
|
|
352
383
|
return p ? p.system : null; // null = unknown key (caller fails closed)
|
|
353
384
|
}
|
|
354
385
|
// Grantable to a CUSTOM role = exists and not system. Unknown key → false (fail-closed).
|
|
386
|
+
// This is the NARROW question — the custom-role wall, unchanged by ADR 0172 and
|
|
387
|
+
// still what config.js strips against. For the per-role answer use isGrantableTo().
|
|
355
388
|
function isGrantable(key) {
|
|
356
389
|
const p = _byKey.get(key);
|
|
357
390
|
return !!p && !p.system;
|
|
@@ -362,6 +395,49 @@ function systemKeys() {
|
|
|
362
395
|
function grantableKeys() {
|
|
363
396
|
return PERMISSIONS.filter((p) => !p.system).map((p) => p.key);
|
|
364
397
|
}
|
|
398
|
+
// Enforced outside the app (pre-push, main-audit, the rank floor git/ssh read).
|
|
399
|
+
// Unknown key → false, but note the callers all pair it with a byKey() existence
|
|
400
|
+
// check first — an unknown key is refused as unknown, never as "not substrate".
|
|
401
|
+
function isSubstrate(key) {
|
|
402
|
+
const p = _byKey.get(key);
|
|
403
|
+
return !!p && p.substrate;
|
|
404
|
+
}
|
|
405
|
+
function substrateKeys() {
|
|
406
|
+
return PERMISSIONS.filter((p) => p.substrate).map((p) => p.key);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ── grantability, per ROLE (ADR 0172) ──────────────────────────────────────────
|
|
410
|
+
//
|
|
411
|
+
// The owner's 2026-08-16 decision made the three sub-Archon RANK roles editable, so
|
|
412
|
+
// "is this grantable" stopped being a property of the key alone. `role` is a
|
|
413
|
+
// governance_roles row ({ is_system, seeded_rank }); the four answers:
|
|
414
|
+
//
|
|
415
|
+
// • NEVER, to any role — a machine-principal key (held by an identity, not a role)
|
|
416
|
+
// and a SUBSTRATE key. The substrate three are inert as grant rows: no route
|
|
417
|
+
// consumes them through requirePermission, so a toggle for one would report
|
|
418
|
+
// success and delegate nothing. Refusing them keeps the tab honest.
|
|
419
|
+
// • the ARCHON rank-role — nothing. It is immutable and already holds everything,
|
|
420
|
+
// and that immutability is also the LOCKOUT GUARD: the only role carrying
|
|
421
|
+
// `governance.manage` by seed can never have it revoked, so no sequence of
|
|
422
|
+
// clicks in this tab can lock the owner out of the tab.
|
|
423
|
+
// • a SEEDED rank-role BELOW archon — everything else, the seven in-app Archon
|
|
424
|
+
// powers included. This is the delegation lever the owner asked for.
|
|
425
|
+
// • a CUSTOM role — operational only: the pre-existing R110 wall, deliberately NOT
|
|
426
|
+
// widened. A rank-role is the owner's own coarse ladder, moved by a rank write;
|
|
427
|
+
// a custom role is ad-hoc and assignable by anyone holding `governance.manage`,
|
|
428
|
+
// so widening it would hand the second group the first group's lever.
|
|
429
|
+
//
|
|
430
|
+
// Fail-closed on everything unrecognised: unknown key, missing role, or a system role
|
|
431
|
+
// whose seeded_rank is not a real rank all answer false.
|
|
432
|
+
function isGrantableTo(key, role) {
|
|
433
|
+
const p = _byKey.get(key);
|
|
434
|
+
if (!p || !role || typeof role !== 'object') return false;
|
|
435
|
+
if (p.principal !== null || p.substrate) return false;
|
|
436
|
+
if (role.is_system) {
|
|
437
|
+
return RANK_ORDER.includes(role.seeded_rank) && role.seeded_rank !== 'archon';
|
|
438
|
+
}
|
|
439
|
+
return !p.system;
|
|
440
|
+
}
|
|
365
441
|
|
|
366
442
|
// ── the principal axis (identity, not roles) ────────────────────────────────────
|
|
367
443
|
// Reads only. Unknown/blank input fails closed to null/false — the same posture the
|
|
@@ -448,6 +524,9 @@ module.exports = {
|
|
|
448
524
|
byKey,
|
|
449
525
|
isSystem,
|
|
450
526
|
isGrantable,
|
|
527
|
+
isGrantableTo,
|
|
528
|
+
isSubstrate,
|
|
529
|
+
substrateKeys,
|
|
451
530
|
systemKeys,
|
|
452
531
|
grantableKeys,
|
|
453
532
|
seedForRank,
|
package/modules/governance/db.js
CHANGED
|
@@ -178,6 +178,35 @@ async function revokeRolePermission({ roleKey, permissionKey }) {
|
|
|
178
178
|
return rowCount > 0;
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
// Replace a role's entire grant set in ONE transaction (ADR 0172's reset). The
|
|
182
|
+
// delete+insert pair must be atomic: a crash between them would leave the role
|
|
183
|
+
// holding NOTHING, and for a rank-role that is every builder at that rank losing
|
|
184
|
+
// every permission until someone noticed. The caller supplies the keys (the route
|
|
185
|
+
// derives them from catalog.seedForRank), so this stays a dumb write like its
|
|
186
|
+
// neighbours — the authority decision is made against the catalog, in the route.
|
|
187
|
+
async function resetRoleGrants({ roleKey, permissionKeys }) {
|
|
188
|
+
const client = await pool.connect();
|
|
189
|
+
try {
|
|
190
|
+
await client.query('BEGIN');
|
|
191
|
+
await client.query(`DELETE FROM governance_role_permissions WHERE role_key = $1`, [roleKey]);
|
|
192
|
+
if (permissionKeys.length > 0) {
|
|
193
|
+
await client.query(
|
|
194
|
+
`INSERT INTO governance_role_permissions (role_key, permission_key)
|
|
195
|
+
SELECT $1, unnest($2::text[])
|
|
196
|
+
ON CONFLICT (role_key, permission_key) DO NOTHING`,
|
|
197
|
+
[roleKey, [...permissionKeys]],
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
await client.query('COMMIT');
|
|
201
|
+
} catch (err) {
|
|
202
|
+
await client.query('ROLLBACK').catch(() => {});
|
|
203
|
+
throw err;
|
|
204
|
+
} finally {
|
|
205
|
+
client.release();
|
|
206
|
+
}
|
|
207
|
+
return permissionKeys.length;
|
|
208
|
+
}
|
|
209
|
+
|
|
181
210
|
async function listAssignments() {
|
|
182
211
|
const { rows } = await pool.query(
|
|
183
212
|
`SELECT br.builder_id, b.github_login, b.display_name, b.rank,
|
|
@@ -221,6 +250,7 @@ module.exports = {
|
|
|
221
250
|
createRole,
|
|
222
251
|
grantRolePermission,
|
|
223
252
|
revokeRolePermission,
|
|
253
|
+
resetRoleGrants,
|
|
224
254
|
listAssignments,
|
|
225
255
|
assignRole,
|
|
226
256
|
unassignRole,
|
|
@@ -108,14 +108,28 @@ function registerSeams() {
|
|
|
108
108
|
// Every route — the three reads included — gates on the `governance.manage`
|
|
109
109
|
// atom (system, floor archon): this surface EDITS the role/permission graph
|
|
110
110
|
// itself, the meta-escalation the catalog flags non-grantable, and even listing
|
|
111
|
-
// it exposes the full authority map.
|
|
112
|
-
// the catalog, not in the db layer (governance_001's app-side-validation split)
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
111
|
+
// it exposes the full authority map. The invariants are enforced HERE, against
|
|
112
|
+
// the catalog, not in the db layer (governance_001's app-side-validation split).
|
|
113
|
+
//
|
|
114
|
+
// ADR 0172 (owner decision, 2026-08-16) split what used to be ONE wall — "a seeded
|
|
115
|
+
// rank-role is immutable" — into two, because grants and assignment are immutable
|
|
116
|
+
// for different reasons and only one of those reasons still holds:
|
|
117
|
+
//
|
|
118
|
+
// • GRANTS on the three sub-Archon rank-roles are now EDITABLE. Their rows were
|
|
119
|
+
// always ordinary `governance_role_permissions` rows (the governance_002 seed
|
|
120
|
+
// writes them); the old refusal was policy, not structure. What a rank means is
|
|
121
|
+
// now the owner's to set from the tab, and `catalog.isGrantableTo()` answers
|
|
122
|
+
// which keys per role. The ARCHON rank-role stays immutable — it holds
|
|
123
|
+
// everything, and its immutability is what makes lockout impossible.
|
|
124
|
+
// • ASSIGNMENT of every seeded rank-role is still refused, unchanged: it derives
|
|
125
|
+
// from `builders.rank` via the R95b sync, so a second writer here would fork
|
|
126
|
+
// authority truth and the sync would silently undo it on the next rank write.
|
|
127
|
+
// Changing who holds a rank-role is still `PATCH /builders/:id/rank`.
|
|
128
|
+
//
|
|
129
|
+
// The custom-role wall is unchanged: a `system:true` key stays non-grantable there
|
|
130
|
+
// (R94's wall — "The governance API (R110) rejects granting it"), and the three
|
|
131
|
+
// SUBSTRATE keys stay non-grantable to everything, rank-roles included, because a
|
|
132
|
+
// grant row for one delegates nothing (ADR 0043 enforces them out of band).
|
|
119
133
|
const { validateOrRespond, LIMITS, parseId } = api;
|
|
120
134
|
const ROLE_KEY_RE = /^[a-z][a-z0-9-]*$/;
|
|
121
135
|
const ROLE_KEY_MAX = 64;
|
|
@@ -136,21 +150,39 @@ module.exports = function buildGovernanceRouter() {
|
|
|
136
150
|
return roleKey;
|
|
137
151
|
}
|
|
138
152
|
|
|
139
|
-
// Load a role
|
|
140
|
-
// rank-role
|
|
141
|
-
|
|
153
|
+
// Load a role whose GRANTS this API may edit (ADR 0172): 404 on a missing role,
|
|
154
|
+
// refuse only the Archon rank-role. Every other role — the three sub-Archon
|
|
155
|
+
// rank-roles and every custom one — is grant-editable; WHICH keys is then
|
|
156
|
+
// `catalog.isGrantableTo(key, role)`, per role. Returns null after responding.
|
|
157
|
+
async function loadGrantEditableRole(res, roleKey) {
|
|
158
|
+
const role = await db.getRoleByKey(roleKey);
|
|
159
|
+
if (!role) {
|
|
160
|
+
res.fail('role_not_found', 404, { role_key: roleKey });
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
if (role.is_system && role.seeded_rank === ARCHON) {
|
|
164
|
+
res.fail('archon_role_immutable', 403, { role_key: roleKey, reason: ARCHON_REASON });
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
return role;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Load a role this API may ASSIGN. Unchanged from R110: every seeded rank-role is
|
|
171
|
+
// refused, because assignment derives from builders.rank via the R95b sync.
|
|
172
|
+
async function loadAssignableRole(res, roleKey) {
|
|
142
173
|
const role = await db.getRoleByKey(roleKey);
|
|
143
174
|
if (!role) {
|
|
144
175
|
res.fail('role_not_found', 404, { role_key: roleKey });
|
|
145
176
|
return null;
|
|
146
177
|
}
|
|
147
178
|
if (role.is_system) {
|
|
148
|
-
res.fail(
|
|
179
|
+
res.fail('seeded_role_assignment_forbidden', 403, { role_key: roleKey, reason: ASSIGN_REASON });
|
|
149
180
|
return null;
|
|
150
181
|
}
|
|
151
182
|
return role;
|
|
152
183
|
}
|
|
153
|
-
const
|
|
184
|
+
const ARCHON = 'archon';
|
|
185
|
+
const ARCHON_REASON = 'the Archon rank-role holds every permission and is immutable — it is the role that can never be locked out of this tab (ADR 0172)';
|
|
154
186
|
const ASSIGN_REASON = 'a seeded rank-role is assigned by rank (R95b sync); change the builder\'s rank instead';
|
|
155
187
|
|
|
156
188
|
// rank: perm governance.manage — the full role list with each role's grants.
|
|
@@ -165,12 +197,19 @@ module.exports = function buildGovernanceRouter() {
|
|
|
165
197
|
});
|
|
166
198
|
|
|
167
199
|
// rank: perm governance.manage — the permission catalog + principals, straight
|
|
168
|
-
// from code (the catalog is CODE, not a table); no DB read.
|
|
169
|
-
//
|
|
200
|
+
// from code (the catalog is CODE, not a table); no DB read. Principals are
|
|
201
|
+
// read-only by construction. Three flags the tab renders its locks from, and the
|
|
202
|
+
// tab must read all three rather than just `grantable`, because since ADR 0172
|
|
203
|
+
// grantability depends on WHICH role is selected:
|
|
204
|
+
// • `grantable` — the CUSTOM-role answer (system:false). Unchanged meaning.
|
|
205
|
+
// • `system` — an in-app Archon power: locked on a custom role, togglable on
|
|
206
|
+
// a sub-Archon rank-role.
|
|
207
|
+
// • `substrate` — locked EVERYWHERE, on every role, forever (ADR 0043).
|
|
170
208
|
router.get('/governance/permissions', api.requireBuilder, api.requirePermission('governance.manage'), (req, res) => {
|
|
171
209
|
const permissions = catalog.PERMISSIONS.map((p) => ({
|
|
172
210
|
key: p.key,
|
|
173
211
|
system: p.system,
|
|
212
|
+
substrate: p.substrate,
|
|
174
213
|
floor: p.floor,
|
|
175
214
|
guards: p.guards,
|
|
176
215
|
scope: p.scope,
|
|
@@ -221,23 +260,31 @@ module.exports = function buildGovernanceRouter() {
|
|
|
221
260
|
}
|
|
222
261
|
});
|
|
223
262
|
|
|
224
|
-
// rank: perm governance.manage — grant one
|
|
225
|
-
//
|
|
226
|
-
//
|
|
263
|
+
// rank: perm governance.manage — grant one permission to a role (the toggle's ON
|
|
264
|
+
// half). The order of refusals is the authority order: role exists → role is not
|
|
265
|
+
// the Archon one → the key exists → the key is grantable TO THIS ROLE.
|
|
227
266
|
router.put('/governance/roles/:roleKey/permissions/:permissionKey', api.requireBuilder, api.requirePermission('governance.manage'), async (req, res) => {
|
|
228
267
|
const roleKey = roleKeyParam(req, res);
|
|
229
268
|
if (roleKey === null) return;
|
|
230
269
|
const permissionKey = req.params.permissionKey;
|
|
231
270
|
try {
|
|
232
|
-
const role = await
|
|
271
|
+
const role = await loadGrantEditableRole(res, roleKey);
|
|
233
272
|
if (role === null) return;
|
|
234
273
|
if (!catalog.byKey(permissionKey)) {
|
|
235
274
|
return res.fail('unknown_permission', 400, { permission_key: permissionKey });
|
|
236
275
|
}
|
|
237
|
-
if (!catalog.
|
|
276
|
+
if (!catalog.isGrantableTo(permissionKey, role)) {
|
|
238
277
|
return res.fail('permission_not_grantable', 400, {
|
|
239
278
|
permission_key: permissionKey,
|
|
240
|
-
|
|
279
|
+
role_key: roleKey,
|
|
280
|
+
// Name the ACTUAL wall. "Not grantable" now has three distinct causes and
|
|
281
|
+
// a caller that cannot tell them apart cannot tell a policy choice
|
|
282
|
+
// (widen the role) from a structural one (nothing will ever work).
|
|
283
|
+
reason: catalog.isSubstrate(permissionKey)
|
|
284
|
+
? 'substrate-enforced (ADR 0043) — enforced outside the app by the pre-push hook / main-audit / the rank floor, so a grant row here would confer nothing. Non-grantable to EVERY role.'
|
|
285
|
+
: catalog.isPrincipalKey(permissionKey)
|
|
286
|
+
? 'a machine-principal permission — held by an exact builders.system_role identity, never by a role (ADR 0151 §3)'
|
|
287
|
+
: 'a system (trust-boundary) permission is non-grantable to a CUSTOM role; only a sub-Archon rank-role may carry one (ADR 0172)',
|
|
241
288
|
});
|
|
242
289
|
}
|
|
243
290
|
const granted = await db.grantRolePermission({ roleKey, permissionKey });
|
|
@@ -257,7 +304,7 @@ module.exports = function buildGovernanceRouter() {
|
|
|
257
304
|
if (roleKey === null) return;
|
|
258
305
|
const permissionKey = req.params.permissionKey;
|
|
259
306
|
try {
|
|
260
|
-
const role = await
|
|
307
|
+
const role = await loadGrantEditableRole(res, roleKey);
|
|
261
308
|
if (role === null) return;
|
|
262
309
|
const removed = await db.revokeRolePermission({ roleKey, permissionKey });
|
|
263
310
|
res.json({ ok: true, role_key: roleKey, permission_key: permissionKey, removed });
|
|
@@ -267,6 +314,41 @@ module.exports = function buildGovernanceRouter() {
|
|
|
267
314
|
}
|
|
268
315
|
});
|
|
269
316
|
|
|
317
|
+
// rank: perm governance.manage — restore a SEEDED rank-role to its catalog
|
|
318
|
+
// default (ADR 0172). The counterweight to making the rank-roles editable: a
|
|
319
|
+
// rank's grant set is ~60 rows, so an owner who drifts one has no practical way
|
|
320
|
+
// back by clicking, and "what does Trusted mean by default" is a question only
|
|
321
|
+
// the catalog can answer. Restores catalog.seedForRank(role.seeded_rank) — the
|
|
322
|
+
// same derivation governance_002 seeded from, so the answer cannot drift from
|
|
323
|
+
// the floors even after later catalog edits.
|
|
324
|
+
//
|
|
325
|
+
// Deliberately NOT offered for a CUSTOM role: a custom role has no catalog
|
|
326
|
+
// default to return to (its "default" is the empty set it was created with), so
|
|
327
|
+
// the honest verb there is revoking the grants, which the toggle already does.
|
|
328
|
+
router.post('/governance/roles/:roleKey/reset', api.requireBuilder, api.requirePermission('governance.manage'), async (req, res) => {
|
|
329
|
+
const roleKey = roleKeyParam(req, res);
|
|
330
|
+
if (roleKey === null) return;
|
|
331
|
+
try {
|
|
332
|
+
const role = await loadGrantEditableRole(res, roleKey);
|
|
333
|
+
if (role === null) return;
|
|
334
|
+
if (!role.is_system || !catalog.RANK_ORDER.includes(role.seeded_rank)) {
|
|
335
|
+
return res.fail('role_has_no_default', 400, {
|
|
336
|
+
role_key: roleKey,
|
|
337
|
+
reason: 'only a seeded rank-role has a catalog default to reset to; revoke a custom role\'s grants instead',
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
// The seed is the floor derivation, which never contains a principal key and
|
|
341
|
+
// — below archon — never a system one. Filtering by isGrantableTo anyway keeps
|
|
342
|
+
// reset from being a back door that writes what the toggle refuses.
|
|
343
|
+
const keys = catalog.seedForRank(role.seeded_rank).filter((k) => catalog.isGrantableTo(k, role));
|
|
344
|
+
const count = await db.resetRoleGrants({ roleKey, permissionKeys: keys });
|
|
345
|
+
res.json({ ok: true, role_key: roleKey, seeded_rank: role.seeded_rank, permission_count: count });
|
|
346
|
+
} catch (err) {
|
|
347
|
+
console.error('[gds] POST /governance/roles/:roleKey/reset', err);
|
|
348
|
+
res.fail('reset_failed', { status: 500, message: 'internal error' });
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
|
|
270
352
|
// rank: perm governance.manage — assign a CUSTOM role to a builder.
|
|
271
353
|
// assigned_by is ALWAYS the session builder, never input (the audit trail's
|
|
272
354
|
// "who did this" must be the authenticated actor).
|
|
@@ -280,7 +362,7 @@ module.exports = function buildGovernanceRouter() {
|
|
|
280
362
|
return res.fail('bad_role_key', 400, { role_key: roleKey, expected: 'a kebab-case slug (^[a-z][a-z0-9-]*$)' });
|
|
281
363
|
}
|
|
282
364
|
try {
|
|
283
|
-
const role = await
|
|
365
|
+
const role = await loadAssignableRole(res, roleKey);
|
|
284
366
|
if (role === null) return;
|
|
285
367
|
const assigned = await db.assignRole({
|
|
286
368
|
builderId: req.body.builder_id,
|
|
@@ -305,7 +387,7 @@ module.exports = function buildGovernanceRouter() {
|
|
|
305
387
|
const roleKey = roleKeyParam(req, res);
|
|
306
388
|
if (roleKey === null) return;
|
|
307
389
|
try {
|
|
308
|
-
const role = await
|
|
390
|
+
const role = await loadAssignableRole(res, roleKey);
|
|
309
391
|
if (role === null) return;
|
|
310
392
|
const removed = await db.unassignRole({ builderId, roleKey });
|
|
311
393
|
res.json({ ok: true, builder_id: builderId, role_key: roleKey, removed });
|
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
.gov-h { color: var(--ink); font-size: 16px; font-weight: 600; margin: 22px 0 8px; }
|
|
7
7
|
.gov-h--minor { font-size: 13px; margin: 18px 0 6px; }
|
|
8
8
|
.gov-grid-note { color: var(--ink-soft); font-size: 12.5px; margin: 0 0 12px; }
|
|
9
|
+
/* Holds the rank-role reset button. Empty for every other role, so it collapses
|
|
10
|
+
rather than reserving a gap — hence no margin on the empty box itself. */
|
|
11
|
+
.gov-grid-actions:not(:empty) { margin: 0 0 14px; }
|
|
9
12
|
.gov-sealed, .gov-empty { color: var(--ink-soft); padding: 16px 0; }
|
|
10
13
|
|
|
11
14
|
/* Two columns on wide screens: role rail left, permission grid right. */
|
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
// public-builders/governance.js — Governance (/builders/governance).
|
|
2
2
|
//
|
|
3
3
|
// The roles/permissions/assignments tab (BV1.R111, ADR 0151 §2) over the R110
|
|
4
|
-
// CRUD routes. Discord-style: pick a role on the left, toggle its
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
4
|
+
// CRUD routes. Discord-style: pick a role on the left, toggle its permissions in
|
|
5
|
+
// the grid, assign custom roles to builders below.
|
|
6
|
+
//
|
|
7
|
+
// Since ADR 0172 grantability depends on WHICH role is selected, so this page's
|
|
8
|
+
// central job is rendering three different locks honestly. The SERVER enforces all
|
|
9
|
+
// of them (ADR 0016 — nothing here grants anything; a disabled button is a
|
|
10
|
+
// courtesy, `isGrantableTo` on the route is the wall):
|
|
11
|
+
// • SUBSTRATE permissions (deploy / protected paths / ssh) render LOCKED for
|
|
12
|
+
// every role, always. They are enforced outside the app (ADR 0043), so a grant
|
|
13
|
+
// row confers nothing — showing a working toggle would be the UI lying.
|
|
14
|
+
// • OWNER POWERS (the seven in-app `system` keys) render LOCKED on a custom role
|
|
15
|
+
// and TOGGLABLE on a sub-Archon rank-role. That asymmetry is the whole of the
|
|
16
|
+
// owner's 2026-08-16 decision, and the section header says so out loud.
|
|
17
|
+
// • the ARCHON rank-role is read-only in full: it holds everything, and its
|
|
18
|
+
// immutability is what makes it impossible to click your way into locking the
|
|
19
|
+
// owner out of this tab. The API refuses it as `archon_role_immutable`.
|
|
20
|
+
// ASSIGNMENT of any rank-role is still refused (`seeded_role_assignment_forbidden`)
|
|
21
|
+
// — you get a rank-role by holding the rank, so that dial is the rank itself.
|
|
14
22
|
//
|
|
15
23
|
// Like The Gate, this reads /api/bongos/me for a friendly Sealed notice —
|
|
16
24
|
// DEFENCE IN DEPTH only. Every /governance/* endpoint (reads included) is ALSO
|
|
@@ -55,8 +63,9 @@
|
|
|
55
63
|
|
|
56
64
|
// The R110 refusal codes, translated for the person clicking.
|
|
57
65
|
const ERROR_COPY = {
|
|
58
|
-
permission_not_grantable: 'That
|
|
59
|
-
|
|
66
|
+
permission_not_grantable: 'That permission can’t be granted to this role. (The server’s reply says which of the three walls applied.)',
|
|
67
|
+
archon_role_immutable: 'The Archon role is read-only — it holds every permission, which is what keeps you from locking yourself out of this page.',
|
|
68
|
+
role_has_no_default: 'Only a rank role has a catalog default to reset to. Revoke a custom role’s permissions individually instead.',
|
|
60
69
|
seeded_role_assignment_forbidden: 'Rank roles are assigned by rank itself — change the builder’s rank instead.',
|
|
61
70
|
role_exists: 'A role with that key already exists.',
|
|
62
71
|
role_not_found: 'That role no longer exists — refreshing.',
|
|
@@ -83,9 +92,13 @@
|
|
|
83
92
|
const list = document.getElementById('gov-role-list');
|
|
84
93
|
list.innerHTML = '';
|
|
85
94
|
for (const role of roles) {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
95
|
+
// The Archon row is called out as read-only, because it is the one role in
|
|
96
|
+
// this list a click will not change and the reader should know before trying.
|
|
97
|
+
const pill = isArchonRole(role)
|
|
98
|
+
? `<span class="gov-pill gov-pill--locked">rank role · read-only</span>`
|
|
99
|
+
: role.is_system
|
|
100
|
+
? `<span class="gov-pill gov-pill--seeded">rank role</span>`
|
|
101
|
+
: `<span class="gov-pill gov-pill--custom">custom</span>`;
|
|
89
102
|
const btn = document.createElement('button');
|
|
90
103
|
btn.type = 'button';
|
|
91
104
|
btn.className = 'gov-role';
|
|
@@ -100,20 +113,38 @@
|
|
|
100
113
|
|
|
101
114
|
// ---- render: permission grid --------------------------------------------
|
|
102
115
|
|
|
116
|
+
// The client-side mirror of catalog.isGrantableTo(). It exists to render the
|
|
117
|
+
// right control, NEVER to decide authority — the route runs the real one and
|
|
118
|
+
// refuses regardless of what this returns. Kept in the same order as the server's
|
|
119
|
+
// so a future catalog change lands in one obvious place on each side.
|
|
120
|
+
const isArchonRole = (role) => !!role && role.is_system && role.seeded_rank === 'archon';
|
|
121
|
+
const isRankRole = (role) => !!role && role.is_system;
|
|
122
|
+
function grantableTo(p, role) {
|
|
123
|
+
if (!role || p.substrate) return false;
|
|
124
|
+
if (role.is_system) return role.seeded_rank !== 'archon';
|
|
125
|
+
return !!p.grantable;
|
|
126
|
+
}
|
|
127
|
+
|
|
103
128
|
function permRow(p, role) {
|
|
104
129
|
const held = !!role && (role.permissions || []).includes(p.key);
|
|
105
130
|
const row = document.createElement('div');
|
|
106
131
|
row.className = 'gov-perm';
|
|
107
132
|
|
|
133
|
+
// Why locked, in the words that tell the reader whether it is a policy they
|
|
134
|
+
// could change or a fact they cannot. Three causes, three sentences.
|
|
135
|
+
const lockReason = p.substrate
|
|
136
|
+
? 'Enforced outside the app (the pre-push hook, the deploy audit, the server itself). Granting it here would change nothing, so it is locked for every role.'
|
|
137
|
+
: isArchonRole(role)
|
|
138
|
+
? 'The Archon role holds everything and is read-only.'
|
|
139
|
+
: 'An owner-level permission. It can be handed to a rank role, but never to a custom one.';
|
|
140
|
+
|
|
108
141
|
let control;
|
|
109
|
-
if (!
|
|
110
|
-
// The R110 wall, rendered: visible, never togglable.
|
|
111
|
-
control = `<span class="gov-pill gov-pill--locked" title="Trust-boundary permission — non-grantable (ADR 0151)">locked</span>`;
|
|
112
|
-
} else if (!role) {
|
|
142
|
+
if (!role) {
|
|
113
143
|
control = '';
|
|
114
|
-
} else if (role
|
|
115
|
-
|
|
116
|
-
|
|
144
|
+
} else if (!grantableTo(p, role)) {
|
|
145
|
+
control = `<span class="gov-pill gov-pill--locked" title="${escapeHtml(lockReason)}">locked</span>`;
|
|
146
|
+
// The Archon role is read-only rather than empty-handed, so say what it holds.
|
|
147
|
+
if (held && isArchonRole(role)) control = `<span class="gov-held-note">held</span>${control}`;
|
|
117
148
|
} else {
|
|
118
149
|
control = `<button type="button" class="gov-toggle" aria-pressed="${held ? 'true' : 'false'}" data-perm="${escapeHtml(p.key)}">${held ? 'granted' : 'grant'}</button>`;
|
|
119
150
|
}
|
|
@@ -139,23 +170,71 @@
|
|
|
139
170
|
return el;
|
|
140
171
|
}
|
|
141
172
|
|
|
173
|
+
function gridNote(role) {
|
|
174
|
+
if (!role) return 'Pick a role to see and edit what it may do.';
|
|
175
|
+
if (isArchonRole(role)) {
|
|
176
|
+
return 'The Archon role holds every permission and is read-only — that is what makes it impossible to lock yourself out of this page. To give a power away, hand it to one of the ranks below instead.';
|
|
177
|
+
}
|
|
178
|
+
if (isRankRole(role)) {
|
|
179
|
+
return `Everything a ${role.label || role.role_key} can do, and yours to change — every builder at this rank is affected immediately. “Reset to default” puts it back to the shipped ladder.`;
|
|
180
|
+
}
|
|
181
|
+
return 'Toggle the everyday permissions this role carries. Owner powers can only go to a rank role, not to a custom one.';
|
|
182
|
+
}
|
|
183
|
+
|
|
142
184
|
function renderGrid() {
|
|
143
185
|
const grid = document.getElementById('gov-grid');
|
|
144
186
|
const role = selectedRole();
|
|
145
187
|
document.getElementById('gov-grid-title').textContent = role
|
|
146
188
|
? `Permissions — ${role.label || role.role_key}` : 'Permissions';
|
|
147
|
-
document.getElementById('gov-grid-note').textContent =
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
189
|
+
document.getElementById('gov-grid-note').textContent = gridNote(role);
|
|
190
|
+
|
|
191
|
+
// The reset escape hatch — only a rank role has a catalog default, and the
|
|
192
|
+
// Archon one never drifts from it, so it shows on exactly the three editable ones.
|
|
193
|
+
const resetBox = document.getElementById('gov-reset');
|
|
194
|
+
resetBox.innerHTML = '';
|
|
195
|
+
if (isRankRole(role) && !isArchonRole(role)) {
|
|
196
|
+
const btn = document.createElement('button');
|
|
197
|
+
btn.type = 'button';
|
|
198
|
+
btn.className = 'gov-btn gov-btn--ghost gov-btn--small';
|
|
199
|
+
btn.textContent = 'Reset to default';
|
|
200
|
+
btn.addEventListener('click', () => resetRole(role, btn));
|
|
201
|
+
resetBox.appendChild(btn);
|
|
202
|
+
}
|
|
152
203
|
|
|
153
204
|
grid.innerHTML = '';
|
|
205
|
+
// Principals are rendered in their own section further down the page; a machine
|
|
206
|
+
// identity is not a row anyone can act on here.
|
|
154
207
|
const humanPerms = permissions.filter((p) => !principals.some((pr) => pr.permission === p.key));
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
208
|
+
// Three groups, split by WHY a row is or isn't editable rather than by the old
|
|
209
|
+
// single `grantable` flag — which since ADR 0172 answers only the custom-role case.
|
|
210
|
+
const substrate = humanPerms.filter((p) => p.substrate);
|
|
211
|
+
const ownerPowers = humanPerms.filter((p) => p.system && !p.substrate);
|
|
212
|
+
const operational = humanPerms.filter((p) => !p.system);
|
|
213
|
+
|
|
214
|
+
const ownerHead = !role || isRankRole(role)
|
|
215
|
+
? `Owner powers (${ownerPowers.length})`
|
|
216
|
+
: `Owner powers — locked for custom roles (${ownerPowers.length})`;
|
|
217
|
+
|
|
158
218
|
grid.appendChild(section(`Operational (${operational.length})`, operational.map((p) => permRow(p, role))));
|
|
219
|
+
grid.appendChild(section(ownerHead, ownerPowers.map((p) => permRow(p, role))));
|
|
220
|
+
grid.appendChild(section(`Enforced outside the app — always locked (${substrate.length})`, substrate.map((p) => permRow(p, role))));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Restore a rank role to the shipped ladder. Confirmed because it is the one
|
|
224
|
+
// action here that discards work in bulk — a drifted role can be ~60 rows.
|
|
225
|
+
async function resetRole(role, btn) {
|
|
226
|
+
const label = role.label || role.role_key;
|
|
227
|
+
if (!window.confirm(`Reset ${label} to its default permissions?\n\nEvery change you've made to this rank is discarded, and it takes effect for every builder at this rank on their next request.`)) return;
|
|
228
|
+
btn.disabled = true;
|
|
229
|
+
try {
|
|
230
|
+
const out = await sendJson('POST', `/governance/roles/${encodeURIComponent(role.role_key)}/reset`);
|
|
231
|
+
toast(`${label} reset to its default ${out.permission_count} permissions.`);
|
|
232
|
+
await reloadRoles();
|
|
233
|
+
} catch (err) {
|
|
234
|
+
if (err && err.kind === '401') return;
|
|
235
|
+
toast(`Could not reset: ${err.message || err}`);
|
|
236
|
+
btn.disabled = false;
|
|
237
|
+
}
|
|
159
238
|
}
|
|
160
239
|
|
|
161
240
|
async function toggleGrant(role, permissionKey, grant, btn) {
|
|
@@ -337,7 +416,7 @@
|
|
|
337
416
|
const rank = (me && (me.builder || me).rank || '').toLowerCase();
|
|
338
417
|
if (rank !== 'archon') { showSealed(); return; }
|
|
339
418
|
|
|
340
|
-
document.getElementById('gov-sub').textContent = 'Roles, permissions, and who holds them
|
|
419
|
+
document.getElementById('gov-sub').textContent = 'Roles, permissions, and who holds them. Ranks below Archon are yours to shape; what the substrate enforces stays locked.';
|
|
341
420
|
document.getElementById('gov-body').hidden = false;
|
|
342
421
|
wireForms();
|
|
343
422
|
try {
|