@bongos/core 1.19.183 → 1.19.185
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +102 -92
- package/clients/bongos-client/bongos-client.global.js +19 -19
- package/clients/bongos-client/index.cjs +19 -19
- package/clients/bongos-client/index.d.ts +29 -29
- package/clients/bongos-client/index.mjs +19 -19
- package/docs/adr/0157-archon-is-rank-and-identity-only.md +2 -2
- package/docs/api/openapi.json +117 -117
- package/docs/api-reference.md +10 -10
- package/docs/canonical-permissions.md +6 -6
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +19 -19
- package/modules/{governance → government}/catalog.js +8 -8
- package/modules/{governance → government}/config.js +6 -6
- package/modules/{governance → government}/db.js +44 -44
- package/modules/government/migrations/government_001_rename_from_governance.sql +213 -0
- package/modules/government/migrations/government_002_rename_permission_keys.sql +112 -0
- package/modules/government/module.json +22 -0
- package/modules/{governance → government}/protected-surfaces.json +0 -1
- package/modules/{governance/rank-roles.js → government/ranks.js} +18 -18
- package/modules/{governance/routes/governance.js → government/routes/government.js} +109 -84
- package/modules/{governance/routes/role-sync.js → government/routes/rank-sync.js} +7 -7
- package/modules/hall-ui/public/gate.js +1 -1
- package/modules/hall-ui/public/governance.js +38 -38
- package/modules/hall-ui/public/harbor.js +2 -2
- package/modules/hall-ui/public/shell.js +3 -3
- package/modules/hall-ui/public/watch.js +1 -1
- package/modules/lifecycle/db.js +6 -6
- package/modules/lifecycle/routes/gate-approvals.js +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/fitness.js +11 -1
- package/scripts/gds/migration-namespace.js +15 -3
- package/scripts/gds/publish-manifest.js +2 -3
- package/scripts/gds/seed-governance-tasks.js +3 -3
- package/src/bongos/auth.js +16 -16
- package/src/bongos/db.js +6 -6
- package/src/bongos/module-scope-map.js +7 -22
- package/src/bongos/permission-path-check.js +3 -3
- package/src/bongos/route-rank-check.js +15 -21
- package/src/bongos/serve-internal.js +2 -2
- package/src/module-api.js +7 -3
- package/tests/auth_page_gate.mjs +2 -2
- package/tests/criterion_authoring_gate.mjs +1 -1
- package/tests/governance_abuse_matrix.mjs +38 -38
- package/tests/governance_catalog.mjs +4 -4
- package/tests/governance_claim_eligibility.mjs +1 -1
- package/tests/governance_config.mjs +7 -7
- package/tests/governance_fold.mjs +4 -4
- package/tests/governance_fold_bench.mjs +2 -2
- package/tests/governance_live_proof.mjs +6 -6
- package/tests/governance_ownership_scope.mjs +2 -2
- package/tests/governance_page_gates.mjs +10 -10
- package/tests/governance_parity.mjs +3 -3
- package/tests/governance_principals.mjs +3 -3
- package/tests/governance_protected_surfaces.mjs +44 -19
- package/tests/governance_require_permission.mjs +11 -11
- package/tests/governance_resolver.mjs +4 -4
- package/tests/governance_role_sync.mjs +35 -35
- package/tests/governance_routes.mjs +98 -98
- package/tests/governance_seed.mjs +27 -4
- package/tests/governance_session_scope.mjs +5 -5
- package/tests/governance_vanilla_boot.mjs +4 -4
- package/tests/hall_page_gate_map.mjs +3 -3
- package/tests/memory_isolation.mjs +2 -2
- package/tests/module_loader.mjs +1 -1
- package/tests/onboarding_observability.mjs +2 -2
- package/tests/rank_tier_single_source.mjs +2 -2
- package/tests/route_permission_check.mjs +2 -2
- package/modules/governance/module.json +0 -11
- /package/config/{governance.neutral.json → government.neutral.json} +0 -0
- /package/modules/{governance → government}/CLAUDE.md +0 -0
- /package/modules/{governance → government}/migrations/governance_001_tables.sql +0 -0
- /package/modules/{governance → government}/migrations/governance_002_seed_and_backfill.sql +0 -0
- /package/modules/{governance → government}/migrations/governance_003_task_abandon_metic.sql +0 -0
- /package/modules/{governance → government}/migrations/governance_004_criterion_create.sql +0 -0
- /package/modules/{governance → government}/migrations/governance_005_resync_builder_rank_roles.sql +0 -0
- /package/modules/{governance → government}/migrations/governance_006_version_create.sql +0 -0
- /package/modules/{governance → government}/migrations/governance_007_delegate_archon_ops_to_metic.sql +0 -0
- /package/modules/{governance → government}/protected-surfaces.js +0 -0
- /package/modules/{governance → government}/resolver.js +0 -0
- /package/modules/{governance → government}/session-scopes.js +0 -0
- /package/modules/{governance → government}/session-scopes.json +0 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
-- government_002_rename_permission_keys.sql
|
|
2
|
+
-- Goal 1000068 R05 (task 1003006) — ADR 0174.
|
|
3
|
+
--
|
|
4
|
+
-- Renames the two permission KEYS that still carried the old vocabulary, in the
|
|
5
|
+
-- grant rows that hold them:
|
|
6
|
+
--
|
|
7
|
+
-- governance.manage -> government.manage
|
|
8
|
+
-- page.view.governance -> page.view.government
|
|
9
|
+
--
|
|
10
|
+
-- These are DATA, not schema: `government_rank_permissions.permission_key` is
|
|
11
|
+
-- free text validated app-side against the code catalog (it is deliberately not
|
|
12
|
+
-- a foreign key — the catalog is CODE, not a table; see governance_001).
|
|
13
|
+
--
|
|
14
|
+
-- ── WHY THIS IS SEPARATE FROM government_001 ────────────────────────────────
|
|
15
|
+
-- 001 renamed the tables and pairs with the code that QUERIES them. This one
|
|
16
|
+
-- pairs with `modules/government/catalog.js`, which declares the key strings,
|
|
17
|
+
-- and it must land in the SAME release as that edit. The coupling is sharper
|
|
18
|
+
-- than it looks: `resolver.js` DROPS any grant naming a key the catalog does not
|
|
19
|
+
-- define (the fail-closed rule). So if these rows were renamed while the catalog
|
|
20
|
+
-- still said `governance.manage`, every renamed grant would resolve to nothing —
|
|
21
|
+
-- and because that key is what gates the Government tab, the owner would be
|
|
22
|
+
-- locked out of the very surface used to diagnose it. The inverse is equally
|
|
23
|
+
-- true: shipping the catalog edit without this migration leaves the live rows
|
|
24
|
+
-- naming keys the catalog no longer defines, with the same result.
|
|
25
|
+
--
|
|
26
|
+
-- Both halves are in this commit, and the live instance's auto-upgrade sweep
|
|
27
|
+
-- stays paused (task 1003011) until the whole rename is released together.
|
|
28
|
+
--
|
|
29
|
+
-- ── ROW-COUNT SAFETY ────────────────────────────────────────────────────────
|
|
30
|
+
-- These are PRIMARY KEY columns (rank_key, permission_key). A rank that somehow
|
|
31
|
+
-- held BOTH the old and the new key would collide on UPDATE, so the update is
|
|
32
|
+
-- written to skip any row whose target already exists, and the assertion at the
|
|
33
|
+
-- end proves the old keys are gone and the new counts match what was there
|
|
34
|
+
-- before. Live baseline captured 2026-08-16 before the rename:
|
|
35
|
+
-- governance.manage -> 1 grant (the archon rank only)
|
|
36
|
+
-- page.view.governance -> 2 grants (metic + archon)
|
|
37
|
+
--
|
|
38
|
+
-- IDEMPOTENT: re-running finds no old keys and changes nothing.
|
|
39
|
+
--
|
|
40
|
+
-- ROLLBACK: reverse the two UPDATEs (new key -> old key). Lossless.
|
|
41
|
+
-- UPDATE government_rank_permissions SET permission_key = 'governance.manage'
|
|
42
|
+
-- WHERE permission_key = 'government.manage';
|
|
43
|
+
-- UPDATE government_rank_permissions SET permission_key = 'page.view.governance'
|
|
44
|
+
-- WHERE permission_key = 'page.view.government';
|
|
45
|
+
|
|
46
|
+
BEGIN;
|
|
47
|
+
|
|
48
|
+
-- Capture the before-counts so the assertion below can prove nothing was lost.
|
|
49
|
+
CREATE TEMP TABLE _govt_key_rename_before ON COMMIT DROP AS
|
|
50
|
+
SELECT
|
|
51
|
+
count(*) FILTER (WHERE permission_key = 'governance.manage') AS old_manage,
|
|
52
|
+
count(*) FILTER (WHERE permission_key = 'page.view.governance') AS old_page,
|
|
53
|
+
count(*) FILTER (WHERE permission_key = 'government.manage') AS new_manage,
|
|
54
|
+
count(*) FILTER (WHERE permission_key = 'page.view.government') AS new_page
|
|
55
|
+
FROM government_rank_permissions;
|
|
56
|
+
|
|
57
|
+
UPDATE government_rank_permissions g
|
|
58
|
+
SET permission_key = 'government.manage'
|
|
59
|
+
WHERE g.permission_key = 'governance.manage'
|
|
60
|
+
AND NOT EXISTS (
|
|
61
|
+
SELECT 1 FROM government_rank_permissions x
|
|
62
|
+
WHERE x.rank_key = g.rank_key AND x.permission_key = 'government.manage');
|
|
63
|
+
|
|
64
|
+
UPDATE government_rank_permissions g
|
|
65
|
+
SET permission_key = 'page.view.government'
|
|
66
|
+
WHERE g.permission_key = 'page.view.governance'
|
|
67
|
+
AND NOT EXISTS (
|
|
68
|
+
SELECT 1 FROM government_rank_permissions x
|
|
69
|
+
WHERE x.rank_key = g.rank_key AND x.permission_key = 'page.view.government');
|
|
70
|
+
|
|
71
|
+
-- Any row the collision guard skipped would be a duplicate grant; drop it so the
|
|
72
|
+
-- old key cannot survive. (Expected to affect zero rows — a rank holding both
|
|
73
|
+
-- spellings should not exist. Written so the assertion below can be absolute.)
|
|
74
|
+
DELETE FROM government_rank_permissions
|
|
75
|
+
WHERE permission_key IN ('governance.manage', 'page.view.governance');
|
|
76
|
+
|
|
77
|
+
DO $$
|
|
78
|
+
DECLARE
|
|
79
|
+
b record;
|
|
80
|
+
a_manage int;
|
|
81
|
+
a_page int;
|
|
82
|
+
leftover int;
|
|
83
|
+
BEGIN
|
|
84
|
+
SELECT * INTO b FROM _govt_key_rename_before;
|
|
85
|
+
SELECT count(*) INTO a_manage FROM government_rank_permissions WHERE permission_key = 'government.manage';
|
|
86
|
+
SELECT count(*) INTO a_page FROM government_rank_permissions WHERE permission_key = 'page.view.government';
|
|
87
|
+
SELECT count(*) INTO leftover FROM government_rank_permissions
|
|
88
|
+
WHERE permission_key IN ('governance.manage', 'page.view.governance');
|
|
89
|
+
|
|
90
|
+
IF leftover <> 0 THEN
|
|
91
|
+
RAISE EXCEPTION 'government key rename: % row(s) still carry an old key — rolling back.', leftover;
|
|
92
|
+
END IF;
|
|
93
|
+
|
|
94
|
+
-- Every grant that existed under either spelling must exist under the new one.
|
|
95
|
+
IF a_manage <> GREATEST(b.old_manage, b.new_manage) THEN
|
|
96
|
+
RAISE EXCEPTION 'government.manage grant count is % but % was expected (before: old=%, new=%) — rolling back.',
|
|
97
|
+
a_manage, GREATEST(b.old_manage, b.new_manage), b.old_manage, b.new_manage;
|
|
98
|
+
END IF;
|
|
99
|
+
IF a_page <> GREATEST(b.old_page, b.new_page) THEN
|
|
100
|
+
RAISE EXCEPTION 'page.view.government grant count is % but % was expected (before: old=%, new=%) — rolling back.',
|
|
101
|
+
a_page, GREATEST(b.old_page, b.new_page), b.old_page, b.new_page;
|
|
102
|
+
END IF;
|
|
103
|
+
|
|
104
|
+
-- The lockout guard: SOMEONE must still hold the key that opens this tab.
|
|
105
|
+
IF a_manage = 0 THEN
|
|
106
|
+
RAISE EXCEPTION 'no rank holds government.manage after the rename — that is a lockout. Rolling back.';
|
|
107
|
+
END IF;
|
|
108
|
+
|
|
109
|
+
RAISE NOTICE 'government key rename OK: government.manage=% page.view.government=%', a_manage, a_page;
|
|
110
|
+
END $$;
|
|
111
|
+
|
|
112
|
+
COMMIT;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"key": "government",
|
|
3
|
+
"title": "Government (configurable ranks & permissions)",
|
|
4
|
+
"description": "Discord-style configurable ranks and permissions (ADR 0151, BONGOS-V1 goal 1000030; renamed governance\u2192government by ADR 0174). Permissions are the atomic authority unit and the four ranks (xenos/thetes/metic/archon) are the positions in the government; this module owns the code-defined permission catalog (each key flagged `system` = trust-boundary, `substrate` = enforced out of band, or operational = delegable) plus the compatibility seed that reproduces the rank thresholds exactly. A core-domain module, default-on: a Cloud Bongos instance ships configurable ranks the way it ships its status page. The word `role` is deliberately absent here \u2014 it is reserved for the three crafts (Artist, Ideator, Builder). Preserves ADR 0016 (authority resolved server-side, per request, uncached).",
|
|
5
|
+
"coreVersion": "^1.18.0",
|
|
6
|
+
"default": true,
|
|
7
|
+
"maintenance": {
|
|
8
|
+
"status": "core-maintained"
|
|
9
|
+
},
|
|
10
|
+
"contributes": {
|
|
11
|
+
"migrations": true,
|
|
12
|
+
"routes": [
|
|
13
|
+
"government",
|
|
14
|
+
"rank-sync"
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
"provides": [
|
|
18
|
+
"government",
|
|
19
|
+
"government.rankSync"
|
|
20
|
+
],
|
|
21
|
+
"consumes": []
|
|
22
|
+
}
|
|
@@ -34,7 +34,6 @@
|
|
|
34
34
|
{ "glob": "modules/lifecycle/routes/", "group": "authz", "why": "BV1.R86: the lifecycle routes carved out of src/bongos/routes/ — the gates follow the routes" },
|
|
35
35
|
{ "glob": "src/bongos/routes.js", "group": "authz", "why": "the route MOUNT table (a new ungated route lands here)" },
|
|
36
36
|
{ "glob": "server.js", "group": "authz", "why": "the Express entrypoint — wires every router + host routing" },
|
|
37
|
-
{ "glob": "modules/governance/", "group": "authz", "why": "the governance module (ADR 0151) — its resolver, catalog, gates AND this registry ARE the authz core; the glob is the prospective carve so CREATION is gated as strictly as edits (ADR 0086 §4). Renamed to modules/government/ by goal 1000068 (ADR 0174); BOTH globs are listed for the transition and this one is dropped with the folder in R04." },
|
|
38
37
|
{ "glob": "modules/government/", "group": "authz", "why": "the same authz core under its post-ADR-0174 name (goal 1000068, task 1003003). Listed BEFORE the folder moves, deliberately: `protected` is DERIVED from this registry, so a module key whose globs are absent here computes protected:false — which would silently drop the Archon gate on goal creation AND the pre-push block on these files for the whole window between the key landing and the folder arriving. The prospective-carve precedent is the modules/governance/ entry directly above (ADR 0086 §4)." },
|
|
39
38
|
{ "glob": "modules/dev-box/boxes.js", "group": "box-access", "why": "boxScopeForRank / provision floor — decides who gets a full source clone" },
|
|
40
39
|
{ "glob": "modules/dev-box/box-access.js", "group": "box-access", "why": "decideSourceAccess (rank → starter|full source)" },
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
// modules/governance/
|
|
3
|
+
// modules/governance/ranks.js — the PURE rank→role rotation decision
|
|
4
4
|
// (ADR 0151, BV1.R95b).
|
|
5
5
|
//
|
|
6
6
|
// A builder holds exactly ONE seeded rank-role. governance_002 seeds four of them
|
|
7
7
|
// (is_system=true, seeded_rank set) and backfills each builder the one matching
|
|
8
8
|
// their rank — but that backfill is run-once, so nothing kept it true afterwards.
|
|
9
|
-
// This is the decision the writer (db.
|
|
9
|
+
// This is the decision the writer (db.syncRank) executes to keep it true:
|
|
10
10
|
// given a builder's CURRENT assignments and a rank, which seeded role to ADD and
|
|
11
11
|
// which to REMOVE.
|
|
12
12
|
//
|
|
@@ -31,43 +31,43 @@
|
|
|
31
31
|
// Pure: no DB, no I/O, no module state. The writer reads the two row sets and
|
|
32
32
|
// executes this verdict, so the unit tests exercise production logic directly.
|
|
33
33
|
|
|
34
|
-
// A row (an assignment joined to
|
|
34
|
+
// A row (an assignment joined to government_ranks, or a role row) is a SEEDED
|
|
35
35
|
// rank-role only when it is both a system role and names the rank it reproduces.
|
|
36
36
|
// Both halves are required: is_system alone would admit a future non-rank system
|
|
37
37
|
// role, and seeded_rank alone would trust a column a custom role could carry.
|
|
38
|
-
function
|
|
38
|
+
function isSeededRank(row) {
|
|
39
39
|
return !!row
|
|
40
40
|
&& row.is_system === true
|
|
41
41
|
&& typeof row.seeded_rank === 'string'
|
|
42
42
|
&& row.seeded_rank.length > 0;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
//
|
|
45
|
+
// planRankSync({ rank, currentRanks, seededRanks }) → { target, add, remove, next }
|
|
46
46
|
//
|
|
47
47
|
// rank the builder's rank AFTER the write being synced.
|
|
48
|
-
//
|
|
49
|
-
//
|
|
48
|
+
// currentRanks the builder's assignments, each { rank_key, is_system, seeded_rank }.
|
|
49
|
+
// seededRanks the seeded rank-roles the DB defines, each { rank_key, seeded_rank,
|
|
50
50
|
// is_system }. Passed in rather than hardcoded so the four role keys
|
|
51
51
|
// stay DB truth — this file never re-declares the seed.
|
|
52
52
|
//
|
|
53
|
-
// target the seeded
|
|
53
|
+
// target the seeded rank_key the builder should end up with, or null when the
|
|
54
54
|
// rank has no seeded role (fail-closed divergence, above).
|
|
55
55
|
// add [] or [target] — empty when it is already held (idempotence).
|
|
56
56
|
// remove every OTHER seeded rank-role currently held. Custom roles never appear.
|
|
57
|
-
// next the resulting
|
|
58
|
-
function
|
|
59
|
-
const match =
|
|
60
|
-
const target = match ? match.
|
|
57
|
+
// next the resulting rank_key set, sorted — "what the role set becomes".
|
|
58
|
+
function planRankSync({ rank, currentRanks = [], seededRanks = [] } = {}) {
|
|
59
|
+
const match = seededRanks.find((r) => isSeededRank(r) && r.seeded_rank === rank);
|
|
60
|
+
const target = match ? match.rank_key : null;
|
|
61
61
|
|
|
62
|
-
const held =
|
|
62
|
+
const held = currentRanks.filter((r) => r && typeof r.rank_key === 'string');
|
|
63
63
|
const remove = held
|
|
64
|
-
.filter((r) =>
|
|
65
|
-
.map((r) => r.
|
|
66
|
-
const add = target && !held.some((r) => r.
|
|
64
|
+
.filter((r) => isSeededRank(r) && r.rank_key !== target)
|
|
65
|
+
.map((r) => r.rank_key);
|
|
66
|
+
const add = target && !held.some((r) => r.rank_key === target) ? [target] : [];
|
|
67
67
|
|
|
68
68
|
const removed = new Set(remove);
|
|
69
69
|
const next = held
|
|
70
|
-
.map((r) => r.
|
|
70
|
+
.map((r) => r.rank_key)
|
|
71
71
|
.filter((key) => !removed.has(key))
|
|
72
72
|
.concat(add)
|
|
73
73
|
.sort();
|
|
@@ -80,4 +80,4 @@ function planRankRoleSync({ rank, currentRoles = [], seededRoles = [] } = {}) {
|
|
|
80
80
|
});
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
module.exports = {
|
|
83
|
+
module.exports = { planRankSync, isSeededRank };
|
|
@@ -81,8 +81,8 @@ const governancePort = {
|
|
|
81
81
|
};
|
|
82
82
|
|
|
83
83
|
function registerSeams() {
|
|
84
|
-
if (!api.hasProvider('
|
|
85
|
-
api.registerProvider('
|
|
84
|
+
if (!api.hasProvider('government')) {
|
|
85
|
+
api.registerProvider('government', governancePort);
|
|
86
86
|
}
|
|
87
87
|
// The session-load fold (task 1002556): the kernel's lookupSession embeds this
|
|
88
88
|
// fragment so the SAME round-trip carries the builder's raw grant keys, and
|
|
@@ -96,8 +96,8 @@ function registerSeams() {
|
|
|
96
96
|
api.registerProvider('session-grants-sql', {
|
|
97
97
|
selectFragment: `,
|
|
98
98
|
(SELECT COALESCE(array_agg(DISTINCT rp.permission_key), '{}')
|
|
99
|
-
FROM
|
|
100
|
-
JOIN
|
|
99
|
+
FROM government_builder_ranks br
|
|
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 `
|
|
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
|
|
@@ -116,7 +116,7 @@ function registerSeams() {
|
|
|
116
116
|
// for different reasons and only one of those reasons still holds:
|
|
117
117
|
//
|
|
118
118
|
// • GRANTS on the three sub-Archon rank-roles are now EDITABLE. Their rows were
|
|
119
|
-
// always ordinary `
|
|
119
|
+
// always ordinary `government_rank_permissions` rows (the governance_002 seed
|
|
120
120
|
// writes them); the old refusal was policy, not structure. What a rank means is
|
|
121
121
|
// now the owner's to set from the tab, and `catalog.isGrantableTo()` answers
|
|
122
122
|
// which keys per role. The ARCHON rank-role stays immutable — it holds
|
|
@@ -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
|
|
135
|
-
const
|
|
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 :
|
|
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
|
|
145
|
-
const
|
|
146
|
-
if (
|
|
147
|
-
res.fail('
|
|
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
|
|
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,
|
|
158
|
-
const role = await db.getRoleByKey(
|
|
157
|
+
async function loadGrantEditableRole(res, rankKey) {
|
|
158
|
+
const role = await db.getRoleByKey(rankKey);
|
|
159
159
|
if (!role) {
|
|
160
|
-
res.fail('
|
|
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('
|
|
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,
|
|
173
|
-
const role = await db.getRoleByKey(
|
|
172
|
+
async function loadAssignableRole(res, rankKey) {
|
|
173
|
+
const role = await db.getRoleByKey(rankKey);
|
|
174
174
|
if (!role) {
|
|
175
|
-
res.fail('
|
|
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('
|
|
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
|
-
//
|
|
189
|
-
|
|
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 /
|
|
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
|
|
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('/
|
|
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
|
|
223
|
-
router.get('/
|
|
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 /
|
|
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
|
|
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('/
|
|
261
|
+
router.post('/government/ranks', api.requireBuilder, api.requirePermission('government.manage'), async (req, res) => {
|
|
237
262
|
if (validateOrRespond(req, res, {
|
|
238
|
-
|
|
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
|
|
243
|
-
if (!
|
|
244
|
-
return res.fail('
|
|
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
|
-
|
|
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('
|
|
281
|
+
return res.fail('rank_exists', 409, { rank_key: rankKey });
|
|
257
282
|
}
|
|
258
|
-
console.error('[gds] POST /
|
|
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
|
|
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('/
|
|
267
|
-
const
|
|
268
|
-
if (
|
|
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,
|
|
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
|
-
|
|
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({
|
|
291
|
-
res.json({ ok: true,
|
|
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 /
|
|
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
|
|
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('/
|
|
303
|
-
const
|
|
304
|
-
if (
|
|
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,
|
|
332
|
+
const role = await loadGrantEditableRole(res, rankKey);
|
|
308
333
|
if (role === null) return;
|
|
309
|
-
const removed = await db.revokeRolePermission({
|
|
310
|
-
res.json({ ok: true,
|
|
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 /
|
|
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
|
|
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('/
|
|
329
|
-
const
|
|
330
|
-
if (
|
|
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,
|
|
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('
|
|
336
|
-
|
|
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({
|
|
345
|
-
res.json({ ok: true,
|
|
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 /
|
|
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
|
|
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('/
|
|
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
|
-
|
|
383
|
+
rank_key: { required: true, type: 'string', minLength: 2, maxLength: RANK_KEY_MAX },
|
|
359
384
|
})) return;
|
|
360
|
-
const
|
|
361
|
-
if (!
|
|
362
|
-
return res.fail('
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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 /
|
|
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
|
|
407
|
+
// rank: perm government.manage — remove a custom-role assignment. Idempotent:
|
|
383
408
|
// `removed` says whether a row went away.
|
|
384
|
-
router.delete('/
|
|
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
|
|
388
|
-
if (
|
|
412
|
+
const rankKey = rankKeyParam(req, res);
|
|
413
|
+
if (rankKey === null) return;
|
|
389
414
|
try {
|
|
390
|
-
const role = await loadAssignableRole(res,
|
|
415
|
+
const role = await loadAssignableRole(res, rankKey);
|
|
391
416
|
if (role === null) return;
|
|
392
|
-
const removed = await db.unassignRole({ builderId,
|
|
393
|
-
res.json({ ok: true, builder_id: builderId,
|
|
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 /
|
|
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
|
});
|