@bongos/core 1.19.186 → 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 +78 -73
- package/config/government.neutral.json +6 -6
- package/docs/adr/0172-editable-rank-roles-substrate-axis.md +1 -1
- package/docs/api/openapi.json +8 -8
- package/docs/api-reference.md +7 -7
- package/modules/builder-settings/wandering-prefs.js +13 -13
- package/modules/government/catalog.js +75 -75
- package/modules/government/config.js +43 -33
- package/modules/government/db.js +57 -57
- 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 +86 -86
- package/modules/government/routes/rank-sync.js +6 -6
- package/modules/government/session-scopes.js +7 -7
- package/modules/government/session-scopes.json +5 -5
- package/modules/hall-ui/public/government.css +1 -1
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/government.js +3 -3
- 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/src/bongos/auth.js +2 -2
- package/src/module-api.js +3 -1
- package/tests/auth_page_gate.mjs +2 -2
- package/tests/{governance_abuse_matrix.mjs → government_abuse_matrix.mjs} +36 -36
- 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} +5 -5
- 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} +32 -32
- 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_page_gate_map.mjs +1 -1
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
|
|
121
|
+
// The db half of the government API routes. Deliberately DUMB writes: the
|
|
122
|
+
// authority invariants (system perms non-grantable, seeded ranks immutable
|
|
123
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;
|
|
@@ -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) {
|