@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.
Files changed (32) hide show
  1. package/.bongos-core.json +46 -36
  2. package/.claude/skills/new-project/SKILL.md +3 -1
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +4 -0
  5. package/clients/bongos-client/index.cjs +4 -0
  6. package/clients/bongos-client/index.d.ts +8 -1
  7. package/clients/bongos-client/index.mjs +4 -0
  8. package/docs/adr/0055-server-mediated-branch-publish.md +2 -0
  9. package/docs/adr/0133-github-app-manifest-onboarding-signin.md +2 -2
  10. package/docs/api/openapi.json +160 -5
  11. package/docs/api-reference.md +4 -2
  12. package/docs/recipes/ops-gotchas.md +8 -1
  13. package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
  14. package/modules/lifecycle/github-push.js +130 -18
  15. package/modules/lifecycle/publish-reconciler.js +44 -1
  16. package/modules/lifecycle/routes/tasks.js +21 -4
  17. package/modules/provisioning/migrations/provisioning_009_project_settings.sql +19 -0
  18. package/modules/provisioning/onboard-plan.js +1 -0
  19. package/modules/provisioning/provisioning.js +59 -10
  20. package/modules/provisioning/routes/provisioning.js +67 -0
  21. package/modules/provisioning/tests/provisioning.mjs +43 -3
  22. package/package-lock.json +2 -2
  23. package/package.json +1 -1
  24. package/public-landing/projects.html +19 -2
  25. package/scripts/gds/onboard.js +11 -3
  26. package/scripts/gds/provision.js +27 -4
  27. package/src/module-api.js +5 -1
  28. package/tests/onboard.mjs +5 -1
  29. package/tests/provision.mjs +30 -2
  30. package/tests/provisioning_settings.mjs +200 -0
  31. package/tests/publish_branch_route.mjs +153 -2
  32. package/tests/publish_reconciler.mjs +63 -0
@@ -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. 276 endpoints across 46 route files.
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` (15)
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 (the App requests none).
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 + GITHUB_APP_INSTALLATION_ID +
24
- // GITHUB_APP_PRIVATE_KEY. Fallback (v1, kept for cutover): a standing fine-grained
25
- // PAT in GITHUB_PUSH_TOKEN. resolveToken() prefers the App, then the PAT; the App
26
- // JWT is RS256-signed with node:crypto (no jsonwebtoken / Octokit dependency).
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/${process.env.GITHUB_APP_INSTALLATION_ID}/access_tokens`,
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
- const token = await resolveToken(deps);
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 + GITHUB_APP_INSTALLATION_ID + GITHUB_APP_PRIVATE_KEY, or GITHUB_PUSH_TOKEN). Subsequent sweeps stay quiet until this changes.`
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: 'Server-mediated push is not configured (no GitHub App nor GITHUB_PUSH_TOKEN on the server). ' +
1588
- 'Ask the operator to provision it; meanwhile ship from a machine with its own git push credentials.' });
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
- // Minimal for sign-in: no repo permissions, no webhook (active:false). PURE.
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 only ever calls GET /user with the user-to-server token — no repo/account
276
- // permissions are needed, so request none (least privilege).
277
- default_permissions: {},
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. The App requests
290
- // zero permissions, so public costs nothing (gap-list W6, task 1003048). Apps
291
- // minted before this fix stay private until flipped by hand — see
292
- // docs/recipes/ops-gotchas.md.
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);