@bongos/core 1.24.1 → 1.24.3

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 (42) hide show
  1. package/.bongos-core.json +68 -43
  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 +4 -0
  14. package/docs/page-inventory.json +2 -1
  15. package/docs/page-readings.json +35 -26
  16. package/modules/economy/governor-docket.js +12 -6
  17. package/modules/government/access-routes.js +70 -0
  18. package/modules/government/access.js +88 -0
  19. package/modules/government/asks.js +5 -2
  20. package/modules/government/db.js +38 -0
  21. package/modules/government/docket.js +12 -2
  22. package/modules/government/migrations/government_036_access_holders.sql +33 -0
  23. package/modules/government/routes/government.js +9 -0
  24. package/modules/government/routing.js +11 -3
  25. package/modules/government/waiting.js +4 -0
  26. package/modules/hall-ui/public/blockers-by-person.js +2 -0
  27. package/modules/hall-ui/public/government-access.js +108 -0
  28. package/modules/hall-ui/public/government.html +21 -0
  29. package/modules/hall-ui/public/street-inside.js +1 -1
  30. package/modules/hall-ui/records/blockers-people.md +14 -0
  31. package/package-lock.json +2 -2
  32. package/package.json +1 -1
  33. package/release-notes.json +16 -0
  34. package/scripts/bongos/government-walk.js +3 -0
  35. package/src/bongos/route-rank-check.js +4 -0
  36. package/src/module-api.js +1 -1
  37. package/tests/access_holders.mjs +214 -0
  38. package/tests/government_board_membership.mjs +4 -1
  39. package/tests/government_routes.mjs +1 -1
  40. package/tests/government_seed.mjs +1 -0
  41. package/tests/governor_docket.mjs +7 -3
  42. package/tests/hall_street_inside.mjs +2 -2
