@bongos/core 1.19.185 → 1.19.186
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 +33 -33
- package/clients/bongos-client/index.d.ts +2 -2
- package/docs/api/openapi.json +4 -4
- package/modules/government/catalog.js +4 -4
- package/modules/government/db.js +1 -1
- package/modules/government/routes/government.js +4 -4
- package/modules/government/routes/rank-sync.js +2 -2
- package/modules/hall-ui/public/gate.js +2 -2
- package/modules/hall-ui/public/{governance.css → government.css} +10 -10
- package/modules/hall-ui/public/{governance.html → government.html} +14 -14
- package/modules/hall-ui/public/{governance.js → government.js} +105 -105
- 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/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/hall-preview/server.js +1 -1
- package/src/bongos/auth.js +9 -9
- package/src/bongos/serve-internal.js +8 -8
- package/src/module-api.js +3 -1
- package/tests/auth_resolve_failure.mjs +4 -4
- package/tests/governance_abuse_matrix.mjs +3 -3
- package/tests/governance_page_gates.mjs +9 -9
- package/tests/governance_routes.mjs +4 -4
- package/tests/hall_nav.mjs +8 -7
- package/tests/hall_page_gate_map.mjs +8 -8
|
@@ -1,24 +1,24 @@
|
|
|
1
|
-
//
|
|
1
|
+
// modules/hall-ui/public/government.js — Government (/builders/government).
|
|
2
2
|
//
|
|
3
|
-
// The
|
|
4
|
-
// CRUD routes. Discord-style: pick a
|
|
5
|
-
// the grid, assign custom
|
|
3
|
+
// The ranks/permissions/assignments tab (BV1.R111, ADR 0151 §2) over the R110
|
|
4
|
+
// CRUD routes. Discord-style: pick a rank on the left, toggle its permissions in
|
|
5
|
+
// the grid, assign custom ranks to builders below.
|
|
6
6
|
//
|
|
7
|
-
// Since ADR 0172 grantability depends on WHICH
|
|
7
|
+
// Since ADR 0172 grantability depends on WHICH rank is selected, so this page's
|
|
8
8
|
// central job is rendering three different locks honestly. The SERVER enforces all
|
|
9
9
|
// of them (ADR 0016 — nothing here grants anything; a disabled button is a
|
|
10
10
|
// courtesy, `isGrantableTo` on the route is the wall):
|
|
11
11
|
// • SUBSTRATE permissions (deploy / protected paths / ssh) render LOCKED for
|
|
12
|
-
// every
|
|
12
|
+
// every rank, always. They are enforced outside the app (ADR 0043), so a grant
|
|
13
13
|
// row confers nothing — showing a working toggle would be the UI lying.
|
|
14
|
-
// • OWNER POWERS (the seven in-app `system` keys) render LOCKED on a custom
|
|
15
|
-
// and TOGGLABLE on a sub-Archon rank
|
|
14
|
+
// • OWNER POWERS (the seven in-app `system` keys) render LOCKED on a custom rank
|
|
15
|
+
// and TOGGLABLE on a sub-Archon rank. That asymmetry is the whole of the
|
|
16
16
|
// owner's 2026-08-16 decision, and the section header says so out loud.
|
|
17
|
-
// • the ARCHON rank
|
|
17
|
+
// • the ARCHON rank is read-only in full: it holds everything, and its
|
|
18
18
|
// immutability is what makes it impossible to click your way into locking the
|
|
19
19
|
// owner out of this tab. The API refuses it as `archon_rank_immutable`.
|
|
20
|
-
// ASSIGNMENT of any rank
|
|
21
|
-
// — you get a rank
|
|
20
|
+
// ASSIGNMENT of any rank is still refused (`seeded_rank_assignment_forbidden`)
|
|
21
|
+
// — you get a rank by holding the rank, so that dial is the rank itself.
|
|
22
22
|
//
|
|
23
23
|
// Like The Gate, this reads /api/bongos/me for a friendly Sealed notice —
|
|
24
24
|
// DEFENCE IN DEPTH only. Every /government/* endpoint (reads included) is ALSO
|
|
@@ -63,50 +63,50 @@
|
|
|
63
63
|
|
|
64
64
|
// The R110 refusal codes, translated for the person clicking.
|
|
65
65
|
const ERROR_COPY = {
|
|
66
|
-
permission_not_grantable: 'That permission can’t be granted to this
|
|
67
|
-
archon_rank_immutable: 'The
|
|
68
|
-
rank_has_no_default: 'Only a rank
|
|
69
|
-
seeded_rank_assignment_forbidden: '
|
|
70
|
-
rank_exists: 'A
|
|
71
|
-
rank_not_found: 'That
|
|
66
|
+
permission_not_grantable: 'That permission can’t be granted to this rank. (The server’s reply says which of the three walls applied.)',
|
|
67
|
+
archon_rank_immutable: 'The Owner rank is read-only — it holds every permission, which is what keeps you from locking yourself out of this page.',
|
|
68
|
+
rank_has_no_default: 'Only a ladder rank has a catalog default to reset to. Revoke a custom rank’s permissions individually instead.',
|
|
69
|
+
seeded_rank_assignment_forbidden: 'Ladder ranks are assigned by rank itself — change the builder’s rank instead.',
|
|
70
|
+
rank_exists: 'A rank with that key already exists.',
|
|
71
|
+
rank_not_found: 'That rank no longer exists — refreshing.',
|
|
72
72
|
builder_not_found: 'No builder with that id.',
|
|
73
73
|
unknown_permission: 'The permission catalog does not define that key.',
|
|
74
|
-
bad_rank_key: '
|
|
74
|
+
bad_rank_key: 'Rank keys are kebab-case: lowercase letters, digits, dashes.',
|
|
75
75
|
validation_failed: 'Missing or invalid fields.',
|
|
76
76
|
};
|
|
77
77
|
const friendlyError = (code) => ERROR_COPY[code] || null;
|
|
78
78
|
|
|
79
79
|
// ---- state ---------------------------------------------------------------
|
|
80
80
|
|
|
81
|
-
let
|
|
81
|
+
let ranks = []; // [{ rank_key, label, description, is_system, seeded_rank, permissions: [] }]
|
|
82
82
|
let permissions = []; // catalog rows: [{ key, system, floor, guards, grantable }]
|
|
83
83
|
let principals = []; // [{ system_role, permission, guards }]
|
|
84
|
-
let assignments = []; // [{ builder_id, github_login, display_name, rank, rank_key,
|
|
85
|
-
let
|
|
84
|
+
let assignments = []; // [{ builder_id, github_login, display_name, rank, rank_key, rank_label, is_system }]
|
|
85
|
+
let selectedRankKey = null;
|
|
86
86
|
|
|
87
|
-
const
|
|
87
|
+
const selectedRank = () => ranks.find((r) => r.rank_key === selectedRankKey) || null;
|
|
88
88
|
|
|
89
|
-
// ---- render:
|
|
89
|
+
// ---- render: rank list ---------------------------------------------------
|
|
90
90
|
|
|
91
|
-
function
|
|
92
|
-
const list = document.getElementById('gov-
|
|
91
|
+
function renderRanks() {
|
|
92
|
+
const list = document.getElementById('gov-rank-list');
|
|
93
93
|
list.innerHTML = '';
|
|
94
|
-
for (const
|
|
95
|
-
// The Archon row is called out as read-only, because it is the one
|
|
94
|
+
for (const rank of ranks) {
|
|
95
|
+
// The Archon row is called out as read-only, because it is the one rank in
|
|
96
96
|
// this list a click will not change and the reader should know before trying.
|
|
97
|
-
const pill =
|
|
98
|
-
? `<span class="gov-pill gov-pill--locked">
|
|
99
|
-
:
|
|
100
|
-
? `<span class="gov-pill gov-pill--seeded">
|
|
97
|
+
const pill = isArchonRank(rank)
|
|
98
|
+
? `<span class="gov-pill gov-pill--locked">ladder · read-only</span>`
|
|
99
|
+
: rank.is_system
|
|
100
|
+
? `<span class="gov-pill gov-pill--seeded">ladder</span>`
|
|
101
101
|
: `<span class="gov-pill gov-pill--custom">custom</span>`;
|
|
102
102
|
const btn = document.createElement('button');
|
|
103
103
|
btn.type = 'button';
|
|
104
|
-
btn.className = 'gov-
|
|
105
|
-
btn.setAttribute('aria-pressed',
|
|
104
|
+
btn.className = 'gov-rank';
|
|
105
|
+
btn.setAttribute('aria-pressed', rank.rank_key === selectedRankKey ? 'true' : 'false');
|
|
106
106
|
btn.innerHTML =
|
|
107
|
-
`<span class="gov-
|
|
108
|
-
<span class="gov-
|
|
109
|
-
btn.addEventListener('click', () => {
|
|
107
|
+
`<span class="gov-rank__head"><span class="gov-rank__label">${escapeHtml(rank.label || rank.rank_key)}</span>${pill}</span>
|
|
108
|
+
<span class="gov-rank__meta"><code>${escapeHtml(rank.rank_key)}</code> · ${(rank.permissions || []).length} permission${(rank.permissions || []).length === 1 ? '' : 's'}</span>`;
|
|
109
|
+
btn.addEventListener('click', () => { selectedRankKey = rank.rank_key; renderRanks(); renderGrid(); });
|
|
110
110
|
list.appendChild(btn);
|
|
111
111
|
}
|
|
112
112
|
}
|
|
@@ -117,34 +117,34 @@
|
|
|
117
117
|
// right control, NEVER to decide authority — the route runs the real one and
|
|
118
118
|
// refuses regardless of what this returns. Kept in the same order as the server's
|
|
119
119
|
// so a future catalog change lands in one obvious place on each side.
|
|
120
|
-
const
|
|
121
|
-
const
|
|
122
|
-
function grantableTo(p,
|
|
123
|
-
if (!
|
|
124
|
-
if (
|
|
120
|
+
const isArchonRank = (rank) => !!rank && rank.is_system && rank.seeded_rank === 'archon';
|
|
121
|
+
const isRankRank = (rank) => !!rank && rank.is_system;
|
|
122
|
+
function grantableTo(p, rank) {
|
|
123
|
+
if (!rank || p.substrate) return false;
|
|
124
|
+
if (rank.is_system) return rank.seeded_rank !== 'archon';
|
|
125
125
|
return !!p.grantable;
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
function permRow(p,
|
|
129
|
-
const held = !!
|
|
128
|
+
function permRow(p, rank) {
|
|
129
|
+
const held = !!rank && (rank.permissions || []).includes(p.key);
|
|
130
130
|
const row = document.createElement('div');
|
|
131
131
|
row.className = 'gov-perm';
|
|
132
132
|
|
|
133
133
|
// Why locked, in the words that tell the reader whether it is a policy they
|
|
134
134
|
// could change or a fact they cannot. Three causes, three sentences.
|
|
135
135
|
const lockReason = p.substrate
|
|
136
|
-
? 'Enforced outside the app (the pre-push hook, the deploy audit, the server itself). Granting it here would change nothing, so it is locked for every
|
|
137
|
-
:
|
|
138
|
-
? 'The Archon
|
|
139
|
-
: 'An owner-level permission. It can be handed to a rank
|
|
136
|
+
? 'Enforced outside the app (the pre-push hook, the deploy audit, the server itself). Granting it here would change nothing, so it is locked for every rank.'
|
|
137
|
+
: isArchonRank(rank)
|
|
138
|
+
? 'The Archon rank holds everything and is read-only.'
|
|
139
|
+
: 'An owner-level permission. It can be handed to a ladder rank, but never to a custom one.';
|
|
140
140
|
|
|
141
141
|
let control;
|
|
142
|
-
if (!
|
|
142
|
+
if (!rank) {
|
|
143
143
|
control = '';
|
|
144
|
-
} else if (!grantableTo(p,
|
|
144
|
+
} else if (!grantableTo(p, rank)) {
|
|
145
145
|
control = `<span class="gov-pill gov-pill--locked" title="${escapeHtml(lockReason)}">locked</span>`;
|
|
146
|
-
// The Archon
|
|
147
|
-
if (held &&
|
|
146
|
+
// The Archon rank is read-only rather than empty-handed, so say what it holds.
|
|
147
|
+
if (held && isArchonRank(rank)) control = `<span class="gov-held-note">held</span>${control}`;
|
|
148
148
|
} else {
|
|
149
149
|
control = `<button type="button" class="gov-toggle" aria-pressed="${held ? 'true' : 'false'}" data-perm="${escapeHtml(p.key)}">${held ? 'granted' : 'grant'}</button>`;
|
|
150
150
|
}
|
|
@@ -158,7 +158,7 @@
|
|
|
158
158
|
${control}`;
|
|
159
159
|
|
|
160
160
|
const btn = row.querySelector('button.gov-toggle');
|
|
161
|
-
if (btn) btn.addEventListener('click', () => toggleGrant(
|
|
161
|
+
if (btn) btn.addEventListener('click', () => toggleGrant(rank, p.key, !held, btn));
|
|
162
162
|
return row;
|
|
163
163
|
}
|
|
164
164
|
|
|
@@ -170,34 +170,34 @@
|
|
|
170
170
|
return el;
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
-
function gridNote(
|
|
174
|
-
if (!
|
|
175
|
-
if (
|
|
176
|
-
return 'The Archon
|
|
173
|
+
function gridNote(rank) {
|
|
174
|
+
if (!rank) return 'Pick a rank to see and edit what it may do.';
|
|
175
|
+
if (isArchonRank(rank)) {
|
|
176
|
+
return 'The Archon rank holds every permission and is read-only — that is what makes it impossible to lock yourself out of this page. To give a power away, hand it to one of the ranks below instead.';
|
|
177
177
|
}
|
|
178
|
-
if (
|
|
179
|
-
return `Everything a ${
|
|
178
|
+
if (isRankRank(rank)) {
|
|
179
|
+
return `Everything a ${rank.label || rank.rank_key} can do, and yours to change — every builder at this rank is affected immediately. “Reset to default” puts it back to the shipped ladder.`;
|
|
180
180
|
}
|
|
181
|
-
return 'Toggle the everyday permissions this
|
|
181
|
+
return 'Toggle the everyday permissions this rank carries. Owner powers can only go to a ladder rank, not to a custom one.';
|
|
182
182
|
}
|
|
183
183
|
|
|
184
184
|
function renderGrid() {
|
|
185
185
|
const grid = document.getElementById('gov-grid');
|
|
186
|
-
const
|
|
187
|
-
document.getElementById('gov-grid-title').textContent =
|
|
188
|
-
? `Permissions — ${
|
|
189
|
-
document.getElementById('gov-grid-note').textContent = gridNote(
|
|
186
|
+
const rank = selectedRank();
|
|
187
|
+
document.getElementById('gov-grid-title').textContent = rank
|
|
188
|
+
? `Permissions — ${rank.label || rank.rank_key}` : 'Permissions';
|
|
189
|
+
document.getElementById('gov-grid-note').textContent = gridNote(rank);
|
|
190
190
|
|
|
191
|
-
// The reset escape hatch — only a rank
|
|
191
|
+
// The reset escape hatch — only a ladder rank has a catalog default, and the
|
|
192
192
|
// Archon one never drifts from it, so it shows on exactly the three editable ones.
|
|
193
193
|
const resetBox = document.getElementById('gov-reset');
|
|
194
194
|
resetBox.innerHTML = '';
|
|
195
|
-
if (
|
|
195
|
+
if (isRankRank(rank) && !isArchonRank(rank)) {
|
|
196
196
|
const btn = document.createElement('button');
|
|
197
197
|
btn.type = 'button';
|
|
198
198
|
btn.className = 'gov-btn gov-btn--ghost gov-btn--small';
|
|
199
199
|
btn.textContent = 'Reset to default';
|
|
200
|
-
btn.addEventListener('click', () =>
|
|
200
|
+
btn.addEventListener('click', () => resetRank(rank, btn));
|
|
201
201
|
resetBox.appendChild(btn);
|
|
202
202
|
}
|
|
203
203
|
|
|
@@ -206,30 +206,30 @@
|
|
|
206
206
|
// identity is not a row anyone can act on here.
|
|
207
207
|
const humanPerms = permissions.filter((p) => !principals.some((pr) => pr.permission === p.key));
|
|
208
208
|
// Three groups, split by WHY a row is or isn't editable rather than by the old
|
|
209
|
-
// single `grantable` flag — which since ADR 0172 answers only the custom-
|
|
209
|
+
// single `grantable` flag — which since ADR 0172 answers only the custom-rank case.
|
|
210
210
|
const substrate = humanPerms.filter((p) => p.substrate);
|
|
211
211
|
const ownerPowers = humanPerms.filter((p) => p.system && !p.substrate);
|
|
212
212
|
const operational = humanPerms.filter((p) => !p.system);
|
|
213
213
|
|
|
214
|
-
const ownerHead = !
|
|
214
|
+
const ownerHead = !rank || isRankRank(rank)
|
|
215
215
|
? `Owner powers (${ownerPowers.length})`
|
|
216
|
-
: `Owner powers — locked for custom
|
|
216
|
+
: `Owner powers — locked for custom ranks (${ownerPowers.length})`;
|
|
217
217
|
|
|
218
|
-
grid.appendChild(section(`Operational (${operational.length})`, operational.map((p) => permRow(p,
|
|
219
|
-
grid.appendChild(section(ownerHead, ownerPowers.map((p) => permRow(p,
|
|
220
|
-
grid.appendChild(section(`Enforced outside the app — always locked (${substrate.length})`, substrate.map((p) => permRow(p,
|
|
218
|
+
grid.appendChild(section(`Operational (${operational.length})`, operational.map((p) => permRow(p, rank))));
|
|
219
|
+
grid.appendChild(section(ownerHead, ownerPowers.map((p) => permRow(p, rank))));
|
|
220
|
+
grid.appendChild(section(`Enforced outside the app — always locked (${substrate.length})`, substrate.map((p) => permRow(p, rank))));
|
|
221
221
|
}
|
|
222
222
|
|
|
223
|
-
// Restore a rank
|
|
224
|
-
// action here that discards work in bulk — a drifted
|
|
225
|
-
async function
|
|
226
|
-
const label =
|
|
223
|
+
// Restore a ladder rank to the shipped ladder. Confirmed because it is the one
|
|
224
|
+
// action here that discards work in bulk — a drifted rank can be ~60 rows.
|
|
225
|
+
async function resetRank(rank, btn) {
|
|
226
|
+
const label = rank.label || rank.rank_key;
|
|
227
227
|
if (!window.confirm(`Reset ${label} to its default permissions?\n\nEvery change you've made to this rank is discarded, and it takes effect for every builder at this rank on their next request.`)) return;
|
|
228
228
|
btn.disabled = true;
|
|
229
229
|
try {
|
|
230
|
-
const out = await sendJson('POST', `/government/ranks/${encodeURIComponent(
|
|
230
|
+
const out = await sendJson('POST', `/government/ranks/${encodeURIComponent(rank.rank_key)}/reset`);
|
|
231
231
|
toast(`${label} reset to its default ${out.permission_count} permissions.`);
|
|
232
|
-
await
|
|
232
|
+
await reloadRanks();
|
|
233
233
|
} catch (err) {
|
|
234
234
|
if (err && err.kind === '401') return;
|
|
235
235
|
toast(`Could not reset: ${err.message || err}`);
|
|
@@ -237,12 +237,12 @@
|
|
|
237
237
|
}
|
|
238
238
|
}
|
|
239
239
|
|
|
240
|
-
async function toggleGrant(
|
|
240
|
+
async function toggleGrant(rank, permissionKey, grant, btn) {
|
|
241
241
|
btn.disabled = true;
|
|
242
242
|
try {
|
|
243
|
-
await sendJson(grant ? 'PUT' : 'DELETE', `/government/ranks/${encodeURIComponent(
|
|
243
|
+
await sendJson(grant ? 'PUT' : 'DELETE', `/government/ranks/${encodeURIComponent(rank.rank_key)}/permissions/${encodeURIComponent(permissionKey)}`);
|
|
244
244
|
toast(grant ? `Granted ${permissionKey}.` : `Revoked ${permissionKey}.`);
|
|
245
|
-
await
|
|
245
|
+
await reloadRanks();
|
|
246
246
|
} catch (err) {
|
|
247
247
|
if (err && err.kind === '401') return;
|
|
248
248
|
toast(`Could not ${grant ? 'grant' : 'revoke'}: ${err.message || err}`);
|
|
@@ -256,7 +256,7 @@
|
|
|
256
256
|
const box = document.getElementById('gov-assignments');
|
|
257
257
|
box.innerHTML = '';
|
|
258
258
|
if (assignments.length === 0) {
|
|
259
|
-
box.innerHTML = `<p class="gov-empty">No
|
|
259
|
+
box.innerHTML = `<p class="gov-empty">No rank assignments yet.</p>`;
|
|
260
260
|
return;
|
|
261
261
|
}
|
|
262
262
|
const wrap = document.createElement('section');
|
|
@@ -265,11 +265,11 @@
|
|
|
265
265
|
const row = document.createElement('div');
|
|
266
266
|
row.className = 'gov-assign-row';
|
|
267
267
|
const who = `${escapeHtml(a.display_name || a.github_login || `builder ${a.builder_id}`)} <span class="muted">(#${escapeHtml(String(a.builder_id))} · ${escapeHtml(a.rank || '')})</span>`;
|
|
268
|
-
const
|
|
269
|
-
? `<span class="gov-pill gov-pill--seeded">${escapeHtml(a.
|
|
270
|
-
: `<span class="gov-pill gov-pill--custom">${escapeHtml(a.
|
|
268
|
+
const rankPill = a.is_system
|
|
269
|
+
? `<span class="gov-pill gov-pill--seeded">${escapeHtml(a.rank_label || a.rank_key)} · via rank</span>`
|
|
270
|
+
: `<span class="gov-pill gov-pill--custom">${escapeHtml(a.rank_label || a.rank_key)}</span>`;
|
|
271
271
|
const action = a.is_system ? '' : `<button type="button" class="gov-btn gov-btn--ghost gov-btn--small" data-act="unassign">Remove</button>`;
|
|
272
|
-
row.innerHTML = `<span class="gov-assign-row__who">${who}</span>${
|
|
272
|
+
row.innerHTML = `<span class="gov-assign-row__who">${who}</span>${rankPill}${action}`;
|
|
273
273
|
const btn = row.querySelector('[data-act=unassign]');
|
|
274
274
|
if (btn) btn.addEventListener('click', () => unassign(a, btn));
|
|
275
275
|
wrap.appendChild(row);
|
|
@@ -278,18 +278,18 @@
|
|
|
278
278
|
}
|
|
279
279
|
|
|
280
280
|
function renderAssignSelect() {
|
|
281
|
-
const sel = document.getElementById('gov-assign-
|
|
282
|
-
const custom =
|
|
281
|
+
const sel = document.getElementById('gov-assign-rank');
|
|
282
|
+
const custom = ranks.filter((r) => !r.is_system);
|
|
283
283
|
sel.innerHTML = custom.length
|
|
284
284
|
? custom.map((r) => `<option value="${escapeHtml(r.rank_key)}">${escapeHtml(r.label || r.rank_key)}</option>`).join('')
|
|
285
|
-
: `<option value="" disabled selected>No custom
|
|
285
|
+
: `<option value="" disabled selected>No custom ranks yet</option>`;
|
|
286
286
|
}
|
|
287
287
|
|
|
288
288
|
async function unassign(a, btn) {
|
|
289
289
|
btn.disabled = true;
|
|
290
290
|
try {
|
|
291
291
|
await sendJson('DELETE', `/government/assignments/${encodeURIComponent(a.builder_id)}/${encodeURIComponent(a.rank_key)}`);
|
|
292
|
-
toast(`Removed ${a.
|
|
292
|
+
toast(`Removed ${a.rank_label || a.rank_key} from builder ${a.builder_id}.`);
|
|
293
293
|
await reloadAssignments();
|
|
294
294
|
} catch (err) {
|
|
295
295
|
if (err && err.kind === '401') return;
|
|
@@ -320,11 +320,11 @@
|
|
|
320
320
|
|
|
321
321
|
// ---- loads ---------------------------------------------------------------
|
|
322
322
|
|
|
323
|
-
async function
|
|
323
|
+
async function reloadRanks() {
|
|
324
324
|
const data = await fetchJson('/government/ranks');
|
|
325
|
-
|
|
326
|
-
if (!
|
|
327
|
-
|
|
325
|
+
ranks = data.ranks || [];
|
|
326
|
+
if (!ranks.some((r) => r.rank_key === selectedRankKey)) selectedRankKey = ranks.length ? ranks[0].rank_key : null;
|
|
327
|
+
renderRanks();
|
|
328
328
|
renderGrid();
|
|
329
329
|
renderAssignSelect();
|
|
330
330
|
}
|
|
@@ -339,7 +339,7 @@
|
|
|
339
339
|
const perms = await fetchJson('/government/permissions');
|
|
340
340
|
permissions = perms.permissions || [];
|
|
341
341
|
principals = perms.principals || [];
|
|
342
|
-
await
|
|
342
|
+
await reloadRanks();
|
|
343
343
|
await reloadAssignments();
|
|
344
344
|
renderPrincipals();
|
|
345
345
|
}
|
|
@@ -359,13 +359,13 @@
|
|
|
359
359
|
if (desc) body.description = desc;
|
|
360
360
|
try {
|
|
361
361
|
const r = await sendJson('POST', '/government/ranks', body);
|
|
362
|
-
toast(`Created
|
|
362
|
+
toast(`Created rank ${r.rank ? r.rank.rank_key : body.rank_key}.`);
|
|
363
363
|
e.target.reset();
|
|
364
|
-
|
|
365
|
-
await
|
|
364
|
+
selectedRankKey = body.rank_key;
|
|
365
|
+
await reloadRanks();
|
|
366
366
|
} catch (err) {
|
|
367
367
|
if (err && err.kind === '401') return;
|
|
368
|
-
toast(`Could not create
|
|
368
|
+
toast(`Could not create rank: ${err.message || err}`);
|
|
369
369
|
} finally {
|
|
370
370
|
btn.disabled = false;
|
|
371
371
|
}
|
|
@@ -375,12 +375,12 @@
|
|
|
375
375
|
e.preventDefault();
|
|
376
376
|
const btn = e.target.querySelector('button');
|
|
377
377
|
const builderId = Number(document.getElementById('gov-assign-builder').value);
|
|
378
|
-
const
|
|
379
|
-
if (!
|
|
378
|
+
const rankKey = document.getElementById('gov-assign-rank').value;
|
|
379
|
+
if (!rankKey) { toast('Create a custom rank first.'); return; }
|
|
380
380
|
btn.disabled = true;
|
|
381
381
|
try {
|
|
382
|
-
await sendJson('POST', '/government/assignments', { builder_id: builderId, rank_key:
|
|
383
|
-
toast(`Assigned ${
|
|
382
|
+
await sendJson('POST', '/government/assignments', { builder_id: builderId, rank_key: rankKey });
|
|
383
|
+
toast(`Assigned ${rankKey} to builder ${builderId}.`);
|
|
384
384
|
e.target.reset();
|
|
385
385
|
await reloadAssignments();
|
|
386
386
|
} catch (err) {
|
|
@@ -404,7 +404,7 @@
|
|
|
404
404
|
// Confirm rank for a friendly sealed notice; the server gates the page and
|
|
405
405
|
// every /governance endpoint on `government.manage` regardless (cosmetic
|
|
406
406
|
// defence-in-depth). government.manage is system:true, so only the archon
|
|
407
|
-
// rank
|
|
407
|
+
// rank can hold it — rank is an accurate client-side proxy.
|
|
408
408
|
let me;
|
|
409
409
|
try {
|
|
410
410
|
me = await fetchJson('/me');
|
|
@@ -416,7 +416,7 @@
|
|
|
416
416
|
const rank = (me && (me.builder || me).rank || '').toLowerCase();
|
|
417
417
|
if (rank !== 'archon') { showSealed(); return; }
|
|
418
418
|
|
|
419
|
-
document.getElementById('gov-sub').textContent = '
|
|
419
|
+
document.getElementById('gov-sub').textContent = 'Ranks, permissions, and who holds them. Ranks below Archon are yours to shape; what the substrate enforces stays locked.';
|
|
420
420
|
document.getElementById('gov-body').hidden = false;
|
|
421
421
|
wireForms();
|
|
422
422
|
try {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// shows a restricted notice below the Metic floor — DEFENCE IN DEPTH only. The data +
|
|
10
10
|
// mutation endpoints (/boxes, /boxes/:id/close, /boxes/:id/block) are ALSO gated
|
|
11
11
|
// server-side on `box.fleet.manage` (ADR 0016), and the page itself is gated by
|
|
12
|
-
// serve-internal.js
|
|
12
|
+
// serve-internal.js requireGovernmentPage — whose own floor is `page.view.government`,
|
|
13
13
|
// metic since ADR 0157. On a 401 it redirects into the web sign-in flow.
|
|
14
14
|
|
|
15
15
|
(() => {
|
|
@@ -328,7 +328,7 @@
|
|
|
328
328
|
const rank = (me && (me.builder || me).rank || '').toLowerCase();
|
|
329
329
|
// ADR 0157: Harbor sits at the `page.view.government` floor (metic), and the fleet
|
|
330
330
|
// data it reads is `box.fleet.manage` (also metic now). Cosmetic —
|
|
331
|
-
//
|
|
331
|
+
// requireGovernmentPage + the route gates are the wall; this just picks body vs
|
|
332
332
|
// sealed notice.
|
|
333
333
|
if (rank !== 'metic' && rank !== 'archon') { showSealed(); return; }
|
|
334
334
|
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// top bar (drawer toggle, page title, greeting slot) into the static mounts
|
|
13
13
|
// (#app-sidebar / #app-topbar / #app-foot) every hall page carries;
|
|
14
14
|
// - rank-gate nav items COSMETICALLY (server-side page gates remain the real
|
|
15
|
-
// access control — serve-internal.js
|
|
15
|
+
// access control — serve-internal.js requireGovernmentPage etc.);
|
|
16
16
|
// - on the index (body[data-page="home"]) route the three views (#home,
|
|
17
17
|
// #standing, #leaderboard) plus the #achievements alias into Standing. The
|
|
18
18
|
// `#/task|idea|blocker/N` deep-link hashes belong to builders.js and are
|
|
@@ -97,13 +97,15 @@
|
|
|
97
97
|
// work — signed, but for newcomers only once the "ship three" stage is
|
|
98
98
|
// current (mirrors the old #hall-nav-work rule, task #833)
|
|
99
99
|
// archon — rank === 'archon'
|
|
100
|
-
//
|
|
100
|
+
// government — rank is metic or archon (ADR 0157): the monitoring/ops pages.
|
|
101
|
+
// NB this gate key is a RANK ALIAS meaning metic-or-archon; it is NOT the
|
|
102
|
+
// `government` MODULE key, and the two are unrelated (ADR 0174).
|
|
101
103
|
// Mirrors the server's `page.view.government` floor, which that ADR
|
|
102
|
-
// lowered archon→metic. Cosmetic only —
|
|
104
|
+
// lowered archon→metic. Cosmetic only — requireGovernmentPage is the wall.
|
|
103
105
|
// `view` marks an index view (hash-routed); `module` gates on window.__MODULES__.
|
|
104
106
|
// task 2175: grouped by AUDIENCE of the data, so the sidebar reads in coherent
|
|
105
107
|
// runs instead of one long list — the project (unlabeled), You, Community,
|
|
106
|
-
// Docs,
|
|
108
|
+
// Docs, Government. A group's `gate` must match its most permissive item's, or the
|
|
107
109
|
// head renders over nothing.
|
|
108
110
|
var NAV = [
|
|
109
111
|
{ group: '', items: [
|
|
@@ -143,7 +145,7 @@
|
|
|
143
145
|
// requireProjectCuratePage is the wall — but a nav that disagrees with the
|
|
144
146
|
// gate teaches the ladder wrong. The module gate hides the tab on
|
|
145
147
|
// instances without the platform-identity hub.
|
|
146
|
-
{ id: 'people', label: 'People', icon: 'people', href: '/people', gate: '
|
|
148
|
+
{ id: 'people', label: 'People', icon: 'people', href: '/people', gate: 'government', module: 'platform-identity' },
|
|
147
149
|
] },
|
|
148
150
|
{ group: 'Docs', gate: 'signed', items: [
|
|
149
151
|
// task 1002710: primer + diagrams were gate:'member' (rank !== xenos) to
|
|
@@ -168,18 +170,20 @@
|
|
|
168
170
|
// subdomain — external so the rewrite shim doesn't turn it into /builders/docs.
|
|
169
171
|
{ id: 'apidocs', label: 'API docs', icon: 'docs', href: '/docs', gate: 'signed', external: true },
|
|
170
172
|
] },
|
|
171
|
-
// Relabeled 'Archon' → 'Governance' in ADR 0157: these pages are no longer
|
|
173
|
+
// Relabeled 'Archon' → 'Governance' in ADR 0157, then → 'Government' by ADR 0174: these pages are no longer
|
|
172
174
|
// Archon-only, so a group header reading "Archon" over items a Metic can open
|
|
173
175
|
// would misdescribe the ladder to the builders most likely to be learning it.
|
|
174
|
-
{ group: '
|
|
175
|
-
{ id: 'watch', label: 'Watch', icon: 'admin', href: '/watch', gate: '
|
|
176
|
-
{ id: 'harbor', label: 'Harbor', icon: 'admin', href: '/harbor', gate: '
|
|
177
|
-
{ id: 'gate', label: 'Gate', icon: 'admin', href: '/gate', gate: '
|
|
178
|
-
{ id: 'sessions', label: 'Sessions', icon: 'admin', href: '/sessions', gate: '
|
|
176
|
+
{ group: 'Government', gate: 'government', items: [
|
|
177
|
+
{ id: 'watch', label: 'Watch', icon: 'admin', href: '/watch', gate: 'government' },
|
|
178
|
+
{ id: 'harbor', label: 'Harbor', icon: 'admin', href: '/harbor', gate: 'government', module: 'dev-box' },
|
|
179
|
+
{ id: 'gate', label: 'Gate', icon: 'admin', href: '/gate', gate: 'government' },
|
|
180
|
+
{ id: 'sessions', label: 'Sessions', icon: 'admin', href: '/sessions', gate: 'government' },
|
|
179
181
|
// BV1.R113: the roles-and-permissions tab (ADR 0151). The one genuinely
|
|
180
|
-
// Archon-only item the '
|
|
181
|
-
// only;
|
|
182
|
-
{ id: '
|
|
182
|
+
// Archon-only item the 'government' gate comment anticipated — cosmetic
|
|
183
|
+
// only; requireGovernmentManagePage (the government.manage atom) is the wall.
|
|
184
|
+
{ id: 'government', label: 'Permissions', icon: 'admin', href: '/government', gate: 'archon' } // NOT 'Ranks': /ranks is the signed-in ladder explainer and two nav items
|
|
185
|
+
// reading 'Ranks' is the confusion this rename exists to remove. This tab
|
|
186
|
+
// is where a rank's permissions are granted, so it says what it does.,
|
|
183
187
|
] },
|
|
184
188
|
];
|
|
185
189
|
|
|
@@ -423,7 +427,7 @@
|
|
|
423
427
|
// ADR 0157: the monitoring/ops pages sit at the `page.view.government` floor,
|
|
424
428
|
// which is metic. Kept as its own key rather than widening 'archon', so a
|
|
425
429
|
// genuinely Archon-only nav item can still be added later.
|
|
426
|
-
case '
|
|
430
|
+
case 'government': return authed && (access.rank === 'metic' || access.rank === 'archon');
|
|
427
431
|
default: return false;
|
|
428
432
|
}
|
|
429
433
|
}
|
|
@@ -887,7 +887,7 @@
|
|
|
887
887
|
|
|
888
888
|
// ADR 0157: Watch sits at the `page.view.government` floor (metic), and every panel
|
|
889
889
|
// it reads (roster, security reports, audit log, sessions, box costs) delegated to
|
|
890
|
-
// metic with it. Cosmetic —
|
|
890
|
+
// metic with it. Cosmetic — requireGovernmentPage + the route gates are the wall.
|
|
891
891
|
if (!builder || (builder.rank !== 'metic' && builder.rank !== 'archon')) {
|
|
892
892
|
showSealed('This page is available from the Metic rank up. Your current rank does not grant access.');
|
|
893
893
|
return;
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.186",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.186",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.186",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
|
@@ -44,7 +44,7 @@ const EMPTY = process.argv.includes('--empty');
|
|
|
44
44
|
// --rank: view the hall as a different rank — the /me fixture's builder.rank is
|
|
45
45
|
// overridden in the response, so the Archon-gated pages (watch, harbor, gate)
|
|
46
46
|
// render their real content instead of the sealed notice. Preview-only theatre:
|
|
47
|
-
// the real pages are server-gated (
|
|
47
|
+
// the real pages are server-gated (requireGovernmentPage / ADR 0016).
|
|
48
48
|
const RANK_OVERRIDE = arg('--rank');
|
|
49
49
|
const MODULES_OFF = String(arg('--modules-off') || '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
50
50
|
// --modules-on: force-report modules as enabled — a dev box's local config
|
package/src/bongos/auth.js
CHANGED
|
@@ -1437,7 +1437,7 @@ function signInWithReturn(req) {
|
|
|
1437
1437
|
// ranks (catalog.RANK_ORDER):
|
|
1438
1438
|
// requireBuilderPage was "any session" floor xenos → page.view.public
|
|
1439
1439
|
// requireNonXenosPage was rank !== 'xenos' floor thetes → page.view.builder
|
|
1440
|
-
//
|
|
1440
|
+
// requireGovernmentPage was rank === 'archon' floor archon → page.view.government
|
|
1441
1441
|
// That third gate was spelled `requireArchonPage` until ADR 0157 lowered
|
|
1442
1442
|
// page.view.government's floor archon→metic and the name stopped being true; the
|
|
1443
1443
|
// floor now lives in modules/government/catalog.js, not in the function's name.
|
|
@@ -1456,8 +1456,8 @@ function signInWithReturn(req) {
|
|
|
1456
1456
|
const PAGE_GATES = Object.freeze({
|
|
1457
1457
|
requireBuilderPage: Object.freeze({ permission: 'page.view.public', deniedTo: '/builders' }),
|
|
1458
1458
|
requireNonXenosPage: Object.freeze({ permission: 'page.view.builder', deniedTo: '/builders/not-ready' }),
|
|
1459
|
-
|
|
1460
|
-
|
|
1459
|
+
requireGovernmentPage: Object.freeze({ permission: 'page.view.government', deniedTo: '/builders' }),
|
|
1460
|
+
requireGovernmentManagePage: Object.freeze({ permission: 'government.manage', deniedTo: '/builders' }),
|
|
1461
1461
|
requireProjectCuratePage: Object.freeze({ permission: 'project.curate', deniedTo: '/builders' }),
|
|
1462
1462
|
});
|
|
1463
1463
|
|
|
@@ -1540,8 +1540,8 @@ async function requireBuilderPage(req, res, next) {
|
|
|
1540
1540
|
// every data endpoint these pages call is independently gated server-side, so no
|
|
1541
1541
|
// cross-builder data leaks even if this gate were bypassed; it keeps a builder
|
|
1542
1542
|
// without the permission from ever seeing the page shell.
|
|
1543
|
-
async function
|
|
1544
|
-
return runPageGate('
|
|
1543
|
+
async function requireGovernmentPage(req, res, next) {
|
|
1544
|
+
return runPageGate('requireGovernmentPage', req, res, next);
|
|
1545
1545
|
}
|
|
1546
1546
|
|
|
1547
1547
|
// The /governance roles-and-permissions tab (BV1.R113) — gated on the
|
|
@@ -1549,8 +1549,8 @@ async function requireGovernancePage(req, res, next) {
|
|
|
1549
1549
|
// /governance/* data route checks, so the page layer can never drift wider than
|
|
1550
1550
|
// the API it fronts. Defense-in-depth as above: the data endpoints are all
|
|
1551
1551
|
// independently gated; this keeps the tab shell from rendering for anyone else.
|
|
1552
|
-
async function
|
|
1553
|
-
return runPageGate('
|
|
1552
|
+
async function requireGovernmentManagePage(req, res, next) {
|
|
1553
|
+
return runPageGate('requireGovernmentManagePage', req, res, next);
|
|
1554
1554
|
}
|
|
1555
1555
|
|
|
1556
1556
|
// The /people builder-directory page (task 1002577) — gated on the
|
|
@@ -1827,8 +1827,8 @@ module.exports = {
|
|
|
1827
1827
|
SESSION_SCOPES,
|
|
1828
1828
|
requireBuilder,
|
|
1829
1829
|
requireBuilderPage,
|
|
1830
|
-
|
|
1831
|
-
|
|
1830
|
+
requireGovernmentPage,
|
|
1831
|
+
requireGovernmentManagePage,
|
|
1832
1832
|
requireNonXenosPage,
|
|
1833
1833
|
requireProjectCuratePage,
|
|
1834
1834
|
requireRank,
|