@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.
- package/.bongos-core.json +86 -56
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +4 -2
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0363-who-decides-what-is-a-table-changed-only-by-amendment.md +1 -0
- package/docs/api/openapi.json +38 -7
- package/docs/api-reference.md +4 -3
- package/docs/copy-inventory.md +354 -354
- package/docs/copy-registry.json +420 -420
- package/docs/design/project-startup-direction.md +7 -0
- package/docs/module-api-changelog.md +9 -0
- package/docs/page-readings.json +963 -992
- package/modules/autonomy/routes/autonomy.js +14 -5
- package/modules/government/decision-board.js +13 -3
- package/modules/government/decision-rules.js +61 -13
- package/modules/lifecycle/routes/done-when.js +2 -2
- package/modules/lifecycle/routes/gate-approvals.js +1 -1
- package/modules/lifecycle/routes/goals.js +4 -4
- package/modules/lifecycle/routes/project-settings.js +2 -2
- package/modules/lifecycle/routes/tasks.js +1 -1
- package/modules/lifecycle/routes/versions.js +1 -1
- package/modules/npm-release/routes/release.js +1 -0
- package/modules/onboarding/routes/access-requests.js +3 -3
- package/modules/provisioning/github-setup.js +129 -0
- package/modules/provisioning/module.json +2 -1
- package/modules/provisioning/provisioning.js +3 -0
- package/modules/provisioning/routes/callback-page.js +66 -1
- package/modules/provisioning/routes/core-upgrade.js +11 -0
- package/modules/provisioning/routes/github-setup.js +113 -0
- package/modules/provisioning/routes/provisioning.js +11 -45
- package/modules/public-landing/public/projects.html +105 -57
- package/modules/render-deploy/routes/act.js +1 -1
- package/modules/security/routes/reports.js +3 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/fitness.js +7 -0
- package/scripts/gds/oauth-secret.js +4 -2
- package/scripts/gds/provision-github-setup.js +148 -0
- package/scripts/gds/provision.js +19 -5
- package/src/bongos/act-alone.js +84 -0
- package/src/bongos/routes/builders.js +6 -1
- package/src/module-api.js +7 -1
- package/tests/autonomy_fence_priority.mjs +3 -1
- package/tests/github_one_trip.mjs +297 -0
- package/tests/government_act_alone_buildings.mjs +430 -0
- package/tests/module_api.mjs +1 -0
- package/tests/npm_release_release.mjs +2 -1
- package/tests/projects_hub_dns_ready.mjs +2 -1
- package/tests/projects_hub_pre_uat.mjs +17 -9
- package/tests/provision.mjs +51 -9
- package/tests/provisioning_github_app_routes.mjs +94 -8
- package/tests/ship_mode.mjs +2 -1
- package/tests/wizard_github_feedback.mjs +8 -5
package/scripts/gds/fitness.js
CHANGED
|
@@ -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
|
|
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 };
|
package/scripts/gds/provision.js
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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,
|
|
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 () => {
|