@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.
Files changed (48) hide show
  1. package/.bongos-core.json +59 -54
  2. package/clients/bongos-client/bongos-client.global.js +19 -19
  3. package/clients/bongos-client/index.cjs +19 -19
  4. package/clients/bongos-client/index.d.ts +29 -29
  5. package/clients/bongos-client/index.mjs +19 -19
  6. package/docs/adr/0157-archon-is-rank-and-identity-only.md +2 -2
  7. package/docs/api/openapi.json +117 -117
  8. package/docs/api-reference.md +10 -10
  9. package/docs/canonical-permissions.md +6 -6
  10. package/modules/dev-box/app/src/vendor/bongos-client.cjs +19 -19
  11. package/modules/government/catalog.js +7 -7
  12. package/modules/government/db.js +8 -8
  13. package/modules/government/migrations/government_002_rename_permission_keys.sql +112 -0
  14. package/modules/government/module.json +1 -1
  15. package/modules/government/protected-surfaces.json +0 -1
  16. package/modules/government/ranks.js +13 -13
  17. package/modules/government/routes/{governance.js → government.js} +105 -80
  18. package/modules/hall-ui/public/gate.js +1 -1
  19. package/modules/hall-ui/public/governance.js +38 -38
  20. package/modules/hall-ui/public/harbor.js +2 -2
  21. package/modules/hall-ui/public/shell.js +3 -3
  22. package/modules/hall-ui/public/watch.js +1 -1
  23. package/modules/lifecycle/routes/gate-approvals.js +1 -1
  24. package/package-lock.json +2 -2
  25. package/package.json +1 -1
  26. package/scripts/gds/publish-manifest.js +0 -2
  27. package/scripts/gds/seed-governance-tasks.js +1 -1
  28. package/src/bongos/auth.js +7 -7
  29. package/src/bongos/module-scope-map.js +7 -22
  30. package/src/bongos/route-rank-check.js +12 -18
  31. package/src/bongos/serve-internal.js +2 -2
  32. package/src/module-api.js +4 -2
  33. package/tests/governance_abuse_matrix.mjs +32 -32
  34. package/tests/governance_catalog.mjs +3 -3
  35. package/tests/governance_fold.mjs +2 -2
  36. package/tests/governance_fold_bench.mjs +2 -2
  37. package/tests/governance_live_proof.mjs +4 -4
  38. package/tests/governance_ownership_scope.mjs +1 -1
  39. package/tests/governance_page_gates.mjs +4 -4
  40. package/tests/governance_principals.mjs +2 -2
  41. package/tests/governance_protected_surfaces.mjs +41 -16
  42. package/tests/governance_require_permission.mjs +5 -5
  43. package/tests/governance_resolver.mjs +2 -2
  44. package/tests/governance_role_sync.mjs +23 -23
  45. package/tests/governance_routes.mjs +94 -94
  46. package/tests/governance_seed.mjs +24 -9
  47. package/tests/governance_session_scope.mjs +4 -4
  48. package/tests/hall_page_gate_map.mjs +2 -2
@@ -97,7 +97,7 @@ function registerSeams() {
97
97
  selectFragment: `,
98
98
  (SELECT COALESCE(array_agg(DISTINCT rp.permission_key), '{}')
99
99
  FROM government_builder_ranks br
100
- JOIN government_rank_permissions rp ON rp.role_key = br.role_key
100
+ JOIN government_rank_permissions rp ON rp.rank_key = br.rank_key
101
101
  WHERE br.builder_id = s.builder_id) AS raw_permission_keys`,
102
102
  });
103
103
  }
