@bongos/core 1.21.92 → 1.21.94

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 (56) hide show
  1. package/.bongos-core.json +86 -56
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +4 -2
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0363-who-decides-what-is-a-table-changed-only-by-amendment.md +1 -0
  8. package/docs/api/openapi.json +38 -7
  9. package/docs/api-reference.md +4 -3
  10. package/docs/copy-inventory.md +354 -354
  11. package/docs/copy-registry.json +420 -420
  12. package/docs/design/project-startup-direction.md +7 -0
  13. package/docs/module-api-changelog.md +9 -0
  14. package/docs/page-readings.json +963 -992
  15. package/modules/autonomy/routes/autonomy.js +14 -5
  16. package/modules/government/decision-board.js +13 -3
  17. package/modules/government/decision-rules.js +61 -13
  18. package/modules/lifecycle/routes/done-when.js +2 -2
  19. package/modules/lifecycle/routes/gate-approvals.js +1 -1
  20. package/modules/lifecycle/routes/goals.js +4 -4
  21. package/modules/lifecycle/routes/project-settings.js +2 -2
  22. package/modules/lifecycle/routes/tasks.js +1 -1
  23. package/modules/lifecycle/routes/versions.js +1 -1
  24. package/modules/npm-release/routes/release.js +1 -0
  25. package/modules/onboarding/routes/access-requests.js +3 -3
  26. package/modules/provisioning/github-setup.js +129 -0
  27. package/modules/provisioning/module.json +2 -1
  28. package/modules/provisioning/provisioning.js +3 -0
  29. package/modules/provisioning/routes/callback-page.js +66 -1
  30. package/modules/provisioning/routes/core-upgrade.js +11 -0
  31. package/modules/provisioning/routes/github-setup.js +113 -0
  32. package/modules/provisioning/routes/provisioning.js +11 -45
  33. package/modules/public-landing/public/projects.html +105 -57
  34. package/modules/render-deploy/routes/act.js +1 -1
  35. package/modules/security/routes/reports.js +3 -0
  36. package/package-lock.json +2 -2
  37. package/package.json +1 -1
  38. package/release-notes.json +12 -0
  39. package/scripts/gds/fitness.js +7 -0
  40. package/scripts/gds/oauth-secret.js +4 -2
  41. package/scripts/gds/provision-github-setup.js +148 -0
  42. package/scripts/gds/provision.js +19 -5
  43. package/src/bongos/act-alone.js +84 -0
  44. package/src/bongos/routes/builders.js +6 -1
  45. package/src/module-api.js +7 -1
  46. package/tests/autonomy_fence_priority.mjs +3 -1
  47. package/tests/github_one_trip.mjs +297 -0
  48. package/tests/government_act_alone_buildings.mjs +430 -0
  49. package/tests/module_api.mjs +1 -0
  50. package/tests/npm_release_release.mjs +2 -1
  51. package/tests/projects_hub_dns_ready.mjs +2 -1
  52. package/tests/projects_hub_pre_uat.mjs +17 -9
  53. package/tests/provision.mjs +51 -9
  54. package/tests/provisioning_github_app_routes.mjs +94 -8
  55. package/tests/ship_mode.mjs +2 -1
  56. package/tests/wizard_github_feedback.mjs +8 -5
@@ -609,6 +609,12 @@ const KERNEL_FILES = [
609
609
  // this roster reads as a kernel->non-kernel leak. It imports only node built-ins
610
610
  // (https, zlib), so joining costs nothing.
611
611
  'src/bongos/package-registry.js',
612
+ // task 1004657 (ADR 0363 D5): requireActAlone, the gate a building's own page mounts
613
+ // after its permission gate — the same trust-gate category as auth.js, re-exported
614
+ // by the doorway, so leaving it off reads as a kernel->non-kernel leak. It imports
615
+ // only module-seams.js and logger.js (both on this roster) and reaches the
616
+ // government module through its port, never by require.
617
+ 'src/bongos/act-alone.js',
612
618
  ];
613
619
 
614
620
  // Kernel files exempt from the no-module-keys scan (Check 10) because naming a
