@bongos/core 1.19.205 → 1.19.207
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 +46 -36
- package/.claude/skills/new-project/SKILL.md +3 -1
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +8 -1
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/adr/0055-server-mediated-branch-publish.md +2 -0
- package/docs/adr/0133-github-app-manifest-onboarding-signin.md +2 -2
- package/docs/api/openapi.json +160 -5
- package/docs/api-reference.md +4 -2
- package/docs/recipes/ops-gotchas.md +8 -1
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
- package/modules/lifecycle/github-push.js +130 -18
- package/modules/lifecycle/publish-reconciler.js +44 -1
- package/modules/lifecycle/routes/tasks.js +21 -4
- package/modules/provisioning/migrations/provisioning_009_project_settings.sql +19 -0
- package/modules/provisioning/onboard-plan.js +1 -0
- package/modules/provisioning/provisioning.js +59 -10
- package/modules/provisioning/routes/provisioning.js +67 -0
- package/modules/provisioning/tests/provisioning.mjs +43 -3
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/projects.html +19 -2
- package/scripts/gds/onboard.js +11 -3
- package/scripts/gds/provision.js +27 -4
- package/src/module-api.js +5 -1
- package/tests/onboard.mjs +5 -1
- package/tests/provision.mjs +30 -2
- package/tests/provisioning_settings.mjs +200 -0
- package/tests/publish_branch_route.mjs +153 -2
- package/tests/publish_reconciler.mjs +63 -0
package/docs/api-reference.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# Bongos API reference
|
|
4
4
|
|
|
5
|
-
> **Generated from the live route files** — the route file is authoritative.
|
|
5
|
+
> **Generated from the live route files** — the route file is authoritative. 278 endpoints across 46 route files.
|
|
6
6
|
> Machine-readable spec: [`docs/api/openapi.json`](api/openapi.json) (OpenAPI 3.1). Rendered docs site: **`/docs`** (e.g. `cloudbongos.com/docs`).
|
|
7
7
|
|
|
8
8
|
Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>.md)): `public` < `any-builder` < `metic+archon` < `archon`.
|
|
@@ -369,7 +369,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
|
|
|
369
369
|
| GET | `/api/bongos/projects/featured` | `public` | — | a curated public showcase; no auth by design. |
|
|
370
370
|
| POST | `/api/bongos/projects/invite` | `metic+archon` | `client_id`, `github_login` | invite a builder into a project (ADR 0141 §5, task #1002115). |
|
|
371
371
|
|
|
372
|
-
## `provisioning` (
|
|
372
|
+
## `provisioning` (17)
|
|
373
373
|
|
|
374
374
|
| Method | Path | Rank | Body | Description |
|
|
375
375
|
|---|---|---|---|---|
|
|
@@ -384,6 +384,8 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
|
|
|
384
384
|
| POST | `/api/bongos/provisioning/instances/:id/domain` | `any-builder` | `domain` | POST /provisioning/instances/:id/domain — give the caller's OWN instance its address (task 1002692, gap 5: no path existed to add a domai… |
|
|
385
385
|
| POST | `/api/bongos/provisioning/instances/:id/force-teardown` | `metic+archon` | — | POST /provisioning/instances/:id/force-teardown — Archon tears down ANY instance. |
|
|
386
386
|
| POST | `/api/bongos/provisioning/instances/:id/github-app` | `any-builder` | `account` | POST /provisioning/instances/:id/github-app — mint a GitHub-App-Manifest onboarding link for the caller's OWN instance (task 2080, ADR 01… |
|
|
387
|
+
| GET | `/api/bongos/provisioning/instances/:id/settings` | `any-builder` | — | GET /provisioning/instances/:id/settings — read a project's effective settings + the allowed values (so a form can render its choices). |
|
|
388
|
+
| PATCH | `/api/bongos/provisioning/instances/:id/settings` | `any-builder` | `platform_visibility`, `joinability` | PATCH /provisioning/instances/:id/settings — change a project's settings. |
|
|
387
389
|
| POST | `/api/bongos/provisioning/instances/:id/teardown` | `any-builder` | — | POST /provisioning/instances/:id/teardown — request teardown of the caller's OWN instance. |
|
|
388
390
|
| GET | `/api/bongos/provisioning/onboard-plan` | `public` | — | GET /provisioning/onboard-plan?mode=greenfield\|adopt&domain=<host> — the CANONICAL onboarding step sequence (task 2085; goal 35 / criteri… |
|
|
389
391
|
| GET | `/api/bongos/provisioning/slug-available` | `any-builder` | — | GET /provisioning/slug-available?slug=<handle> — is this handle free to create under? rank: any authenticated builder (same gate as the c… |
|
|
@@ -131,9 +131,16 @@ Organized by topic, not chronology. New entries go in the matching section; crea
|
|
|
131
131
|
|
|
132
132
|
- **Symptom:** On a wizard-created instance, any GitHub account other than the App's owner clicks "Sign in with GitHub" and gets a bare GitHub 404 — before the instance is ever involved. The project is effectively single-builder on the web. (Device Flow sign-in for the same non-owner works, which makes the failure look like anything but an App setting.)
|
|
133
133
|
- **Root cause:** the ADR 0133 manifest flow used to mint the GitHub App with `public: false`, and GitHub 404s a private App's authorize URL for every non-owner. Fixed for newly minted Apps (`buildGithubAppManifest` now sets `public: true` — gap-list W6, task 1003048), but a manifest can't be re-applied to an existing App.
|
|
134
|
-
- **Fix for pre-existing instances:** the App owner flips it by hand — github.com → Settings → Developer settings → GitHub Apps → (the instance's App) → **Advanced** → **Make public**. One click, no permission changes
|
|
134
|
+
- **Fix for pre-existing instances:** the App owner flips it by hand — github.com → Settings → Developer settings → GitHub Apps → (the instance's App) → **Advanced** → **Make public**. One click, no permission changes needed for sign-in.
|
|
135
135
|
- **Live evidence:** reproduced twice against Spike Harbor (instance 18) with a fresh verified-email account, 2026-08-16 — see `docs/audits/project-creation-gap-list.md` W6.
|
|
136
136
|
|
|
137
|
+
### Manifest-born GitHub Apps created before the task-1003049 fix are NOT a publish credential — every ship strands at `confirmed`
|
|
138
|
+
|
|
139
|
+
- **Symptom:** on a fresh instance every `/builder-ship` parks at `confirmed`; `GET /tasks/:id/publish-status` answers 503 `push_unconfigured`; the archon has to merge PRs and `POST /tasks/:id/ship` by hand. Silent — work just piles up (gap-list W7, proven live on Spike Harbor task 1).
|
|
140
|
+
- **Root cause:** the ADR 0133 manifest used to request **zero** repo permissions and the conversion's App private key (`pem`) was discarded, so no publish credential (ADR 0055) ever existed on a wizard-born instance. Fixed for newly minted Apps (task 1003049): the manifest now requests `contents: write` + `pull_requests: write` + `actions: read`, `placeManifestCreds` writes `GITHUB_APP_ID` + `GITHUB_APP_PRIVATE_KEY` into web.env, and the server discovers the installation id at runtime — the one human step left is **installing the App on the repo** (the onboarding `app-install` step).
|
|
141
|
+
- **Fix for pre-existing instances:** in the App's GitHub settings — (1) **Permissions & events**: grant Contents read/write, Pull requests read/write, Actions read-only; (2) **General → Private keys**: generate a key; (3) put `GITHUB_APP_ID=<id>` and `GITHUB_APP_PRIVATE_KEY=<pem as one line with literal \n>` in `/etc/<slug>/web.env` and restart the unit; (4) **Install App** → pick the project repo (owners must re-approve the new permissions on an already-installed App). Alternative: a fine-grained `GITHUB_PUSH_TOKEN` PAT with the same three scopes (ADR 0055).
|
|
142
|
+
- **How you know it worked:** `publish-status` stops 503ing, and the publish-reconciler log line `WARN … not installed` (if the App wasn't installed yet) goes quiet after the install.
|
|
143
|
+
|
|
137
144
|
---
|
|
138
145
|
|
|
139
146
|
## Mac / local dev
|
|
@@ -542,6 +542,10 @@ function createClient(opts = {}) {
|
|
|
542
542
|
postProvisioningInstancesIdForceTeardown: (args) => request("POST", "/provisioning/instances/{id}/force-teardown", { hasBody: true }, args),
|
|
543
543
|
// POST /provisioning/instances/{id}/github-app — rank: any-builder — POST /provisioning/instances/:id/github-app
|
|
544
544
|
postProvisioningInstancesIdGithubApp: (args) => request("POST", "/provisioning/instances/{id}/github-app", { hasBody: true }, args),
|
|
545
|
+
// GET /provisioning/instances/{id}/settings — rank: any-builder — GET /provisioning/instances/:id/settings
|
|
546
|
+
getProvisioningInstancesIdSettings: (args) => request("GET", "/provisioning/instances/{id}/settings", { hasBody: false }, args),
|
|
547
|
+
// PATCH /provisioning/instances/{id}/settings — rank: any-builder — PATCH /provisioning/instances/:id/settings
|
|
548
|
+
patchProvisioningInstancesIdSettings: (args) => request("PATCH", "/provisioning/instances/{id}/settings", { hasBody: true }, args),
|
|
545
549
|
// POST /provisioning/instances/{id}/teardown — rank: any-builder — POST /provisioning/instances/:id/teardown
|
|
546
550
|
postProvisioningInstancesIdTeardown: (args) => request("POST", "/provisioning/instances/{id}/teardown", { hasBody: true }, args),
|
|
547
551
|
// GET /provisioning/onboard-plan — rank: public — GET /provisioning/onboard-plan
|
|
@@ -20,10 +20,12 @@
|
|
|
20
20
|
// THE SERVER CREDENTIAL (task 1028 / ADR 0055 hardening). Preferred: a GitHub
|
|
21
21
|
// APP — the server holds ONLY the App private key and mints a SHORT-LIVED,
|
|
22
22
|
// repo-scoped INSTALLATION token (1h) per push window, so there's no standing
|
|
23
|
-
// push secret at rest. Set GITHUB_APP_ID +
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
23
|
+
// push secret at rest. Set GITHUB_APP_ID + GITHUB_APP_PRIVATE_KEY (the
|
|
24
|
+
// installation id is discovered from the repo at runtime; GITHUB_APP_INSTALLATION_ID
|
|
25
|
+
// still wins when set — task 1003049). Fallback (v1, kept for cutover): a standing
|
|
26
|
+
// fine-grained PAT in GITHUB_PUSH_TOKEN. resolveToken() prefers the App, then the
|
|
27
|
+
// PAT; the App JWT is RS256-signed with node:crypto (no jsonwebtoken / Octokit
|
|
28
|
+
// dependency).
|
|
27
29
|
//
|
|
28
30
|
// This module is the server seam: push a bundle, open/find the PR, enable auto-
|
|
29
31
|
// merge, report status. All git runs through execFile arg-arrays (shell:false,
|
|
@@ -58,12 +60,12 @@ const SAFE_SHA_RE = /^[0-9a-f]{7,64}$/;
|
|
|
58
60
|
|
|
59
61
|
// Configured if EITHER credential is present: a GitHub App (preferred) or the
|
|
60
62
|
// v1 PAT fallback. The route 503s when neither is set (task 1028).
|
|
63
|
+
// GITHUB_APP_INSTALLATION_ID is OPTIONAL (task 1003049): a fresh instance's
|
|
64
|
+
// manifest-born App has no known installation id at cred-placement time, so the
|
|
65
|
+
// server discovers it at runtime from GET /repos/{owner}/{repo}/installation —
|
|
66
|
+
// the one human onboarding step left is installing the App on the repo.
|
|
61
67
|
function appConfigured() {
|
|
62
|
-
return !!(
|
|
63
|
-
process.env.GITHUB_APP_ID &&
|
|
64
|
-
process.env.GITHUB_APP_INSTALLATION_ID &&
|
|
65
|
-
process.env.GITHUB_APP_PRIVATE_KEY
|
|
66
|
-
);
|
|
68
|
+
return !!(process.env.GITHUB_APP_ID && process.env.GITHUB_APP_PRIVATE_KEY);
|
|
67
69
|
}
|
|
68
70
|
|
|
69
71
|
function isConfigured() {
|
|
@@ -200,10 +202,76 @@ function mintAppJwt({ appId, privateKeyPem, nowSec }) {
|
|
|
200
202
|
|
|
201
203
|
// Module-level cache of the current installation token, reused until ~60s before
|
|
202
204
|
// expiry. Installation tokens are short-lived by construction (1h) and repo-
|
|
203
|
-
// scoped to the single repo this server serves.
|
|
205
|
+
// scoped to the single repo this server serves. The discovered installation id
|
|
206
|
+
// is cached separately — it only changes if the App is uninstalled/reinstalled,
|
|
207
|
+
// and a stale id fails the token exchange loudly (APP_TOKEN_FAILED), never silently.
|
|
204
208
|
let _instCache = { token: null, expEpoch: 0 };
|
|
209
|
+
let _instIdCache = null;
|
|
210
|
+
// Negative cache for APP_NOT_INSTALLED: during an active ship the publish-status
|
|
211
|
+
// poll fires every few seconds, and re-discovering a known-absent installation
|
|
212
|
+
// each time re-mints a JWT + two GitHub calls for the same answer.
|
|
213
|
+
const NOT_INSTALLED_TTL_MS = 60_000;
|
|
214
|
+
let _notInstalled = { untilMs: 0, err: null };
|
|
205
215
|
function _resetAppTokenCacheForTests() {
|
|
206
216
|
_instCache = { token: null, expEpoch: 0 };
|
|
217
|
+
_instIdCache = null;
|
|
218
|
+
_notInstalled = { untilMs: 0, err: null };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function appJwtHeaders(jwt) {
|
|
222
|
+
return {
|
|
223
|
+
Authorization: `Bearer ${jwt}`,
|
|
224
|
+
Accept: 'application/vnd.github+json',
|
|
225
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
226
|
+
'User-Agent': userAgent(),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Discover the App's installation id on THIS repo (task 1003049). A manifest-born
|
|
231
|
+
// instance (ADR 0133) knows its App id + private key at cred-placement time but not
|
|
232
|
+
// the installation id — the owner installs the App as a separate onboarding step —
|
|
233
|
+
// so the server derives it live instead of requiring a third env var. Throws
|
|
234
|
+
// APP_NOT_INSTALLED (with a best-effort install_url) when the App exists but nobody
|
|
235
|
+
// has installed it on the repo yet: that is the one remaining human step, and the
|
|
236
|
+
// refusal must say so in a sentence, not a bare code.
|
|
237
|
+
async function discoverInstallationId(jwt, deps = {}) {
|
|
238
|
+
if (_instIdCache) return _instIdCache;
|
|
239
|
+
const nowMs = deps.now ? deps.now() : Date.now();
|
|
240
|
+
if (_notInstalled.err && nowMs < _notInstalled.untilMs) throw _notInstalled.err;
|
|
241
|
+
const info = deps.repoInfo || loadRepoInfo();
|
|
242
|
+
if (!info || info.error) throw err('NO_REPO_INFO');
|
|
243
|
+
const { owner, repo } = info;
|
|
244
|
+
const doFetch = deps.fetchImpl || fetch;
|
|
245
|
+
const res = await doFetch(`https://api.github.com/repos/${owner}/${repo}/installation`, {
|
|
246
|
+
headers: appJwtHeaders(jwt),
|
|
247
|
+
});
|
|
248
|
+
if (res.status === 404) {
|
|
249
|
+
// The App is real (the JWT authenticated) but not installed on the repo.
|
|
250
|
+
// Fetch the App's own page for a clickable install URL (best-effort — the
|
|
251
|
+
// guidance sentence works without it).
|
|
252
|
+
let installUrl = null;
|
|
253
|
+
try {
|
|
254
|
+
const appRes = await doFetch('https://api.github.com/app', { headers: appJwtHeaders(jwt) });
|
|
255
|
+
if (appRes.ok) {
|
|
256
|
+
const app = await appRes.json();
|
|
257
|
+
if (app && app.html_url) installUrl = `${app.html_url}/installations/new`;
|
|
258
|
+
}
|
|
259
|
+
} catch (_) { /* best-effort */ }
|
|
260
|
+
const e = err('APP_NOT_INSTALLED', { owner, repo, install_url: installUrl });
|
|
261
|
+
_notInstalled = { untilMs: nowMs + NOT_INSTALLED_TTL_MS, err: e };
|
|
262
|
+
throw e;
|
|
263
|
+
}
|
|
264
|
+
if (!res.ok) {
|
|
265
|
+
const detail = await res.text().catch(() => '');
|
|
266
|
+
throw err('APP_TOKEN_FAILED', { status: res.status, detail: detail.slice(0, 200) });
|
|
267
|
+
}
|
|
268
|
+
const data = await res.json();
|
|
269
|
+
if (!data || data.id == null) {
|
|
270
|
+
throw err('APP_TOKEN_FAILED', { status: res.status, detail: 'installation lookup returned no id' });
|
|
271
|
+
}
|
|
272
|
+
_notInstalled = { untilMs: 0, err: null };
|
|
273
|
+
_instIdCache = String(data.id);
|
|
274
|
+
return _instIdCache;
|
|
207
275
|
}
|
|
208
276
|
|
|
209
277
|
// Returns a fresh/cached App installation token, or null if the App isn't
|
|
@@ -219,20 +287,22 @@ async function getInstallationToken(deps = {}) {
|
|
|
219
287
|
privateKeyPem: process.env.GITHUB_APP_PRIVATE_KEY,
|
|
220
288
|
nowSec,
|
|
221
289
|
});
|
|
290
|
+
// An explicit env id wins (the ADR 0055 v1 shape); otherwise discover it from
|
|
291
|
+
// the repo (task 1003049 — fresh instances never know it up front).
|
|
292
|
+
const instId = process.env.GITHUB_APP_INSTALLATION_ID || (await discoverInstallationId(jwt, deps));
|
|
222
293
|
const doFetch = deps.fetchImpl || fetch;
|
|
223
294
|
const res = await doFetch(
|
|
224
|
-
`https://api.github.com/app/installations/${
|
|
295
|
+
`https://api.github.com/app/installations/${instId}/access_tokens`,
|
|
225
296
|
{
|
|
226
297
|
method: 'POST',
|
|
227
|
-
headers:
|
|
228
|
-
Authorization: `Bearer ${jwt}`,
|
|
229
|
-
Accept: 'application/vnd.github+json',
|
|
230
|
-
'X-GitHub-Api-Version': '2022-11-28',
|
|
231
|
-
'User-Agent': userAgent(),
|
|
232
|
-
},
|
|
298
|
+
headers: appJwtHeaders(jwt),
|
|
233
299
|
}
|
|
234
300
|
);
|
|
235
301
|
if (!res.ok) {
|
|
302
|
+
// A DISCOVERED id can go stale (App uninstalled + reinstalled gets a new
|
|
303
|
+
// installation id). Drop it so the next attempt re-discovers instead of
|
|
304
|
+
// failing until a server restart. An env-pinned id is the operator's to fix.
|
|
305
|
+
if (!process.env.GITHUB_APP_INSTALLATION_ID) _instIdCache = null;
|
|
236
306
|
const detail = await res.text().catch(() => '');
|
|
237
307
|
throw err('APP_TOKEN_FAILED', { status: res.status, detail: detail.slice(0, 200) });
|
|
238
308
|
}
|
|
@@ -244,6 +314,42 @@ async function getInstallationToken(deps = {}) {
|
|
|
244
314
|
return data.token;
|
|
245
315
|
}
|
|
246
316
|
|
|
317
|
+
// Installations of this App on accounts OTHER than this repo's owner (task
|
|
318
|
+
// 1003049 grade, hacker finding). The App is public (W6) and now carries the
|
|
319
|
+
// ADR 0055 write scopes, so any third party CAN install it on their own repos —
|
|
320
|
+
// this server never uses those grants, but a leaked App private key could, so an
|
|
321
|
+
// unexpected install must be visible, never silent. The publish-reconciler calls
|
|
322
|
+
// this once per sweep and WARNs on a non-empty answer. Best-effort by design:
|
|
323
|
+
// returns null when it cannot tell (App unconfigured, repo info missing, API
|
|
324
|
+
// miss) — a watch must never break the sweep it rides on.
|
|
325
|
+
async function foreignInstallations(deps = {}) {
|
|
326
|
+
if (!appConfigured()) return null;
|
|
327
|
+
const info = deps.repoInfo || loadRepoInfo();
|
|
328
|
+
if (!info || info.error) return null;
|
|
329
|
+
const nowSec = Math.floor((deps.now ? deps.now() : Date.now()) / 1000);
|
|
330
|
+
const jwt = mintAppJwt({
|
|
331
|
+
appId: process.env.GITHUB_APP_ID,
|
|
332
|
+
privateKeyPem: process.env.GITHUB_APP_PRIVATE_KEY,
|
|
333
|
+
nowSec,
|
|
334
|
+
});
|
|
335
|
+
const doFetch = deps.fetchImpl || fetch;
|
|
336
|
+
let data;
|
|
337
|
+
try {
|
|
338
|
+
const res = await doFetch('https://api.github.com/app/installations?per_page=100', {
|
|
339
|
+
headers: appJwtHeaders(jwt),
|
|
340
|
+
});
|
|
341
|
+
if (!res.ok) return null;
|
|
342
|
+
data = await res.json();
|
|
343
|
+
} catch (_) {
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
if (!Array.isArray(data)) return null;
|
|
347
|
+
const owner = String(info.owner || '').toLowerCase();
|
|
348
|
+
return data
|
|
349
|
+
.map((i) => ({ id: i && i.id, account: (i && i.account && i.account.login) || null }))
|
|
350
|
+
.filter((i) => String(i.account || '').toLowerCase() !== owner);
|
|
351
|
+
}
|
|
352
|
+
|
|
247
353
|
// Resolve the credential for git push + GitHub API: an explicitly-injected token
|
|
248
354
|
// (tests) first, then a GitHub App installation token, then the v1 PAT, else null.
|
|
249
355
|
async function resolveToken(deps = {}) {
|
|
@@ -863,7 +969,11 @@ async function updateBranchIfBehind({ branch }, deps = {}) {
|
|
|
863
969
|
// returns false on any failure, in which case the gate-review check sees no attestation
|
|
864
970
|
// and escalates — the SAFE direction (no rank bypass without a real server signal).
|
|
865
971
|
async function setCommitStatus({ sha, state, context, description, targetUrl }, deps = {}) {
|
|
866
|
-
|
|
972
|
+
// resolveToken can THROW (APP_TOKEN_FAILED / APP_NOT_INSTALLED); this helper's
|
|
973
|
+
// contract is "false on any failure" — the gate-review check then escalates,
|
|
974
|
+
// which is the safe direction.
|
|
975
|
+
let token;
|
|
976
|
+
try { token = await resolveToken(deps); } catch (_) { return false; }
|
|
867
977
|
if (!token) return false;
|
|
868
978
|
if (!SAFE_SHA_RE.test(String(sha || ''))) return false;
|
|
869
979
|
const repoInfo = deps.repoInfo || loadRepoInfo();
|
|
@@ -1253,6 +1363,8 @@ module.exports = {
|
|
|
1253
1363
|
isAncestorOfDefaultBranch,
|
|
1254
1364
|
resolveToken,
|
|
1255
1365
|
getInstallationToken,
|
|
1366
|
+
discoverInstallationId,
|
|
1367
|
+
foreignInstallations,
|
|
1256
1368
|
// exported for unit tests
|
|
1257
1369
|
buildAuthUrl,
|
|
1258
1370
|
mintAppJwt,
|
|
@@ -191,13 +191,56 @@ async function runSweep(deps = {}) {
|
|
|
191
191
|
if (!warn.unconfigured) {
|
|
192
192
|
warn.unconfigured = true;
|
|
193
193
|
errFn(
|
|
194
|
-
`[publish-reconciler] WARN ${describeTarget({ configured: false, repo: resolveTarget(deps) })} — confirmed→shipped can NEVER land until a credential is set (GITHUB_APP_ID +
|
|
194
|
+
`[publish-reconciler] WARN ${describeTarget({ configured: false, repo: resolveTarget(deps) })} — confirmed→shipped can NEVER land until a credential is set (GITHUB_APP_ID + GITHUB_APP_PRIVATE_KEY — installation id is auto-discovered — or GITHUB_PUSH_TOKEN). Subsequent sweeps stay quiet until this changes.`
|
|
195
195
|
);
|
|
196
196
|
}
|
|
197
197
|
return { skipped: 'unconfigured', checked: 0, reconciled: 0, merged: 0, resolvedConflicts: 0, errors: 0, strandsFlagged: 0 };
|
|
198
198
|
}
|
|
199
199
|
warn.unconfigured = false; // re-arm: a later credential LOSS must warn again
|
|
200
200
|
|
|
201
|
+
// task 1003049 — an App that is configured but NOT INSTALLED on the repo would
|
|
202
|
+
// pass the gate above and then throw APP_NOT_INSTALLED inside every per-branch
|
|
203
|
+
// call, where the best-effort catches swallow it — silently re-creating the
|
|
204
|
+
// stranding this sweep exists to prevent. Probe the token once (cached, so a
|
|
205
|
+
// healthy steady state costs nothing) and WARN once per streak instead.
|
|
206
|
+
if (typeof push.getInstallationToken === 'function' && typeof push.appConfigured === 'function' && push.appConfigured()) {
|
|
207
|
+
try {
|
|
208
|
+
await push.getInstallationToken();
|
|
209
|
+
warn.appNotInstalled = false; // re-arm: a later uninstall must warn again
|
|
210
|
+
// Foreign-install watch (task 1003049 grade): the App is public (W6) and
|
|
211
|
+
// carries write scopes, so a stranger CAN install it on their own repo.
|
|
212
|
+
// This server never uses that grant, but a leaked App key could — an
|
|
213
|
+
// unexpected install is surfaced loudly, once per streak, never silently.
|
|
214
|
+
if (typeof push.foreignInstallations === 'function') {
|
|
215
|
+
try {
|
|
216
|
+
const foreign = await push.foreignInstallations();
|
|
217
|
+
if (foreign && foreign.length) {
|
|
218
|
+
if (!warn.foreignInstalls) {
|
|
219
|
+
warn.foreignInstalls = true;
|
|
220
|
+
errFn(
|
|
221
|
+
`[publish-reconciler] WARN this project's GitHub App is installed on ${foreign.length} account(s) beyond the repo owner (${foreign.map((f) => f.account).filter(Boolean).join(', ')}). The server never uses those grants, but the App key's blast radius includes them — uninstall the App there (or rotate the key) if this is unexpected.`
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
} else if (foreign) {
|
|
225
|
+
warn.foreignInstalls = false; // re-arm once the roster is clean again
|
|
226
|
+
}
|
|
227
|
+
} catch (_) { /* best-effort watch — never breaks the sweep */ }
|
|
228
|
+
}
|
|
229
|
+
} catch (e) {
|
|
230
|
+
if (e && e.code === 'APP_NOT_INSTALLED') {
|
|
231
|
+
if (!warn.appNotInstalled) {
|
|
232
|
+
warn.appNotInstalled = true;
|
|
233
|
+
errFn(
|
|
234
|
+
`[publish-reconciler] WARN the project's GitHub App is not installed on ${e.owner}/${e.repo} — confirmed→shipped can NEVER land until someone installs it${e.install_url ? ` (${e.install_url})` : ''}. Subsequent sweeps stay quiet until this changes.`
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
return { skipped: 'app_not_installed', checked: 0, reconciled: 0, merged: 0, resolvedConflicts: 0, errors: 0, strandsFlagged: 0 };
|
|
238
|
+
}
|
|
239
|
+
// Any other failure (APP_TOKEN_FAILED, network) is transient territory — let
|
|
240
|
+
// the sweep run; the per-branch calls handle their own misses.
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
201
244
|
let rows;
|
|
202
245
|
try {
|
|
203
246
|
rows = await db.listConfirmedTasksForReconcile({ limit: deps.limit || SWEEP_LIMIT });
|
|
@@ -1552,6 +1552,21 @@ module.exports = function buildTasksRouter() {
|
|
|
1552
1552
|
}
|
|
1553
1553
|
});
|
|
1554
1554
|
|
|
1555
|
+
// Plain-sentence publish-credential refusals (task 1003049 / gap-list W7). A
|
|
1556
|
+
// bare 'push_unconfigured' left fresh-instance owners with no idea what was
|
|
1557
|
+
// missing while every ship silently piled at 'confirmed' — the refusal must
|
|
1558
|
+
// say what to set up and where.
|
|
1559
|
+
const PUSH_UNCONFIGURED_MSG =
|
|
1560
|
+
'This project has no publish credential, so the server cannot push or merge task branches and shipped work waits at confirmed. ' +
|
|
1561
|
+
"To fix it, install the project's GitHub App on the repo (the onboarding “Install the GitHub App” step) with GITHUB_APP_ID + GITHUB_APP_PRIVATE_KEY set in the server env, " +
|
|
1562
|
+
'or set a fine-grained GITHUB_PUSH_TOKEN PAT there instead (ADR 0055).';
|
|
1563
|
+
const appNotInstalledMessage = (e) => {
|
|
1564
|
+
const repo = e && e.owner && e.repo ? `${e.owner}/${e.repo}` : 'the project repository';
|
|
1565
|
+
const where = e && e.install_url ? `at ${e.install_url}` : "from the App's page in GitHub settings (Install App)";
|
|
1566
|
+
return `The project's GitHub App is configured but not installed on ${repo}, so the server cannot push or merge task branches. ` +
|
|
1567
|
+
`Install it ${where}, then retry — no other setup is needed.`;
|
|
1568
|
+
};
|
|
1569
|
+
|
|
1555
1570
|
// ==========================================================================
|
|
1556
1571
|
// Server-mediated branch publish (ADR 0055 / task 1025)
|
|
1557
1572
|
// --------------------------------------------------------------------------
|
|
@@ -1584,8 +1599,8 @@ module.exports = function buildTasksRouter() {
|
|
|
1584
1599
|
// (or an Archon) may publish this task's branch.
|
|
1585
1600
|
if (!(await gateTaskOwnership(req, res, id))) return;
|
|
1586
1601
|
if (!githubPush.isConfigured()) {
|
|
1587
|
-
return res.fail('push_unconfigured', { status: 503, message:
|
|
1588
|
-
'
|
|
1602
|
+
return res.fail('push_unconfigured', { status: 503, message: PUSH_UNCONFIGURED_MSG +
|
|
1603
|
+
' Meanwhile you can ship from a machine with its own git push credentials.' });
|
|
1589
1604
|
}
|
|
1590
1605
|
const { branch, head_sha: headSha, value_summary: valueSummary } = req.body;
|
|
1591
1606
|
if (!githubPush.isSafeBranch(branch)) return res.fail('bad_branch', 400);
|
|
@@ -1647,7 +1662,8 @@ module.exports = function buildTasksRouter() {
|
|
|
1647
1662
|
if (e.code === 'BAD_BRANCH') return res.fail('bad_branch', 400);
|
|
1648
1663
|
if (e.code === 'BAD_SHA') return res.fail('bad_head_sha', 400);
|
|
1649
1664
|
if (e.code === 'NO_REPO_INFO') return res.fail('no_repo_info', 500);
|
|
1650
|
-
if (e.code === 'PUSH_UNCONFIGURED') return res.fail('push_unconfigured', 503);
|
|
1665
|
+
if (e.code === 'PUSH_UNCONFIGURED') return res.fail('push_unconfigured', { status: 503, message: PUSH_UNCONFIGURED_MSG });
|
|
1666
|
+
if (e.code === 'APP_NOT_INSTALLED') return res.fail('push_unconfigured', { status: 503, message: appNotInstalledMessage(e) });
|
|
1651
1667
|
if (e.code === 'PR_CREATE_FAILED') return res.fail('pr_create_failed', 502, { detail: e.detail });
|
|
1652
1668
|
// task 1547: GIT_FAILED now carries WHICH git step failed + the credential-
|
|
1653
1669
|
// scrubbed stderr (github-push.defaultExec). Log both so the next push outage
|
|
@@ -1672,7 +1688,7 @@ module.exports = function buildTasksRouter() {
|
|
|
1672
1688
|
const id = parseId(req, res, { code: 'bad_task_id' });
|
|
1673
1689
|
if (id === null) return;
|
|
1674
1690
|
if (!(await gateTaskOwnership(req, res, id))) return;
|
|
1675
|
-
if (!githubPush.isConfigured()) return res.fail('push_unconfigured', 503);
|
|
1691
|
+
if (!githubPush.isConfigured()) return res.fail('push_unconfigured', { status: 503, message: PUSH_UNCONFIGURED_MSG });
|
|
1676
1692
|
const branch = req.query.branch;
|
|
1677
1693
|
if (!githubPush.isSafeBranch(branch)) return res.fail('bad_branch', 400);
|
|
1678
1694
|
try {
|
|
@@ -1739,6 +1755,7 @@ module.exports = function buildTasksRouter() {
|
|
|
1739
1755
|
return res.json({ ok: true, ...status, reconciled, reconcile_blocked, merge_driver });
|
|
1740
1756
|
} catch (e) {
|
|
1741
1757
|
if (e.code === 'NO_REPO_INFO') return res.fail('no_repo_info', 500);
|
|
1758
|
+
if (e.code === 'APP_NOT_INSTALLED') return res.fail('push_unconfigured', { status: 503, message: appNotInstalledMessage(e) });
|
|
1742
1759
|
console.error('[gds] GET /tasks/:id/publish-status', e.code || e.message);
|
|
1743
1760
|
return res.fail('status_failed', 502);
|
|
1744
1761
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
-- provisioning_009_project_settings.sql — per-project settings storage (task
|
|
2
|
+
-- 1003040, goal 1000058: the project-management page wave's foundation).
|
|
3
|
+
--
|
|
4
|
+
-- WHY jsonb, not columns: the manage-page wave adds settings one task at a time
|
|
5
|
+
-- (platform visibility, joinability, repo visibility, …) and a new key must not
|
|
6
|
+
-- cost a migration each time. The column is a dumb store; the known keys + their
|
|
7
|
+
-- vocabularies are enforced at the API layer (provisioning.SETTINGS_VOCAB /
|
|
8
|
+
-- the settings routes' validator), and unknown stored values fall back to the
|
|
9
|
+
-- default at read time (effectiveSettings) rather than leaking out.
|
|
10
|
+
--
|
|
11
|
+
-- DEFAULTS = today's behavior: '{}'::jsonb READS as
|
|
12
|
+
-- { platform_visibility: 'public', joinability: 'apply' } — the default lives in
|
|
13
|
+
-- code, applied at read time, so ADD COLUMN backfills every existing row with an
|
|
14
|
+
-- empty object and no instance changes behavior.
|
|
15
|
+
ALTER TABLE provisioning_instances
|
|
16
|
+
ADD COLUMN IF NOT EXISTS settings jsonb NOT NULL DEFAULT '{}'::jsonb;
|
|
17
|
+
|
|
18
|
+
INSERT INTO schema_migrations (version) VALUES ('provisioning_009_project_settings')
|
|
19
|
+
ON CONFLICT (version) DO NOTHING;
|
|
@@ -44,6 +44,7 @@ function onboardPlan(mode = 'greenfield', opts = {}) {
|
|
|
44
44
|
{ key: 'seed-version', kind: 'auto', label: 'Seed the first version (V1) so the instance can take tasks immediately (task 2119)' },
|
|
45
45
|
{ key: 'oauth-app', kind: 'human', label: 'Wire GitHub sign-in — one-click GitHub App (task 2080), or a manual OAuth app' },
|
|
46
46
|
{ key: 'oauth-place', kind: 'auto', shapes: CONTROL_PLANE_SHAPES, label: 'Place the OAuth secret — auto via the Manifest flow, or verified manual (task 1979)' },
|
|
47
|
+
{ key: 'app-install', kind: 'human', label: 'Install the GitHub App on the repo — the publish credential that lets shipped work land itself (task 1003049, ADR 0055)' },
|
|
47
48
|
{ key: 'first-sign-in', kind: 'human', label: 'First sign-in (seats the founding Archon)' },
|
|
48
49
|
{ key: 'verify', kind: 'auto', label: 'Verify /healthz auth_configured' },
|
|
49
50
|
{ key: 'brand', kind: 'auto', label: 'Brand the instance look' },
|
|
@@ -59,6 +59,17 @@ const PROVISION_ENQUEUE_FROM = new Set(['requested', 'provisioning', 'error', 't
|
|
|
59
59
|
// itself. Mid-flight or torn-down states refuse truthfully.
|
|
60
60
|
const DOMAIN_ATTACH_FROM = new Set(['requested', 'error', 'active']);
|
|
61
61
|
|
|
62
|
+
// Per-project settings (task 1003040, goal 1000058): the known keys + their
|
|
63
|
+
// vocabularies, enforced at the API layer — the jsonb `settings` column
|
|
64
|
+
// (migration provisioning_009) is a dumb store with room for future keys.
|
|
65
|
+
// DEFAULT_SETTINGS is TODAY's behavior (public surfaces, apply-to-join),
|
|
66
|
+
// applied at read time so pre-existing rows need no data backfill.
|
|
67
|
+
const SETTINGS_VOCAB = Object.freeze({
|
|
68
|
+
platform_visibility: ['public', 'gated'],
|
|
69
|
+
joinability: ['open', 'apply', 'invite_only'],
|
|
70
|
+
});
|
|
71
|
+
const DEFAULT_SETTINGS = Object.freeze({ platform_visibility: 'public', joinability: 'apply' });
|
|
72
|
+
|
|
62
73
|
// Monthly cost estimate (USD) for a dedicated droplet tier — VISIBILITY only
|
|
63
74
|
// (ADR 0111 §6), never a bill. A small local table so this module needs no
|
|
64
75
|
// sibling-module import; P2 (shared control-plane client extraction) is where a
|
|
@@ -210,6 +221,19 @@ function publicInstance(row) {
|
|
|
210
221
|
};
|
|
211
222
|
}
|
|
212
223
|
|
|
224
|
+
// The instance's EFFECTIVE settings: the defaults overlaid with the stored,
|
|
225
|
+
// on-vocabulary keys. An off-vocabulary or unknown stored value falls back to
|
|
226
|
+
// its default rather than leaking into the read surface. PURE.
|
|
227
|
+
function effectiveSettings(row) {
|
|
228
|
+
const stored = row && row.settings && typeof row.settings === 'object' && !Array.isArray(row.settings)
|
|
229
|
+
? row.settings : {};
|
|
230
|
+
const out = { ...DEFAULT_SETTINGS };
|
|
231
|
+
for (const [key, vocab] of Object.entries(SETTINGS_VOCAB)) {
|
|
232
|
+
if (vocab.includes(stored[key])) out[key] = stored[key];
|
|
233
|
+
}
|
|
234
|
+
return out;
|
|
235
|
+
}
|
|
236
|
+
|
|
213
237
|
// ---------------------------------------------------------------------------
|
|
214
238
|
// GitHub App Manifest flow — PURE builders (task 2080, ADR 0133).
|
|
215
239
|
// GitHub has no create-an-OAuth-App API, but the App *Manifest* flow lets the owner
|
|
@@ -265,16 +289,21 @@ function githubAppName({ domain, brand, project } = {}) {
|
|
|
265
289
|
// • callback_urls — where "Login with GitHub" on the INSTANCE redirects during real
|
|
266
290
|
// sign-in. Both /api/gds/... (what auth.js actually builds) and the /api/bongos/...
|
|
267
291
|
// alias are registered so sign-in works regardless of which prefix a build emits.
|
|
268
|
-
//
|
|
292
|
+
// No webhook (active:false). PURE.
|
|
269
293
|
function buildGithubAppManifest({ instanceName, slug, instanceDomain, mothershipOrigin, brand }) {
|
|
270
294
|
const instOrigin = `https://${instanceDomain}`;
|
|
271
295
|
const redirectUrl = `${String(mothershipOrigin || '').replace(/\/+$/, '')}/api/bongos/provisioning/github-app/callback`;
|
|
272
296
|
return {
|
|
273
297
|
name: githubAppName({ domain: instanceDomain, brand, project: instanceName || slug }),
|
|
274
298
|
url: instOrigin,
|
|
275
|
-
// Sign-in
|
|
276
|
-
//
|
|
277
|
-
|
|
299
|
+
// Sign-in itself needs no permissions (GET /user with a user-to-server token),
|
|
300
|
+
// but this App DOUBLES as the instance's ADR 0055 publish credential (task
|
|
301
|
+
// 1003049 / gap-list W7): once installed on the repo, the server mints
|
|
302
|
+
// installation tokens to push task branches, open/merge PRs, and read deploy
|
|
303
|
+
// runs — without them every ship strands at 'confirmed' behind manual archon
|
|
304
|
+
// merges. Exactly the ADR 0055 scopes, nothing more; the App still cannot
|
|
305
|
+
// push to branch-protected main nor bypass required checks.
|
|
306
|
+
default_permissions: { contents: 'write', pull_requests: 'write', actions: 'read' },
|
|
278
307
|
default_events: [],
|
|
279
308
|
// No webhook: active:false. A url is still supplied (GitHub validates the shape) but
|
|
280
309
|
// is never called while inactive; point it at the mothership so it's a real host.
|
|
@@ -286,10 +315,15 @@ function buildGithubAppManifest({ instanceName, slug, instanceDomain, mothership
|
|
|
286
315
|
],
|
|
287
316
|
// public: GitHub answers a PRIVATE App's web authorize URL for any non-owner
|
|
288
317
|
// account with a bare 404 (before the instance is ever involved), so a private
|
|
289
|
-
// App makes the project structurally single-builder on the web
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
//
|
|
318
|
+
// App makes the project structurally single-builder on the web (gap-list W6,
|
|
319
|
+
// task 1003048). TRADE-OFF now that the App carries write scopes: public means
|
|
320
|
+
// any third party can install it on THEIR repos, and a leaked App private key's
|
|
321
|
+
// blast radius includes those installs — the server itself only ever uses its
|
|
322
|
+
// own repo's installation, but the credential could reach further if compromised.
|
|
323
|
+
// Accepted to keep W6; mitigated by the publish-reconciler's foreign-install
|
|
324
|
+
// watch (github-push.foreignInstallations), which warns whenever the App is
|
|
325
|
+
// installed anywhere beyond the repo owner. Apps minted before the W6 fix stay
|
|
326
|
+
// private until flipped by hand — see docs/recipes/ops-gotchas.md.
|
|
293
327
|
public: true,
|
|
294
328
|
};
|
|
295
329
|
}
|
|
@@ -335,6 +369,20 @@ async function getInstanceByDomain(db, domain) {
|
|
|
335
369
|
return rows[0] || null;
|
|
336
370
|
}
|
|
337
371
|
|
|
372
|
+
// Merge a VALIDATED settings patch into the instance's settings column (task
|
|
373
|
+
// 1003040). Narrow like updateInstanceDomain: settings + updated_at only — a
|
|
374
|
+
// settings change is a deliberate owner action, so surfacing it in the
|
|
375
|
+
// updated_at-ordered card list is correct (unlike the liveness overlay).
|
|
376
|
+
async function updateInstanceSettings(db, id, patch) {
|
|
377
|
+
const { rows } = await db.query(
|
|
378
|
+
`UPDATE provisioning_instances
|
|
379
|
+
SET settings = settings || $2::jsonb, updated_at = now()
|
|
380
|
+
WHERE id = $1 RETURNING *`,
|
|
381
|
+
[id, JSON.stringify(patch)]
|
|
382
|
+
);
|
|
383
|
+
return rows[0] || null;
|
|
384
|
+
}
|
|
385
|
+
|
|
338
386
|
// Record an instance's domain — the ONLY column this touches (task 1002692). Status
|
|
339
387
|
// changes stay setInstanceStatus's job; keeping this narrow means the domain-attach
|
|
340
388
|
// route can never drift an instance's lifecycle as a side effect.
|
|
@@ -725,9 +773,10 @@ module.exports = {
|
|
|
725
773
|
// constants
|
|
726
774
|
INSTANCE_STATES, HOSTING_SHAPES, ONBOARD_MODES, PROJECT_TYPES, INTENT_ACTIONS, TEARDOWN_FROM,
|
|
727
775
|
PROVISION_ENQUEUE_FROM, DOMAIN_ATTACH_FROM, TIER_MONTHLY_USD, CERT_ELIGIBLE_STATES,
|
|
776
|
+
SETTINGS_VOCAB, DEFAULT_SETTINGS,
|
|
728
777
|
// pure
|
|
729
778
|
isValidSlug, costEstimateUsd, publicInstance, normalizeHostname,
|
|
730
|
-
defaultDomainForSlug, isReservedHostname,
|
|
779
|
+
defaultDomainForSlug, isReservedHostname, effectiveSettings,
|
|
731
780
|
// pure — GitHub App Manifest flow (task 2080)
|
|
732
781
|
githubAppName, buildGithubAppManifest, manifestPostUrl,
|
|
733
782
|
// data access
|
|
@@ -735,7 +784,7 @@ module.exports = {
|
|
|
735
784
|
getInstanceById, getInstanceBySlug, getInstanceByDomain, listInstancesForOwner, listAllInstances,
|
|
736
785
|
// data access — verification overlay (task 1002696)
|
|
737
786
|
listActiveInstancesForLiveness, recordLivenessProbe, recordDnsNote, clearErrorNote,
|
|
738
|
-
createInstance, setInstanceStatus, updateInstanceDomain,
|
|
787
|
+
createInstance, setInstanceStatus, updateInstanceDomain, updateInstanceSettings,
|
|
739
788
|
enqueueIntent, enqueueTeardown, getOpenIntent, claimNextIntent, resolveIntent,
|
|
740
789
|
recordEvent, instanceStateForSlug, costLedger,
|
|
741
790
|
// data access — GitHub App Manifest attempts (task 2080)
|
|
@@ -591,6 +591,69 @@ module.exports = function provisioningRoutes() {
|
|
|
591
591
|
}
|
|
592
592
|
});
|
|
593
593
|
|
|
594
|
+
// ── Per-project settings (task 1003040, goal 1000058) ─────────────────────
|
|
595
|
+
// The foundation the manage-page wave hangs off: effective settings are the
|
|
596
|
+
// DEFAULTS (today's behavior — public surfaces, apply-to-join) overlaid with
|
|
597
|
+
// the instance's stored keys. Own-scoped with an Archon override: the owner
|
|
598
|
+
// manages their own project; an Archon may act on any (fleet support). 404
|
|
599
|
+
// for everyone else — no existence leak (the GET-by-id precedent).
|
|
600
|
+
function settingsAccessDenied(inst, builder) {
|
|
601
|
+
if (!inst) return true;
|
|
602
|
+
if (builder.rank === 'archon') return false;
|
|
603
|
+
return String(inst.owner_builder_id) !== String(builder.id);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// GET /provisioning/instances/:id/settings — read a project's effective
|
|
607
|
+
// settings + the allowed values (so a form can render its choices).
|
|
608
|
+
// rank: any authenticated builder (own resource; archon may read any).
|
|
609
|
+
router.get('/provisioning/instances/:id/settings', auth.requireBuilder, async (req, res) => {
|
|
610
|
+
const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
|
|
611
|
+
if (!id) return;
|
|
612
|
+
try {
|
|
613
|
+
const inst = await provisioning.getInstanceById(pool, id);
|
|
614
|
+
if (settingsAccessDenied(inst, req.builder)) return res.fail('instance_not_found', 404);
|
|
615
|
+
res.json({ ok: true, settings: provisioning.effectiveSettings(inst), options: provisioning.SETTINGS_VOCAB });
|
|
616
|
+
} catch (err) {
|
|
617
|
+
console.error('[provisioning] GET /provisioning/instances/:id/settings', err);
|
|
618
|
+
res.fail('settings_read_failed', { status: 500, message: 'internal error' });
|
|
619
|
+
}
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
// PATCH /provisioning/instances/:id/settings — change a project's settings.
|
|
623
|
+
// Body: any subset of the known keys; strict validation refuses unknown keys
|
|
624
|
+
// and off-vocabulary values. rank: any authenticated builder (own resource;
|
|
625
|
+
// archon may change any — the override is audited via the event's actor).
|
|
626
|
+
router.patch('/provisioning/instances/:id/settings', auth.requireBuilder, async (req, res) => {
|
|
627
|
+
const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
|
|
628
|
+
if (!id) return;
|
|
629
|
+
if (validateOrRespond(req, res, {
|
|
630
|
+
platform_visibility: { type: 'string', enum: provisioning.SETTINGS_VOCAB.platform_visibility },
|
|
631
|
+
joinability: { type: 'string', enum: provisioning.SETTINGS_VOCAB.joinability },
|
|
632
|
+
})) return;
|
|
633
|
+
const patch = {};
|
|
634
|
+
for (const key of Object.keys(provisioning.SETTINGS_VOCAB)) {
|
|
635
|
+
if ((req.body || {})[key] !== undefined) patch[key] = req.body[key];
|
|
636
|
+
}
|
|
637
|
+
if (!Object.keys(patch).length) {
|
|
638
|
+
return res.fail('bad_settings', { status: 400,
|
|
639
|
+
message: `The body carries no settings to change — send any of: ${Object.keys(provisioning.SETTINGS_VOCAB).join(', ')}.` });
|
|
640
|
+
}
|
|
641
|
+
try {
|
|
642
|
+
const inst = await provisioning.getInstanceById(pool, id);
|
|
643
|
+
if (settingsAccessDenied(inst, req.builder)) return res.fail('instance_not_found', 404);
|
|
644
|
+
const updated = await provisioning.updateInstanceSettings(pool, id, patch);
|
|
645
|
+
await provisioning.recordEvent(pool, {
|
|
646
|
+
instanceId: id, ownerBuilderId: inst.owner_builder_id, event: 'settings',
|
|
647
|
+
detail: `settings changed: ${Object.entries(patch).map(([k, v]) => `${k}=${v}`).join(', ')}`,
|
|
648
|
+
actor: String(inst.owner_builder_id) === String(req.builder.id) ? 'api:self' : 'api:archon',
|
|
649
|
+
}).catch(() => {});
|
|
650
|
+
res.json({ ok: true, settings: provisioning.effectiveSettings(updated) });
|
|
651
|
+
} catch (err) {
|
|
652
|
+
console.error('[provisioning] PATCH /provisioning/instances/:id/settings', err);
|
|
653
|
+
res.fail('settings_update_failed', { status: 500, message: 'internal error' });
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
|
|
594
657
|
// POST /provisioning/instances/:id/teardown — request teardown of the caller's
|
|
595
658
|
// OWN instance. rank: any authenticated builder (own resource). Enqueues a
|
|
596
659
|
// 'teardown' intent; the runner executes it (web tier holds no tokens). 404 when
|
|
@@ -892,6 +955,10 @@ module.exports = function provisioningRoutes() {
|
|
|
892
955
|
terminal: status === 'done' || status === 'error' || status === 'expired',
|
|
893
956
|
domain: inst && inst.domain ? inst.domain : null,
|
|
894
957
|
instance_status: inst ? inst.status : null,
|
|
958
|
+
// The App's public slug (task 1003049): lets the wizard render the one-click
|
|
959
|
+
// install link (github.com/apps/<slug>/installations/new) for the app-install
|
|
960
|
+
// step. Public info — the App's page is public by design (task 1003048).
|
|
961
|
+
app_slug: row.app_slug || null,
|
|
895
962
|
});
|
|
896
963
|
} catch (err) {
|
|
897
964
|
console.error('[provisioning] GET /provisioning/github-app/status', err);
|