@@ -105,7 +105,7 @@ function registerSeams() {
105
105
 
106
106
  // ── the R110 CRUD surface (ADR 0151 §2 — the governance tab's server half) ────
107
107
  //
108
- // Every route — the three reads included — gates on the `governance.manage`
108
+ // Every route — the three reads included — gates on the `government.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
111
  // it exposes the full authority map. The invariants are enforced HERE, against
@@ -131,37 +131,37 @@ function registerSeams() {
131
131
  // SUBSTRATE keys stay non-grantable to everything, rank-roles included, because a
132
132
  // grant row for one delegates nothing (ADR 0043 enforces them out of band).
133
133
  const { validateOrRespond, LIMITS, parseId } = api;
134
- const ROLE_KEY_RE = /^[a-z][a-z0-9-]*$/;
135
- const ROLE_KEY_MAX = 64;
134
+ const RANK_KEY_RE = /^[a-z][a-z0-9-]*$/;
135
+ const RANK_KEY_MAX = 64;
136
136
 
137
137
  module.exports = function buildGovernanceRouter() {
138
138
  registerSeams();
139
139
  const router = express.Router();
140
140
 
141
- // Param guard for :roleKey. Responds bad_role_key and returns null on a
141
+ // Param guard for :rankKey. Responds bad_rank_key and returns null on a
142
142
  // malformed slug (seeded keys like 'metic' pass shape and are refused later,
143
143
  // by policy, with the honest code).
144
- function roleKeyParam(req, res) {
145
- const roleKey = req.params.roleKey;
146
- if (roleKey.length > ROLE_KEY_MAX || !ROLE_KEY_RE.test(roleKey)) {
147
- res.fail('bad_role_key', 400, { role_key: roleKey, expected: 'a kebab-case slug (^[a-z][a-z0-9-]*$)' });
144
+ function rankKeyParam(req, res) {
145
+ const rankKey = req.params.rankKey;
146
+ if (rankKey.length > RANK_KEY_MAX || !RANK_KEY_RE.test(rankKey)) {
147
+ res.fail('bad_rank_key', 400, { rank_key: rankKey, expected: 'a kebab-case slug (^[a-z][a-z0-9-]*$)' });
148
148
  return null;
149
149
  }
150
- return roleKey;
150
+ return rankKey;
151
151
  }
152
152
 
153
153
  // Load a role whose GRANTS this API may edit (ADR 0172): 404 on a missing role,
154
154
  // refuse only the Archon rank-role. Every other role — the three sub-Archon
155
155
  // rank-roles and every custom one — is grant-editable; WHICH keys is then
156
156
  // `catalog.isGrantableTo(key, role)`, per role. Returns null after responding.
157
- async function loadGrantEditableRole(res, roleKey) {
158
- const role = await db.getRoleByKey(roleKey);
157
+ async function loadGrantEditableRole(res, rankKey) {
158
+ const role = await db.getRoleByKey(rankKey);
159
159
  if (!role) {
160
- res.fail('role_not_found', 404, { role_key: roleKey });
160
+ res.fail('rank_not_found', 404, { rank_key: rankKey });
161
161
  return null;
162
162
  }
163
163
  if (role.is_system && role.seeded_rank === ARCHON) {
164
- res.fail('archon_role_immutable', 403, { role_key: roleKey, reason: ARCHON_REASON });
164
+ res.fail('archon_rank_immutable', 403, { rank_key: rankKey, reason: ARCHON_REASON });
165
165
  return null;
166
166
  }
167
167
  return role;
@@ -169,14 +169,14 @@ module.exports = function buildGovernanceRouter() {
169
169
 
170
170
  // Load a role this API may ASSIGN. Unchanged from R110: every seeded rank-role is
171
171
  // refused, because assignment derives from builders.rank via the R95b sync.
172
- async function loadAssignableRole(res, roleKey) {
173
- const role = await db.getRoleByKey(roleKey);
172
+ async function loadAssignableRole(res, rankKey) {
173
+ const role = await db.getRoleByKey(rankKey);
174
174
  if (!role) {
175
- res.fail('role_not_found', 404, { role_key: roleKey });
175
+ res.fail('rank_not_found', 404, { rank_key: rankKey });
176
176
  return null;
177
177
  }
178
178
  if (role.is_system) {
179
- res.fail('seeded_role_assignment_forbidden', 403, { role_key: roleKey, reason: ASSIGN_REASON });
179
+ res.fail('seeded_rank_assignment_forbidden', 403, { rank_key: rankKey, reason: ASSIGN_REASON });
180
180
  return null;
181
181
  }
182
182
  return role;
@@ -185,18 +185,43 @@ module.exports = function buildGovernanceRouter() {
185
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)';
186
186
  const ASSIGN_REASON = 'a seeded rank-role is assigned by rank (R95b sync); change the builder\'s rank instead';
187
187
 
188
- // rank: perm governance.manage — the full role list with each role's grants.
189
- router.get('/governance/roles', api.requireBuilder, api.requirePermission('governance.manage'), async (req, res) => {
188
+ // ── one-release compatibility aliases: /governance/* -> /government/* ──────
189
+ // ADR 0174 committed to keeping the pre-rename paths answering for exactly one
190
+ // release. The case this actually protects is narrow but real: a browser that
191
+ // still has the OLD hall page open when the instance upgrades will keep calling
192
+ // /governance/* against the new server. Everything else that consumed these
193
+ // paths (the hall bundle, the generated client) ships INSIDE the same core
194
+ // package and moves in lockstep.
195
+ //
196
+ // A 308 preserves the method and body, so a PUT/DELETE grant toggle survives
197
+ // the hop. The alias carries NO gate of its own — it redirects, and the real
198
+ // route re-runs requireBuilder + requirePermission on the followed request, so
199
+ // this cannot become a way around the wall.
200
+ //
201
+ // REMOVE THIS BLOCK one release after the rename ships (goal 1000068 follow-up).
202
+ // The `alias` counter in the log line is how we tell whether anything still
203
+ // calls them before deleting.
204
+ const LEGACY_PATH_PREFIX = '/governance';
205
+ router.all(`${LEGACY_PATH_PREFIX}/*rest`, (req, res, next) => {
206
+ const rest = req.originalUrl.slice(req.originalUrl.indexOf(LEGACY_PATH_PREFIX) + LEGACY_PATH_PREFIX.length);
207
+ if (!rest) return next();
208
+ const target = `/government${rest.replace(/^\/roles(?=\/|$)/, '/ranks')}`;
209
+ console.warn(`[government] legacy path hit: ${req.method} ${req.originalUrl} -> ${target} (ADR 0174 alias; remove one release after the rename)`);
210
+ return res.redirect(308, target);
211
+ });
212
+
213
+ // rank: perm government.manage — the full role list with each role's grants.
214
+ router.get('/government/ranks', api.requireBuilder, api.requirePermission('government.manage'), async (req, res) => {
190
215
  try {
191
216
  const roles = await db.listRolesWithGrants();
192
217
  res.json({ roles });
193
218
  } catch (err) {
194
- console.error('[gds] GET /governance/roles', err);
219
+ console.error('[gds] GET /government/ranks', err);
195
220
  res.fail('roles_list_failed', { status: 500, message: 'internal error' });
196
221
  }
197
222
  });
198
223
 
199
- // rank: perm governance.manage — the permission catalog + principals, straight
224
+ // rank: perm government.manage — the permission catalog + principals, straight
200
225
  // from code (the catalog is CODE, not a table); no DB read. Principals are
201
226
  // read-only by construction. Three flags the tab renders its locks from, and the
202
227
  // tab must read all three rather than just `grantable`, because since ADR 0172
@@ -205,7 +230,7 @@ module.exports = function buildGovernanceRouter() {
205
230
  // • `system` — an in-app Archon power: locked on a custom role, togglable on
206
231
  // a sub-Archon rank-role.
207
232
  // • `substrate` — locked EVERYWHERE, on every role, forever (ADR 0043).
208
- router.get('/governance/permissions', api.requireBuilder, api.requirePermission('governance.manage'), (req, res) => {
233
+ router.get('/government/permissions', api.requireBuilder, api.requirePermission('government.manage'), (req, res) => {
209
234
  const permissions = catalog.PERMISSIONS.map((p) => ({
210
235
  key: p.key,
211
236
  system: p.system,
@@ -219,33 +244,33 @@ module.exports = function buildGovernanceRouter() {
219
244
  res.json({ permissions, principals: catalog.principals() });
220
245
  });
221
246
 
222
- // rank: perm governance.manage — every builder↔role assignment row.
223
- router.get('/governance/assignments', api.requireBuilder, api.requirePermission('governance.manage'), async (req, res) => {
247
+ // rank: perm government.manage — every builder↔role assignment row.
248
+ router.get('/government/assignments', api.requireBuilder, api.requirePermission('government.manage'), async (req, res) => {
224
249
  try {
225
250
  const assignments = await db.listAssignments();
226
251
  res.json({ assignments });
227
252
  } catch (err) {
228
- console.error('[gds] GET /governance/assignments', err);
253
+ console.error('[gds] GET /government/assignments', err);
229
254
  res.fail('assignments_list_failed', { status: 500, message: 'internal error' });
230
255
  }
231
256
  });
232
257
 
233
- // rank: perm governance.manage — create a CUSTOM role (is_system false, no
258
+ // rank: perm government.manage — create a CUSTOM role (is_system false, no
234
259
  // seeded_rank — the seeded four exist only via governance_002). Grants are
235
260
  // added one at a time via the toggle below, so creation carries none.
236
- router.post('/governance/roles', api.requireBuilder, api.requirePermission('governance.manage'), async (req, res) => {
261
+ router.post('/government/ranks', api.requireBuilder, api.requirePermission('government.manage'), async (req, res) => {
237
262
  if (validateOrRespond(req, res, {
238
- role_key: { required: true, type: 'string', minLength: 2, maxLength: ROLE_KEY_MAX },
263
+ rank_key: { required: true, type: 'string', minLength: 2, maxLength: RANK_KEY_MAX },
239
264
  label: { required: true, type: 'string', minLength: 1, maxLength: LIMITS.TITLE },
240
265
  description: { type: 'string', maxLength: 2000 },
241
266
  })) return;
242
- const roleKey = req.body.role_key;
243
- if (!ROLE_KEY_RE.test(roleKey)) {
244
- return res.fail('bad_role_key', 400, { role_key: roleKey, expected: 'a kebab-case slug (^[a-z][a-z0-9-]*$)' });
267
+ const rankKey = req.body.rank_key;
268
+ if (!RANK_KEY_RE.test(rankKey)) {
269
+ return res.fail('bad_rank_key', 400, { rank_key: rankKey, expected: 'a kebab-case slug (^[a-z][a-z0-9-]*$)' });
245
270
  }
246
271
  try {
247
272
  const role = await db.createRole({
248
- roleKey,
273
+ rankKey,
249
274
  label: req.body.label,
250
275
  description: req.body.description ?? null,
251
276
  createdBy: req.builder.id,
@@ -253,22 +278,22 @@ module.exports = function buildGovernanceRouter() {
253
278
  res.status(201).json({ role: { ...role, permissions: [] } });
254
279
  } catch (err) {
255
280
  if (err && err.code === '23505') {
256
- return res.fail('role_exists', 409, { role_key: roleKey });
281
+ return res.fail('rank_exists', 409, { rank_key: rankKey });
257
282
  }
258
- console.error('[gds] POST /governance/roles', err);
283
+ console.error('[gds] POST /government/ranks', err);
259
284
  res.fail('role_create_failed', { status: 500, message: 'internal error' });
260
285
  }
261
286
  });
262
287
 
263
- // rank: perm governance.manage — grant one permission to a role (the toggle's ON
288
+ // rank: perm government.manage — grant one permission to a role (the toggle's ON
264
289
  // half). The order of refusals is the authority order: role exists → role is not
265
290
  // the Archon one → the key exists → the key is grantable TO THIS ROLE.
266
- router.put('/governance/roles/:roleKey/permissions/:permissionKey', api.requireBuilder, api.requirePermission('governance.manage'), async (req, res) => {
267
- const roleKey = roleKeyParam(req, res);
268
- if (roleKey === null) return;
291
+ router.put('/government/ranks/:rankKey/permissions/:permissionKey', api.requireBuilder, api.requirePermission('government.manage'), async (req, res) => {
292
+ const rankKey = rankKeyParam(req, res);
293
+ if (rankKey === null) return;
269
294
  const permissionKey = req.params.permissionKey;
270
295
  try {
271
- const role = await loadGrantEditableRole(res, roleKey);
296
+ const role = await loadGrantEditableRole(res, rankKey);
272
297
  if (role === null) return;
273
298
  if (!catalog.byKey(permissionKey)) {
274
299
  return res.fail('unknown_permission', 400, { permission_key: permissionKey });
@@ -276,7 +301,7 @@ module.exports = function buildGovernanceRouter() {
276
301
  if (!catalog.isGrantableTo(permissionKey, role)) {
277
302
  return res.fail('permission_not_grantable', 400, {
278
303
  permission_key: permissionKey,
279
- role_key: roleKey,
304
+ rank_key: rankKey,
280
305
  // Name the ACTUAL wall. "Not grantable" now has three distinct causes and
281
306
  // a caller that cannot tell them apart cannot tell a policy choice
282
307
  // (widen the role) from a structural one (nothing will ever work).
@@ -287,34 +312,34 @@ module.exports = function buildGovernanceRouter() {
287
312
  : 'a system (trust-boundary) permission is non-grantable to a CUSTOM role; only a sub-Archon rank-role may carry one (ADR 0172)',
288
313
  });
289
314
  }
290
- const granted = await db.grantRolePermission({ roleKey, permissionKey });
291
- res.json({ ok: true, role_key: roleKey, permission_key: permissionKey, granted });
315
+ const granted = await db.grantRolePermission({ rankKey, permissionKey });
316
+ res.json({ ok: true, rank_key: rankKey, permission_key: permissionKey, granted });
292
317
  } catch (err) {
293
- console.error('[gds] PUT /governance/roles/:roleKey/permissions/:permissionKey', err);
318
+ console.error('[gds] PUT /government/ranks/:rankKey/permissions/:permissionKey', err);
294
319
  res.fail('grant_failed', { status: 500, message: 'internal error' });
295
320
  }
296
321
  });
297
322
 
298
- // rank: perm governance.manage — revoke a grant (the toggle's OFF half).
323
+ // rank: perm government.manage — revoke a grant (the toggle's OFF half).
299
324
  // Deliberately NOT validated against the catalog: a grant whose key the
300
325
  // catalog since retired is exactly the row an Archon most needs to remove
301
326
  // (the resolver already drops it — this is hygiene, not authority).
302
- router.delete('/governance/roles/:roleKey/permissions/:permissionKey', api.requireBuilder, api.requirePermission('governance.manage'), async (req, res) => {
303
- const roleKey = roleKeyParam(req, res);
304
- if (roleKey === null) return;
327
+ router.delete('/government/ranks/:rankKey/permissions/:permissionKey', api.requireBuilder, api.requirePermission('government.manage'), async (req, res) => {
328
+ const rankKey = rankKeyParam(req, res);
329
+ if (rankKey === null) return;
305
330
  const permissionKey = req.params.permissionKey;
306
331
  try {
307
- const role = await loadGrantEditableRole(res, roleKey);
332
+ const role = await loadGrantEditableRole(res, rankKey);
308
333
  if (role === null) return;
309
- const removed = await db.revokeRolePermission({ roleKey, permissionKey });
310
- res.json({ ok: true, role_key: roleKey, permission_key: permissionKey, removed });
334
+ const removed = await db.revokeRolePermission({ rankKey, permissionKey });
335
+ res.json({ ok: true, rank_key: rankKey, permission_key: permissionKey, removed });
311
336
  } catch (err) {
312
- console.error('[gds] DELETE /governance/roles/:roleKey/permissions/:permissionKey', err);
337
+ console.error('[gds] DELETE /government/ranks/:rankKey/permissions/:permissionKey', err);
313
338
  res.fail('revoke_failed', { status: 500, message: 'internal error' });
314
339
  }
315
340
  });
316
341
 
317
- // rank: perm governance.manage — restore a SEEDED rank-role to its catalog
342
+ // rank: perm government.manage — restore a SEEDED rank-role to its catalog
318
343
  // default (ADR 0172). The counterweight to making the rank-roles editable: a
319
344
  // rank's grant set is ~60 rows, so an owner who drifts one has no practical way
320
345
  // back by clicking, and "what does Trusted mean by default" is a question only
@@ -325,15 +350,15 @@ module.exports = function buildGovernanceRouter() {
325
350
  // Deliberately NOT offered for a CUSTOM role: a custom role has no catalog
326
351
  // default to return to (its "default" is the empty set it was created with), so
327
352
  // 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;
353
+ router.post('/government/ranks/:rankKey/reset', api.requireBuilder, api.requirePermission('government.manage'), async (req, res) => {
354
+ const rankKey = rankKeyParam(req, res);
355
+ if (rankKey === null) return;
331
356
  try {
332
- const role = await loadGrantEditableRole(res, roleKey);
357
+ const role = await loadGrantEditableRole(res, rankKey);
333
358
  if (role === null) return;
334
359
  if (!role.is_system || !catalog.RANK_ORDER.includes(role.seeded_rank)) {
335
- return res.fail('role_has_no_default', 400, {
336
- role_key: roleKey,
360
+ return res.fail('rank_has_no_default', 400, {
361
+ rank_key: rankKey,
337
362
  reason: 'only a seeded rank-role has a catalog default to reset to; revoke a custom role\'s grants instead',
338
363
  });
339
364
  }
@@ -341,58 +366,58 @@ module.exports = function buildGovernanceRouter() {
341
366
  // — below archon — never a system one. Filtering by isGrantableTo anyway keeps
342
367
  // reset from being a back door that writes what the toggle refuses.
343
368
  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 });
369
+ const count = await db.resetRoleGrants({ rankKey, permissionKeys: keys });
370
+ res.json({ ok: true, rank_key: rankKey, seeded_rank: role.seeded_rank, permission_count: count });
346
371
  } catch (err) {
347
- console.error('[gds] POST /governance/roles/:roleKey/reset', err);
372
+ console.error('[gds] POST /government/ranks/:rankKey/reset', err);
348
373
  res.fail('reset_failed', { status: 500, message: 'internal error' });
349
374
  }
350
375
  });
351
376
 
352
- // rank: perm governance.manage — assign a CUSTOM role to a builder.
377
+ // rank: perm government.manage — assign a CUSTOM role to a builder.
353
378
  // assigned_by is ALWAYS the session builder, never input (the audit trail's
354
379
  // "who did this" must be the authenticated actor).
355
- router.post('/governance/assignments', api.requireBuilder, api.requirePermission('governance.manage'), async (req, res) => {
380
+ router.post('/government/assignments', api.requireBuilder, api.requirePermission('government.manage'), async (req, res) => {
356
381
  if (validateOrRespond(req, res, {
357
382
  builder_id: { required: true, type: 'integer', min: 1 },
358
- role_key: { required: true, type: 'string', minLength: 2, maxLength: ROLE_KEY_MAX },
383
+ rank_key: { required: true, type: 'string', minLength: 2, maxLength: RANK_KEY_MAX },
359
384
  })) return;
360
- const roleKey = req.body.role_key;
361
- if (!ROLE_KEY_RE.test(roleKey)) {
362
- return res.fail('bad_role_key', 400, { role_key: roleKey, expected: 'a kebab-case slug (^[a-z][a-z0-9-]*$)' });
385
+ const rankKey = req.body.rank_key;
386
+ if (!RANK_KEY_RE.test(rankKey)) {
387
+ return res.fail('bad_rank_key', 400, { rank_key: rankKey, expected: 'a kebab-case slug (^[a-z][a-z0-9-]*$)' });
363
388
  }
364
389
  try {
365
- const role = await loadAssignableRole(res, roleKey);
390
+ const role = await loadAssignableRole(res, rankKey);
366
391
  if (role === null) return;
367
392
  const assigned = await db.assignRole({
368
393
  builderId: req.body.builder_id,
369
- roleKey,
394
+ rankKey,
370
395
  assignedBy: req.builder.id,
371
396
  });
372
- res.status(assigned ? 201 : 200).json({ ok: true, builder_id: req.body.builder_id, role_key: roleKey, assigned });
397
+ res.status(assigned ? 201 : 200).json({ ok: true, builder_id: req.body.builder_id, rank_key: rankKey, assigned });
373
398
  } catch (err) {
374
399
  if (err && err.code === '23503') {
375
400
  return res.fail('builder_not_found', 404, { builder_id: req.body.builder_id });
376
401
  }
377
- console.error('[gds] POST /governance/assignments', err);
402
+ console.error('[gds] POST /government/assignments', err);
378
403
  res.fail('assign_failed', { status: 500, message: 'internal error' });
379
404
  }
380
405
  });
381
406
 
382
- // rank: perm governance.manage — remove a custom-role assignment. Idempotent:
407
+ // rank: perm government.manage — remove a custom-role assignment. Idempotent:
383
408
  // `removed` says whether a row went away.
384
- router.delete('/governance/assignments/:builderId/:roleKey', api.requireBuilder, api.requirePermission('governance.manage'), async (req, res) => {
409
+ router.delete('/government/assignments/:builderId/:rankKey', api.requireBuilder, api.requirePermission('government.manage'), async (req, res) => {
385
410
  const builderId = parseId(req, res, { param: 'builderId', code: 'bad_builder_id', positiveInt: true });
386
411
  if (builderId === null) return;
387
- const roleKey = roleKeyParam(req, res);
388
- if (roleKey === null) return;
412
+ const rankKey = rankKeyParam(req, res);
413
+ if (rankKey === null) return;
389
414
  try {
390
- const role = await loadAssignableRole(res, roleKey);
415
+ const role = await loadAssignableRole(res, rankKey);
391
416
  if (role === null) return;
392
- const removed = await db.unassignRole({ builderId, roleKey });
393
- res.json({ ok: true, builder_id: builderId, role_key: roleKey, removed });
417
+ const removed = await db.unassignRole({ builderId, rankKey });
418
+ res.json({ ok: true, builder_id: builderId, rank_key: rankKey, removed });
394
419
  } catch (err) {
395
- console.error('[gds] DELETE /governance/assignments/:builderId/:roleKey', err);
420
+ console.error('[gds] DELETE /government/assignments/:builderId/:rankKey', err);
396
421
  res.fail('unassign_failed', { status: 500, message: 'internal error' });
397
422
  }
398
423
  });
@@ -217,7 +217,7 @@
217
217
  me = null;
218
218
  }
219
219
  const rank = (me && (me.builder || me).rank || '').toLowerCase();
220
- // ADR 0157: the Gate page sits at the `page.view.governance` floor (metic). The
220
+ // ADR 0157: the Gate page sits at the `page.view.government` floor (metic). The
221
221
  // server's requireGovernancePage is the wall — this only decides whether to render the
222
222
  // body or the sealed notice, so it must agree or a Metic sees "sealed" on a page
223
223
  // they were just admitted to.
@@ -16,13 +16,13 @@
16
16
  // owner's 2026-08-16 decision, and the section header says so out loud.
17
17
  // • the ARCHON rank-role is read-only in full: it holds everything, and its
18
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`)
19
+ // owner out of this tab. The API refuses it as `archon_rank_immutable`.
20
+ // ASSIGNMENT of any rank-role is still refused (`seeded_rank_assignment_forbidden`)
21
21
  // — you get a rank-role by holding the rank, so that dial is the rank itself.
22
22
  //
23
23
  // Like The Gate, this reads /api/bongos/me for a friendly Sealed notice —
24
- // DEFENCE IN DEPTH only. Every /governance/* endpoint (reads included) is ALSO
25
- // gated server-side on the `governance.manage` atom (archon floor). On 401 →
24
+ // DEFENCE IN DEPTH only. Every /government/* endpoint (reads included) is ALSO
25
+ // gated server-side on the `government.manage` atom (archon floor). On 401 →
26
26
  // web sign-in.
27
27
  //
28
28
  // DELIBERATELY NOT IN THE SIDEBAR YET: the nav entry + the server page gate are
@@ -64,27 +64,27 @@
64
64
  // The R110 refusal codes, translated for the person clicking.
65
65
  const ERROR_COPY = {
66
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.',
69
- seeded_role_assignment_forbidden: 'Rank roles are assigned by rank itself — change the builder’s rank instead.',
70
- role_exists: 'A role with that key already exists.',
71
- role_not_found: 'That role no longer exists — refreshing.',
67
+ archon_rank_immutable: 'The Archon role is read-only — it holds every permission, which is what keeps you from locking yourself out of this page.',
68
+ rank_has_no_default: 'Only a rank role has a catalog default to reset to. Revoke a custom role’s permissions individually instead.',
69
+ seeded_rank_assignment_forbidden: 'Rank roles are assigned by rank itself — change the builder’s rank instead.',
70
+ rank_exists: 'A role with that key already exists.',
71
+ rank_not_found: 'That role no longer exists — refreshing.',
72
72
  builder_not_found: 'No builder with that id.',
73
73
  unknown_permission: 'The permission catalog does not define that key.',
74
- bad_role_key: 'Role keys are kebab-case: lowercase letters, digits, dashes.',
74
+ bad_rank_key: 'Role keys are kebab-case: lowercase letters, digits, dashes.',
75
75
  validation_failed: 'Missing or invalid fields.',
76
76
  };
77
77
  const friendlyError = (code) => ERROR_COPY[code] || null;
78
78
 
79
79
  // ---- state ---------------------------------------------------------------
80
80
 
81
- let roles = []; // [{ role_key, label, description, is_system, seeded_rank, permissions: [] }]
81
+ let roles = []; // [{ rank_key, label, description, is_system, seeded_rank, permissions: [] }]
82
82
  let permissions = []; // catalog rows: [{ key, system, floor, guards, grantable }]
83
83
  let principals = []; // [{ system_role, permission, guards }]
84
- let assignments = []; // [{ builder_id, github_login, display_name, rank, role_key, role_label, is_system }]
84
+ let assignments = []; // [{ builder_id, github_login, display_name, rank, rank_key, role_label, is_system }]
85
85
  let selectedRoleKey = null;
86
86
 
87
- const selectedRole = () => roles.find((r) => r.role_key === selectedRoleKey) || null;
87
+ const selectedRole = () => roles.find((r) => r.rank_key === selectedRoleKey) || null;
88
88
 
89
89
  // ---- render: role list ---------------------------------------------------
90
90
 
@@ -102,11 +102,11 @@
102
102
  const btn = document.createElement('button');
103
103
  btn.type = 'button';
104
104
  btn.className = 'gov-role';
105
- btn.setAttribute('aria-pressed', role.role_key === selectedRoleKey ? 'true' : 'false');
105
+ btn.setAttribute('aria-pressed', role.rank_key === selectedRoleKey ? 'true' : 'false');
106
106
  btn.innerHTML =
107
- `<span class="gov-role__head"><span class="gov-role__label">${escapeHtml(role.label || role.role_key)}</span>${pill}</span>
108
- <span class="gov-role__meta"><code>${escapeHtml(role.role_key)}</code> · ${(role.permissions || []).length} permission${(role.permissions || []).length === 1 ? '' : 's'}</span>`;
109
- btn.addEventListener('click', () => { selectedRoleKey = role.role_key; renderRoles(); renderGrid(); });
107
+ `<span class="gov-role__head"><span class="gov-role__label">${escapeHtml(role.label || role.rank_key)}</span>${pill}</span>
108
+ <span class="gov-role__meta"><code>${escapeHtml(role.rank_key)}</code> · ${(role.permissions || []).length} permission${(role.permissions || []).length === 1 ? '' : 's'}</span>`;
109
+ btn.addEventListener('click', () => { selectedRoleKey = role.rank_key; renderRoles(); renderGrid(); });
110
110
  list.appendChild(btn);
111
111
  }
112
112
  }
@@ -176,7 +176,7 @@
176
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
177
  }
178
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.`;
179
+ return `Everything a ${role.label || role.rank_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
180
  }
181
181
  return 'Toggle the everyday permissions this role carries. Owner powers can only go to a rank role, not to a custom one.';
182
182
  }
@@ -185,7 +185,7 @@
185
185
  const grid = document.getElementById('gov-grid');
186
186
  const role = selectedRole();
187
187
  document.getElementById('gov-grid-title').textContent = role
188
- ? `Permissions — ${role.label || role.role_key}` : 'Permissions';
188
+ ? `Permissions — ${role.label || role.rank_key}` : 'Permissions';
189
189
  document.getElementById('gov-grid-note').textContent = gridNote(role);
190
190
 
191
191
  // The reset escape hatch — only a rank role has a catalog default, and the
@@ -223,11 +223,11 @@
223
223
  // Restore a rank role to the shipped ladder. Confirmed because it is the one
224
224
  // action here that discards work in bulk — a drifted role can be ~60 rows.
225
225
  async function resetRole(role, btn) {
226
- const label = role.label || role.role_key;
226
+ const label = role.label || role.rank_key;
227
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
228
  btn.disabled = true;
229
229
  try {
230
- const out = await sendJson('POST', `/governance/roles/${encodeURIComponent(role.role_key)}/reset`);
230
+ const out = await sendJson('POST', `/government/ranks/${encodeURIComponent(role.rank_key)}/reset`);
231
231
  toast(`${label} reset to its default ${out.permission_count} permissions.`);
232
232
  await reloadRoles();
233
233
  } catch (err) {
@@ -240,7 +240,7 @@
240
240
  async function toggleGrant(role, permissionKey, grant, btn) {
241
241
  btn.disabled = true;
242
242
  try {
243
- await sendJson(grant ? 'PUT' : 'DELETE', `/governance/roles/${encodeURIComponent(role.role_key)}/permissions/${encodeURIComponent(permissionKey)}`);
243
+ await sendJson(grant ? 'PUT' : 'DELETE', `/government/ranks/${encodeURIComponent(role.rank_key)}/permissions/${encodeURIComponent(permissionKey)}`);
244
244
  toast(grant ? `Granted ${permissionKey}.` : `Revoked ${permissionKey}.`);
245
245
  await reloadRoles();
246
246
  } catch (err) {
@@ -266,8 +266,8 @@
266
266
  row.className = 'gov-assign-row';
267
267
  const who = `${escapeHtml(a.display_name || a.github_login || `builder ${a.builder_id}`)} <span class="muted">(#${escapeHtml(String(a.builder_id))} · ${escapeHtml(a.rank || '')})</span>`;
268
268
  const rolePill = a.is_system
269
- ? `<span class="gov-pill gov-pill--seeded">${escapeHtml(a.role_label || a.role_key)} · via rank</span>`
270
- : `<span class="gov-pill gov-pill--custom">${escapeHtml(a.role_label || a.role_key)}</span>`;
269
+ ? `<span class="gov-pill gov-pill--seeded">${escapeHtml(a.role_label || a.rank_key)} · via rank</span>`
270
+ : `<span class="gov-pill gov-pill--custom">${escapeHtml(a.role_label || a.rank_key)}</span>`;
271
271
  const action = a.is_system ? '' : `<button type="button" class="gov-btn gov-btn--ghost gov-btn--small" data-act="unassign">Remove</button>`;
272
272
  row.innerHTML = `<span class="gov-assign-row__who">${who}</span>${rolePill}${action}`;
273
273
  const btn = row.querySelector('[data-act=unassign]');
@@ -281,15 +281,15 @@
281
281
  const sel = document.getElementById('gov-assign-role');
282
282
  const custom = roles.filter((r) => !r.is_system);
283
283
  sel.innerHTML = custom.length
284
- ? custom.map((r) => `<option value="${escapeHtml(r.role_key)}">${escapeHtml(r.label || r.role_key)}</option>`).join('')
284
+ ? custom.map((r) => `<option value="${escapeHtml(r.rank_key)}">${escapeHtml(r.label || r.rank_key)}</option>`).join('')
285
285
  : `<option value="" disabled selected>No custom roles yet</option>`;
286
286
  }
287
287
 
288
288
  async function unassign(a, btn) {
289
289
  btn.disabled = true;
290
290
  try {
291
- await sendJson('DELETE', `/governance/assignments/${encodeURIComponent(a.builder_id)}/${encodeURIComponent(a.role_key)}`);
292
- toast(`Removed ${a.role_label || a.role_key} from builder ${a.builder_id}.`);
291
+ await sendJson('DELETE', `/government/assignments/${encodeURIComponent(a.builder_id)}/${encodeURIComponent(a.rank_key)}`);
292
+ toast(`Removed ${a.role_label || a.rank_key} from builder ${a.builder_id}.`);
293
293
  await reloadAssignments();
294
294
  } catch (err) {
295
295
  if (err && err.kind === '401') return;
@@ -321,22 +321,22 @@
321
321
  // ---- loads ---------------------------------------------------------------
322
322
 
323
323
  async function reloadRoles() {
324
- const data = await fetchJson('/governance/roles');
324
+ const data = await fetchJson('/government/ranks');
325
325
  roles = data.roles || [];
326
- if (!roles.some((r) => r.role_key === selectedRoleKey)) selectedRoleKey = roles.length ? roles[0].role_key : null;
326
+ if (!roles.some((r) => r.rank_key === selectedRoleKey)) selectedRoleKey = roles.length ? roles[0].rank_key : null;
327
327
  renderRoles();
328
328
  renderGrid();
329
329
  renderAssignSelect();
330
330
  }
331
331
 
332
332
  async function reloadAssignments() {
333
- const data = await fetchJson('/governance/assignments');
333
+ const data = await fetchJson('/government/assignments');
334
334
  assignments = data.assignments || [];
335
335
  renderAssignments();
336
336
  }
337
337
 
338
338
  async function load() {
339
- const perms = await fetchJson('/governance/permissions');
339
+ const perms = await fetchJson('/government/permissions');
340
340
  permissions = perms.permissions || [];
341
341
  principals = perms.principals || [];
342
342
  await reloadRoles();
@@ -352,16 +352,16 @@
352
352
  const btn = e.target.querySelector('button');
353
353
  btn.disabled = true;
354
354
  const body = {
355
- role_key: document.getElementById('gov-create-key').value.trim(),
355
+ rank_key: document.getElementById('gov-create-key').value.trim(),
356
356
  label: document.getElementById('gov-create-label').value.trim(),
357
357
  };
358
358
  const desc = document.getElementById('gov-create-desc').value.trim();
359
359
  if (desc) body.description = desc;
360
360
  try {
361
- const r = await sendJson('POST', '/governance/roles', body);
362
- toast(`Created role ${r.role ? r.role.role_key : body.role_key}.`);
361
+ const r = await sendJson('POST', '/government/ranks', body);
362
+ toast(`Created role ${r.role ? r.role.rank_key : body.rank_key}.`);
363
363
  e.target.reset();
364
- selectedRoleKey = body.role_key;
364
+ selectedRoleKey = body.rank_key;
365
365
  await reloadRoles();
366
366
  } catch (err) {
367
367
  if (err && err.kind === '401') return;
@@ -379,7 +379,7 @@
379
379
  if (!roleKey) { toast('Create a custom role first.'); return; }
380
380
  btn.disabled = true;
381
381
  try {
382
- await sendJson('POST', '/governance/assignments', { builder_id: builderId, role_key: roleKey });
382
+ await sendJson('POST', '/government/assignments', { builder_id: builderId, rank_key: roleKey });
383
383
  toast(`Assigned ${roleKey} to builder ${builderId}.`);
384
384
  e.target.reset();
385
385
  await reloadAssignments();
@@ -402,8 +402,8 @@
402
402
 
403
403
  async function init() {
404
404
  // Confirm rank for a friendly sealed notice; the server gates the page and
405
- // every /governance endpoint on `governance.manage` regardless (cosmetic
406
- // defence-in-depth). governance.manage is system:true, so only the archon
405
+ // every /governance endpoint on `government.manage` regardless (cosmetic
406
+ // defence-in-depth). government.manage is system:true, so only the archon
407
407
  // rank-role can hold it — rank is an accurate client-side proxy.
408
408
  let me;
409
409
  try {
@@ -9,7 +9,7 @@
9
9
  // shows a restricted notice below the Metic floor — DEFENCE IN DEPTH only. The data +
10
10
  // mutation endpoints (/boxes, /boxes/:id/close, /boxes/:id/block) are ALSO gated
11
11
  // server-side on `box.fleet.manage` (ADR 0016), and the page itself is gated by
12
- // serve-internal.js requireGovernancePage — whose own floor is `page.view.governance`,
12
+ // serve-internal.js requireGovernancePage — whose own floor is `page.view.government`,
13
13
  // metic since ADR 0157. On a 401 it redirects into the web sign-in flow.
14
14
 
15
15
  (() => {
@@ -326,7 +326,7 @@
326
326
  me = null;
327
327
  }
328
328
  const rank = (me && (me.builder || me).rank || '').toLowerCase();
329
- // ADR 0157: Harbor sits at the `page.view.governance` floor (metic), and the fleet
329
+ // ADR 0157: Harbor sits at the `page.view.government` floor (metic), and the fleet
330
330
  // data it reads is `box.fleet.manage` (also metic now). Cosmetic —
331
331
  // requireGovernancePage + the route gates are the wall; this just picks body vs
332
332
  // sealed notice.