@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.
Files changed (38) hide show
  1. package/.bongos-core.json +64 -39
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +6 -0
  4. package/clients/bongos-client/index.cjs +6 -0
  5. package/clients/bongos-client/index.d.ts +6 -0
  6. package/clients/bongos-client/index.mjs +6 -0
  7. package/docs/adr/0385-money-lives-with-a-processor-credits-stay-a-project-meter.md +12 -1
  8. package/docs/adr/README.md +1 -1
  9. package/docs/api/openapi.json +140 -1
  10. package/docs/api-reference.md +5 -2
  11. package/docs/copy-inventory.md +11 -6
  12. package/docs/copy-registry.json +63 -8
  13. package/docs/module-api-changelog.md +2 -0
  14. package/docs/page-inventory.json +2 -1
  15. package/docs/page-readings.json +29 -20
  16. package/modules/government/access-routes.js +70 -0
  17. package/modules/government/access.js +88 -0
  18. package/modules/government/asks.js +5 -2
  19. package/modules/government/db.js +38 -0
  20. package/modules/government/docket.js +12 -2
  21. package/modules/government/migrations/government_036_access_holders.sql +33 -0
  22. package/modules/government/routes/government.js +9 -0
  23. package/modules/government/routing.js +11 -3
  24. package/modules/government/waiting.js +4 -0
  25. package/modules/hall-ui/public/blockers-by-person.js +2 -0
  26. package/modules/hall-ui/public/government-access.js +108 -0
  27. package/modules/hall-ui/public/government.html +21 -0
  28. package/modules/hall-ui/records/blockers-people.md +14 -0
  29. package/package-lock.json +2 -2
  30. package/package.json +1 -1
  31. package/release-notes.json +10 -0
  32. package/scripts/bongos/government-walk.js +3 -0
  33. package/src/bongos/route-rank-check.js +4 -0
  34. package/src/module-api.js +1 -1
  35. package/tests/access_holders.mjs +214 -0
  36. package/tests/government_board_membership.mjs +4 -1
  37. package/tests/government_routes.mjs +1 -1
  38. 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 board:<building>.' };
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.1",
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.1",
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.1",
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",
@@ -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.1'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
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, '&lt;'));
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
- const block = src.slice(start);
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.