@bongos/core 1.19.185 → 1.19.187
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 +90 -85
- package/clients/bongos-client/index.d.ts +2 -2
- package/config/government.neutral.json +6 -6
- package/docs/adr/0172-editable-rank-roles-substrate-axis.md +1 -1
- package/docs/api/openapi.json +12 -12
- package/docs/api-reference.md +7 -7
- package/modules/builder-settings/wandering-prefs.js +13 -13
- package/modules/government/catalog.js +79 -79
- package/modules/government/config.js +43 -33
- package/modules/government/db.js +58 -58
- package/modules/government/protected-surfaces.js +7 -7
- package/modules/government/protected-surfaces.json +4 -4
- package/modules/government/ranks.js +17 -17
- package/modules/government/resolver.js +24 -24
- package/modules/government/routes/government.js +88 -88
- package/modules/government/routes/rank-sync.js +8 -8
- package/modules/government/session-scopes.js +7 -7
- package/modules/government/session-scopes.json +5 -5
- package/modules/hall-ui/public/gate.js +2 -2
- package/modules/hall-ui/public/{governance.css → government.css} +11 -11
- package/modules/hall-ui/public/{governance.html → government.html} +14 -14
- package/modules/hall-ui/public/{governance.js → government.js} +108 -108
- package/modules/hall-ui/public/harbor.js +2 -2
- package/modules/hall-ui/public/shell.js +19 -15
- package/modules/hall-ui/public/watch.js +1 -1
- package/modules/lifecycle/claim-eligibility.js +1 -1
- package/modules/lifecycle/db.js +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/fitness.js +138 -0
- package/scripts/hall-preview/server.js +1 -1
- package/src/bongos/auth.js +11 -11
- package/src/bongos/serve-internal.js +8 -8
- package/src/module-api.js +5 -1
- package/tests/auth_page_gate.mjs +2 -2
- package/tests/auth_resolve_failure.mjs +4 -4
- package/tests/{governance_abuse_matrix.mjs → government_abuse_matrix.mjs} +39 -39
- package/tests/{governance_catalog.mjs → government_catalog.mjs} +11 -11
- package/tests/{governance_claim_eligibility.mjs → government_claim_eligibility.mjs} +2 -2
- package/tests/{governance_config.mjs → government_config.mjs} +54 -54
- package/tests/{governance_fold.mjs → government_fold.mjs} +6 -6
- package/tests/{governance_fold_bench.mjs → government_fold_bench.mjs} +3 -3
- package/tests/{governance_live_proof.mjs → government_live_proof.mjs} +5 -5
- package/tests/{governance_ownership_scope.mjs → government_ownership_scope.mjs} +16 -16
- package/tests/{governance_page_gates.mjs → government_page_gates.mjs} +14 -14
- package/tests/{governance_parity.mjs → government_parity.mjs} +3 -3
- package/tests/{governance_principals.mjs → government_principals.mjs} +14 -14
- package/tests/{governance_protected_surfaces.mjs → government_protected_surfaces.mjs} +1 -1
- package/tests/{governance_role_sync.mjs → government_rank_sync.mjs} +2 -2
- package/tests/{governance_require_permission.mjs → government_require_permission.mjs} +1 -1
- package/tests/{governance_resolver.mjs → government_resolver.mjs} +6 -6
- package/tests/{governance_routes.mjs → government_routes.mjs} +36 -36
- package/tests/{governance_seed.mjs → government_seed.mjs} +8 -8
- package/tests/{governance_session_scope.mjs → government_session_scope.mjs} +10 -10
- package/tests/{governance_vanilla_boot.mjs → government_vanilla_boot.mjs} +26 -26
- package/tests/government_vocabulary.mjs +94 -0
- package/tests/hall_nav.mjs +8 -7
- package/tests/hall_page_gate_map.mjs +9 -9
|
@@ -1,29 +1,29 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
// modules/
|
|
3
|
+
// modules/government/resolver.js — the permission resolver (ADR 0151, BV1.R95).
|
|
4
4
|
//
|
|
5
|
-
// Turns a builder's
|
|
6
|
-
// permission granted to every
|
|
5
|
+
// Turns a builder's RANKS into their effective PERMISSION SET: the union of every
|
|
6
|
+
// permission granted to every rank they hold. This is the heart of the
|
|
7
7
|
// permissions-as-atom model — `requirePermission()` (R97) and every migrated gate
|
|
8
8
|
// (R104-R107) resolve authority through here.
|
|
9
9
|
//
|
|
10
|
-
// TWO AXES, two entry points (R98, ADR 0151 §3).
|
|
10
|
+
// TWO AXES, two entry points (R98, ADR 0151 §3). Ranks are one axis; a machine
|
|
11
11
|
// PRINCIPAL's capability is the other, resolved from its exact `system_role` and
|
|
12
|
-
// never from a
|
|
12
|
+
// never from a rank. They are kept apart on purpose — see resolvePrincipalPermissions
|
|
13
13
|
// below, and the principal keys effectivePermissions drops.
|
|
14
14
|
//
|
|
15
15
|
// It preserves ADR 0016 exactly:
|
|
16
16
|
// • server-side — the answer is computed on the server from DB rows, never from
|
|
17
17
|
// a token claim or anything the client can influence;
|
|
18
18
|
// • per request — resolveBuilderPermissions() is called on the request that
|
|
19
|
-
// needs it, so a revoked
|
|
19
|
+
// needs it, so a revoked rank takes effect on the builder's very next request;
|
|
20
20
|
// • UNCACHED — there is no memo, no TTL, no process-local map. Caching authority
|
|
21
21
|
// is the one optimisation this file must never take (a cached grant survives
|
|
22
22
|
// its revocation, which is the failure mode the trust boundary exists to
|
|
23
23
|
// prevent). If a future profile shows the join is hot, the answer is a DB
|
|
24
24
|
// index, not a cache.
|
|
25
25
|
//
|
|
26
|
-
// Fail-closed throughout: a builder with no
|
|
26
|
+
// Fail-closed throughout: a builder with no ranks resolves to the EMPTY set, and a
|
|
27
27
|
// grant naming a key the code catalog doesn't define is DROPPED (see
|
|
28
28
|
// effectivePermissions below) rather than admitted on trust.
|
|
29
29
|
|
|
@@ -31,18 +31,18 @@ const catalog = require('./catalog');
|
|
|
31
31
|
const db = require('./db');
|
|
32
32
|
const sessionScopes = require('./session-scopes');
|
|
33
33
|
|
|
34
|
-
// Pure core of the
|
|
34
|
+
// Pure core of the RANK axis, split out so it is unit-testable without a database.
|
|
35
35
|
//
|
|
36
36
|
// Filters raw grant rows against the code catalog: the catalog is the source of
|
|
37
37
|
// truth for what a permission IS (every key maps to a real route — modules/
|
|
38
|
-
//
|
|
38
|
+
// government/CLAUDE.md), so a DB row naming an unknown key is stale or hand-
|
|
39
39
|
// inserted and grants NOTHING. Returns a frozen, sorted, de-duplicated array.
|
|
40
40
|
//
|
|
41
41
|
// It also DROPS every principal key (R98). A machine identity's capability is the
|
|
42
|
-
// other axis — it comes from `builders.system_role`, never from a
|
|
42
|
+
// other axis — it comes from `builders.system_role`, never from a rank — so a grant
|
|
43
43
|
// row naming `principal.*` confers nothing here no matter how it got written (a
|
|
44
|
-
// hand-inserted row, a future bug in the
|
|
45
|
-
// rejection). That makes "a
|
|
44
|
+
// hand-inserted row, a future bug in the government CRUD's system-permission
|
|
45
|
+
// rejection). That makes "a rank cannot confer a principal permission" structural
|
|
46
46
|
// rather than merely policed at the write side.
|
|
47
47
|
function effectivePermissions(rawKeys) {
|
|
48
48
|
const known = new Set();
|
|
@@ -55,8 +55,8 @@ function effectivePermissions(rawKeys) {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
// The PRINCIPAL axis (R98, ADR 0151 §3) — deliberately a SEPARATE entry point, not
|
|
58
|
-
// a branch inside the
|
|
59
|
-
//
|
|
58
|
+
// a branch inside the rank resolve. A builder's authority is the union of their
|
|
59
|
+
// ranks; a machine principal's authority is its identity, and collapsing the two
|
|
60
60
|
// into one lookup is exactly the mistake that would let a rank reach a principal
|
|
61
61
|
// capability.
|
|
62
62
|
//
|
|
@@ -64,7 +64,7 @@ function effectivePermissions(rawKeys) {
|
|
|
64
64
|
// already reads fresh from the DB per request (alongside rank), and which no API
|
|
65
65
|
// route writes — a principal is provisioned out-of-band by migration, under a DB
|
|
66
66
|
// CHECK allowlist. The match is EXACT string equality, so NULL (every human,
|
|
67
|
-
// including an Archon), a differently-cased value, or an unknown
|
|
67
|
+
// including an Archon), a differently-cased value, or an unknown rank resolves to
|
|
68
68
|
// the empty set. Mirrors requireBfgPrincipal / requireDiscordBot in src/bongos/
|
|
69
69
|
// auth.js, which stay the HTTP enforcement point.
|
|
70
70
|
function resolvePrincipalPermissions(systemRole) {
|
|
@@ -111,7 +111,7 @@ async function builderHasPermissions(builderId, required) {
|
|
|
111
111
|
// two different places, and folding it into the plain resolve would silently drop
|
|
112
112
|
// the second one.
|
|
113
113
|
//
|
|
114
|
-
// 1. the
|
|
114
|
+
// 1. the RANK fact — does the builder hold the key? (this file, from the DB)
|
|
115
115
|
// 2. the OWNER fact — is the concrete row theirs? (REQUEST-scoped; only the
|
|
116
116
|
// handler that loaded the row knows it)
|
|
117
117
|
//
|
|
@@ -122,7 +122,7 @@ async function builderHasPermissions(builderId, required) {
|
|
|
122
122
|
// path here that turns an ownership-scoped key into an admit on its own.
|
|
123
123
|
//
|
|
124
124
|
// Pure + DB-free where it can be: the comparison below never touches Postgres, and
|
|
125
|
-
// the
|
|
125
|
+
// the rank fact reuses resolveBuilderPermissions, so ADR 0016 holds unchanged —
|
|
126
126
|
// per-request, server-side, no stored answer.
|
|
127
127
|
|
|
128
128
|
// The owner comparison, normalized once so ~15 sites stop hand-rolling it. STRICT:
|
|
@@ -169,27 +169,27 @@ async function authorizeOwnedOrAny(builderId, ownKey, context) {
|
|
|
169
169
|
|
|
170
170
|
// ── the SESSION-SCOPE axis (R99, ADR 0151 §3) ───────────────────────────────────
|
|
171
171
|
// The FOURTH entry point, separate for the same reason the other three are: it
|
|
172
|
-
// answers a different question with a different input.
|
|
172
|
+
// answers a different question with a different input. Ranks answer "what may this
|
|
173
173
|
// PERSON do"; a principal answers "what may this MACHINE do"; an ownership scope
|
|
174
174
|
// answers "whose row may it be done to"; a session-scope answers "how much of that
|
|
175
175
|
// does THIS CREDENTIAL carry". A dev-box token is minted onto a machine outside the
|
|
176
176
|
// ADR 0016 trust boundary (#919/SR#2, ADR 0053), so it must stay limited even when
|
|
177
|
-
// its builder is an Archon — which no
|
|
177
|
+
// its builder is an Archon — which no rank-shaped answer can express.
|
|
178
178
|
//
|
|
179
179
|
// A SESSION-SCOPE CAN ONLY EVER NARROW, in both directions:
|
|
180
|
-
// • INTERSECTION, never union. The scope may not GRANT a permission the
|
|
181
|
-
// not confer — so the composition below is written as a FILTER of the
|
|
180
|
+
// • INTERSECTION, never union. The scope may not GRANT a permission the ranks do
|
|
181
|
+
// not confer — so the composition below is written as a FILTER of the RANK
|
|
182
182
|
// answer. There is no branch here that can add a key, which is what makes
|
|
183
183
|
// "a box token cannot escalate" structural rather than merely intended.
|
|
184
184
|
// • DENY-BY-DEFAULT. A key the allow-list does not name is denied; an
|
|
185
185
|
// unrecognised session source resolves to NOTHING rather than to "full".
|
|
186
186
|
// Widening is therefore not expressible without deleting the filter, and R103's
|
|
187
|
-
// parity is untouched: effectivePermissions is the unchanged
|
|
187
|
+
// parity is untouched: effectivePermissions is the unchanged RANK answer this
|
|
188
188
|
// composes ON TOP of, never inside.
|
|
189
189
|
//
|
|
190
190
|
// Pure and DB-free (the DB half is resolveBuilderPermissions'), so an unknown key
|
|
191
191
|
// on an allow-list is inert as well as refused at load: it can never appear in the
|
|
192
|
-
// catalog-filtered
|
|
192
|
+
// catalog-filtered rank answer, so it can never survive the intersection.
|
|
193
193
|
function narrowToSessionScope(rawKeys, source) {
|
|
194
194
|
const scope = sessionScopes.scopeFor(source);
|
|
195
195
|
if (!scope) return Object.freeze([]); // unrecognised source → no authority at all
|
|
@@ -199,7 +199,7 @@ function narrowToSessionScope(rawKeys, source) {
|
|
|
199
199
|
return Object.freeze(held.filter((key) => allowed.has(key)));
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
-
// What can THIS SESSION actually do? The builder's
|
|
202
|
+
// What can THIS SESSION actually do? The builder's ranks ∩ this session's scope,
|
|
203
203
|
// as a frozen sorted array of catalog keys. Fails closed BEFORE touching the DB on
|
|
204
204
|
// a source the registry does not declare.
|
|
205
205
|
async function resolveSessionPermissions(builderId, source) {
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
// The
|
|
3
|
+
// The government module's kernel PORT registration (ADR 0151, BV1.R95).
|
|
4
4
|
//
|
|
5
|
-
//
|
|
5
|
+
// government exposes the permission RESOLVER as the `government` kernel PORT.
|
|
6
6
|
// CORE files that must NOT import a module resolve it instead — first among them
|
|
7
7
|
// src/bongos/auth.js, whose `requirePermission()` gate (R97) needs a builder's
|
|
8
8
|
// effective permission set on every gated request. The one-way rule (ADR 0083)
|
|
9
|
-
// forbids core requiring modules/
|
|
9
|
+
// forbids core requiring modules/government/ directly; the port is how the
|
|
10
10
|
// capability crosses that line.
|
|
11
11
|
//
|
|
12
12
|
// Why a route factory for a (currently) routeless capability: `mountModuleRoutes`
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// rebuilds the GDS router, so registering here keeps the port present for the
|
|
15
15
|
// route-building tests too. Registration is hasProvider-guarded so
|
|
16
16
|
// re-instantiation never trips the seam's single-provider duplicate guard.
|
|
17
|
-
// (Mirrors modules/economy/routes/reward.js.) The R110
|
|
17
|
+
// (Mirrors modules/economy/routes/reward.js.) The R110 government CRUD
|
|
18
18
|
// endpoints live on this same router, below.
|
|
19
19
|
|
|
20
20
|
const express = require('express');
|
|
@@ -27,7 +27,7 @@ const sessionScopes = require('../session-scopes');
|
|
|
27
27
|
// The port surface. Deliberately narrow: resolution + the catalog reads a gate or
|
|
28
28
|
// the Archon tab needs. It grows with R98-R101 (principals, session-scopes,
|
|
29
29
|
// ownership scoping, the protected-surface registry) — one object port rather
|
|
30
|
-
// than a port per capability, so core resolves `
|
|
30
|
+
// than a port per capability, so core resolves `government` once.
|
|
31
31
|
//
|
|
32
32
|
// The principal half (R98) is READ-ONLY by construction: resolution keyed on an
|
|
33
33
|
// exact system_role, plus `listPrincipals` for the tab to render. There is no
|
|
@@ -47,10 +47,10 @@ const sessionScopes = require('../session-scopes');
|
|
|
47
47
|
// The session-scope half (R99) is READ-ONLY and deliberately NOT the enforcement
|
|
48
48
|
// path: the kernel gate (auth.requireBuilder) reads the same registry as static
|
|
49
49
|
// data, so a request never depends on this port being registered to stay
|
|
50
|
-
// deny-by-default. What the port adds is the composed answer —
|
|
51
|
-
// session's scope — for the
|
|
50
|
+
// deny-by-default. What the port adds is the composed answer — ranks ∩ this
|
|
51
|
+
// session's scope — for the Government tab and for any gate that wants the
|
|
52
52
|
// session's real authority rather than the builder's.
|
|
53
|
-
const
|
|
53
|
+
const governmentPort = {
|
|
54
54
|
resolveBuilderPermissions: resolver.resolveBuilderPermissions,
|
|
55
55
|
// The pure catalog-drop half, for the session-load fold (task 1002556): the
|
|
56
56
|
// kernel feeds it the raw grant keys its own session query carried, so a
|
|
@@ -58,7 +58,7 @@ const governancePort = {
|
|
|
58
58
|
// resolveBuilderPermissions given the same rows — DB-free by construction.
|
|
59
59
|
effectivePermissions: resolver.effectivePermissions,
|
|
60
60
|
builderHasPermissions: resolver.builderHasPermissions,
|
|
61
|
-
|
|
61
|
+
getRankKeysForBuilder: db.getRankKeysForBuilder,
|
|
62
62
|
isSystem: catalog.isSystem,
|
|
63
63
|
isGrantable: catalog.isGrantable,
|
|
64
64
|
permissionKeys: catalog.permissionKeys,
|
|
@@ -82,13 +82,13 @@ const governancePort = {
|
|
|
82
82
|
|
|
83
83
|
function registerSeams() {
|
|
84
84
|
if (!api.hasProvider('government')) {
|
|
85
|
-
api.registerProvider('government',
|
|
85
|
+
api.registerProvider('government', governmentPort);
|
|
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
|
|
89
89
|
// requirePermission runs the pure effectivePermissions instead of a second
|
|
90
90
|
// query. Registered HERE — capability-named, module-agnostic on the kernel
|
|
91
|
-
// side (ADR 0083 §Decision #4) — because this module owns the
|
|
91
|
+
// side (ADR 0083 §Decision #4) — because this module owns the government SQL.
|
|
92
92
|
// Contract: a boot-time CONSTANT (never user input) yielding one
|
|
93
93
|
// `raw_permission_keys` column against the kernel query's alias `s`
|
|
94
94
|
// (builder_sessions). Raw keys only — the catalog-drop stays in the resolver.
|
|
@@ -103,38 +103,38 @@ function registerSeams() {
|
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
// ── the R110 CRUD surface (ADR 0151 §2 — the
|
|
106
|
+
// ── the R110 CRUD surface (ADR 0151 §2 — the Government tab's server half) ────
|
|
107
107
|
//
|
|
108
108
|
// Every route — the three reads included — gates on the `government.manage`
|
|
109
|
-
// atom (system, floor archon): this surface EDITS the
|
|
109
|
+
// atom (system, floor archon): this surface EDITS the rank/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
|
|
112
112
|
// the catalog, not in the db layer (governance_001's app-side-validation split).
|
|
113
113
|
//
|
|
114
114
|
// ADR 0172 (owner decision, 2026-08-16) split what used to be ONE wall — "a seeded
|
|
115
|
-
// rank
|
|
115
|
+
// rank is immutable" — into two, because grants and assignment are immutable
|
|
116
116
|
// for different reasons and only one of those reasons still holds:
|
|
117
117
|
//
|
|
118
|
-
// • GRANTS on the three sub-Archon
|
|
118
|
+
// • GRANTS on the three sub-Archon ranks are now EDITABLE. Their rows were
|
|
119
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
|
-
// which keys per
|
|
122
|
+
// which keys per rank. The ARCHON rank stays immutable — it holds
|
|
123
123
|
// everything, and its immutability is what makes lockout impossible.
|
|
124
|
-
// • ASSIGNMENT of every seeded rank
|
|
124
|
+
// • ASSIGNMENT of every seeded rank is still refused, unchanged: it derives
|
|
125
125
|
// from `builders.rank` via the R95b sync, so a second writer here would fork
|
|
126
126
|
// authority truth and the sync would silently undo it on the next rank write.
|
|
127
|
-
// Changing who holds a rank
|
|
127
|
+
// Changing who holds a rank is still `PATCH /builders/:id/rank`.
|
|
128
128
|
//
|
|
129
|
-
// The custom-
|
|
130
|
-
// (R94's wall — "The
|
|
131
|
-
// SUBSTRATE keys stay non-grantable to everything,
|
|
129
|
+
// The custom-rank wall is unchanged: a `system:true` key stays non-grantable there
|
|
130
|
+
// (R94's wall — "The government API (R110) rejects granting it"), and the three
|
|
131
|
+
// SUBSTRATE keys stay non-grantable to everything, ranks 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
134
|
const RANK_KEY_RE = /^[a-z][a-z0-9-]*$/;
|
|
135
135
|
const RANK_KEY_MAX = 64;
|
|
136
136
|
|
|
137
|
-
module.exports = function
|
|
137
|
+
module.exports = function buildGovernmentRouter() {
|
|
138
138
|
registerSeams();
|
|
139
139
|
const router = express.Router();
|
|
140
140
|
|
|
@@ -150,40 +150,40 @@ module.exports = function buildGovernanceRouter() {
|
|
|
150
150
|
return rankKey;
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
-
// Load a
|
|
154
|
-
// refuse only the Archon rank
|
|
155
|
-
//
|
|
156
|
-
// `catalog.isGrantableTo(key,
|
|
157
|
-
async function
|
|
158
|
-
const
|
|
159
|
-
if (!
|
|
153
|
+
// Load a rank whose GRANTS this API may edit (ADR 0172): 404 on a missing rank,
|
|
154
|
+
// refuse only the Archon rank. Every other rank — the three sub-Archon
|
|
155
|
+
// ranks and every custom one — is grant-editable; WHICH keys is then
|
|
156
|
+
// `catalog.isGrantableTo(key, rank)`, per rank. Returns null after responding.
|
|
157
|
+
async function loadGrantEditableRank(res, rankKey) {
|
|
158
|
+
const rank = await db.getRankByKey(rankKey);
|
|
159
|
+
if (!rank) {
|
|
160
160
|
res.fail('rank_not_found', 404, { rank_key: rankKey });
|
|
161
161
|
return null;
|
|
162
162
|
}
|
|
163
|
-
if (
|
|
163
|
+
if (rank.is_system && rank.seeded_rank === ARCHON) {
|
|
164
164
|
res.fail('archon_rank_immutable', 403, { rank_key: rankKey, reason: ARCHON_REASON });
|
|
165
165
|
return null;
|
|
166
166
|
}
|
|
167
|
-
return
|
|
167
|
+
return rank;
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
-
// Load a
|
|
170
|
+
// Load a rank this API may ASSIGN. Unchanged from R110: every seeded rank is
|
|
171
171
|
// refused, because assignment derives from builders.rank via the R95b sync.
|
|
172
|
-
async function
|
|
173
|
-
const
|
|
174
|
-
if (!
|
|
172
|
+
async function loadAssignableRank(res, rankKey) {
|
|
173
|
+
const rank = await db.getRankByKey(rankKey);
|
|
174
|
+
if (!rank) {
|
|
175
175
|
res.fail('rank_not_found', 404, { rank_key: rankKey });
|
|
176
176
|
return null;
|
|
177
177
|
}
|
|
178
|
-
if (
|
|
178
|
+
if (rank.is_system) {
|
|
179
179
|
res.fail('seeded_rank_assignment_forbidden', 403, { rank_key: rankKey, reason: ASSIGN_REASON });
|
|
180
180
|
return null;
|
|
181
181
|
}
|
|
182
|
-
return
|
|
182
|
+
return rank;
|
|
183
183
|
}
|
|
184
184
|
const ARCHON = 'archon';
|
|
185
|
-
const ARCHON_REASON = 'the Archon rank-
|
|
186
|
-
const ASSIGN_REASON = 'a seeded rank-
|
|
185
|
+
const ARCHON_REASON = 'the Archon rank-rank holds every permission and is immutable — it is the rank that can never be locked out of this tab (ADR 0172)';
|
|
186
|
+
const ASSIGN_REASON = 'a seeded rank-rank is assigned by rank (R95b sync); change the builder\'s rank instead';
|
|
187
187
|
|
|
188
188
|
// ── one-release compatibility aliases: /governance/* -> /government/* ──────
|
|
189
189
|
// ADR 0174 committed to keeping the pre-rename paths answering for exactly one
|
|
@@ -205,19 +205,19 @@ module.exports = function buildGovernanceRouter() {
|
|
|
205
205
|
router.all(`${LEGACY_PATH_PREFIX}/*rest`, (req, res, next) => {
|
|
206
206
|
const rest = req.originalUrl.slice(req.originalUrl.indexOf(LEGACY_PATH_PREFIX) + LEGACY_PATH_PREFIX.length);
|
|
207
207
|
if (!rest) return next();
|
|
208
|
-
const target = `/government${rest.replace(/^\/
|
|
208
|
+
const target = `/government${rest.replace(/^\/ranks(?=\/|$)/, '/ranks')}`;
|
|
209
209
|
console.warn(`[government] legacy path hit: ${req.method} ${req.originalUrl} -> ${target} (ADR 0174 alias; remove one release after the rename)`);
|
|
210
210
|
return res.redirect(308, target);
|
|
211
211
|
});
|
|
212
212
|
|
|
213
|
-
// rank: perm government.manage — the full
|
|
213
|
+
// rank: perm government.manage — the full rank list with each rank's grants.
|
|
214
214
|
router.get('/government/ranks', api.requireBuilder, api.requirePermission('government.manage'), async (req, res) => {
|
|
215
215
|
try {
|
|
216
|
-
const
|
|
217
|
-
res.json({
|
|
216
|
+
const ranks = await db.listRanksWithGrants();
|
|
217
|
+
res.json({ ranks });
|
|
218
218
|
} catch (err) {
|
|
219
219
|
console.error('[gds] GET /government/ranks', err);
|
|
220
|
-
res.fail('
|
|
220
|
+
res.fail('ranks_list_failed', { status: 500, message: 'internal error' });
|
|
221
221
|
}
|
|
222
222
|
});
|
|
223
223
|
|
|
@@ -225,11 +225,11 @@ module.exports = function buildGovernanceRouter() {
|
|
|
225
225
|
// from code (the catalog is CODE, not a table); no DB read. Principals are
|
|
226
226
|
// read-only by construction. Three flags the tab renders its locks from, and the
|
|
227
227
|
// tab must read all three rather than just `grantable`, because since ADR 0172
|
|
228
|
-
// grantability depends on WHICH
|
|
229
|
-
// • `grantable` — the CUSTOM-
|
|
230
|
-
// • `system` — an in-app Archon power: locked on a custom
|
|
231
|
-
// a sub-Archon rank
|
|
232
|
-
// • `substrate` — locked EVERYWHERE, on every
|
|
228
|
+
// grantability depends on WHICH rank is selected:
|
|
229
|
+
// • `grantable` — the CUSTOM-rank answer (system:false). Unchanged meaning.
|
|
230
|
+
// • `system` — an in-app Archon power: locked on a custom rank, togglable on
|
|
231
|
+
// a sub-Archon rank.
|
|
232
|
+
// • `substrate` — locked EVERYWHERE, on every rank, forever (ADR 0043).
|
|
233
233
|
router.get('/government/permissions', api.requireBuilder, api.requirePermission('government.manage'), (req, res) => {
|
|
234
234
|
const permissions = catalog.PERMISSIONS.map((p) => ({
|
|
235
235
|
key: p.key,
|
|
@@ -244,7 +244,7 @@ module.exports = function buildGovernanceRouter() {
|
|
|
244
244
|
res.json({ permissions, principals: catalog.principals() });
|
|
245
245
|
});
|
|
246
246
|
|
|
247
|
-
// rank: perm government.manage — every builder↔
|
|
247
|
+
// rank: perm government.manage — every builder↔rank assignment row.
|
|
248
248
|
router.get('/government/assignments', api.requireBuilder, api.requirePermission('government.manage'), async (req, res) => {
|
|
249
249
|
try {
|
|
250
250
|
const assignments = await db.listAssignments();
|
|
@@ -255,7 +255,7 @@ module.exports = function buildGovernanceRouter() {
|
|
|
255
255
|
}
|
|
256
256
|
});
|
|
257
257
|
|
|
258
|
-
// rank: perm government.manage — create a CUSTOM
|
|
258
|
+
// rank: perm government.manage — create a CUSTOM rank (is_system false, no
|
|
259
259
|
// seeded_rank — the seeded four exist only via governance_002). Grants are
|
|
260
260
|
// added one at a time via the toggle below, so creation carries none.
|
|
261
261
|
router.post('/government/ranks', api.requireBuilder, api.requirePermission('government.manage'), async (req, res) => {
|
|
@@ -269,50 +269,50 @@ module.exports = function buildGovernanceRouter() {
|
|
|
269
269
|
return res.fail('bad_rank_key', 400, { rank_key: rankKey, expected: 'a kebab-case slug (^[a-z][a-z0-9-]*$)' });
|
|
270
270
|
}
|
|
271
271
|
try {
|
|
272
|
-
const
|
|
272
|
+
const rank = await db.createRank({
|
|
273
273
|
rankKey,
|
|
274
274
|
label: req.body.label,
|
|
275
275
|
description: req.body.description ?? null,
|
|
276
276
|
createdBy: req.builder.id,
|
|
277
277
|
});
|
|
278
|
-
res.status(201).json({
|
|
278
|
+
res.status(201).json({ rank: { ...rank, permissions: [] } });
|
|
279
279
|
} catch (err) {
|
|
280
280
|
if (err && err.code === '23505') {
|
|
281
281
|
return res.fail('rank_exists', 409, { rank_key: rankKey });
|
|
282
282
|
}
|
|
283
283
|
console.error('[gds] POST /government/ranks', err);
|
|
284
|
-
res.fail('
|
|
284
|
+
res.fail('rank_create_failed', { status: 500, message: 'internal error' });
|
|
285
285
|
}
|
|
286
286
|
});
|
|
287
287
|
|
|
288
|
-
// rank: perm government.manage — grant one permission to a
|
|
289
|
-
// half). The order of refusals is the authority order:
|
|
290
|
-
// the Archon one → the key exists → the key is grantable TO THIS
|
|
288
|
+
// rank: perm government.manage — grant one permission to a rank (the toggle's ON
|
|
289
|
+
// half). The order of refusals is the authority order: rank exists → rank is not
|
|
290
|
+
// the Archon one → the key exists → the key is grantable TO THIS RANK.
|
|
291
291
|
router.put('/government/ranks/:rankKey/permissions/:permissionKey', api.requireBuilder, api.requirePermission('government.manage'), async (req, res) => {
|
|
292
292
|
const rankKey = rankKeyParam(req, res);
|
|
293
293
|
if (rankKey === null) return;
|
|
294
294
|
const permissionKey = req.params.permissionKey;
|
|
295
295
|
try {
|
|
296
|
-
const
|
|
297
|
-
if (
|
|
296
|
+
const rank = await loadGrantEditableRank(res, rankKey);
|
|
297
|
+
if (rank === null) return;
|
|
298
298
|
if (!catalog.byKey(permissionKey)) {
|
|
299
299
|
return res.fail('unknown_permission', 400, { permission_key: permissionKey });
|
|
300
300
|
}
|
|
301
|
-
if (!catalog.isGrantableTo(permissionKey,
|
|
301
|
+
if (!catalog.isGrantableTo(permissionKey, rank)) {
|
|
302
302
|
return res.fail('permission_not_grantable', 400, {
|
|
303
303
|
permission_key: permissionKey,
|
|
304
304
|
rank_key: rankKey,
|
|
305
305
|
// Name the ACTUAL wall. "Not grantable" now has three distinct causes and
|
|
306
306
|
// a caller that cannot tell them apart cannot tell a policy choice
|
|
307
|
-
// (widen the
|
|
307
|
+
// (widen the rank) from a structural one (nothing will ever work).
|
|
308
308
|
reason: catalog.isSubstrate(permissionKey)
|
|
309
|
-
? '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
|
|
309
|
+
? '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 rank.'
|
|
310
310
|
: catalog.isPrincipalKey(permissionKey)
|
|
311
|
-
? 'a machine-principal permission — held by an exact builders.system_role identity, never by a
|
|
312
|
-
: 'a system (trust-boundary) permission is non-grantable to a CUSTOM
|
|
311
|
+
? 'a machine-principal permission — held by an exact builders.system_role identity, never by a rank (ADR 0151 §3)'
|
|
312
|
+
: 'a system (trust-boundary) permission is non-grantable to a CUSTOM rank; only a sub-Archon rank-rank may carry one (ADR 0172)',
|
|
313
313
|
});
|
|
314
314
|
}
|
|
315
|
-
const granted = await db.
|
|
315
|
+
const granted = await db.grantRankPermission({ rankKey, permissionKey });
|
|
316
316
|
res.json({ ok: true, rank_key: rankKey, permission_key: permissionKey, granted });
|
|
317
317
|
} catch (err) {
|
|
318
318
|
console.error('[gds] PUT /government/ranks/:rankKey/permissions/:permissionKey', err);
|
|
@@ -329,9 +329,9 @@ module.exports = function buildGovernanceRouter() {
|
|
|
329
329
|
if (rankKey === null) return;
|
|
330
330
|
const permissionKey = req.params.permissionKey;
|
|
331
331
|
try {
|
|
332
|
-
const
|
|
333
|
-
if (
|
|
334
|
-
const removed = await db.
|
|
332
|
+
const rank = await loadGrantEditableRank(res, rankKey);
|
|
333
|
+
if (rank === null) return;
|
|
334
|
+
const removed = await db.revokeRankPermission({ rankKey, permissionKey });
|
|
335
335
|
res.json({ ok: true, rank_key: rankKey, permission_key: permissionKey, removed });
|
|
336
336
|
} catch (err) {
|
|
337
337
|
console.error('[gds] DELETE /government/ranks/:rankKey/permissions/:permissionKey', err);
|
|
@@ -339,42 +339,42 @@ module.exports = function buildGovernanceRouter() {
|
|
|
339
339
|
}
|
|
340
340
|
});
|
|
341
341
|
|
|
342
|
-
// rank: perm government.manage — restore a SEEDED rank
|
|
343
|
-
// default (ADR 0172). The counterweight to making the
|
|
342
|
+
// rank: perm government.manage — restore a SEEDED rank to its catalog
|
|
343
|
+
// default (ADR 0172). The counterweight to making the ranks editable: a
|
|
344
344
|
// rank's grant set is ~60 rows, so an owner who drifts one has no practical way
|
|
345
345
|
// back by clicking, and "what does Trusted mean by default" is a question only
|
|
346
|
-
// the catalog can answer. Restores catalog.seedForRank(
|
|
346
|
+
// the catalog can answer. Restores catalog.seedForRank(rank.seeded_rank) — the
|
|
347
347
|
// same derivation governance_002 seeded from, so the answer cannot drift from
|
|
348
348
|
// the floors even after later catalog edits.
|
|
349
349
|
//
|
|
350
|
-
// Deliberately NOT offered for a CUSTOM
|
|
350
|
+
// Deliberately NOT offered for a CUSTOM rank: a custom rank has no catalog
|
|
351
351
|
// default to return to (its "default" is the empty set it was created with), so
|
|
352
352
|
// the honest verb there is revoking the grants, which the toggle already does.
|
|
353
353
|
router.post('/government/ranks/:rankKey/reset', api.requireBuilder, api.requirePermission('government.manage'), async (req, res) => {
|
|
354
354
|
const rankKey = rankKeyParam(req, res);
|
|
355
355
|
if (rankKey === null) return;
|
|
356
356
|
try {
|
|
357
|
-
const
|
|
358
|
-
if (
|
|
359
|
-
if (!
|
|
357
|
+
const rank = await loadGrantEditableRank(res, rankKey);
|
|
358
|
+
if (rank === null) return;
|
|
359
|
+
if (!rank.is_system || !catalog.RANK_ORDER.includes(rank.seeded_rank)) {
|
|
360
360
|
return res.fail('rank_has_no_default', 400, {
|
|
361
361
|
rank_key: rankKey,
|
|
362
|
-
reason: 'only a seeded rank-
|
|
362
|
+
reason: 'only a seeded rank-rank has a catalog default to reset to; revoke a custom rank\'s grants instead',
|
|
363
363
|
});
|
|
364
364
|
}
|
|
365
365
|
// The seed is the floor derivation, which never contains a principal key and
|
|
366
366
|
// — below archon — never a system one. Filtering by isGrantableTo anyway keeps
|
|
367
367
|
// reset from being a back door that writes what the toggle refuses.
|
|
368
|
-
const keys = catalog.seedForRank(
|
|
369
|
-
const count = await db.
|
|
370
|
-
res.json({ ok: true, rank_key: rankKey, seeded_rank:
|
|
368
|
+
const keys = catalog.seedForRank(rank.seeded_rank).filter((k) => catalog.isGrantableTo(k, rank));
|
|
369
|
+
const count = await db.resetRankGrants({ rankKey, permissionKeys: keys });
|
|
370
|
+
res.json({ ok: true, rank_key: rankKey, seeded_rank: rank.seeded_rank, permission_count: count });
|
|
371
371
|
} catch (err) {
|
|
372
372
|
console.error('[gds] POST /government/ranks/:rankKey/reset', err);
|
|
373
373
|
res.fail('reset_failed', { status: 500, message: 'internal error' });
|
|
374
374
|
}
|
|
375
375
|
});
|
|
376
376
|
|
|
377
|
-
// rank: perm government.manage — assign a CUSTOM
|
|
377
|
+
// rank: perm government.manage — assign a CUSTOM rank to a builder.
|
|
378
378
|
// assigned_by is ALWAYS the session builder, never input (the audit trail's
|
|
379
379
|
// "who did this" must be the authenticated actor).
|
|
380
380
|
router.post('/government/assignments', api.requireBuilder, api.requirePermission('government.manage'), async (req, res) => {
|
|
@@ -387,9 +387,9 @@ module.exports = function buildGovernanceRouter() {
|
|
|
387
387
|
return res.fail('bad_rank_key', 400, { rank_key: rankKey, expected: 'a kebab-case slug (^[a-z][a-z0-9-]*$)' });
|
|
388
388
|
}
|
|
389
389
|
try {
|
|
390
|
-
const
|
|
391
|
-
if (
|
|
392
|
-
const assigned = await db.
|
|
390
|
+
const rank = await loadAssignableRank(res, rankKey);
|
|
391
|
+
if (rank === null) return;
|
|
392
|
+
const assigned = await db.assignRank({
|
|
393
393
|
builderId: req.body.builder_id,
|
|
394
394
|
rankKey,
|
|
395
395
|
assignedBy: req.builder.id,
|
|
@@ -404,7 +404,7 @@ module.exports = function buildGovernanceRouter() {
|
|
|
404
404
|
}
|
|
405
405
|
});
|
|
406
406
|
|
|
407
|
-
// rank: perm government.manage — remove a custom-
|
|
407
|
+
// rank: perm government.manage — remove a custom-rank assignment. Idempotent:
|
|
408
408
|
// `removed` says whether a row went away.
|
|
409
409
|
router.delete('/government/assignments/:builderId/:rankKey', api.requireBuilder, api.requirePermission('government.manage'), async (req, res) => {
|
|
410
410
|
const builderId = parseId(req, res, { param: 'builderId', code: 'bad_builder_id', positiveInt: true });
|
|
@@ -412,9 +412,9 @@ module.exports = function buildGovernanceRouter() {
|
|
|
412
412
|
const rankKey = rankKeyParam(req, res);
|
|
413
413
|
if (rankKey === null) return;
|
|
414
414
|
try {
|
|
415
|
-
const
|
|
416
|
-
if (
|
|
417
|
-
const removed = await db.
|
|
415
|
+
const rank = await loadAssignableRank(res, rankKey);
|
|
416
|
+
if (rank === null) return;
|
|
417
|
+
const removed = await db.unassignRank({ builderId, rankKey });
|
|
418
418
|
res.json({ ok: true, builder_id: builderId, rank_key: rankKey, removed });
|
|
419
419
|
} catch (err) {
|
|
420
420
|
console.error('[gds] DELETE /government/assignments/:builderId/:rankKey', err);
|
|
@@ -425,4 +425,4 @@ module.exports = function buildGovernanceRouter() {
|
|
|
425
425
|
return router;
|
|
426
426
|
};
|
|
427
427
|
|
|
428
|
-
module.exports.
|
|
428
|
+
module.exports.governmentPort = governmentPort;
|
|
@@ -1,27 +1,27 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
// The
|
|
3
|
+
// The government module's rank SYNC port (ADR 0151, BV1.R95b).
|
|
4
4
|
//
|
|
5
|
-
// A second, deliberately tiny kernel port beside `
|
|
6
|
-
// `
|
|
5
|
+
// A second, deliberately tiny kernel port beside `government` (routes/government.js).
|
|
6
|
+
// `government` is the READ surface every gate resolves on every request; this is the
|
|
7
7
|
// single WRITE core needs — "this builder's rank is now X, make their seeded
|
|
8
|
-
// rank
|
|
8
|
+
// rank match". They are kept apart on purpose:
|
|
9
9
|
// • the read port is on the hot path of every permission-gated request and is
|
|
10
10
|
// resolved by requirePermission(); a mutation has no business hanging off it;
|
|
11
11
|
// • this one is resolved by exactly three writers (the two _applyRankChangeTx
|
|
12
12
|
// twins + the sign-in path) and is a TRANSACTION PARTICIPANT — it takes the
|
|
13
|
-
// caller's client so the
|
|
13
|
+
// caller's client so the rank rotation commits or rolls back with the rank write.
|
|
14
14
|
// The port name is namespaced (`government.rankSync`) like the `ideas.capture` /
|
|
15
15
|
// `art.getBuilderGeminiKeyRow` / `grade.builderRollingAvgGrade` ports.
|
|
16
16
|
//
|
|
17
|
-
// Registered from a route factory for the same reason routes/
|
|
17
|
+
// Registered from a route factory for the same reason routes/government.js is:
|
|
18
18
|
// mountModuleRoutes is the one boot hook that runs BOTH in the live server and
|
|
19
19
|
// whenever a test rebuilds the router, and registration is hasProvider-guarded so a
|
|
20
20
|
// rebuild never trips the seam's single-provider duplicate guard.
|
|
21
21
|
//
|
|
22
22
|
// The router is EMPTY and must stay empty. Rank-derived assignment is automatic and
|
|
23
23
|
// has no HTTP surface; the MANUAL assignment CRUD is R110's Archon-gated tab on the
|
|
24
|
-
//
|
|
24
|
+
// government router. An endpoint here would be a way to hand out a rank without
|
|
25
25
|
// changing the rank.
|
|
26
26
|
|
|
27
27
|
const express = require('express');
|
|
@@ -41,7 +41,7 @@ function registerSeams() {
|
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
module.exports = function
|
|
44
|
+
module.exports = function buildGovernmentRankSyncRouter() {
|
|
45
45
|
registerSeams();
|
|
46
46
|
return express.Router();
|
|
47
47
|
};
|