@@ -641,6 +647,7 @@ const KERNEL_NAMES_MODULES_OK = new Set([
641
647
  'src/bongos/auth-github.js', // ticks the 'onboarding' port on first sign-in
642
648
  'src/bongos/auth-idp.js', // same, on the federated path
643
649
  'src/bongos/auth-discord.js', // the Discord account-link flow itself
650
+ 'src/bongos/act-alone.js', // resolves the 'government' port by key for checkActAlone (task 1004657)
644
651
  'src/bongos/permission-path-check.js', // names module path subtrees to PROTECT them
645
652
  'src/bongos/route-rank-check.js', // names module route dirs to rank-gate them
646
653
  'src/role-responsibilities.js', // the ideator's VERBATIM statement reads "Make good ideas" — the English word, colliding with the `ideas` key. Owner text; unparaphrasable.
@@ -172,12 +172,14 @@ function main() {
172
172
  cp.execSync(`systemctl restart ${slug}`, { stdio: 'inherit' });
173
173
  console.log(`✓ restarted ${slug}`);
174
174
 
175
- // (3) VERIFY — probe /healthz for auth_configured (retry briefly for restart).
175
+ // (3) VERIFY — probe the JSON health answer for auth_configured (retry briefly for
176
+ // restart). /api/bongos/healthz, not the root /healthz, which answers a plain "ok"
177
+ // that never parses (task 1004577).
176
178
  if (!port) { console.error('✖ no PORT in web.env and no --port given — cannot probe /healthz'); process.exit(1); }
177
179
  let probe = { authConfigured: false };
178
180
  for (let i = 0; i < 10; i++) {
179
181
  try {
180
- const out = cp.execSync(`curl -sS --max-time 5 http://127.0.0.1:${port}/healthz`, { stdio: ['ignore', 'pipe', 'pipe'] }).toString();
182
+ const out = cp.execSync(`curl -sS --max-time 5 http://127.0.0.1:${port}/api/bongos/healthz`, { stdio: ['ignore', 'pipe', 'pipe'] }).toString();
181
183
  probe = parseHealthzAuth(out);
182
184
  if (probe.ok || probe.authConfigured) break;
183
185
  } catch { /* not up yet */ }
@@ -0,0 +1,148 @@
1
+ 'use strict';
2
+ // scripts/gds/provision-github-setup.js — the control-plane runner's check of the two
3
+ // GitHub steps a founder finishes ON GITHUB after the app exists: "Install it on your repo"
4
+ // and "Command-line sign-in is on" (task 1004649; the checklist is derived in
5
+ // modules/provisioning/github-setup.js).
6
+ //
7
+ // WHY THE RUNNER. Only the runner holds the app's credentials (it placed them in the
8
+ // project's chmod-600 web.env; ADR 0111 §2 / ADR 0133 — the web tier never sees a secret).
9
+ // The setup_url redirect GitHub sends after an install carries an installation_id, but
10
+ // GitHub's own docs warn it can be spoofed, so a tick needs GitHub's answer to a question
11
+ // asked with the app's own key:
12
+ // install — GET /repos/{owner}/{repo}/installation, authenticated as the app (a JWT
13
+ // signed with its private key). 200 with an id = installed on THIS repo.
14
+ // device — POST github.com/login/device/code with the app's client id. GitHub answers a
15
+ // device code when device flow is on and `device_flow_disabled` when it is off.
16
+ // The unused code simply expires; nothing is signed in. GitHub has NO manifest
17
+ // field and NO API that turns device flow on (spike, task 1004649), so this
18
+ // only ever OBSERVES the founder's own tick on GitHub.
19
+ // A verified fact is recorded once as a provisioning_events row; the checklist reads it.
20
+ //
21
+ // BOUNDED. Only attempts that settled within VERIFY_WINDOW_MS, or whose install was
22
+ // reported within it, are checked, at most SWEEP_LIMIT per tick — so a finished or
23
+ // abandoned project costs nothing, and a founder who ticks device flow later re-opens the
24
+ // window by reinstalling or from the hall (task 1004652 owns "check again").
25
+ // Secrets: web.env is read on the box, parsed in memory, and only NAMES ever reach a log.
26
+
27
+ const crypto = require('node:crypto');
28
+ const { webEnvPath } = require('./provision-units.js');
29
+ const { SETUP_EVENTS, VERIFY_WINDOW_MS, githubSetupFacts, targetRepo } = require('../../modules/provisioning/github-setup.js');
30
+
31
+ const SWEEP_LIMIT = 20;
32
+ const UA = 'cloud-bongos-provisioner';
33
+
34
+ function b64url(input) {
35
+ return Buffer.from(input).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
36
+ }
37
+
38
+ // An app JWT (RS256, 9 minutes, iat backdated for skew) — GitHub's app authentication.
39
+ function appJwt({ appId, pem, nowSec = Math.floor(Date.now() / 1000) }) {
40
+ const head = b64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
41
+ const body = b64url(JSON.stringify({ iat: nowSec - 60, exp: nowSec + 9 * 60, iss: String(appId) }));
42
+ const signer = crypto.createSign('RSA-SHA256');
43
+ signer.update(`${head}.${body}`);
44
+ signer.end();
45
+ return `${head}.${body}.${b64url(signer.sign(pem))}`;
46
+ }
47
+
48
+ // The app's id, private key and client id out of a web.env body. The pem is stored as one
49
+ // line with literal \n (provision-units.js webEnvBody); the client id under the pack's
50
+ // prefix, which this does not need to know. Missing pieces are null. PURE.
51
+ function appCredsFromEnv(body) {
52
+ const val = (re) => { const m = String(body || '').match(re); return m ? m[1].trim() : null; };
53
+ const pemLine = val(/^GITHUB_APP_PRIVATE_KEY=(.+)$/m);
54
+ return {
55
+ appId: val(/^GITHUB_APP_ID=(\d+)\s*$/m),
56
+ pem: pemLine ? pemLine.replace(/\\n/g, '\n') : null,
57
+ clientId: val(/^[A-Z0-9_]+_GITHUB_CLIENT_ID=(\S+)\s*$/m),
58
+ };
59
+ }
60
+
61
+ // Is the app installed on owner/repo? true · false (404: the app is real, not installed
62
+ // there) · null (cannot tell — any other answer, or no network).
63
+ async function installedOnRepo({ appId, pem, repo, fetchImpl = globalThis.fetch }) {
64
+ try {
65
+ const res = await fetchImpl(`https://api.github.com/repos/${repo}/installation`, {
66
+ headers: { Authorization: `Bearer ${appJwt({ appId, pem })}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28', 'User-Agent': UA },
67
+ });
68
+ if (res.status === 404) return false;
69
+ if (!res.ok) return null;
70
+ const j = await res.json().catch(() => null);
71
+ return !!(j && j.id != null);
72
+ } catch (_) { return null; }
73
+ }
74
+
75
+ // Does GitHub accept a device-flow start for this client id? true · false
76
+ // (device_flow_disabled) · null (any other answer — never read as "off").
77
+ async function deviceFlowOn({ clientId, fetchImpl = globalThis.fetch }) {
78
+ try {
79
+ const res = await fetchImpl('https://github.com/login/device/code', {
80
+ method: 'POST',
81
+ headers: { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': UA },
82
+ body: `client_id=${encodeURIComponent(clientId)}`,
83
+ });
84
+ const j = await res.json().catch(() => null);
85
+ if (j && j.device_code) return true;
86
+ if (j && /device_flow_disabled/.test(String(j.error || ''))) return false;
87
+ return null;
88
+ } catch (_) { return null; }
89
+ }
90
+
91
+ // The attempts worth checking this tick: each instance's LATEST attempt, past the
92
+ // credential placement (an app id recorded), settled or reported within the window.
93
+ async function attemptsToVerify(db) {
94
+ const windowSec = Math.round(VERIFY_WINDOW_MS / 1000);
95
+ const { rows } = await db.query(
96
+ `WITH latest AS (
97
+ SELECT DISTINCT ON (instance_id) * FROM provisioning_oauth_manifests ORDER BY instance_id, created_at DESC)
98
+ SELECT l.id, l.instance_id, l.created_at, l.requested_by, i.slug, i.target_ref, i.hosting_shape, i.status AS instance_status
99
+ FROM latest l JOIN provisioning_instances i ON i.id = l.instance_id
100
+ WHERE l.app_id IS NOT NULL AND l.status IN ('done', 'error') AND i.status = 'active'
101
+ AND (l.updated_at > now() - ($1 * interval '1 second')
102
+ OR EXISTS (SELECT 1 FROM provisioning_events e WHERE e.instance_id = l.instance_id
103
+ AND e.event = $2 AND e.created_at > now() - ($1 * interval '1 second')))
104
+ ORDER BY l.updated_at DESC LIMIT ${SWEEP_LIMIT}`,
105
+ [windowSec, SETUP_EVENTS.installReported]);
106
+ return rows;
107
+ }
108
+
109
+ // One tick: for every attempt in the window, check whatever is not verified yet and record
110
+ // what GitHub confirmed. Returns { checked, installed, deviceFlow }.
111
+ async function verifyGithubAppSetups(deps) {
112
+ const { db, provisioning, log, apply } = deps;
113
+ const out = { checked: 0, installed: 0, deviceFlow: 0 };
114
+ const rows = await attemptsToVerify(db);
115
+ if (!apply) {
116
+ for (const r of rows) log(` would check the GitHub install + device flow for ${r.slug} (manifest #${r.id})`);
117
+ return out;
118
+ }
119
+ for (const r of rows) {
120
+ const facts = await githubSetupFacts(db, r.instance_id, r.created_at);
121
+ if (facts.installedAt && facts.deviceFlowAt) continue;
122
+ const onControlBox = r.hosting_shape === 'cloud-host' || r.hosting_shape === 'control-plane';
123
+ const boxExec = onControlBox ? (deps.controlExec || deps.exec) : deps.exec;
124
+ const read = boxExec(`${deps.privileged ? 'sudo ' : ''}cat ${webEnvPath(r)}`, { allowFail: true });
125
+ const creds = appCredsFromEnv(read && read.stdout);
126
+ out.checked++;
127
+ const repo = targetRepo(r);
128
+ const fetchImpl = deps.fetchImpl;
129
+ if (!facts.installedAt && creds.appId && creds.pem && repo) {
130
+ if (await installedOnRepo({ appId: creds.appId, pem: creds.pem, repo, fetchImpl })) {
131
+ await provisioning.recordEvent(db, { instanceId: r.instance_id, ownerBuilderId: r.requested_by, event: SETUP_EVENTS.installed,
132
+ detail: `the GitHub app is installed on ${repo} (checked with the app's own key)`, actor: 'runner' });
133
+ out.installed++; log(` ✓ ${r.slug}: GitHub app installed on ${repo}`);
134
+ }
135
+ }
136
+ if (!facts.deviceFlowAt && creds.clientId) {
137
+ if (await deviceFlowOn({ clientId: creds.clientId, fetchImpl })) {
138
+ await provisioning.recordEvent(db, { instanceId: r.instance_id, ownerBuilderId: r.requested_by, event: SETUP_EVENTS.deviceFlow,
139
+ detail: 'command-line sign-in (device flow) is on for the GitHub app', actor: 'runner' });
140
+ out.deviceFlow++; log(` ✓ ${r.slug}: command-line sign-in is on`);
141
+ }
142
+ }
143
+ }
144
+ if (out.checked) log(`github-setup — checked=${out.checked} installed=${out.installed} device-flow=${out.deviceFlow}`);
145
+ return out;
146
+ }
147
+
148
+ module.exports = { appJwt, appCredsFromEnv, installedOnRepo, deviceFlowOn, attemptsToVerify, verifyGithubAppSetups, SWEEP_LIMIT };
@@ -1039,6 +1039,13 @@ async function convertManifestCode(code, { fetchImpl = globalThis.fetch, userAge
1039
1039
  };
1040
1040
  }
1041
1041
 
1042
+ // The sign-in probe: the project's JSON health answer, which carries auth_configured
1043
+ // (src/bongos/routes/healthz.js). NOT healthzCmd — the root /healthz answers a plain "ok",
1044
+ // which never parses, so every setup that worked was recorded FAILED (task 1004577).
1045
+ function authHealthzCmd(inst) {
1046
+ return `curl -sS --max-time 5 http://127.0.0.1:${inst.port}/api/bongos/healthz`;
1047
+ }
1048
+
1042
1049
  // Convert one manifest row's code → creds and PLACE them (rewrite web.env + restart +
1043
1050
  // verify /healthz). Uses the SAME executors provisionInstance does, so co-tenant (local
1044
1051
  // or remote-cohost) and standalone (control-plane-local) are handled identically. Returns { ok, appId, appSlug } | { ok:false, error }.
@@ -1082,15 +1089,19 @@ async function placeManifestCreds(inst, mrow, deps) {
1082
1089
  appId: creds.appId, appPem: creds.appPem,
1083
1090
  }), { mode: 0o600, sudo: true });
1084
1091
  pmFor(deps).restartService(inst, { exec: boxExec, sudoP });
1092
+ // The app now exists AND its credentials are placed — the checklist's first tick, and the
1093
+ // slug the callback page needs to send the founder on to installing it (task 1004649).
1094
+ if (apply && deps.onCredsPlaced) await Promise.resolve().then(() => deps.onCredsPlaced({ appId: creds.appId, appSlug: creds.appSlug })).catch(() => {});
1085
1095
 
1086
- // 4) verify /healthz reports auth_configured=true (only meaningful under --apply).
1096
+ // 4) verify the project reports auth_configured=true (only meaningful under --apply).
1087
1097
  // PATIENT (F23): the child must boot before it can answer; losing this race
1088
- // used to record a failure for a setup that had succeeded.
1098
+ // used to record a failure for a setup that had succeeded. The JSON answer lives
1099
+ // at /api/bongos/healthz — the root /healthz says a plain "ok" (task 1004577).
1089
1100
  if (apply) {
1090
1101
  const sleep = deps.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
1091
1102
  let authConfigured = false;
1092
1103
  for (let i = 0; i < MANIFEST_VERIFY_TRIES; i++) {
1093
- const out = boxExec(healthzCmd(inst), { allowFail: true });
1104
+ const out = boxExec(authHealthzCmd(inst), { allowFail: true });
1094
1105
  if (oauthSecret.parseHealthzAuth(out && out.stdout).authConfigured) { authConfigured = true; break; }
1095
1106
  await sleep(MANIFEST_VERIFY_INTERVAL_MS);
1096
1107
  }
@@ -1149,7 +1160,9 @@ async function drainOAuthManifests(deps) {
1149
1160
  try {
1150
1161
  const inst = await provisioning.getInstanceById(db, mrow.instance_id);
1151
1162
  if (!inst) { await provisioning.resolveOAuthManifest(db, mrow.id, 'error', { last_error: 'instance row vanished' }); errors++; continue; }
1152
- const result = await placeManifestCreds(inst, mrow, deps);
1163
+ // the slug lands on the row the moment the credentials are placed (task 1004649)
1164
+ const onCredsPlaced = (c) => provisioning.resolveOAuthManifest(db, mrow.id, 'converting', { app_id: c.appId, app_slug: c.appSlug });
1165
+ const result = await placeManifestCreds(inst, mrow, { ...deps, onCredsPlaced });
1153
1166
  if (result && result.ok === false) {
1154
1167
  // A SPENT code is terminal, always (F23): GitHub's manifest code is
1155
1168
  // single-use, so resetting the row to 'code_received' guarantees the
@@ -1257,6 +1270,7 @@ async function cmdRunIntents(deps) {
1257
1270
  }
1258
1271
  await snapshotAllCoreVersions(deps); // refresh what the deploy control reads, AFTER the drain (ADR 0293 D3/D6)
1259
1272
  const m = await drainOAuthManifests(deps); // same tick: convert + place any GitHub-App-Manifest creds awaiting the control plane
1273
+ await require('./provision-github-setup.js').verifyGithubAppSetups(deps).catch((e) => log(` github-setup check failed (non-blocking): ${e.message}`)); // install + device flow, asked of GitHub with the app's key (task 1004649)
1260
1274
  log(`done — ran=${ran} ok=${ok} errors=${errors}; oauth-manifest converted=${m.converted} errors=${m.errors}`);
1261
1275
  return { ran, ok, errors, manifests: m };
1262
1276
  }
@@ -1438,7 +1452,7 @@ module.exports = {
1438
1452
  // restart an unreachable ACTIVE instance (task 1003238) — the settings leg minus the env patch
1439
1453
  restartInstance,
1440
1454
  // GitHub App Manifest flow (task 2080, ADR 0133)
1441
- convertManifestCode, placeManifestCreds, drainOAuthManifests, MANIFEST_UA,
1455
+ convertManifestCode, placeManifestCreds, drainOAuthManifests, authHealthzCmd, MANIFEST_UA,
1442
1456
  // cross-site identity federation (ADR 0141 §4, task #1002109)
1443
1457
  federationHubOrigin, shouldFederate, federateInstance,
1444
1458
  };
@@ -0,0 +1,84 @@
1
+ 'use strict';
2
+
3
+ // src/bongos/act-alone.js — WHO DECIDES WHAT, on every building's own page
4
+ // (task 1004657, goal 1000111; ADR 0363 D5 and its Consequences).
5
+ //
6
+ // WHY. A project's decision-rules table (modules/government/decision-rules.js)
7
+ // can hand a building to a sitting: "Release is decided by the council, by
8
+ // majority". Until this file, only People's own routes asked the table before
9
+ // letting one builder act; every other building's page still let whoever held
10
+ // its permission act alone, so a passed amendment changed the board and not
11
+ // the building. requireActAlone(area) is the one gate those pages mount, AFTER
12
+ // their permission gate, so the permission stays the coarse wall and the row
13
+ // only narrows who may use it alone (never wider — ADR 0363 D5).
14
+ //
15
+ // THE REFUSAL is People's, exactly (modules/government/routes/government.js,
16
+ // peopleMayActAlone): 409 `decided_by_sitting` with { area, rule, who,
17
+ // what_this_means, open_with }, so the hall renders one sentence for every
18
+ // building and the builder is pointed at the sitting (POST
19
+ // /government/board/matters) instead.
20
+ //
21
+ // FAIL DIRECTIONS, mirroring checkActAlone and People:
22
+ // • no government module, or a port without checkActAlone → AS TODAY (the
23
+ // route's own permission is the whole gate). A malformed or missing row
24
+ // already reads as its as-today default inside checkActAlone (D2).
25
+ // • the table cannot be read at all (checkActAlone throws), or the answer is
26
+ // not one this gate knows → 503 `decision_rule_unreadable`, fail-CLOSED, as
27
+ // People does: an unreadable rule never waves a lone act through.
28
+ //
29
+ // A PASSED MATTER IS NOT A LONE ACT. When a sitting passes a matter, the owning
30
+ // module carries it out through its 'governor.matterAct' contribution, which
31
+ // calls the route's underlying write directly (modules/autonomy/matter-acts.js,
32
+ // modules/lifecycle/matter-acts.js, the Treasury act in
33
+ // src/bongos/routes/builders.js) and never passes through this middleware — the
34
+ // sitting is the authority. tests/government_act_alone_buildings.mjs pins that.
35
+ //
36
+ // `unless(req)` exempts a request that is not a decision at all (a dry run that
37
+ // only reads, the owner door of a tenant pin move) or a BRAKE (switching
38
+ // unattended building off, pausing one runner): stopping a machine never waits
39
+ // for a meeting. Pass a named function — the route classifier reads the line.
40
+ //
41
+ // Server-side, per request, uncached (ADR 0016). Core and modules reach the
42
+ // government module only through its port, never by require (ADR 0083).
43
+
44
+ const seams = require('../module-seams');
45
+ const log = require('./logger').logger.child({ src: 'act-alone' });
46
+
47
+ function logError(message) {
48
+ try {
49
+ log.error(message);
50
+ } catch (_) { /* logging must never decide a request */ }
51
+ }
52
+
53
+ function requireActAlone(area, { unless = null } = {}) {
54
+ if (typeof area !== 'string' || area.length === 0) {
55
+ throw new TypeError('requireActAlone(area): name the building whose row decides this route');
56
+ }
57
+ if (unless !== null && typeof unless !== 'function') {
58
+ throw new TypeError('requireActAlone(area, { unless }): unless must be a function of the request');
59
+ }
60
+ return async function actAloneGate(req, res, next) {
61
+ if (unless && unless(req) === true) return next();
62
+ const government = seams.resolveOptional('government');
63
+ if (!government || typeof government.checkActAlone !== 'function') return next();
64
+ let out;
65
+ try {
66
+ out = await government.checkActAlone(area, req.builder && req.builder.id);
67
+ } catch (err) {
68
+ logError(`${area} decision rule unreadable: ${err && err.message}`);
69
+ return res.fail('decision_rule_unreadable', { status: 503, message: `who decides at ${area} could not be read just now` });
70
+ }
71
+ if (out && out.ok === true) return next();
72
+ if (!out || out.reason !== 'decided_by_sitting') {
73
+ // unknown_area is a wiring bug in the route, not a fact about the caller.
74
+ logError(`requireActAlone('${area}') got ${out && out.reason} — refusing fail-closed`);
75
+ return res.fail('decision_rule_unreadable', { status: 503, message: `who decides at ${area} could not be read just now` });
76
+ }
77
+ return res.fail('decided_by_sitting', 409, {
78
+ area: out.area, rule: out.rule, who: out.who,
79
+ what_this_means: out.what_this_means, open_with: out.open_with,
80
+ });
81
+ };
82
+ }
83
+
84
+ module.exports = { requireActAlone };
@@ -26,6 +26,11 @@ const path = require('node:path');
26
26
  const { execFile } = require('node:child_process');
27
27
  const express = require('express');
28
28
  const auth = require('../auth');
29
+ // WHO DECIDES AT TREASURY (task 1004657; ADR 0363 D5): the budget write refuses a
30
+ // lone act while Treasury's decision-rules row names a sitting. A passed Treasury
31
+ // matter sets the limit through setMonthlyBudget directly (the governor.matterAct
32
+ // below), never through the route, so the sitting is never refused by its own gate.
33
+ const { requireActAlone } = require('../act-alone');
29
34
  const db = require('../db');
30
35
  const { validateOrRespond, LIMITS, parseId } = require('./_helpers');
31
36
  // The onboarding state machine carved into modules/onboarding/ (BV1.R81 / ADR
@@ -398,7 +403,7 @@ module.exports = function buildBuildersRouter() {
398
403
  // rank: archon — changes another builder's cost ceiling. NOT ownership-scoped:
399
404
  // archon-only admin op. Gate: requirePermission('builder.budget.set') — BV1.R104;
400
405
  // the seeded rank-role makes the permission and the old rank floor identical (ADR 0151).
401
- router.patch('/builders/:id/budget', auth.requireBuilder, auth.requirePermission('builder.budget.set'), async (req, res) => {
406
+ router.patch('/builders/:id/budget', auth.requireBuilder, auth.requirePermission('builder.budget.set'), requireActAlone('treasury'), async (req, res) => {
402
407
  const targetId = parseId(req, res, { code: 'bad_builder_id', positiveInt: true });
403
408
  if (targetId === null) return;
404
409
  if (validateOrRespond(req, res, { monthly_budget_usd: {} })) return; // number ≥0, or null to clear; validated below
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.92'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.94'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -133,6 +133,12 @@ module.exports = {
133
133
  // the equivalent requireRank admitted, so a migrated module route is
134
134
  // behaviour-identical (BV1.R105).
135
135
  get requirePermission() { return require('./bongos/auth').requirePermission; },
136
+ // requireActAlone(area, { unless }) — WHO DECIDES WHAT on a building's own
137
+ // page (task 1004657; ADR 0363 D5). Mounted AFTER the route's permission
138
+ // gate: while the project's decision-rules row hands that building to a
139
+ // sitting, one builder acting alone is refused (409 decided_by_sitting,
140
+ // pointing at the matter route). No government module → as today.
141
+ get requireActAlone() { return require('./bongos/act-alone').requireActAlone; },
136
142
  get rankMeetsThreshold() { return require('./bongos/auth').rankMeetsThreshold; },
137
143
  get requireBfgPrincipal() { return require('./bongos/auth').requireBfgPrincipal; },
138
144
  // allowBoxScope — DEPRECATED no-op, kept so the doorway loses no export inside
@@ -64,8 +64,10 @@ test('POST /autonomy/fence/priority sits behind the same owner-only atom as the
64
64
  const layer = router.stack.find((l) => l.route && l.route.path === '/autonomy/fence/priority');
65
65
  assert.ok(layer, 'the route is registered');
66
66
  assert.equal(layer.route.methods.post, true);
67
- assert.equal(layer.route.stack.length, 3, 'requireBuilder + requirePermission + handler');
67
+ assert.equal(layer.route.stack.length, 4, 'requireBuilder + requirePermission + requireActAlone + handler');
68
68
  assert.equal(layer.route.stack[1].handle.permKey, 'autonomy.fence.manage');
69
+ // task 1004657: Automation's decision rule is asked after the permission gate.
70
+ assert.equal(layer.route.stack[2].handle.name, 'actAloneGate');
69
71
  });
70
72
 
71
73
  test('the route sets, clears, and refuses a goal that is not allowlisted', async () => {