@@ -44,10 +44,12 @@ const MARKUP = /\*\*|`|^#|\[[^\]]*\]\(|https?:\/\/|<\/?[a-z]/im;
44
44
  // Control characters other than tab and line breaks (line breaks have their own rule).
45
45
  // eslint-disable-next-line no-control-regex
46
46
  const CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/;
47
+ const access = require('./access');
47
48
  const BUILDINGS = Object.freeze(['charter', 'rules', 'disputes', 'people', 'planning', 'automation', 'review', 'release', 'treasury']);
48
49
  // The seats an ask can wait on (gov-living.js seatResolver's grammar). An
49
50
  // unseated building falls to the owner when it is routed (owner ruling).
50
- const SEAT = new RegExp(`^(owner|(lead|board):(${BUILDINGS.join('|')})|committee:(${BUILDINGS.join('|')})\\.[a-z][a-z0-9_]{0,31})$`);
51
+ // `access:<key>` (task 1005000): whoever holds that outside access (access.js).
52
+ const SEAT = new RegExp(`^(owner|(lead|board):(${BUILDINGS.join('|')})|committee:(${BUILDINGS.join('|')})\\.[a-z][a-z0-9_]{0,31}|${access.SEAT_PART})$`);
51
53
  // An answer's key: what a tap sends. Its act, optional: the matter's own act it
52
54
  // takes ('go ahead', 'record', 'force:tested_by_hand'); the deck maps it onto
53
55
  // the desk row's real route and refuses one the row does not offer.
@@ -150,7 +152,7 @@ function checkAsk(raw) {
150
152
  // ---- who it waits on: a seat the direction desk can route ----
151
153
  const seat = str(c.waits_on).trim();
152
154
  if (!seat) err('waits_on', 'missing', 'Say who this waits on.');
153
- else if (!SEAT.test(seat)) err('waits_on', 'unknown_seat', `"${seat.slice(0, 40)}" is not a seat: say owner, lead:<building>, committee:<building>.<name> or board:<building>.`);
155
+ else if (!SEAT.test(seat)) err('waits_on', 'unknown_seat', `"${seat.slice(0, 40)}" is not a seat: say owner, lead:<building>, committee:<building>.<name>, board:<building> or access:<key>.`);
154
156
 
155
157
  // ---- what happens if nobody answers ----
156
158
  lineRule('if_nobody_answers', c.if_nobody_answers, 'Say what happens if nobody answers.');
@@ -206,6 +208,7 @@ const ASK_REQUIRED_MESSAGE = 'An AI session files a blocker with an ask: a quest
206
208
  function seatWords(seat) {
207
209
  const s = str(seat);
208
210
  if (s === 'owner') return 'the owner';
211
+ if (/^access:/.test(s)) return access.accessLabel(s.slice(7));
209
212
  const m = /^(lead|board|committee):([a-z]+)(?:\.([a-z0-9_]+))?$/.exec(s);
210
213
  if (!m) return s;
211
214
  const building = m[2].charAt(0).toUpperCase() + m[2].slice(1);
@@ -888,6 +888,41 @@ async function listActiveArchons() {
888
888
  return rows;
889
889
  }
890
890
 
891
+ // ACCESS HOLDERS (task 1005000, government_036): who holds each outside access.
892
+ // Active builders only — someone who left the project is nobody to ask. A record
893
+ // of who to ask, never authority: no gate reads these rows (access.js).
894
+ async function listAccessHolders() {
895
+ const { rows } = await pool.query(
896
+ `SELECT h.access_key, h.builder_id, h.granted_at, b.github_login, b.display_name
897
+ FROM government_access_holders h
898
+ JOIN builders b ON b.id = h.builder_id
899
+ WHERE b.status = 'active' AND b.system_role IS NULL
900
+ ORDER BY h.access_key, h.granted_at, h.builder_id`,
901
+ );
902
+ return rows;
903
+ }
904
+
905
+ // Record that a builder holds an access. Idempotent: an existing row is kept as it
906
+ // was (its original granted_at/by). → true when a row was added.
907
+ async function grantAccess(accessKey, builderId, grantedBy) {
908
+ const { rowCount } = await pool.query(
909
+ `INSERT INTO government_access_holders (access_key, builder_id, granted_by)
910
+ VALUES ($1, $2, $3)
911
+ ON CONFLICT (access_key, builder_id) DO NOTHING`,
912
+ [accessKey, builderId, grantedBy],
913
+ );
914
+ return rowCount > 0;
915
+ }
916
+
917
+ // → true when a row was removed.
918
+ async function revokeAccess(accessKey, builderId) {
919
+ const { rowCount } = await pool.query(
920
+ 'DELETE FROM government_access_holders WHERE access_key = $1 AND builder_id = $2',
921
+ [accessKey, builderId],
922
+ );
923
+ return rowCount > 0;
924
+ }
925
+
891
926
  // One builder of this project, or null: who a Lead appointment names. Only an
892
927
  // active person (no machine identity) can be appointed.
893
928
  async function getActiveBuilder(builderId) {
@@ -1306,6 +1341,9 @@ module.exports = {
1306
1341
  applyLaws,
1307
1342
  listLeads,
1308
1343
  listActiveArchons,
1344
+ listAccessHolders,
1345
+ grantAccess,
1346
+ revokeAccess,
1309
1347
  getActiveBuilder,
1310
1348
  applyLeadAct,
1311
1349
  listBuildouts,
@@ -86,6 +86,7 @@ const governmentConfig = require('./config');
86
86
  const asksDb = require('./asks-db');
87
87
  const routing = require('./routing');
88
88
  const waiting = require('./waiting');
89
+ const access = require('./access');
89
90
  const { ownerOf } = require('./leads');
90
91
 
91
92
  const log = api.logger('government');
@@ -840,8 +841,17 @@ async function docketFor(builder) {
840
841
  // nothing — the docket then says `routed: false` rather than guess whose it is.
841
842
  async function loadSeats(government) {
842
843
  try {
843
- const [leads, archons] = await Promise.all([db.listLeads(), db.listActiveArchons()]);
844
- return { leads, owner: ownerOf(archons), holders: (government && government.holders) || {} };
844
+ // task 1005000: who holds each outside access. Its own read fails OPEN to nobody
845
+ // recorded (every access seat then falls to the owner), so a missing table on an
846
+ // instance that has not migrated yet never stops the City routing. Started inside
847
+ // a .then so a db layer without the function (an older stub, the walk's store)
848
+ // fails open too rather than throwing before the catch is attached.
849
+ const accessRows = Promise.resolve().then(() => db.listAccessHolders()).catch((err) => {
850
+ log.warn({ err: err && err.message }, 'governor docket: access holders unreadable, access seats fall to the owner');
851
+ return [];
852
+ });
853
+ const [leads, archons, held] = await Promise.all([db.listLeads(), db.listActiveArchons(), accessRows]);
854
+ return { leads, owner: ownerOf(archons), holders: (government && government.holders) || {}, access: access.holdersByKey(held) };
845
855
  } catch (err) {
846
856
  log.warn({ err: err && err.message }, 'governor docket: seats unreadable, nothing routed');
847
857
  return null;
@@ -0,0 +1,33 @@
1
+ -- government_036_access_holders.sql — who holds the keys OUTSIDE Bongos, and an ask
2
+ -- that waits on them (task 1005000, goal 1000111).
3
+ --
4
+ -- WHAT THIS ADDS
5
+ --
6
+ -- government_access_holders — one row per (outside access, builder): the GitHub
7
+ -- repository's admin, the server, the domain's DNS, the hosting account, package
8
+ -- publishing, payments. The vocabulary is modules/government/access.js `ACCESS`,
9
+ -- held equal to the CHECK below by tests/access_holders.mjs. Written by the owner
10
+ -- only (the routes gate government.manage); who granted it and when ride the row.
11
+ --
12
+ -- It is a RECORD OF WHO TO ASK, never an authority grant: no permission atom, rank
13
+ -- check or gate reads this table, and it stores no credential — only who holds one.
14
+ --
15
+ -- government_asks.waits_on gains the seat `access:<key>`, so an ask can wait on
16
+ -- whoever holds an outside access (routing.js; nobody recorded → the owner).
17
+
18
+ BEGIN;
19
+
20
+ CREATE TABLE IF NOT EXISTS government_access_holders (
21
+ access_key text NOT NULL
22
+ CHECK (access_key IN ('github-admin', 'server-ssh', 'dns', 'hosting', 'npm-publish', 'payments')),
23
+ builder_id bigint NOT NULL REFERENCES builders(id) ON DELETE CASCADE,
24
+ granted_by bigint REFERENCES builders(id) ON DELETE SET NULL,
25
+ granted_at timestamptz NOT NULL DEFAULT now(),
26
+ PRIMARY KEY (access_key, builder_id)
27
+ );
28
+
29
+ ALTER TABLE government_asks DROP CONSTRAINT IF EXISTS government_asks_waits_on_check;
30
+ ALTER TABLE government_asks ADD CONSTRAINT government_asks_waits_on_check
31
+ CHECK (waits_on ~ '^(owner|(lead|board):(charter|rules|disputes|people|planning|automation|review|release|treasury)|committee:(charter|rules|disputes|people|planning|automation|review|release|treasury)\.[a-z][a-z0-9_]{0,31}|access:(github-admin|server-ssh|dns|hosting|npm-publish|payments))$');
32
+
33
+ COMMIT;
@@ -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>
@@ -471,7 +471,7 @@
471
471
  treasury: {
472
472
  holds: 'The budget, and who gets paid for what.', rule: 'majority', cap: 7,
473
473
  growth: 'Each new spending line takes a floor.', annex: 'room for an annex of spending lines',
474
- floors: [['Builder rewards', 'paid for shipped work', { floor: 'treasury.rewards' }], ['AI grading', 'the reviewer that grades every ship', { floor: 'treasury.grading' }], ['Hosting', 'servers and storage', { floor: 'treasury.hosting', desk: 'treasury.backup' }], ['Reserve', 'what is left this month', { reading: 'money', desk: 'treasury.budgets' }], ['Spending matters', 'spending put to a vote', { source: 'treasury_spending' }]],
474
+ floors: [['Builder rewards', 'credits earned for shipped work', { floor: 'treasury.rewards' }], ['AI grading', 'the reviewer that grades every ship', { floor: 'treasury.grading' }], ['Hosting', 'servers and storage', { floor: 'treasury.hosting', desk: 'treasury.backup' }], ['Reserve', 'what is left this month', { reading: 'money', desk: 'treasury.budgets' }], ['Spending matters', 'spending put to a vote', { source: 'treasury_spending' }]],
475
475
  },
476
476
  });
477
477
  const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
@@ -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.3",
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.3",
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.3",
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,21 @@
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
+ }
10814
+ ],
10815
+ "1.24.3": [
10816
+ {
10817
+ "id": "1005006",
10818
+ "text": "The Treasury screen now counts builder rewards in credits instead of dollars, and no longer says one credit is a dollar."
10819
+ }
10804
10820
  ]
10805
10821
  }
@@ -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.3'; // 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');