@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,22 +1,22 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
// modules/
|
|
3
|
+
// modules/government/config.js — the government config contract (ADR 0151, BV1.R96).
|
|
4
4
|
//
|
|
5
|
-
//
|
|
5
|
+
// Rank TEMPLATES and their display labels are per-instance CONFIG; live grants and
|
|
6
6
|
// assignments are DB rows. This file resolves the config half, mirroring
|
|
7
7
|
// src/branding.js precedence exactly:
|
|
8
8
|
//
|
|
9
9
|
// env override -> instance config (config/government.json) -> neutral
|
|
10
10
|
// starter (config/government.neutral.json)
|
|
11
11
|
//
|
|
12
|
-
// A fresh instance with NO config resolves to the PLAIN neutral
|
|
12
|
+
// A fresh instance with NO config resolves to the PLAIN neutral rank set — never to
|
|
13
13
|
// a host's labels (fail-to-vanilla, the same posture branding takes). R102 proves
|
|
14
14
|
// that boot.
|
|
15
15
|
//
|
|
16
16
|
// WHAT LIVES HERE vs WHAT LIVES IN THE DB — get this wrong and config becomes a
|
|
17
17
|
// second source of authority truth, which is exactly what ADR 0016 forbids:
|
|
18
|
-
// • config — TEMPLATES/DEFAULTS: a
|
|
19
|
-
// permission list a starting CUSTOM
|
|
18
|
+
// • config — TEMPLATES/DEFAULTS: a rank's display label, its description, and the
|
|
19
|
+
// permission list a starting CUSTOM rank template proposes. Shapes only.
|
|
20
20
|
// • DB — LIVE STATE: government_ranks / government_rank_permissions /
|
|
21
21
|
// government_builder_ranks (R93). WHO HOLDS WHAT is a row, resolved server-side
|
|
22
22
|
// per request by resolver.js. Nothing in this file grants anything to anybody.
|
|
@@ -25,21 +25,21 @@
|
|
|
25
25
|
// 1. A template naming a `system: true` catalog key is STRIPPED. An instance's
|
|
26
26
|
// config file is editable by anyone who can write the checkout, so it must never
|
|
27
27
|
// be able to hand out deploy / rank-set / protected-path power. catalog
|
|
28
|
-
// .isGrantable() is that wall — the same one the
|
|
28
|
+
// .isGrantable() is that wall — the same one the government API and the Archon
|
|
29
29
|
// tab use, so this cannot drift from the catalog.
|
|
30
30
|
// 2. A template naming a key the catalog does not define is DROPPED (the catalog is
|
|
31
31
|
// the source of truth for what a permission IS — resolver.js, same rule).
|
|
32
|
-
// 3. A `permissions` list on one of the four SEEDED
|
|
32
|
+
// 3. A `permissions` list on one of the four SEEDED ranks is DROPPED wholesale.
|
|
33
33
|
// Their grants are DERIVED from catalog floors and materialised by
|
|
34
|
-
// governance_002; config may relabel a seeded
|
|
34
|
+
// governance_002; config may relabel a seeded rank, never re-grant it.
|
|
35
35
|
//
|
|
36
36
|
// Stripping rather than throwing matches resolver.js: a typo in a config file must
|
|
37
37
|
// fail CLOSED, not brick the boot.
|
|
38
38
|
//
|
|
39
39
|
// SLICE BOUNDARY: this file is the config CONTRACT, and deliberately has no production
|
|
40
|
-
// caller yet — R96 is the "what shape is a
|
|
40
|
+
// caller yet — R96 is the "what shape is a rank template, and what can config never
|
|
41
41
|
// do" decision, landed before the surfaces that read it. R110/R111 (the Archon
|
|
42
|
-
//
|
|
42
|
+
// Government tab) is the first intended consumer, and R102 (the vanilla-boot proof)
|
|
43
43
|
// asserts against the neutral starter. Wiring a speculative caller now would guess at
|
|
44
44
|
// their needs; the tests pin the contract until they land.
|
|
45
45
|
|
|
@@ -62,7 +62,7 @@ const SEEDED_RANK_ROLES = new Set(catalog.RANK_ORDER);
|
|
|
62
62
|
|
|
63
63
|
const isObj = (v) => v && typeof v === 'object' && !Array.isArray(v);
|
|
64
64
|
|
|
65
|
-
// Instance
|
|
65
|
+
// Instance ranks merge over neutral PER RANK KEY and per field, so an instance can
|
|
66
66
|
// relabel `metic` without restating the other three. The shape is exactly two levels
|
|
67
67
|
// deep, so this is a targeted merge rather than branding's generic deepMerge.
|
|
68
68
|
function mergeRoles(neutral, instance) {
|
|
@@ -80,13 +80,18 @@ function mergeRoles(neutral, instance) {
|
|
|
80
80
|
// widen authority, so there is no env knob that adds a permission. It exists for the
|
|
81
81
|
// co-tenant case branding's AUTH_IDP override exists for — several hosted instances
|
|
82
82
|
// sharing the neutral pack, differentiated only by env, with no config file of their
|
|
83
|
-
// own. Format: "<PREFIX>
|
|
84
|
-
// only RELABEL a
|
|
85
|
-
// inert, rather than quietly growing the
|
|
86
|
-
function applyLabelOverrides(
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
83
|
+
// own. Format: "<PREFIX>_GOVERNMENT_RANK_LABELS=metic=Trusted,archon=Owner". It can
|
|
84
|
+
// only RELABEL a rank the merged config already defines — a typo names no rank and is
|
|
85
|
+
// inert, rather than quietly growing the rank set from an env string.
|
|
86
|
+
function applyLabelOverrides(ranks, env) {
|
|
87
|
+
// GOVERNMENT_RANK_LABELS is the post-ADR-0174 name. The pre-rename spelling is
|
|
88
|
+
// still read because an instance sets this in its own shell/env file, OUTSIDE
|
|
89
|
+
// this repo — dropping it would silently stop honouring a label override an
|
|
90
|
+
// operator already configured. Remove once instances have migrated.
|
|
91
|
+
const raw = api.resolveEnv('GOVERNMENT_RANK_LABELS', { env })
|
|
92
|
+
|| api.resolveEnv('GOVERNANCE_ROLE_LABELS', { env });
|
|
93
|
+
if (!raw) return ranks;
|
|
94
|
+
const out = { ...ranks };
|
|
90
95
|
for (const pair of String(raw).split(',')) {
|
|
91
96
|
const eq = pair.indexOf('=');
|
|
92
97
|
if (eq <= 0) continue;
|
|
@@ -116,27 +121,32 @@ function sanitizeRole(key, raw) {
|
|
|
116
121
|
seeded,
|
|
117
122
|
label: typeof raw.label === 'string' && raw.label ? raw.label : key,
|
|
118
123
|
description: typeof raw.description === 'string' ? raw.description : '',
|
|
119
|
-
// Rule 3: a seeded rank
|
|
124
|
+
// Rule 3: a seeded rank's grants come from catalog floors via governance_002.
|
|
120
125
|
permissions: seeded ? Object.freeze([]) : grantableOnly(raw.permissions),
|
|
121
126
|
});
|
|
122
127
|
}
|
|
123
128
|
|
|
124
129
|
// Pure resolver — all inputs injected, so tests drive it with zero I/O (the
|
|
125
130
|
// resolveBranding pattern).
|
|
126
|
-
function
|
|
127
|
-
|
|
128
|
-
|
|
131
|
+
function resolveGovernmentConfig({ neutral, instance, env = process.env } = {}) {
|
|
132
|
+
// `ranks` is the post-ADR-0174 key. An INSTANCE file authored before the rename
|
|
133
|
+
// may still say `roles`; accept it so an external self-hosted instance does not
|
|
134
|
+
// silently lose its templates on upgrade. The neutral file shipped in core is
|
|
135
|
+
// always `ranks`, so this fallback only ever reads host-owned config.
|
|
136
|
+
const instanceRanks = (instance || {}).ranks || (instance || {}).roles;
|
|
137
|
+
const merged = applyLabelOverrides(mergeRoles((neutral || {}).ranks, instanceRanks), env);
|
|
138
|
+
const ranks = {};
|
|
129
139
|
for (const key of Object.keys(merged).sort()) {
|
|
130
|
-
if (!isObj(merged[key])) continue; // a malformed entry is not a
|
|
131
|
-
|
|
140
|
+
if (!isObj(merged[key])) continue; // a malformed entry is not a rank, and grants nothing
|
|
141
|
+
ranks[key] = sanitizeRole(key, merged[key]);
|
|
132
142
|
}
|
|
133
|
-
// The four
|
|
143
|
+
// The four ranks ALWAYS exist (governance_002 seeds them unconditionally), so a
|
|
134
144
|
// config that omits or mangles one falls back to the canonical rank name rather than
|
|
135
|
-
// dropping the
|
|
145
|
+
// dropping the rank out of the display set.
|
|
136
146
|
for (const key of catalog.RANK_ORDER) {
|
|
137
|
-
if (!
|
|
147
|
+
if (!ranks[key]) ranks[key] = sanitizeRole(key, {});
|
|
138
148
|
}
|
|
139
|
-
return Object.freeze({
|
|
149
|
+
return Object.freeze({ ranks: Object.freeze(ranks) });
|
|
140
150
|
}
|
|
141
151
|
|
|
142
152
|
// READ, never require(): a bare require('../../config/X.json') of a boundary-excluded
|
|
@@ -148,7 +158,7 @@ function readJson(p) {
|
|
|
148
158
|
|
|
149
159
|
// Disk loader with the documented precedence. BOTH files are optional: the instance
|
|
150
160
|
// pack because a vanilla instance ships only the neutral starter, and the neutral
|
|
151
|
-
// starter because the four
|
|
161
|
+
// starter because the four ranks still resolve to their canonical names without
|
|
152
162
|
// it. Unlike branding — where a missing identity is fatal — no value here is required,
|
|
153
163
|
// so the loader fails to sane defaults rather than throwing.
|
|
154
164
|
//
|
|
@@ -157,8 +167,8 @@ function readJson(p) {
|
|
|
157
167
|
// consumer needs it on a hot path, resolve once at boot and pass the frozen result —
|
|
158
168
|
// do NOT add a cache here (this file sits next to resolver.js, whose no-cache rule is
|
|
159
169
|
// absolute; keeping config cache-free too means nobody has to check which is which).
|
|
160
|
-
function
|
|
161
|
-
return
|
|
170
|
+
function loadGovernmentConfig({ neutralPath = NEUTRAL_PATH, instancePath = INSTANCE_PATH, env = process.env } = {}) {
|
|
171
|
+
return resolveGovernmentConfig({
|
|
162
172
|
neutral: readJson(neutralPath) || {},
|
|
163
173
|
instance: readJson(instancePath) || {},
|
|
164
174
|
env,
|
|
@@ -166,8 +176,8 @@ function loadGovernanceConfig({ neutralPath = NEUTRAL_PATH, instancePath = INSTA
|
|
|
166
176
|
}
|
|
167
177
|
|
|
168
178
|
module.exports = {
|
|
169
|
-
|
|
170
|
-
|
|
179
|
+
resolveGovernmentConfig,
|
|
180
|
+
loadGovernmentConfig,
|
|
171
181
|
grantableOnly,
|
|
172
182
|
NEUTRAL_PATH,
|
|
173
183
|
INSTANCE_PATH,
|
package/modules/government/db.js
CHANGED
|
@@ -1,22 +1,22 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
// modules/
|
|
3
|
+
// modules/government/db.js — the government db slice (ADR 0151, BV1.R95).
|
|
4
4
|
//
|
|
5
|
-
// Reads the
|
|
5
|
+
// Reads the rank/grant/assignment rows governance_001_tables.sql defines. The
|
|
6
6
|
// per-domain db-slice pattern (ADR 0091 §2): the pool comes through the published
|
|
7
7
|
// doorway (src/module-api.js), never `../../src/bongos/db`.
|
|
8
8
|
//
|
|
9
9
|
// NO CACHING, deliberately. Every read here runs against the live DB on the
|
|
10
|
-
// request that needs it, so a revoked
|
|
10
|
+
// request that needs it, so a revoked rank takes effect on the builder's very
|
|
11
11
|
// next request — the ADR 0016 trust-boundary rule the resolver inherits.
|
|
12
12
|
|
|
13
13
|
const { pool } = require('../../src/module-api');
|
|
14
14
|
const { planRankSync } = require('./ranks');
|
|
15
15
|
|
|
16
|
-
// Every permission_key granted to any
|
|
17
|
-
//
|
|
16
|
+
// Every permission_key granted to any rank the builder holds. Duplicates across
|
|
17
|
+
// ranks are collapsed by the DISTINCT — the resolver's UNION semantics. Returns
|
|
18
18
|
// a flat array of raw key strings (validated against the catalog by resolver.js);
|
|
19
|
-
// [] for a builder with no
|
|
19
|
+
// [] for a builder with no ranks, which fails closed to zero authority.
|
|
20
20
|
async function getPermissionKeysForBuilder(builderId) {
|
|
21
21
|
const { rows } = await pool.query(
|
|
22
22
|
`SELECT DISTINCT rp.permission_key
|
|
@@ -28,9 +28,9 @@ async function getPermissionKeysForBuilder(builderId) {
|
|
|
28
28
|
return rows.map((r) => r.permission_key);
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
// The
|
|
31
|
+
// The rank keys a builder holds, for introspection surfaces (the Archon tab,
|
|
32
32
|
// R110/R111) and for explaining a 403. Not part of the resolve path.
|
|
33
|
-
async function
|
|
33
|
+
async function getRankKeysForBuilder(builderId) {
|
|
34
34
|
const { rows } = await pool.query(
|
|
35
35
|
`SELECT rank_key FROM government_builder_ranks WHERE builder_id = $1 ORDER BY rank_key`,
|
|
36
36
|
[builderId],
|
|
@@ -38,24 +38,24 @@ async function getRoleKeysForBuilder(builderId) {
|
|
|
38
38
|
return rows.map((r) => r.rank_key);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
// --- the rank→
|
|
41
|
+
// --- the rank→rank WRITE half (BV1.R95b) ------------------------------------
|
|
42
42
|
//
|
|
43
43
|
// Everything above this line only READS. Until R95b the only write to
|
|
44
44
|
// government_builder_ranks in the whole repo was governance_002's run-once
|
|
45
|
-
// backfill, so a builder created after it had NO
|
|
46
|
-
// permission gate) and a builder whose rank CHANGED kept their OLD rank
|
|
45
|
+
// backfill, so a builder created after it had NO rank (locked out of every
|
|
46
|
+
// permission gate) and a builder whose rank CHANGED kept their OLD rank
|
|
47
47
|
// (a demoted Archon still resolving Archon permissions — the ADR 0016 defect).
|
|
48
48
|
//
|
|
49
49
|
// The writer below is a TRANSACTION PARTICIPANT: every caller hands it the client
|
|
50
|
-
// its own BEGIN/COMMIT is running on, so the
|
|
50
|
+
// its own BEGIN/COMMIT is running on, so the rank rotation commits — or rolls back
|
|
51
51
|
// — atomically with the rank write it accompanies. Core reaches it through the
|
|
52
52
|
// `government.rankSync` kernel port (routes/rank-sync.js); it never imports this
|
|
53
53
|
// module (ADR 0083).
|
|
54
54
|
|
|
55
|
-
// The seeded
|
|
55
|
+
// The seeded ranks as the DB actually defines them. Read per call rather than
|
|
56
56
|
// cached: it is the same authority data the resolver refuses to memoise, and this
|
|
57
57
|
// runs on rank writes + sign-in, never in a hot loop.
|
|
58
|
-
async function
|
|
58
|
+
async function getSeededRanks(q) {
|
|
59
59
|
const { rows } = await q.query(
|
|
60
60
|
`SELECT rank_key, seeded_rank, is_system
|
|
61
61
|
FROM government_ranks
|
|
@@ -64,9 +64,9 @@ async function getSeededRankRoles(q) {
|
|
|
64
64
|
return rows;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
// A builder's assignments joined to their
|
|
68
|
-
// seeded rank
|
|
69
|
-
async function
|
|
67
|
+
// A builder's assignments joined to their rank definitions, so the plan can tell a
|
|
68
|
+
// seeded rank from an Archon-assigned custom one.
|
|
69
|
+
async function getRankAssignmentsForBuilder(q, builderId) {
|
|
70
70
|
const { rows } = await q.query(
|
|
71
71
|
`SELECT br.rank_key, r.is_system, r.seeded_rank
|
|
72
72
|
FROM government_builder_ranks br
|
|
@@ -77,15 +77,15 @@ async function getRoleAssignmentsForBuilder(q, builderId) {
|
|
|
77
77
|
return rows;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
// syncRank(client, { builderId, rank }) — make the builder's seeded rank
|
|
80
|
+
// syncRank(client, { builderId, rank }) — make the builder's seeded rank
|
|
81
81
|
// match `rank`: add the matching one, DELETE every other seeded one, leave custom
|
|
82
|
-
//
|
|
82
|
+
// ranks alone. `client` is the caller's open transaction client; pass null/undefined
|
|
83
83
|
// to run on the pool (the sign-in path, which has no surrounding transaction).
|
|
84
84
|
//
|
|
85
|
-
// Idempotent — a builder whose
|
|
85
|
+
// Idempotent — a builder whose rank already matches costs two SELECTs and no write,
|
|
86
86
|
// so calling it on every sign-in quietly repairs drift instead of accumulating it.
|
|
87
87
|
// The DELETE is keyed on the plan's `remove` list, which is built only from rows the
|
|
88
|
-
// join proved are is_system seeded
|
|
88
|
+
// join proved are is_system seeded ranks, so a custom rank can never be caught by it.
|
|
89
89
|
// assigned_by is left NULL: a rank-derived assignment is the system's, not a person's
|
|
90
90
|
// (governance_002's backfill uses the same convention; the human decision is audited
|
|
91
91
|
// in credit_log's rank_change row).
|
|
@@ -94,8 +94,8 @@ async function syncRank(client, { builderId, rank } = {}) {
|
|
|
94
94
|
return { target: null, added: [], removed: [] };
|
|
95
95
|
}
|
|
96
96
|
const q = client || pool;
|
|
97
|
-
const seededRoles = await
|
|
98
|
-
const currentRoles = await
|
|
97
|
+
const seededRoles = await getSeededRanks(q);
|
|
98
|
+
const currentRoles = await getRankAssignmentsForBuilder(q, builderId);
|
|
99
99
|
const plan = planRankSync({ rank, currentRoles, seededRoles });
|
|
100
100
|
|
|
101
101
|
if (plan.remove.length > 0) {
|
|
@@ -116,16 +116,16 @@ async function syncRank(client, { builderId, rank } = {}) {
|
|
|
116
116
|
return { target: plan.target, added: [...plan.add], removed: [...plan.remove] };
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
-
// --- the R110 CRUD surface (
|
|
119
|
+
// --- the R110 CRUD surface (ranks / grants / assignments) --------------------
|
|
120
120
|
//
|
|
121
|
-
// The db half of the
|
|
122
|
-
// authority invariants (system perms non-grantable, seeded
|
|
123
|
-
// through the API) are enforced in routes/
|
|
121
|
+
// The db half of the government API routes. Deliberately DUMB writes: the
|
|
122
|
+
// authority invariants (system perms non-grantable, seeded ranks immutable
|
|
123
|
+
// through the API) are enforced in routes/government.js against the catalog —
|
|
124
124
|
// the same split governance_001 documents (permission_key is validated app-side,
|
|
125
125
|
// not a FK). Grant/assign are ON CONFLICT DO NOTHING so the tab's toggle is
|
|
126
126
|
// idempotent; each returns whether a row actually changed.
|
|
127
127
|
|
|
128
|
-
async function
|
|
128
|
+
async function listRanksWithGrants() {
|
|
129
129
|
const { rows } = await pool.query(
|
|
130
130
|
`SELECT r.rank_key, r.label, r.description, r.is_system, r.seeded_rank,
|
|
131
131
|
r.created_by, r.created_at,
|
|
@@ -139,62 +139,62 @@ async function listRolesWithGrants() {
|
|
|
139
139
|
return rows;
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
-
async function
|
|
142
|
+
async function getRankByKey(rankKey) {
|
|
143
143
|
const { rows } = await pool.query(
|
|
144
144
|
`SELECT rank_key, label, description, is_system, seeded_rank, created_by, created_at
|
|
145
145
|
FROM government_ranks
|
|
146
146
|
WHERE rank_key = $1`,
|
|
147
|
-
[
|
|
147
|
+
[rankKey],
|
|
148
148
|
);
|
|
149
149
|
return rows[0] ?? null;
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
-
async function
|
|
152
|
+
async function createRank({ rankKey, label, description, createdBy }) {
|
|
153
153
|
const { rows } = await pool.query(
|
|
154
154
|
`INSERT INTO government_ranks (rank_key, label, description, is_system, seeded_rank, created_by)
|
|
155
155
|
VALUES ($1, $2, $3, false, NULL, $4)
|
|
156
156
|
RETURNING rank_key, label, description, is_system, seeded_rank, created_by, created_at`,
|
|
157
|
-
[
|
|
157
|
+
[rankKey, label, description ?? null, createdBy],
|
|
158
158
|
);
|
|
159
159
|
return rows[0];
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
-
async function
|
|
162
|
+
async function grantRankPermission({ rankKey, permissionKey }) {
|
|
163
163
|
const { rowCount } = await pool.query(
|
|
164
164
|
`INSERT INTO government_rank_permissions (rank_key, permission_key)
|
|
165
165
|
VALUES ($1, $2)
|
|
166
166
|
ON CONFLICT (rank_key, permission_key) DO NOTHING`,
|
|
167
|
-
[
|
|
167
|
+
[rankKey, permissionKey],
|
|
168
168
|
);
|
|
169
169
|
return rowCount > 0;
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
-
async function
|
|
172
|
+
async function revokeRankPermission({ rankKey, permissionKey }) {
|
|
173
173
|
const { rowCount } = await pool.query(
|
|
174
174
|
`DELETE FROM government_rank_permissions
|
|
175
175
|
WHERE rank_key = $1 AND permission_key = $2`,
|
|
176
|
-
[
|
|
176
|
+
[rankKey, permissionKey],
|
|
177
177
|
);
|
|
178
178
|
return rowCount > 0;
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
-
// Replace a
|
|
182
|
-
// delete+insert pair must be atomic: a crash between them would leave the
|
|
183
|
-
// holding NOTHING, and for a rank
|
|
181
|
+
// Replace a rank's entire grant set in ONE transaction (ADR 0172's reset). The
|
|
182
|
+
// delete+insert pair must be atomic: a crash between them would leave the rank
|
|
183
|
+
// holding NOTHING, and for a rank that is every builder at that rank losing
|
|
184
184
|
// every permission until someone noticed. The caller supplies the keys (the route
|
|
185
185
|
// derives them from catalog.seedForRank), so this stays a dumb write like its
|
|
186
186
|
// neighbours — the authority decision is made against the catalog, in the route.
|
|
187
|
-
async function
|
|
187
|
+
async function resetRankGrants({ rankKey, permissionKeys }) {
|
|
188
188
|
const client = await pool.connect();
|
|
189
189
|
try {
|
|
190
190
|
await client.query('BEGIN');
|
|
191
|
-
await client.query(`DELETE FROM government_rank_permissions WHERE rank_key = $1`, [
|
|
191
|
+
await client.query(`DELETE FROM government_rank_permissions WHERE rank_key = $1`, [rankKey]);
|
|
192
192
|
if (permissionKeys.length > 0) {
|
|
193
193
|
await client.query(
|
|
194
194
|
`INSERT INTO government_rank_permissions (rank_key, permission_key)
|
|
195
195
|
SELECT $1, unnest($2::text[])
|
|
196
196
|
ON CONFLICT (rank_key, permission_key) DO NOTHING`,
|
|
197
|
-
[
|
|
197
|
+
[rankKey, [...permissionKeys]],
|
|
198
198
|
);
|
|
199
199
|
}
|
|
200
200
|
await client.query('COMMIT');
|
|
@@ -210,7 +210,7 @@ async function resetRoleGrants({ roleKey, permissionKeys }) {
|
|
|
210
210
|
async function listAssignments() {
|
|
211
211
|
const { rows } = await pool.query(
|
|
212
212
|
`SELECT br.builder_id, b.github_login, b.display_name, b.rank,
|
|
213
|
-
br.rank_key, r.label AS
|
|
213
|
+
br.rank_key, r.label AS rank_label, r.is_system, r.seeded_rank,
|
|
214
214
|
br.assigned_by, br.assigned_at
|
|
215
215
|
FROM government_builder_ranks br
|
|
216
216
|
JOIN builders b ON b.id = br.builder_id
|
|
@@ -220,38 +220,38 @@ async function listAssignments() {
|
|
|
220
220
|
return rows;
|
|
221
221
|
}
|
|
222
222
|
|
|
223
|
-
async function
|
|
223
|
+
async function assignRank({ builderId, rankKey, assignedBy }) {
|
|
224
224
|
const { rowCount } = await pool.query(
|
|
225
225
|
`INSERT INTO government_builder_ranks (builder_id, rank_key, assigned_by)
|
|
226
226
|
VALUES ($1, $2, $3)
|
|
227
227
|
ON CONFLICT (builder_id, rank_key) DO NOTHING`,
|
|
228
|
-
[builderId,
|
|
228
|
+
[builderId, rankKey, assignedBy],
|
|
229
229
|
);
|
|
230
230
|
return rowCount > 0;
|
|
231
231
|
}
|
|
232
232
|
|
|
233
|
-
async function
|
|
233
|
+
async function unassignRank({ builderId, rankKey }) {
|
|
234
234
|
const { rowCount } = await pool.query(
|
|
235
235
|
`DELETE FROM government_builder_ranks
|
|
236
236
|
WHERE builder_id = $1 AND rank_key = $2`,
|
|
237
|
-
[builderId,
|
|
237
|
+
[builderId, rankKey],
|
|
238
238
|
);
|
|
239
239
|
return rowCount > 0;
|
|
240
240
|
}
|
|
241
241
|
|
|
242
242
|
module.exports = {
|
|
243
243
|
getPermissionKeysForBuilder,
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
244
|
+
getRankKeysForBuilder,
|
|
245
|
+
getSeededRanks,
|
|
246
|
+
getRankAssignmentsForBuilder,
|
|
247
247
|
syncRank,
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
248
|
+
listRanksWithGrants,
|
|
249
|
+
getRankByKey,
|
|
250
|
+
createRank,
|
|
251
|
+
grantRankPermission,
|
|
252
|
+
revokeRankPermission,
|
|
253
|
+
resetRankGrants,
|
|
254
254
|
listAssignments,
|
|
255
|
-
|
|
256
|
-
|
|
255
|
+
assignRank,
|
|
256
|
+
unassignRank,
|
|
257
257
|
};
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
// modules/
|
|
4
|
-
//
|
|
3
|
+
// modules/government/protected-surfaces.js — the protected-surface registry's
|
|
4
|
+
// GOVERNMENT-side API (ADR 0151 §4, BV1.R101).
|
|
5
5
|
//
|
|
6
6
|
// POLICY OWNS / SUBSTRATE ENFORCES — the whole point of this slice.
|
|
7
7
|
//
|
|
8
|
-
//
|
|
8
|
+
// GOVERNMENT OWNS THE DATA. protected-surfaces.json (next to this file) says
|
|
9
9
|
// WHICH repo paths are protected, at WHAT rank floor, mapped to WHICH
|
|
10
10
|
// `system: true` catalog permission. That file is the source of truth; the
|
|
11
|
-
// Archon
|
|
11
|
+
// Archon Government tab (R110/R111) edits it as policy. Before R101 the same
|
|
12
12
|
// information was a hardcoded PROTECTED_GLOBS constant inside the kernel.
|
|
13
13
|
//
|
|
14
14
|
// THE OUT-OF-BAND SUBSTRATE STAYS THE ENFORCER. Nothing here enforces
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
// Moving enforcement into the app is the regression R101 exists to prevent
|
|
21
21
|
// (ADR 0151 §"Alternatives considered").
|
|
22
22
|
//
|
|
23
|
-
// NO DB DEPENDENCY. A DB-backed override is a
|
|
23
|
+
// NO DB DEPENDENCY. A DB-backed override is a government-tab concern layered
|
|
24
24
|
// on top and must never become a dependency of the hook — the hook runs on a
|
|
25
25
|
// laptop with no database and no network. This module is therefore
|
|
26
26
|
// synchronous, DB-free and requires nothing but the catalog and the doorway.
|
|
@@ -43,10 +43,10 @@ const ppc = api.permissionPathCheck;
|
|
|
43
43
|
const SURFACES = ppc.PROTECTED_SURFACES;
|
|
44
44
|
|
|
45
45
|
// ── invariants asserted at module load (fail loud on an authoring typo) ───────
|
|
46
|
-
// The
|
|
46
|
+
// The government module is where the registry meets the permission catalog, so
|
|
47
47
|
// this is where "every protected surface maps to a real, non-grantable
|
|
48
48
|
// permission" is checked. A surface mapping to an unknown key, or to a
|
|
49
|
-
// `system: false` (grantable) key, would mean an Archon could hand a custom
|
|
49
|
+
// `system: false` (grantable) key, would mean an Archon could hand a custom rank
|
|
50
50
|
// the power the substrate is supposed to reserve — exactly the wall ADR 0151 §4
|
|
51
51
|
// builds. Fail loud rather than silently protecting nothing.
|
|
52
52
|
for (const s of SURFACES) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_readme": [
|
|
3
|
-
"The PROTECTED-SURFACE REGISTRY (ADR 0151 §4, BV1.R101).
|
|
3
|
+
"The PROTECTED-SURFACE REGISTRY (ADR 0151 §4, BV1.R101). GOVERNMENT OWNS THIS DATA:",
|
|
4
4
|
"which repo paths are protected, at what rank floor, mapped to which system:true catalog",
|
|
5
5
|
"permission. It replaces the PROTECTED_GLOBS constant that used to be hardcoded in",
|
|
6
6
|
"src/bongos/permission-path-check.js.",
|
|
@@ -8,14 +8,14 @@
|
|
|
8
8
|
"POLICY OWNS / SUBSTRATE ENFORCES. Editing this file re-maps POLICY. It does NOT move",
|
|
9
9
|
"enforcement into the app: the pre-push git hook, the grader pre-pass, main-audit.js,",
|
|
10
10
|
"gate-review.js and the goal-scope check all read this registry OUT OF BAND (no DB, no",
|
|
11
|
-
"server, no HTTP) and remain the enforcers (ADR 0043). A
|
|
11
|
+
"server, no HTTP) and remain the enforcers (ADR 0043). A government-tab edit can widen or",
|
|
12
12
|
"narrow the protected set and re-map a floor; it can never make the request path the",
|
|
13
13
|
"authority.",
|
|
14
14
|
"",
|
|
15
15
|
"WHY JSON IN THE REPO AND NOT A DB TABLE: the enforcers run where there is no database and",
|
|
16
16
|
"no server (a git pre-push hook on a laptop, a CI job). The registry's canonical form must",
|
|
17
17
|
"therefore be synchronously loadable from the checkout. A DB-backed override is a",
|
|
18
|
-
"
|
|
18
|
+
"government-tab concern layered on top — never a dependency of the hook.",
|
|
19
19
|
"",
|
|
20
20
|
"SCHEMA: floor/permission at the top level are the defaults; a surface may override either.",
|
|
21
21
|
"'glob' follows the permission-path-check matcher: an exact path, a trailing '/' subtree,",
|
|
@@ -34,7 +34,7 @@
|
|
|
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/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/
|
|
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/government/ entry directly above (ADR 0086 §4)." },
|
|
38
38
|
{ "glob": "modules/dev-box/boxes.js", "group": "box-access", "why": "boxScopeForRank / provision floor — decides who gets a full source clone" },
|
|
39
39
|
{ "glob": "modules/dev-box/box-access.js", "group": "box-access", "why": "decideSourceAccess (rank → starter|full source)" },
|
|
40
40
|
{ "glob": "modules/dev-box/box-credential.js", "group": "box-access", "why": "mints the box source credential" },
|
|
@@ -1,40 +1,40 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
// modules/
|
|
3
|
+
// modules/government/ranks.js — the PURE rank→rank rotation decision
|
|
4
4
|
// (ADR 0151, BV1.R95b).
|
|
5
5
|
//
|
|
6
|
-
// A builder holds exactly ONE seeded rank
|
|
6
|
+
// A builder holds exactly ONE seeded rank. 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
9
|
// This is the decision the writer (db.syncRank) executes to keep it true:
|
|
10
|
-
// given a builder's CURRENT assignments and a rank, which seeded
|
|
10
|
+
// given a builder's CURRENT assignments and a rank, which seeded rank to ADD and
|
|
11
11
|
// which to REMOVE.
|
|
12
12
|
//
|
|
13
13
|
// THE REMOVAL IS THE SECURITY HALF. An additive-only sync would leave a demoted
|
|
14
|
-
// Archon still holding the `archon`
|
|
14
|
+
// Archon still holding the `archon` rank, so resolveBuilderPermissions() would keep
|
|
15
15
|
// returning Archon permissions. That is the exact privilege retention ADR 0016's
|
|
16
16
|
// "a demotion takes effect on the builder's very next request" promise forbids: the
|
|
17
17
|
// resolver is correctly per-request and uncached, but it reads an ASSIGNMENT, and a
|
|
18
18
|
// stale assignment breaks the freshness guarantee one layer beneath the resolver.
|
|
19
19
|
//
|
|
20
|
-
// CUSTOM
|
|
20
|
+
// CUSTOM RANKS ARE NEVER TOUCHED. An Archon-assigned rank (is_system=false, the
|
|
21
21
|
// R110 tab's output) must survive a rank change — only rows that are BOTH
|
|
22
|
-
// is_system AND carry a seeded_rank rotate. Keying the removal on "every
|
|
22
|
+
// is_system AND carry a seeded_rank rotate. Keying the removal on "every rank this
|
|
23
23
|
// builder holds" would silently revoke delegated authority on every promotion.
|
|
24
24
|
//
|
|
25
|
-
// A rank with NO seeded
|
|
26
|
-
// CHECK still admits — yields target=null: every seeded
|
|
25
|
+
// A rank with NO seeded rank — one of the twelve reserved ranks migration 023's
|
|
26
|
+
// CHECK still admits — yields target=null: every seeded rank is removed and none is
|
|
27
27
|
// added, so the builder resolves to ZERO permissions. That is governance_002's
|
|
28
28
|
// deliberate fail-closed divergence (ADR 0151), preserved rather than re-derived —
|
|
29
|
-
// never invent a
|
|
29
|
+
// never invent a rank for a rank the seed does not define.
|
|
30
30
|
//
|
|
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 government_ranks, or a
|
|
35
|
-
// rank
|
|
34
|
+
// A row (an assignment joined to government_ranks, or a rank row) is a SEEDED
|
|
35
|
+
// rank only when it is both a system rank and names the rank it reproduces.
|
|
36
36
|
// Both halves are required: is_system alone would admit a future non-rank system
|
|
37
|
-
//
|
|
37
|
+
// rank, and seeded_rank alone would trust a column a custom rank could carry.
|
|
38
38
|
function isSeededRank(row) {
|
|
39
39
|
return !!row
|
|
40
40
|
&& row.is_system === true
|
|
@@ -46,15 +46,15 @@ function isSeededRank(row) {
|
|
|
46
46
|
//
|
|
47
47
|
// rank the builder's rank AFTER the write being synced.
|
|
48
48
|
// currentRanks the builder's assignments, each { rank_key, is_system, seeded_rank }.
|
|
49
|
-
// seededRanks the seeded
|
|
50
|
-
// is_system }. Passed in rather than hardcoded so the four
|
|
49
|
+
// seededRanks the seeded ranks the DB defines, each { rank_key, seeded_rank,
|
|
50
|
+
// is_system }. Passed in rather than hardcoded so the four rank keys
|
|
51
51
|
// stay DB truth — this file never re-declares the seed.
|
|
52
52
|
//
|
|
53
53
|
// target the seeded rank_key the builder should end up with, or null when the
|
|
54
|
-
// rank has no seeded
|
|
54
|
+
// rank has no seeded rank (fail-closed divergence, above).
|
|
55
55
|
// add [] or [target] — empty when it is already held (idempotence).
|
|
56
|
-
// remove every OTHER seeded rank
|
|
57
|
-
// next the resulting rank_key set, sorted — "what the
|
|
56
|
+
// remove every OTHER seeded rank currently held. Custom ranks never appear.
|
|
57
|
+
// next the resulting rank_key set, sorted — "what the rank set becomes".
|
|
58
58
|
function planRankSync({ rank, currentRanks = [], seededRanks = [] } = {}) {
|
|
59
59
|
const match = seededRanks.find((r) => isSeededRank(r) && r.seeded_rank === rank);
|
|
60
60
|
const target = match ? match.rank_key : null;
|