@bongos/core 1.24.1 → 1.24.2
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 +64 -39
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +6 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/adr/0385-money-lives-with-a-processor-credits-stay-a-project-meter.md +12 -1
- package/docs/adr/README.md +1 -1
- package/docs/api/openapi.json +140 -1
- package/docs/api-reference.md +5 -2
- package/docs/copy-inventory.md +11 -6
- package/docs/copy-registry.json +63 -8
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-inventory.json +2 -1
- package/docs/page-readings.json +29 -20
- package/modules/government/access-routes.js +70 -0
- package/modules/government/access.js +88 -0
- package/modules/government/asks.js +5 -2
- package/modules/government/db.js +38 -0
- package/modules/government/docket.js +12 -2
- package/modules/government/migrations/government_036_access_holders.sql +33 -0
- package/modules/government/routes/government.js +9 -0
- package/modules/government/routing.js +11 -3
- package/modules/government/waiting.js +4 -0
- package/modules/hall-ui/public/blockers-by-person.js +2 -0
- package/modules/hall-ui/public/government-access.js +108 -0
- package/modules/hall-ui/public/government.html +21 -0
- package/modules/hall-ui/records/blockers-people.md +14 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +10 -0
- package/scripts/bongos/government-walk.js +3 -0
- package/src/bongos/route-rank-check.js +4 -0
- package/src/module-api.js +1 -1
- package/tests/access_holders.mjs +214 -0
- package/tests/government_board_membership.mjs +4 -1
- package/tests/government_routes.mjs +1 -1
- package/tests/government_seed.mjs +1 -0
|
@@ -34,6 +34,7 @@ const counts = require('../counts');
|
|
|
34
34
|
const asks = require('../asks');
|
|
35
35
|
const asksDb = require('../asks-db');
|
|
36
36
|
const routing = require('../routing');
|
|
37
|
+
const { accessHandlers } = require('../access-routes');
|
|
37
38
|
const governmentConfig = require('../config');
|
|
38
39
|
const membershipPredicate = require('../membership-predicate');
|
|
39
40
|
const positions = require('../positions');
|
|
@@ -689,6 +690,14 @@ module.exports = function buildGovernmentRouter() {
|
|
|
689
690
|
}
|
|
690
691
|
});
|
|
691
692
|
|
|
693
|
+
// ACCESS HOLDERS (task 1005000): who holds the keys outside Bongos, so an ask can wait
|
|
694
|
+
// on access:<key>. Read on the docket's atom; recorded by the owner. A record of who to
|
|
695
|
+
// ask, never authority (access.js). Bodies: access-routes.js.
|
|
696
|
+
const accessRoutes = accessHandlers({ db, log, parseId, validateOrRespond });
|
|
697
|
+
router.get('/government/access', api.requireBuilder, api.requirePermission('board.vote.cast'), accessRoutes.read);
|
|
698
|
+
router.post('/government/access/:accessKey/holders', api.requireBuilder, api.requirePermission('government.manage'), accessRoutes.grant);
|
|
699
|
+
router.delete('/government/access/:accessKey/holders/:builderId', api.requireBuilder, api.requirePermission('government.manage'), accessRoutes.revoke);
|
|
700
|
+
|
|
692
701
|
// rank: perm board.vote.cast — the Board Room view: open items with their
|
|
693
702
|
// clock, the OPEN BALLOT, the closed history (R16 of goal 1000069). Gated on
|
|
694
703
|
// the XENOS-floored vote atom, deliberately: seeing the ballot is part of
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
// request, uncached (ADR 0016), and routes every matter with routeAll.
|
|
39
39
|
|
|
40
40
|
const { answerFor } = require('./leads');
|
|
41
|
+
const access = require('./access');
|
|
41
42
|
const asks = require('./asks');
|
|
42
43
|
|
|
43
44
|
const BUILDING_NAMES = Object.freeze({
|
|
@@ -59,6 +60,7 @@ function seatOf(matter) {
|
|
|
59
60
|
function seatLabel(seat) {
|
|
60
61
|
const s = String(seat || '');
|
|
61
62
|
if (s === 'owner') return 'the owner';
|
|
63
|
+
if (/^access:/.test(s)) return access.accessLabel(s.slice(7));
|
|
62
64
|
const m = /^(lead|board):([a-z]+)$/.exec(s);
|
|
63
65
|
if (m) return `the ${BUILDING_NAMES[m[2]] || m[2]} ${m[1] === 'lead' ? 'Lead' : 'board'}`;
|
|
64
66
|
return asks.seatWords(s);
|
|
@@ -87,7 +89,7 @@ function directoryOf(ctx) {
|
|
|
87
89
|
for (const l of Array.isArray(c.leads) ? c.leads : Object.values(c.leads || {})) {
|
|
88
90
|
if (l && !l.removed_at) put(l.builder_id, l.github_login, l.display_name);
|
|
89
91
|
}
|
|
90
|
-
for (const list of Object.values(c.holders || {})) {
|
|
92
|
+
for (const list of [...Object.values(c.holders || {}), ...Object.values(c.access || {})]) {
|
|
91
93
|
for (const p of Array.isArray(list) ? list : []) {
|
|
92
94
|
if (p && typeof p === 'object') put(p.builder_id ?? p.id, p.github_login ?? p.login, p.display_name ?? p.name);
|
|
93
95
|
}
|
|
@@ -103,7 +105,8 @@ function peopleOf(ids, dir) {
|
|
|
103
105
|
// seat the seat that answers it, after any fall
|
|
104
106
|
// builder_ids who sits there now ([] when nobody does)
|
|
105
107
|
// ctx { leads: Lead rows in office, owner: leads ownerOf(archons) shape
|
|
106
|
-
// { builders: [{ id }] }, holders: { building: [people] | null }
|
|
108
|
+
// { builders: [{ id }] }, holders: { building: [people] | null },
|
|
109
|
+
// access: { key: [holder rows] } (task 1005000) }
|
|
107
110
|
function resolveSeat(asked, ctx) {
|
|
108
111
|
const c = ctx || {};
|
|
109
112
|
const owner = c.owner || { builders: [] };
|
|
@@ -120,6 +123,11 @@ function resolveSeat(asked, ctx) {
|
|
|
120
123
|
const held = idsOf((c.holders || {})[m[1]]);
|
|
121
124
|
return held.length ? seated(asked, held) : toOwner();
|
|
122
125
|
}
|
|
126
|
+
// task 1005000: an outside access — its recorded holders; nobody recorded → the owner.
|
|
127
|
+
if ((m = /^access:([a-z-]+)$/.exec(String(asked || ''))) && access.isAccessKey(m[1])) {
|
|
128
|
+
const held = idsOf((c.access || {})[m[1]]);
|
|
129
|
+
return held.length ? seated(asked, held) : toOwner();
|
|
130
|
+
}
|
|
123
131
|
if ((m = /^committee:([a-z]+)\.[a-z0-9_]+$/.exec(String(asked || '')))) {
|
|
124
132
|
return { ...resolveSeat(`lead:${m[1]}`, c), asked };
|
|
125
133
|
}
|
|
@@ -178,7 +186,7 @@ function passRefusal(matter, viewerId, toSeat, ctx, now = Date.now()) {
|
|
|
178
186
|
if (!m.ask) return { code: 'no_ask', status: 409, message: 'This matter has no ask to pass on yet.' };
|
|
179
187
|
if (m.source === 'board') return { code: 'vote_not_passable', status: 409, message: 'A vote is its voters\' to answer; it cannot be passed on.' };
|
|
180
188
|
const to = String(toSeat || '').trim();
|
|
181
|
-
if (!asks.SEAT.test(to)) return { code: 'unknown_seat', status: 400, message: 'Name a seat: owner, lead:<building>, committee:<building>.<name> or
|
|
189
|
+
if (!asks.SEAT.test(to)) return { code: 'unknown_seat', status: 400, message: 'Name a seat: owner, lead:<building>, committee:<building>.<name>, board:<building> or access:<key>.' };
|
|
182
190
|
const route = routeMatter(m, viewerId, ctx, now);
|
|
183
191
|
if (!route.yours) return { code: 'not_yours', status: 403, message: 'Only whoever this waits on can pass it on.' };
|
|
184
192
|
if (to === m.ask.waits_on || resolveSeat(to, ctx).seat === route.seat) return { code: 'same_seat', status: 409, message: 'It already waits on that seat.' };
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
// PURE: no db. docket.js reads the blockers, their asks and the seats, then calls this.
|
|
22
22
|
|
|
23
23
|
const routing = require('./routing');
|
|
24
|
+
const access = require('./access');
|
|
24
25
|
|
|
25
26
|
// One blocker as a line under a person: what it is, where it lives, and the seat it
|
|
26
27
|
// reaches that person through, in words ("as the owner", "as the Planning Lead").
|
|
@@ -36,6 +37,9 @@ function itemOf(m, r) {
|
|
|
36
37
|
as: r.label,
|
|
37
38
|
asked: r.asked,
|
|
38
39
|
fell_to_owner: !!r.fell_to_owner,
|
|
40
|
+
// task 1005000: a blocker only an outside-access holder can clear reads as its own
|
|
41
|
+
// category ("needs repo-owner access"), not as one more of the owner's.
|
|
42
|
+
needs: /^access:/.test(String(r.asked || '')) ? access.needsWords(String(r.asked).slice(7)) : null,
|
|
39
43
|
question: m.ask && m.ask.question ? String(m.ask.question) : null,
|
|
40
44
|
};
|
|
41
45
|
}
|
|
@@ -26,6 +26,8 @@
|
|
|
26
26
|
|
|
27
27
|
// "as the owner" · "as the Planning Lead" · "as the owner, since Planning has no Lead".
|
|
28
28
|
function asWords(item) {
|
|
29
|
+
// task 1005000: only whoever holds an outside access can clear it — say so first.
|
|
30
|
+
if (item.needs) return item.fell_to_owner ? item.needs + ', and nobody is recorded holding it' : item.needs;
|
|
29
31
|
var as = item.as || 'their seat';
|
|
30
32
|
if (item.fell_to_owner && /^lead:([a-z]+)$/.test(String(item.asked || ''))) {
|
|
31
33
|
var b = String(item.asked).slice(5);
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// government-access.js — Outside access, on the Government page's Permissions tab
|
|
2
|
+
// (task 1005000).
|
|
3
|
+
//
|
|
4
|
+
// WHY. Some blockers can only be cleared by whoever holds access OUTSIDE Bongos: the
|
|
5
|
+
// GitHub repository's admin, the server, DNS, hosting, package publishing, payments.
|
|
6
|
+
// The owner records who holds each here (GET/POST/DELETE /government/access), and an
|
|
7
|
+
// ask can then wait on `access:<key>` — it reaches that person, and reads on the
|
|
8
|
+
// Blockers page as "needs repo-owner access". An access nobody is recorded holding
|
|
9
|
+
// falls to the owner, like every unheld seat.
|
|
10
|
+
//
|
|
11
|
+
// A RECORD OF WHO TO ASK, NEVER A PERMISSION: holding one here unlocks nothing in
|
|
12
|
+
// Bongos, and no credential is stored — the page says so. The server gates the
|
|
13
|
+
// writes on government.manage; the Permissions tab is already sealed for everyone
|
|
14
|
+
// else (government.js sealRanksPanel), so this file draws no gate of its own.
|
|
15
|
+
//
|
|
16
|
+
// UMD so tests/access_holders.mjs can run `accessHtml` without a browser.
|
|
17
|
+
(function (root, factory) {
|
|
18
|
+
var mod = factory();
|
|
19
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = mod;
|
|
20
|
+
else root.OTBGovAccess = mod;
|
|
21
|
+
}(typeof self !== 'undefined' ? self : this, function () {
|
|
22
|
+
'use strict';
|
|
23
|
+
|
|
24
|
+
// accessHtml(list, esc) → one row per access: its name, what it is, who holds it
|
|
25
|
+
// (each with a Remove control), or that it falls to the owner.
|
|
26
|
+
function accessHtml(list, esc) {
|
|
27
|
+
if (!Array.isArray(list) || !list.length) return '';
|
|
28
|
+
return '<ul class="ov-rows">' + list.map(function (a) {
|
|
29
|
+
var holders = Array.isArray(a.holders) ? a.holders : [];
|
|
30
|
+
var who = holders.length
|
|
31
|
+
? holders.map(function (h) {
|
|
32
|
+
return '<span class="ov-fact">' + esc(h.name || h.login || ('builder ' + h.id))
|
|
33
|
+
+ ' <button type="button" class="btn-ghost" data-act="revoke" data-key="' + esc(a.key) + '" data-id="' + esc(h.id) + '">Remove</button></span>';
|
|
34
|
+
}).join('')
|
|
35
|
+
: '<span class="ov-fact">Nobody recorded — it falls to the owner</span>';
|
|
36
|
+
return '<li class="ov-row"><div class="ov-row__main">'
|
|
37
|
+
+ '<span class="ov-row__title">' + esc(a.title || a.name) + '</span>'
|
|
38
|
+
+ '<span class="ov-row__sub">' + esc(a.what) + '</span>'
|
|
39
|
+
+ '<div class="ov-row__facts">' + who + '<span class="ov-mono">' + esc(a.seat) + '</span></div>'
|
|
40
|
+
+ '</div></li>';
|
|
41
|
+
}).join('') + '</ul>';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The Record form's picker: the same display name the list shows.
|
|
45
|
+
function optionsHtml(list, esc) {
|
|
46
|
+
return (Array.isArray(list) ? list : []).map(function (a) { return '<option value="' + esc(a.key) + '">' + esc(a.title || a.name) + '</option>'; }).join('');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function mount() {
|
|
50
|
+
var doc = typeof document !== 'undefined' ? document : null;
|
|
51
|
+
var box = doc && doc.getElementById('gov-access');
|
|
52
|
+
var form = doc && doc.getElementById('gov-access-form');
|
|
53
|
+
var select = doc && doc.getElementById('gov-access-key');
|
|
54
|
+
var esc = window.OTB && window.OTB.escapeHtml;
|
|
55
|
+
if (!box || !form || !select || typeof esc !== 'function') return;
|
|
56
|
+
var toast = function (m) { if (window.OTB && window.OTB.toast) window.OTB.toast(m, { duration: 3200 }); };
|
|
57
|
+
var call = function (method, path, body) {
|
|
58
|
+
return fetch('/api/bongos' + path, {
|
|
59
|
+
method: method,
|
|
60
|
+
credentials: 'same-origin',
|
|
61
|
+
headers: body ? { 'Content-Type': 'application/json' } : {},
|
|
62
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
63
|
+
}).then(function (r) {
|
|
64
|
+
return r.json().catch(function () { return {}; }).then(function (d) { return { ok: r.ok, status: r.status, data: d }; });
|
|
65
|
+
});
|
|
66
|
+
};
|
|
67
|
+
var said = function (r) { var e = r.data && r.data.error; return (e && e.message) || ('It did not save (' + r.status + ').'); };
|
|
68
|
+
|
|
69
|
+
function load() {
|
|
70
|
+
return call('GET', '/government/access').then(function (r) {
|
|
71
|
+
if (!r.ok || !r.data || !Array.isArray(r.data.access)) {
|
|
72
|
+
box.innerHTML = '<p class="ov-row__sub">Outside access could not be read just now.</p>';
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (!select.options.length) {
|
|
76
|
+
select.innerHTML = optionsHtml(r.data.access, esc);
|
|
77
|
+
}
|
|
78
|
+
box.innerHTML = accessHtml(r.data.access, esc);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
box.addEventListener('click', function (ev) {
|
|
83
|
+
var btn = ev.target.closest && ev.target.closest('[data-act=revoke]');
|
|
84
|
+
if (!btn) return;
|
|
85
|
+
btn.disabled = true;
|
|
86
|
+
call('DELETE', '/government/access/' + encodeURIComponent(btn.dataset.key) + '/holders/' + encodeURIComponent(btn.dataset.id))
|
|
87
|
+
.then(function (r) { if (!r.ok) { toast(said(r)); btn.disabled = false; return null; } return load(); });
|
|
88
|
+
});
|
|
89
|
+
form.addEventListener('submit', function (ev) {
|
|
90
|
+
ev.preventDefault();
|
|
91
|
+
var id = Number(doc.getElementById('gov-access-builder').value);
|
|
92
|
+
call('POST', '/government/access/' + encodeURIComponent(select.value) + '/holders', { builder_id: id })
|
|
93
|
+
.then(function (r) {
|
|
94
|
+
if (!r.ok) { toast(said(r)); return null; }
|
|
95
|
+
form.reset();
|
|
96
|
+
return load();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
load();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
|
|
103
|
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mount);
|
|
104
|
+
else mount();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { accessHtml: accessHtml, optionsHtml: optionsHtml };
|
|
108
|
+
}));
|
|
@@ -116,6 +116,26 @@
|
|
|
116
116
|
<div id="gov-assignments"><span class="kit-skel"></span></div>
|
|
117
117
|
</section>
|
|
118
118
|
|
|
119
|
+
<!-- task 1005000: who holds the keys OUTSIDE Bongos (GitHub admin, server, DNS,
|
|
120
|
+
hosting, publishing, payments), so an ask can wait on access:<key>.
|
|
121
|
+
Drawn by government-access.js. -->
|
|
122
|
+
<section class="scroll" aria-labelledby="gov-access-h">
|
|
123
|
+
<h2 class="scroll__h" id="gov-access-h">Outside access</h2>
|
|
124
|
+
<p class="scroll__lede">Who holds the keys outside the project, so a blocker that needs one reaches them. This is a record of who to ask: it grants nothing here and stores no password or token.</p>
|
|
125
|
+
<form class="ov-form" id="gov-access-form" autocomplete="off">
|
|
126
|
+
<div class="ov-field ov-field--sm">
|
|
127
|
+
<label class="ov-field__l" for="gov-access-builder">Builder id</label>
|
|
128
|
+
<input id="gov-access-builder" type="number" min="1" placeholder="e.g. 95" required>
|
|
129
|
+
</div>
|
|
130
|
+
<div class="ov-field">
|
|
131
|
+
<label class="ov-field__l" for="gov-access-key">Access</label>
|
|
132
|
+
<select id="gov-access-key" required></select>
|
|
133
|
+
</div>
|
|
134
|
+
<button class="btn-accent" type="submit">Record</button>
|
|
135
|
+
</form>
|
|
136
|
+
<div id="gov-access"><span class="kit-skel"></span></div>
|
|
137
|
+
</section>
|
|
138
|
+
|
|
119
139
|
<section class="scroll" aria-labelledby="gov-principals-h">
|
|
120
140
|
<h2 class="scroll__h" id="gov-principals-h">Machine principals</h2>
|
|
121
141
|
<p class="scroll__lede">Capabilities held by a machine identity, never by a person or a rank. Shown for completeness; nothing here can be granted.</p>
|
|
@@ -143,5 +163,6 @@
|
|
|
143
163
|
BEFORE government.js, which destructures window.OTBBoardLib at IIFE entry. -->
|
|
144
164
|
<script src="/builders/board-lib.js?v=2026-09-10-boardlib1"></script>
|
|
145
165
|
<script src="/builders/government.js?v=2026-09-02-gov-rows"></script>
|
|
166
|
+
<script src="/builders/government-access.js"></script>
|
|
146
167
|
</body>
|
|
147
168
|
</html>
|
|
@@ -22,3 +22,17 @@ show the seat alone — or, on /blockers, every blocker under "Requires project
|
|
|
22
22
|
fixture, because `tests/hall_preview_governor_fixtures.mjs` re-derives every route and fails on a guess.
|
|
23
23
|
|
|
24
24
|
Tests: `tests/blockers_people.mjs`.
|
|
25
|
+
|
|
26
|
+
## Outside access (task 1005000)
|
|
27
|
+
|
|
28
|
+
- **The Permissions tab gains *Outside access*** (`government-access.js`, `#gov-access`): the six keys
|
|
29
|
+
that live outside the project (repo owner, server access, domain settings, hosting account, package
|
|
30
|
+
publishing, payments), who is recorded holding each, a Remove control per holder, and a Record form.
|
|
31
|
+
An access nobody holds says "Nobody recorded — it falls to the owner". The tab is already sealed for
|
|
32
|
+
non-Archons; the server gates the writes on `government.manage`.
|
|
33
|
+
- **The lede says it grants nothing and stores no password or token** — the record is who to ask.
|
|
34
|
+
- **On the people view**, a blocker waiting on an access seat reads "needs repo-owner access" under its
|
|
35
|
+
holder (`asWords` reads the server's `needs`), and "…, and nobody is recorded holding it" when it fell
|
|
36
|
+
to the owner.
|
|
37
|
+
|
|
38
|
+
Tests: `tests/access_holders.mjs`.
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.24.
|
|
3
|
+
"version": "1.24.2",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.24.
|
|
9
|
+
"version": "1.24.2",
|
|
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.24.
|
|
3
|
+
"version": "1.24.2",
|
|
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",
|
package/release-notes.json
CHANGED
|
@@ -10801,5 +10801,15 @@
|
|
|
10801
10801
|
"id": "1004934",
|
|
10802
10802
|
"text": "We decided that Market Watch will search the web each week on the project's own AI key and under the owner's monthly cap, at about $4 to $5 per weekly check of 40 competitors."
|
|
10803
10803
|
}
|
|
10804
|
+
],
|
|
10805
|
+
"1.24.2": [
|
|
10806
|
+
{
|
|
10807
|
+
"id": "1005000",
|
|
10808
|
+
"text": "The project can now record who holds the keys that live outside it: the GitHub repository's admin, the server, the domain settings, the hosting account, package publishing and payments. The owner records them on the Government"
|
|
10809
|
+
},
|
|
10810
|
+
{
|
|
10811
|
+
"id": "1004970",
|
|
10812
|
+
"text": "The payments decision record now carries the owner's answers: credits can be tuned but not switched off, pay rules live on the settings page, and Verro is Verro Labs."
|
|
10813
|
+
}
|
|
10804
10814
|
]
|
|
10805
10815
|
}
|
|
@@ -388,6 +388,9 @@ function makeStore(catalog) {
|
|
|
388
388
|
}
|
|
389
389
|
return { enacted, repealed };
|
|
390
390
|
},
|
|
391
|
+
// Who holds each outside access (task 1005000): the walk records none, so every
|
|
392
|
+
// access seat falls to the owner, as it does on a project that has recorded none.
|
|
393
|
+
async listAccessHolders() { return []; },
|
|
391
394
|
// The owner seat: every active Archon, oldest id first.
|
|
392
395
|
async listActiveArchons() {
|
|
393
396
|
return [...s.builders.values()].filter((b) => isLive(b) && b.rank === 'archon')
|
|
@@ -304,6 +304,10 @@ const GOVERNMENT_PINS = {
|
|
|
304
304
|
// task 1004999: open blockers under the people they wait on, and the viewer's own.
|
|
305
305
|
'GET /government/blockers/people': 'perm:board.vote.cast',
|
|
306
306
|
'GET /government/blockers/for-me': 'perm:board.vote.cast',
|
|
307
|
+
// task 1005000: who holds the keys outside Bongos (read), recorded by the owner (write).
|
|
308
|
+
'GET /government/access': 'perm:board.vote.cast',
|
|
309
|
+
'POST /government/access/:accessKey/holders': 'perm:government.manage',
|
|
310
|
+
'DELETE /government/access/:accessKey/holders/:builderId': 'perm:government.manage',
|
|
307
311
|
// task 1004529: who decides what — a change to the decision-rules table is an
|
|
308
312
|
// amendment by another door (same atom as proposing one), and a building's
|
|
309
313
|
// matter is put to its sitting by the same atom. Only a passed amendment
|
package/src/module-api.js
CHANGED
|
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
|
|
|
81
81
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
82
82
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
83
83
|
|
|
84
|
-
const CORE_VERSION = '1.24.
|
|
84
|
+
const CORE_VERSION = '1.24.2'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
85
85
|
|
|
86
86
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
87
87
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// tests/access_holders.mjs — who holds the keys OUTSIDE Bongos, and blockers that need
|
|
2
|
+
// them (task 1005000, goal 1000111).
|
|
3
|
+
//
|
|
4
|
+
// WHAT IT GUARDS (the task's done-when, one by one):
|
|
5
|
+
// 1. Only the owner can record a holder: the writes refuse anyone without
|
|
6
|
+
// government.manage; the read is the docket's atom.
|
|
7
|
+
// 2. An ask on access:<key> resolves to exactly the recorded holders, and to the
|
|
8
|
+
// owner when none is recorded.
|
|
9
|
+
// 3. Such a blocker shows under its holder as "needs repo-owner access".
|
|
10
|
+
// 4. No secret is stored: the table has no column a credential could live in.
|
|
11
|
+
// And the trap: it is a record of who to ask, never authority — no gate, rank check
|
|
12
|
+
// or permission atom reads it.
|
|
13
|
+
//
|
|
14
|
+
// Also: the vocabulary, the ask grammar (asks.SEAT) and both migration CHECKs agree.
|
|
15
|
+
// DB-free: the government db is stubbed by property. Run: node --test tests/access_holders.mjs
|
|
16
|
+
|
|
17
|
+
import { test, after } from 'node:test';
|
|
18
|
+
import assert from 'node:assert/strict';
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import http from 'node:http';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { createRequire } from 'node:module';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
|
|
25
|
+
const require = createRequire(import.meta.url);
|
|
26
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
27
|
+
const gov = (f) => path.join(ROOT, 'modules', 'government', f);
|
|
28
|
+
const access = require(gov('access.js'));
|
|
29
|
+
const asks = require(gov('asks.js'));
|
|
30
|
+
const routing = require(gov('routing.js'));
|
|
31
|
+
const waiting = require(gov('waiting.js'));
|
|
32
|
+
const leads = require(gov('leads.js'));
|
|
33
|
+
const people = require(path.join(ROOT, 'modules', 'hall-ui', 'public', 'blockers-by-person.js'));
|
|
34
|
+
const page = require(path.join(ROOT, 'modules', 'hall-ui', 'public', 'government-access.js'));
|
|
35
|
+
const SQL = fs.readFileSync(gov('migrations/government_036_access_holders.sql'), 'utf8');
|
|
36
|
+
|
|
37
|
+
const owner = leads.ownerOf([{ id: 1, github_login: 'lars', display_name: 'Lars' }]);
|
|
38
|
+
const KIM = { access_key: 'github-admin', builder_id: 5, github_login: 'kim', display_name: 'Kim', granted_at: '2026-10-29T00:00:00Z' };
|
|
39
|
+
const blocker = (id, waitsOn) => ({ id: `blockers:${id}`, source: 'blockers', building: 'planning', state: 'needs', title: `Blocker ${id}`, opened_at: '2026-10-29T00:00:00Z', ref: { type: 'blocker', id: String(id) }, ask: { question: 'Allow it?', waits_on: waitsOn, if_nobody_answers: 'It stays stuck.' } });
|
|
40
|
+
|
|
41
|
+
test('one vocabulary: access.js, the holders CHECK and the asks CHECK name the same keys', () => {
|
|
42
|
+
const holders = /access_key IN \(([^)]+)\)/.exec(SQL)[1].match(/'([^']+)'/g).map((s) => s.slice(1, -1));
|
|
43
|
+
assert.deepEqual(holders, access.KEYS);
|
|
44
|
+
const seat = new RegExp(/CHECK \(waits_on ~ '([^']+)'\)/.exec(SQL)[1]);
|
|
45
|
+
for (const s of ['owner', 'lead:planning', 'board:charter', 'committee:review.testers', ...access.KEYS.map((k) => `access:${k}`)]) {
|
|
46
|
+
assert.equal(seat.test(s), true, `${s}: the table takes it`);
|
|
47
|
+
assert.equal(asks.SEAT.test(s), true, `${s}: the module takes it`);
|
|
48
|
+
}
|
|
49
|
+
for (const s of ['access:nope', 'access:', 'access:github-admin.x', 'lead:nowhere']) {
|
|
50
|
+
assert.equal(seat.test(s), false, `${s}: the table refuses it`);
|
|
51
|
+
assert.equal(asks.SEAT.test(s), false, `${s}: the module refuses it`);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('an ask can wait on an outside access, and reads as its holder', () => {
|
|
56
|
+
const ok = asks.checkAsk({
|
|
57
|
+
question: 'Allow the publish token to change CI workflow files?',
|
|
58
|
+
options: [{ key: 'yes', label: 'Add the scope', result: 'It lands on the next ship.' }, { key: 'no', label: 'Leave it', result: 'It stays stuck.' }],
|
|
59
|
+
recommend: { key: 'yes', why: 'One setting fixes it.' },
|
|
60
|
+
waits_on: 'access:github-admin',
|
|
61
|
+
if_nobody_answers: 'It stays stuck.',
|
|
62
|
+
});
|
|
63
|
+
assert.equal(ok.ok, true, JSON.stringify(ok.errors || []));
|
|
64
|
+
assert.equal(asks.seatWords('access:github-admin'), 'the repo owner');
|
|
65
|
+
assert.equal(asks.checkAsk({ ...ok.ask, waits_on: 'access:nope' }).ok, false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('access:<key> resolves to exactly the recorded holders, and to the owner when none is', () => {
|
|
69
|
+
const seats = { leads: [], owner, holders: {}, access: access.holdersByKey([KIM]) };
|
|
70
|
+
const held = routing.resolveSeat('access:github-admin', seats);
|
|
71
|
+
assert.deepEqual([held.seat, held.label, held.builder_ids, held.fell_to_owner], ['access:github-admin', 'the repo owner', ['5'], false]);
|
|
72
|
+
const unheld = routing.resolveSeat('access:dns', seats);
|
|
73
|
+
assert.deepEqual([unheld.seat, unheld.builder_ids, unheld.fell_to_owner], ['owner', ['1'], true]);
|
|
74
|
+
const route = routing.routeMatter(blocker(1, 'access:github-admin'), '5', seats, Date.now());
|
|
75
|
+
assert.deepEqual([route.yours, route.people], [true, [{ id: '5', login: 'kim', name: 'Kim' }]]);
|
|
76
|
+
assert.equal(routing.routeMatter(blocker(1, 'access:github-admin'), '1', seats, Date.now()).yours, false, 'not the owner\'s while a holder is recorded');
|
|
77
|
+
// no access read at all (an instance that has not migrated): every access seat falls to the owner
|
|
78
|
+
assert.deepEqual(routing.resolveSeat('access:github-admin', { leads: [], owner, holders: {} }).builder_ids, ['1']);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('a blocker on an access seat shows under its holder as "needs repo-owner access"', () => {
|
|
82
|
+
const g = waiting.groupByPerson([blocker(7, 'access:github-admin'), blocker(8, 'access:payments')], { leads: [], owner, holders: {}, access: access.holdersByKey([KIM]) });
|
|
83
|
+
const kim = g.people.find((p) => p.person.name === 'Kim');
|
|
84
|
+
assert.deepEqual(kim.items.map((i) => [i.id, i.needs]), [['7', 'needs repo-owner access']]);
|
|
85
|
+
const lars = g.people.find((p) => p.person.name === 'Lars');
|
|
86
|
+
assert.deepEqual(lars.items.map((i) => [i.id, i.needs, i.fell_to_owner]), [['8', 'needs access to payments', true]]);
|
|
87
|
+
const esc = (s) => String(s);
|
|
88
|
+
const html = people.peopleHtml({ available: true, ...g }, { esc, fmtDate: (d) => d.slice(0, 10) });
|
|
89
|
+
assert.match(html, /needs repo-owner access · filed/);
|
|
90
|
+
assert.match(html, /needs access to payments, and nobody is recorded holding it/);
|
|
91
|
+
assert.equal(people.asWords({ as: 'the owner', asked: 'owner' }), 'as the owner', 'an ordinary seat reads as before');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('no secret is stored, and nothing that gates reads it', () => {
|
|
95
|
+
const cols = /CREATE TABLE IF NOT EXISTS government_access_holders \(([\s\S]*?)\n\);/.exec(SQL)[1]
|
|
96
|
+
.split('\n').map((l) => /^\s+([a-z_]+)\s/.exec(l)).filter(Boolean).map((m) => m[1]).filter((c) => c !== 'PRIMARY');
|
|
97
|
+
assert.deepEqual(cols, ['access_key', 'builder_id', 'granted_by', 'granted_at']);
|
|
98
|
+
for (const f of ['src/bongos/auth.js', 'modules/government/resolver.js', 'modules/government/catalog.js', 'src/bongos/act-alone.js']) {
|
|
99
|
+
const src = fs.readFileSync(path.join(ROOT, f), 'utf8');
|
|
100
|
+
assert.doesNotMatch(src, /access_holders|listAccessHolders|require\(['"][./]*access['"]\)/, `${f} never reads access holders`);
|
|
101
|
+
}
|
|
102
|
+
const catalog = require(gov('catalog.js'));
|
|
103
|
+
const keys = catalog.PERMISSIONS.map((p) => p.key || p);
|
|
104
|
+
assert.ok(keys.length > 50, 'the real catalog, not an empty list');
|
|
105
|
+
for (const k of access.KEYS) assert.ok(!keys.some((p) => String(p).includes(k)), `${k} is not a permission atom`);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('the access view: each access, what it is, its holders, or that it falls to the owner', () => {
|
|
109
|
+
const v = access.accessView([KIM, { ...KIM, access_key: 'not-a-key' }]);
|
|
110
|
+
assert.deepEqual(v.map((a) => a.key), access.KEYS);
|
|
111
|
+
const gh = v.find((a) => a.key === 'github-admin');
|
|
112
|
+
assert.deepEqual([gh.seat, gh.holders.map((h) => h.name), gh.falls_to_owner], ['access:github-admin', ['Kim'], false]);
|
|
113
|
+
assert.equal(v.find((a) => a.key === 'dns').falls_to_owner, true);
|
|
114
|
+
const html = page.accessHtml(v, (s) => String(s).replace(/</g, '<'));
|
|
115
|
+
assert.match(html, /Repo owner/);
|
|
116
|
+
assert.match(html, /data-act="revoke" data-key="github-admin" data-id="5"/);
|
|
117
|
+
assert.match(html, /Nobody recorded — it falls to the owner/);
|
|
118
|
+
// the picker and the list say the same name, capitalised the same way
|
|
119
|
+
const opts = page.optionsHtml(v, (s) => String(s));
|
|
120
|
+
for (const a of v) {
|
|
121
|
+
assert.match(opts, new RegExp('<option value="' + a.key + '">' + a.title + '</option>'));
|
|
122
|
+
assert.ok(html.includes('>' + a.title + '<'), a.key + ': the list shows the same name');
|
|
123
|
+
assert.match(a.title, /^[A-Z]/);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('every access reads as plain words when a blocker needs it — no "access access"', () => {
|
|
128
|
+
for (const k of access.KEYS) {
|
|
129
|
+
const w = access.needsWords(k);
|
|
130
|
+
assert.match(w, /^needs [a-z]/, k);
|
|
131
|
+
assert.doesNotMatch(w, /access access|-access access/, k + ': ' + w);
|
|
132
|
+
}
|
|
133
|
+
assert.equal(access.needsWords('github-admin'), 'needs repo-owner access');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('the City keeps routing when the access read fails or is missing: every access seat falls to the owner', async () => {
|
|
137
|
+
const docket = require(gov('docket.js'));
|
|
138
|
+
const db = require(gov('db.js'));
|
|
139
|
+
const real = { leads: db.listLeads, archons: db.listActiveArchons, access: db.listAccessHolders };
|
|
140
|
+
db.listLeads = async () => [];
|
|
141
|
+
db.listActiveArchons = async () => [{ id: 1, github_login: 'lars', display_name: 'Lars' }];
|
|
142
|
+
try {
|
|
143
|
+
db.listAccessHolders = async () => { throw new Error('relation government_access_holders does not exist'); };
|
|
144
|
+
const failed = await docket.loadSeats({ holders: {} });
|
|
145
|
+
assert.ok(failed, 'seats still read');
|
|
146
|
+
assert.deepEqual(routing.resolveSeat('access:github-admin', failed).builder_ids, ['1'], 'falls to the owner');
|
|
147
|
+
assert.deepEqual(failed.access['github-admin'], []);
|
|
148
|
+
db.listAccessHolders = undefined;
|
|
149
|
+
const missing = await docket.loadSeats({ holders: {} });
|
|
150
|
+
assert.ok(missing, 'a db layer without the read still routes');
|
|
151
|
+
assert.deepEqual(routing.resolveSeat('access:dns', missing).builder_ids, ['1']);
|
|
152
|
+
db.listAccessHolders = async () => [KIM];
|
|
153
|
+
assert.deepEqual(routing.resolveSeat('access:github-admin', await docket.loadSeats({ holders: {} })).builder_ids, ['5']);
|
|
154
|
+
} finally {
|
|
155
|
+
db.listLeads = real.leads; db.listActiveArchons = real.archons; db.listAccessHolders = real.access;
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// --- the routes, through the real router and the real permission gate ---------------
|
|
160
|
+
|
|
161
|
+
const express = require('express');
|
|
162
|
+
const api = require(path.join(ROOT, 'src', 'module-api.js'));
|
|
163
|
+
const seams = require(path.join(ROOT, 'src', 'module-seams.js'));
|
|
164
|
+
const { attachFail } = require(path.join(ROOT, 'src', 'bongos', 'middleware', 'error-envelope.js'));
|
|
165
|
+
const govDb = require(gov('db.js'));
|
|
166
|
+
|
|
167
|
+
const rows = [];
|
|
168
|
+
const REAL = { list: govDb.listAccessHolders, grant: govDb.grantAccess, revoke: govDb.revokeAccess, who: govDb.getActiveBuilder };
|
|
169
|
+
govDb.listAccessHolders = async () => rows.slice();
|
|
170
|
+
govDb.grantAccess = async (k, id, by) => { if (rows.some((r) => r.access_key === k && r.builder_id === id)) return false; rows.push({ access_key: k, builder_id: id, granted_by: by, github_login: 'kim', display_name: 'Kim' }); return true; };
|
|
171
|
+
govDb.revokeAccess = async (k, id) => { const i = rows.findIndex((r) => r.access_key === k && r.builder_id === id); if (i < 0) return false; rows.splice(i, 1); return true; };
|
|
172
|
+
govDb.getActiveBuilder = async (id) => (id === 5 ? { id: 5, github_login: 'kim', display_name: 'Kim' } : null);
|
|
173
|
+
const realRequireBuilder = api.requireBuilder;
|
|
174
|
+
api.requireBuilder = (req, res, next) => { req.builder = { id: 1, rank: 'archon' }; next(); };
|
|
175
|
+
function grant(keys) { seams._reset(); seams.registerProvider('government', { resolveBuilderPermissions: async () => keys }); }
|
|
176
|
+
grant(['government.manage', 'board.vote.cast']);
|
|
177
|
+
const buildRouter = require(gov('routes/government.js'));
|
|
178
|
+
const app = express();
|
|
179
|
+
app.use(express.json());
|
|
180
|
+
app.use(attachFail);
|
|
181
|
+
app.use(buildRouter());
|
|
182
|
+
const server = http.createServer(app);
|
|
183
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
184
|
+
const BASE = `http://127.0.0.1:${server.address().port}`;
|
|
185
|
+
after(() => {
|
|
186
|
+
server.close();
|
|
187
|
+
api.requireBuilder = realRequireBuilder;
|
|
188
|
+
govDb.listAccessHolders = REAL.list; govDb.grantAccess = REAL.grant; govDb.revokeAccess = REAL.revoke; govDb.getActiveBuilder = REAL.who;
|
|
189
|
+
seams._reset();
|
|
190
|
+
});
|
|
191
|
+
const call = async (method, p, body) => {
|
|
192
|
+
const res = await fetch(BASE + p, { method, headers: body ? { 'content-type': 'application/json' } : {}, body: body ? JSON.stringify(body) : undefined });
|
|
193
|
+
return { status: res.status, json: await res.json().catch(() => null) };
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
test('only the owner records a holder; everyone who can open the City can read who holds what', async () => {
|
|
197
|
+
grant(['board.vote.cast']);
|
|
198
|
+
assert.equal((await call('POST', '/government/access/github-admin/holders', { builder_id: 5 })).status, 403, 'a voter cannot record');
|
|
199
|
+
assert.equal((await call('DELETE', '/government/access/github-admin/holders/5')).status, 403, 'nor remove');
|
|
200
|
+
assert.equal((await call('GET', '/government/access')).status, 200, 'but can read');
|
|
201
|
+
grant(['government.manage', 'board.vote.cast']);
|
|
202
|
+
const added = await call('POST', '/government/access/github-admin/holders', { builder_id: 5 });
|
|
203
|
+
assert.deepEqual([added.status, added.json.added], [201, true]);
|
|
204
|
+
assert.equal(rows[0].granted_by, 1, 'who recorded it rides the row');
|
|
205
|
+
assert.deepEqual([(await call('POST', '/government/access/github-admin/holders', { builder_id: 5 })).status], [200], 'idempotent');
|
|
206
|
+
const read = await call('GET', '/government/access');
|
|
207
|
+
assert.deepEqual(read.json.access.find((a) => a.key === 'github-admin').holders.map((h) => h.id), ['5']);
|
|
208
|
+
assert.equal((await call('POST', '/government/access/nope/holders', { builder_id: 5 })).status, 400, 'an unknown access');
|
|
209
|
+
assert.equal((await call('POST', '/government/access/dns/holders', { builder_id: 99 })).status, 404, 'an unknown builder');
|
|
210
|
+
assert.equal((await call('POST', '/government/access/dns/holders', { builder_id: 'x' })).status, 400);
|
|
211
|
+
assert.equal((await call('POST', '/government/access/dns/holders', { builder_id: 5, token: 'ghp_x' })).status, 400, 'strict: an unknown field (a token, say) is refused, never stored');
|
|
212
|
+
const removed = await call('DELETE', '/government/access/github-admin/holders/5');
|
|
213
|
+
assert.deepEqual([removed.status, removed.json.removed, rows.length], [200, true, 0]);
|
|
214
|
+
});
|
|
@@ -256,7 +256,10 @@ test('the membership queries exclude machines and the deactivated — structural
|
|
|
256
256
|
const src = fs.readFileSync(path.join(GOV, 'db.js'), 'utf8');
|
|
257
257
|
const start = src.indexOf('getActiveBuildersWithRank');
|
|
258
258
|
assert.ok(start > -1);
|
|
259
|
-
|
|
259
|
+
// bounded to the two membership reads: a later query filtering status for its own
|
|
260
|
+
// reasons (task 1005000's access holders) is not one of them
|
|
261
|
+
const end = src.indexOf('\nasync function ', src.indexOf('async function builderHoldsRankActive') + 1);
|
|
262
|
+
const block = src.slice(start, end > start ? end : undefined);
|
|
260
263
|
assert.equal((block.match(/b\.status = 'active'/g) || []).length, 2, 'both reads filter status');
|
|
261
264
|
assert.equal((block.match(/b\.system_role IS NULL/g) || []).length, 2, 'both reads exclude principals');
|
|
262
265
|
});
|
|
@@ -104,7 +104,7 @@ test('every /governance route gates via REAL middleware on exactly its PINNED at
|
|
|
104
104
|
// task 1004712: the treasury is read, and its pots and borrows decided, where the room is (the holder and the seat are the walls).
|
|
105
105
|
// task 1004715: the strictness presets are read where the law book is.
|
|
106
106
|
// task 1004716: the counts are read where the docket they count is.
|
|
107
|
-
const boardFamily = (key) => key.includes('/board/') || key.includes('/government/asks') || key.endsWith('/government/constitution') || key.endsWith('/government/docket') || key.includes('/government/blockers/') || key.endsWith('/government/counts') || key.endsWith('/government/laws') || key.endsWith('/government/leads') || key.endsWith('/government/buildouts') || key.includes('/government/treasury') || key.endsWith('/government/presets');
|
|
107
|
+
const boardFamily = (key) => key.includes('/board/') || key.includes('/government/asks') || key.endsWith('/government/constitution') || key.endsWith('/government/docket') || key.includes('/government/blockers/') || key.endsWith('/government/access') || key.endsWith('/government/counts') || key.endsWith('/government/laws') || key.endsWith('/government/leads') || key.endsWith('/government/buildouts') || key.includes('/government/treasury') || key.endsWith('/government/presets');
|
|
108
108
|
for (const r of classified.filter((x) => !boardFamily(x.key))) {
|
|
109
109
|
assert.deepEqual(r.perms, ['government.manage'], `${r.key}: the admin surface gates on the manage atom`);
|
|
110
110
|
}
|
|
@@ -52,6 +52,7 @@ const NON_GRANT_MIGRATIONS = new Set([
|
|
|
52
52
|
'government_030_buildouts.sql', // a Lead's build-outs, on the record at once or by a passed vote (task 1004711)
|
|
53
53
|
'government_031_treasury.sql', // the treasury: a pot per goal and the loans a dry pot asks the reserve for (task 1004712)
|
|
54
54
|
'government_032_ask_flags.sql', // the reword list: matters a reader flagged as unclear (owner 2026-10-23)
|
|
55
|
+
'government_036_access_holders.sql', // who holds the keys outside Bongos + the access:<key> ask seat (task 1005000)
|
|
55
56
|
]);
|
|
56
57
|
|
|
57
58
|
// Permission keys RENAMED after the grant migrations that first granted them.
|