@bongos/core 1.19.331 → 1.19.333

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 (61) hide show
  1. package/.bongos-core.json +163 -43
  2. package/docs/api/openapi.json +2 -2
  3. package/docs/api-reference.md +1 -1
  4. package/docs/copy-inventory.md +202 -202
  5. package/docs/copy-registry.json +384 -384
  6. package/modules/grading/grader-config.js +98 -0
  7. package/modules/grading/grader-model.js +150 -0
  8. package/modules/grading/grader-prompt.js +139 -0
  9. package/modules/grading/grader-score.js +706 -0
  10. package/modules/grading/grader-subagent.js +328 -0
  11. package/modules/grading/grader.js +5 -1287
  12. package/modules/hall-ui/public/builders.js +19 -1182
  13. package/modules/hall-ui/public/goals-page.js +11 -997
  14. package/modules/hall-ui/public/goals-render.js +1032 -0
  15. package/modules/hall-ui/public/goals.html +4 -0
  16. package/modules/hall-ui/public/hall-render.js +1211 -0
  17. package/modules/hall-ui/public/index.html +6 -0
  18. package/modules/lifecycle/routes/goal-route-authz.js +400 -0
  19. package/modules/lifecycle/routes/goals.js +1 -367
  20. package/modules/lifecycle/routes/task-route-helpers.js +219 -0
  21. package/modules/lifecycle/routes/task-write-routes.js +542 -0
  22. package/modules/lifecycle/routes/tasks.js +3 -696
  23. package/package-lock.json +2 -2
  24. package/package.json +1 -1
  25. package/public-landing/assets/cosmos.css +4 -0
  26. package/public-landing/projects.html +103 -23
  27. package/scripts/gds/box-infra.js +280 -0
  28. package/scripts/gds/box.js +1 -245
  29. package/scripts/gds/fitness-checks-identity.js +320 -0
  30. package/scripts/gds/fitness-checks-vocabulary.js +230 -0
  31. package/scripts/gds/fitness-checks-write-validation.js +144 -0
  32. package/scripts/gds/fitness-lib.js +82 -0
  33. package/scripts/gds/fitness.js +55 -668
  34. package/scripts/gds/provision-config.js +204 -0
  35. package/scripts/gds/provision-net.js +221 -0
  36. package/scripts/gds/provision-repo.js +849 -0
  37. package/scripts/gds/provision-units.js +262 -0
  38. package/scripts/gds/provision.js +4 -1395
  39. package/src/bongos/auth-admission.js +264 -0
  40. package/src/bongos/auth-config.js +151 -0
  41. package/src/bongos/auth-discord.js +198 -0
  42. package/src/bongos/auth-github.js +336 -0
  43. package/src/bongos/auth-idp.js +330 -0
  44. package/src/bongos/auth.js +5 -1100
  45. package/src/module-api.js +5 -1
  46. package/tests/auth_admission_gate.mjs +3 -1
  47. package/tests/est_advisory.mjs +3 -1
  48. package/tests/goal_advisory.mjs +3 -2
  49. package/tests/goal_criterion_confirm_ui.mjs +6 -0
  50. package/tests/goal_subtitle.mjs +5 -2
  51. package/tests/goals_card_view.mjs +10 -3
  52. package/tests/grader_generated_provenance.mjs +3 -1
  53. package/tests/hall_palette.mjs +3 -1
  54. package/tests/helpers.mjs +58 -25
  55. package/tests/lib_sh_resolution.mjs +3 -1
  56. package/tests/membership_kind_lockstep.mjs +3 -1
  57. package/tests/migration_allocation.mjs +5 -2
  58. package/tests/patch_gate_ordering.mjs +3 -1
  59. package/tests/projects_hub_pre_uat.mjs +53 -0
  60. package/tests/rank_tier_single_source.mjs +16 -3
  61. package/tests/reward_gate.mjs +3 -2
@@ -49,1402 +49,11 @@ const path = require('node:path');
49
49
  const cp = require('node:child_process');
50
50
  const crypto = require('node:crypto');
51
51
 
52
- const REPO_ROOT = path.resolve(__dirname, '..', '..');
52
+ const { CONFIG, MANIFEST_UA, MANIFEST_VERIFY_INTERVAL_MS, MANIFEST_VERIFY_TRIES, MAX_INTENT_ATTEMPTS, REPO_ROOT, coreVersionSafe, hasFlag, loadDeps, oauthSecret, provisionerBotEmail } = require('./provision-config.js');
53
+ const { APP_USER, alreadyScaffolded, buildCorePinRefreshCommit, dbCreateCmd, dbName, deployKeyPath, deployKeyTitle, ensurePrivateRepoAccess, installedCorePackDir, installedCoreTarball, instanceInitSpec, instanceRepoRemote, migrateCmd, onboardMode, parseTargetRef, planCorePinRefresh, refreshStandaloneCorePin, resolveOwnerGithubToken, resolveVendorableCoreTarball, safeVersionLabel, scaffoldStandaloneRepo, seedFirstVersionCmd, standaloneInstallCmd, standaloneMigrateCmd, standalonePullCmd, standaloneRegenDocsCmd, standaloneRoot } = require('./provision-repo.js');
54
+ const { backupScriptPath, backupService, backupServicePath, backupTimer, backupTimerPath, backupUnitName, serviceUnit, serviceUnitPath, webEnvBody, webEnvPath } = require('./provision-units.js');
55
+ const { caddyBlock, caddySnippetPath, checkDnsTokenScope, classifyZoneScope, dnsUpsertEnv, federateInstance, federationHubOrigin, healthzCmd, shouldFederate, zoneForDomain } = require('./provision-net.js');
53
56
 
54
- // The verified OAuth-cred placement helpers (task 1979) — reused by the GitHub App
55
- // Manifest flow (task 2080) to VALIDATE the creds GitHub returns before writing them
56
- // and to PARSE the post-restart /healthz auth_configured probe. A sibling script; a
57
- // plain require (its require.main guard means importing it runs no CLI).
58
- const oauthSecret = require('./oauth-secret');
59
-
60
- // The registry-install gates + env-fed .npmrc (ADR 0134, task 2090). Lets the
61
- // standalone scaffold OPT IN to pulling the PRIVATE @bongos/core from the npm
62
- // registry (PROVISION_CORE_FROM_REGISTRY=1) instead of vendoring a tarball, with a
63
- // fail-soft fallback to the vendored path when no NPM_TOKEN is present.
64
- const { registryCoreRequested, hasNpmToken } = require('./npmrc');
65
-
66
- // The HTTP User-Agent the manifest conversion stamps its GitHub call with (GitHub
67
- // requires a UA). Overridable so a fork can identify itself; not secret.
68
- const MANIFEST_UA = process.env.PROVISION_GITHUB_UA || 'cloudbongos-provisioner';
69
-
70
- // The running core version (no caret) — for naming the vendored tarball the standalone
71
- // scaffold pins. Fail-soft (a partial checkout returns null; the scaffold then errors
72
- // with guidance rather than pinning a `…-null.tgz`). Mirrors init.js coreVersionSafe().
73
- function coreVersionSafe() {
74
- try { return require(path.join(REPO_ROOT, 'src/module-api')).CORE_VERSION; } catch { return null; }
75
- }
76
-
77
- function numEnv(name, dflt) {
78
- const v = process.env[name];
79
- if (v == null || v === '') return dflt;
80
- const n = Number(v);
81
- return Number.isFinite(n) && n > 0 ? n : dflt;
82
- }
83
- // A boolean env flag: '1'/'true'/'yes'/'on' (case-insensitive) → true, else false.
84
- function boolEnv(name) {
85
- return /^(1|true|yes|on)$/i.test(String(process.env[name] || '').trim());
86
- }
87
- function hasFlag(args, name) { return args.includes(name); }
88
-
89
- const CONFIG = {
90
- // The port range a CO-TENANT instance process binds on the shared box. 3000 (OTB)
91
- // and 3002 (cloudbongos.com) are taken; new co-tenant instances start at 3003.
92
- portBase: numEnv('PROVISION_PORT_BASE', 3003),
93
- reservedPorts: (process.env.PROVISION_RESERVED_PORTS || '3000,3001,3002')
94
- .split(',').map((s) => Number(s.trim())).filter(Number.isFinite),
95
- // Where a per-instance secrets file lives: /etc/<slug>/web.env (the standup
96
- // runbook's /etc/cloudbongos/web.env shape). chmod 600, OUTSIDE the repo.
97
- etcBase: process.env.PROVISION_ETC_BASE || '/etc',
98
- // The dir holding per-instance Caddy site snippets. Each instance gets its OWN
99
- // <slug>.caddy file (idempotent overwrite, no fragile append to a shared file);
100
- // the main Caddyfile carries a one-time `import <dir>/*.caddy` (operator setup).
101
- caddySnippetDir: process.env.PROVISION_CADDY_SNIPPET_DIR || '/etc/caddy/sites',
102
- // Where nightly pg_dump backups land (db-backup-nightly.sh's GDS_BACKUP_DIR
103
- // default). Shared across co-tenant instances on one box — dumps are named
104
- // `<db>-<ts>.sql.gz`, so each instance's files + retention prune stay scoped to
105
- // its own DB. Owned by the app user, mode 0750. (task 2213, productizes 2210.)
106
- backupDir: process.env.PROVISION_BACKUP_DIR || '/var/backups/gds',
107
- // Dedicated-droplet defaults (only used for hosting_shape='dedicated').
108
- region: process.env.PROVISION_REGION || 'nyc3',
109
- baseImage: process.env.PROVISION_BASE_IMAGE || 'ubuntu-24-04-x64',
110
- tag: process.env.PROVISION_TAG || 'cloudbongos-instance',
111
- doToken: process.env.DO_API_TOKEN || null,
112
- sshKeyIds: (process.env.BOX_SSH_KEY_IDS || '').split(',').map((s) => s.trim()).filter(Boolean),
113
- // The Cloudflare DNS hook (the SAME infra/box-dns-cloudflare.sh box.js uses).
114
- // P4 (#1945) formalizes the UPSERT: point the instance's domain at the target
115
- // public IP (below), in the domain's OWN Cloudflare zone, proxied=false (see
116
- // dnsUpsertEnv). No hook configured → DNS is skipped (a co-tenant instance is
117
- // still reachable by IP; TLS then can't be issued until DNS lands).
118
- dnsHook: process.env.BOX_DNS_HOOK || null,
119
- // This box's PUBLIC IP — the A-record target for a CO-TENANT instance's domain.
120
- // Set in the control-plane /etc/<inst>/box.env (never the web tier). A dedicated
121
- // instance instead points its A record at the freshly-created droplet's IP. Under
122
- // remote-exec (PROVISION_REMOTE_HOST) this is the CO-HOSTING box's IP — where the
123
- // co-tenant actually runs (ADR 0130).
124
- publicIp: process.env.PROVISION_PUBLIC_IP || process.env.BOX_PUBLIC_IP || null,
125
- // The CONTROL PLANE's OWN public IP — the A-record target for a STANDALONE instance
126
- // (task 2074). A standalone runs control-plane-LOCAL (its scaffolded repo dir lives
127
- // here, not on the remote co-hosting box), so its domain must point at THIS box, not
128
- // at publicIp (which, under remote-exec, is the co-hosting box). Only needed when
129
- // remote-exec splits the two boxes; all-local (no remoteHost) falls back to publicIp
130
- // since the control plane and the run box are then the same machine.
131
- controlPlaneIp: process.env.PROVISION_CONTROL_PLANE_IP || null,
132
- // Parent dir for a STANDALONE instance's OWN repo checkout (hosting_shape='standalone',
133
- // ADR 0108): each lives at <standaloneBase>/<slug> with @bongos/core installed as
134
- // an npm dep. Distinct from co-tenant (which shares THIS control-plane checkout).
135
- standaloneBase: process.env.PROVISION_STANDALONE_BASE || '/srv/cloudbongos',
136
- // ── Privilege escalation (task 2066, ADR 0128) ───────────────────────────
137
- // The intent runner runs as a NON-root user (provision-intent-runner.service:
138
- // User=lars) that holds passwordless sudo. That user cannot itself write under
139
- // /etc, reload systemd/Caddy, or peer-auth to Postgres as a superuser — so the
140
- // runbook steps below FAIL as bare commands (the goal-35 blocker). With
141
- // PROVISION_SUDO=1 (alias PROVISION_PRIVILEGED=1) the privileged steps escalate:
142
- // • /etc writes → temp file (lars-writable) + `sudo install -D -m <mode>`
143
- // • systemctl / rm /etc → `sudo …`
144
- // • createdb / migrate → `sudo -u postgres …` (postgres peer-auths as the PG
145
- // superuser — the Ubuntu default — so it never needs `lars` to be a PG role)
146
- // OFF (default) preserves the run-as-root / test / co-tenant-legacy behavior
147
- // (bare commands, direct fs writes), so nothing outside a sudo context changes.
148
- // Dry-run ALWAYS logs the exact command either way. NB: the runner unit must NOT
149
- // set NoNewPrivileges=true — it blocks sudo (see infra/provision-intent-runner.service).
150
- privileged: boolEnv('PROVISION_SUDO') || boolEnv('PROVISION_PRIVILEGED'),
151
- // ── Remote orchestration over SSH (task 2065, ADR 0130) ──────────────────
152
- // The token-separation endgame (ADR 0126): run this runner ON the token-holding
153
- // control-plane box, and have it drive co-tenant workloads that still LIVE on the
154
- // co-hosting box over SSH — so the co-hosting box can hold ZERO cloud tokens.
155
- // When PROVISION_REMOTE_HOST is set, the BOX-MUTATING steps (createdb, migrate,
156
- // /etc writes, systemctl, Caddy, healthz) run on that host over `ssh`; the
157
- // TOKEN step (the Cloudflare DNS hook) + the standalone repo scaffold STAY local
158
- // on the control plane, where the token + the packaging tools live. UNSET
159
- // (default) = every step runs locally, byte-identical to today.
160
- remoteHost: process.env.PROVISION_REMOTE_HOST || null,
161
- // The co-hosting box's checkout dir — the remote migrate's cwd. NO baked-in default
162
- // (task 1002756: the founder-pinned path was an identity leak, and a silent wrong
163
- // cwd aims mutating SSH steps at a dir that may not exist); remote mode requires it
164
- // explicitly (guarded at the makeExec call), local mode never reads it.
165
- remoteCwd: process.env.PROVISION_REMOTE_CHECKOUT || null,
166
- // ssh options for the control-plane → co-hosting hop. BatchMode so a missing key
167
- // fails fast (never hangs on a prompt); accept-new pins the host key on first
168
- // connect (the operator can pre-seed known_hosts for strict pinning).
169
- sshOpts: process.env.PROVISION_SSH_OPTS || '-o BatchMode=yes -o StrictHostKeyChecking=accept-new',
170
- };
171
-
172
- // task 1002757 — the provisioner's git identity resolves from the instance's own
173
- // apex (env → bot@<apex> → neutral), never the hub's address hardcoded.
174
- const { provisionerBotEmail } = require('./instance-apex');
175
-
176
- const MAX_INTENT_ATTEMPTS = numEnv('PROVISION_INTENT_MAX_ATTEMPTS', 3);
177
-
178
- // Manifest-placement verification budget (task 1002720 / QA-walk F23): a child
179
- // instance is a full Node boot away from reporting auth_configured=true, and the
180
- // old 10×1s poll routinely lost that race — recording a failure for a setup that
181
- // succeeded, then burning the SINGLE-USE manifest code on the retry. ~3 min of
182
- // patience; the poll runs after the intent loop, so it delays nothing.
183
- const MANIFEST_VERIFY_TRIES = numEnv('PROVISION_MANIFEST_VERIFY_TRIES', 90);
184
- const MANIFEST_VERIFY_INTERVAL_MS = numEnv('PROVISION_MANIFEST_VERIFY_INTERVAL_MS', 2000);
185
-
186
- // ---------------------------------------------------------------------------
187
- // Lazy dep load (so --help / arg errors need no Postgres) — the box.js pattern.
188
- // ---------------------------------------------------------------------------
189
- let _deps;
190
- function loadDeps() {
191
- if (_deps) return _deps;
192
- // eslint-disable-next-line global-require
193
- const { pool } = require(path.join(REPO_ROOT, 'src/bongos/pool'));
194
- // eslint-disable-next-line global-require
195
- const provisioning = require(path.join(REPO_ROOT, 'modules/provisioning/provisioning'));
196
- // eslint-disable-next-line global-require
197
- const { DoApi } = require(path.join(REPO_ROOT, 'scripts/gds/do-api'));
198
- // Cross-site identity (ADR 0141 §4, task #1002109). Resolve the hub-side module by a
199
- // VARIABLE key, gated on isModuleEnabled — the module-loader's own dynamic-dispatch
200
- // pattern (the run-unit-tests.js precedent), NOT a static 'modules/platform-identity/'
201
- // literal that would couple this publishable core file to an instance-EXCLUDED module
202
- // (fitness Check 16b / task 2135). A vanilla core that neither ships nor enables the
203
- // module never resolves it → no MODULE_NOT_FOUND on a fresh boot. Null here → federation
204
- // dormant (shouldFederate skips) → the instance owns its auth, byte-identical to today.
205
- let platformIdentity = null, hubKeys = null;
206
- const PI_KEY = 'platform-identity';
207
- let piEnabled = false;
208
- try { piEnabled = require(path.join(REPO_ROOT, 'src/modules')).isModuleEnabled(PI_KEY); } catch { piEnabled = false; }
209
- if (piEnabled) {
210
- const piBase = path.join(REPO_ROOT, 'modules', PI_KEY);
211
- try { platformIdentity = require(path.join(piBase, 'platform-identity')); hubKeys = require(path.join(piBase, 'hub-keys')); }
212
- catch { platformIdentity = null; hubKeys = null; }
213
- }
214
- _deps = { pool, provisioning, DoApi, platformIdentity, hubKeys };
215
- return _deps;
216
- }
217
-
218
- // ---------------------------------------------------------------------------
219
- // PURE step-command generators (exported for tests). Each returns the exact shell
220
- // command a runbook step runs. Kept pure so local exec (co-tenant) and the
221
- // dedicated droplet's cloud-init both build from ONE source of truth.
222
- // ---------------------------------------------------------------------------
223
-
224
- // A slug is validated DNS/db-safe upstream (provisioning.isValidSlug), so it is
225
- // already shell-safe (only [a-z0-9-]). We still never interpolate untrusted text.
226
- function dbName(inst) { return inst.db_name || inst.slug; }
227
-
228
- // Escalate a DB command to run as the postgres OS user (→ PG superuser via peer auth,
229
- // the Ubuntu default) when privileged — the DB-auth fix (task 2066). The runner's
230
- // `lars` is not a usable PG role, so bare peer-auth-as-lars fails; `sudo -u postgres`
231
- // sidesteps it without granting lars a role. PURE given the flag.
232
- function asPostgres(cmd, privileged) { return privileged ? `sudo -u postgres ${cmd}` : cmd; }
233
-
234
- function dbCreateCmd(inst, { privileged = false } = {}) {
235
- // Idempotent: the caller runs it allowFail — "database already exists" is success.
236
- // Non-privileged = bare `createdb` (peer auth as the running user, same as the
237
- // standup runbook / OTB); privileged = `sudo -u postgres createdb` (task 2066).
238
- return asPostgres(`createdb ${dbName(inst)}`, privileged);
239
- }
240
- function migrateCmd(inst, { privileged = false } = {}) {
241
- // GDS-only schema — skips migrations/game/*.sql (no game tables on a vanilla
242
- // instance), per the standup runbook (ADR 0105 instance seeds off). Privileged
243
- // runs migrate as postgres (task 2066): `sudo` resets env, so the PGDATABASE/
244
- // MEDUSA_GDS_ONLY vars ride `env …` and the script runs via `bash …` (a bare
245
- // `VAR=val ./script` would be parsed BY sudo as a command). Precondition: the
246
- // control-plane checkout must be readable by the postgres user (ADR 0128).
247
- return privileged
248
- ? `sudo -u postgres env PGDATABASE=${dbName(inst)} MEDUSA_GDS_ONLY=1 bash ./scripts/migrate.sh`
249
- : `PGDATABASE=${dbName(inst)} MEDUSA_GDS_ONLY=1 ./scripts/migrate.sh`;
250
- }
251
-
252
- // Seed the instance's FIRST version so it can take tasks from minute one (task 2119,
253
- // goal 35). A freshly-migrated instance DB comes up with an EMPTY versions table —
254
- // migrate runs instance-seeds-off (ADR 0105) and `bongos init` ran --no-seed — yet
255
- // every task requires a version (tasks.version_id NOT NULL) and POST /versions is
256
- // Archon-only (no Archon exists until first sign-in). Without a seed here, a brand-new
257
- // project has NOWHERE to file a task. This runs on the box's DB path (no rank gate),
258
- // ONLY IF the versions table is empty: idempotent, safe to re-run, and it never
259
- // disturbs a project that already has versions. Mirrors seedViaApi's shape — V1
260
- // (status 'building') + one done-when criterion so /status has a finish line from day
261
- // one. `track` is set EXPLICITLY: versions.track is NOT NULL with no default (mig 112
262
- // dropped the CHECK), and 'product' matches the canonical V1 seed in 003_pms.sql.
263
- //
264
- // The id/name/done_when come from instanceInitSpec so the starter version has ONE
265
- // source of truth (the same spec the greenfield `bongos init` seed uses). SQL string
266
- // literals are DOLLAR-QUOTED ($v$…$v$) so the whole -c argument is single-quoted —
267
- // the one form that is safe UNCHANGED across all three executors: inline (/bin/sh),
268
- // over SSH (bash -s), AND embedded in a dedicated droplet's cloud-init (bash -lc "…"),
269
- // with no nested-quote or $-expansion hazard.
270
- function seedFirstVersionCmd(inst, { privileged = false } = {}) {
271
- const v = instanceInitSpec(inst).version;
272
- const q = (s) => `$v$${s}$v$`; // dollar-quoted literal (no value contains a single quote or $)
273
- const sql =
274
- `INSERT INTO versions (id, name, track, status, done_when) ` +
275
- `SELECT ${q(v.id)}, ${q(v.name)}, $v$product$v$, $v$building$v$, ${q(v.done_when)} ` +
276
- `WHERE NOT EXISTS (SELECT 1 FROM versions); ` +
277
- `INSERT INTO done_when_criteria (version_id, criterion_id, criterion_md, sort_order) ` +
278
- `SELECT ${q(v.id)}, $v$first-goal$v$, ${q(v.done_when)}, 10 ` +
279
- `WHERE EXISTS (SELECT 1 FROM versions WHERE id = ${q(v.id)}) ` +
280
- `AND NOT EXISTS (SELECT 1 FROM done_when_criteria WHERE version_id = ${q(v.id)} AND criterion_id = $v$first-goal$v$);`;
281
- return asPostgres(`psql -v ON_ERROR_STOP=1 -d ${dbName(inst)} -c '${sql}'`, privileged);
282
- }
283
-
284
- // ── STANDALONE shape (hosting_shape='standalone', ADR 0108, task 1978) ────────
285
- // A standalone instance runs from its OWN repo checkout (host content: config/,
286
- // migrations/instance/, the core PIN in package.json) with @bongos/core installed
287
- // as an npm DEPENDENCY — NOT the shared control-plane checkout the co-tenant shape uses.
288
- // It is the ADR 0108 DESTINATION model; these generators make provision.js drive the
289
- // scripts/deploy/deploy-instance.sh runbook API-driven instead of by hand.
290
- const CORE_PKG_DIR = 'node_modules/@bongos/core';
291
- // The unix account the instance's Node process + its backup timer run as. Mirrors
292
- // serviceUnit's hardcoded `User=lars`/`Group=lars` (the droplet's app user); kept as
293
- // one constant so the backup dir owner + the backup unit's User= can't drift from it.
294
- const APP_USER = 'lars';
295
- function standaloneRoot(inst, { base = CONFIG.standaloneBase } = {}) { return path.posix.join(base, inst.slug); }
296
- // Sync the instance's own repo (host content + the core pin). Mirrors deploy-instance.sh
297
- // step 1 — assumes the repo is already cloned at standaloneRoot (initial clone is a
298
- // precondition, like the runbook's `git pull`). Run with cwd = standaloneRoot.
299
- function standalonePullCmd() { return `git pull --ff-only origin main`; }
300
- // Install the pinned core + instance deps — replaces co-tenant's shared checkout
301
- // (deploy-instance.sh step 2). Run with cwd = standaloneRoot.
302
- function standaloneInstallCmd() { return `npm ci --omit=dev`; }
303
- // Migrate core + instance from the INSTANCE root's copy of the three-root migrate.sh
304
- // (the installed core's, falling back to an in-tree copy — deploy-instance.sh step 3).
305
- // Run with cwd = standaloneRoot.
306
- function standaloneMigrateCmd(inst, { privileged = false } = {}) {
307
- // Privileged: run as postgres (task 2066), passing env via `env …` so it survives
308
- // sudo's env reset (the instance root must be postgres-readable, ADR 0128).
309
- return privileged
310
- ? `sudo -u postgres env PGDATABASE=${dbName(inst)} MEDUSA_GDS_ONLY=1 bash ${CORE_PKG_DIR}/scripts/migrate.sh`
311
- : `PGDATABASE=${dbName(inst)} MEDUSA_GDS_ONLY=1 bash ${CORE_PKG_DIR}/scripts/migrate.sh`;
312
- }
313
- // Regenerate the instance's whole-file navigation docs (session-log index) from the pinned core,
314
- // AFTER the pull+install+migrate — the standalone analogue of the monorepo's deploy-prod.sh regen
315
- // block (ADR 0110 §2, task 1002314). Best-effort + LOUD (the script always exits 0 + prints
316
- // ::ERROR:: on a generator failure) so a stale/absent nav doc never fails a deploy. Run with
317
- // cwd = standaloneRoot so resolveInstanceRoot() writes into the instance repo. Without this a
318
- // standalone instance's docs never refresh on deploy — the gap the owner hit ("docs didn't
319
- // transfer from primea"): its bespoke pull-deploy regenerated nothing.
320
- function standaloneRegenDocsCmd() { return `node ${CORE_PKG_DIR}/scripts/gds/regen-instance-docs.js`; }
321
-
322
- // ── STANDALONE scaffold (ADR 0108 / 0125, task 2055) ──────────────────────────
323
- // The MISSING first leg of the greenfield "one guided flow": provision.js used to
324
- // THROW if the instance repo wasn't already cloned. These generate + run the scaffold
325
- // that clones an EMPTY target_ref and turns it into an installable, bootable instance.
326
-
327
- // Parse an 'owner/repo' (or a github SSH/HTTPS URL) target_ref → { owner, name }.
328
- // Null when unparseable, so the scaffold can fail with actionable guidance.
329
- function parseTargetRef(ref) {
330
- const m = String(ref || '').trim()
331
- .replace(/^git@[^:]+:/, '')
332
- .replace(/^https?:\/\/[^/]+\//, '')
333
- .match(/^([A-Za-z0-9][A-Za-z0-9_.-]*)\/([A-Za-z0-9][A-Za-z0-9_.-]*?)(?:\.git)?$/);
334
- return m ? { owner: m[1], name: m[2] } : null;
335
- }
336
-
337
- // The git remote for an instance's OWN repo. Reuses the SAME SSH deploy alias the
338
- // control-plane checkout uses (git@<alias>:<owner>/<repo>.git) — NO secret in the
339
- // command (SSH-key auth, nothing to leak into journald), consistent with how the
340
- // platform repo is wired. Standing up a new instance repo therefore needs only that
341
- // the control plane's deploy key has WRITE access to that repo (ADR 0125). Alias is
342
- // overridable via PROVISION_GIT_SSH_ALIAS.
343
- function instanceRepoRemote(repo, { sshAlias = process.env.PROVISION_GIT_SSH_ALIAS || 'github-deploy' } = {}) {
344
- return `git@${sshAlias}:${repo.owner}/${repo.name}.git`;
345
- }
346
-
347
- // A minimal, VALID `bongos init` spec (init.validateSpec greenfield) from the
348
- // provisioning row + the owner's github login. The scaffold produces a bootable,
349
- // NEUTRAL instance; the owner brands + scopes it later via init's kickoff tasks.
350
- function instanceInitSpec(inst, { ownerLogin } = {}) {
351
- const repo = parseTargetRef(inst.target_ref);
352
- return {
353
- identity: { productName: inst.slug },
354
- repo: repo || { owner: ownerLogin || 'owner', name: inst.slug },
355
- firstAdmin: ownerLogin || (repo && repo.owner) || 'owner',
356
- currency: { label: 'credits' },
357
- domains: { publicOrigin: inst.domain ? `https://${inst.domain}` : `http://localhost:${inst.port || 3000}` },
358
- modules: [],
359
- version: { id: 'V1', name: 'V1', done_when: 'A task goes claim → grade → ship.' },
360
- };
361
- }
362
-
363
- // The .gitignore a scaffolded instance repo carries: keep node_modules + the local
364
- // dist/ + secrets OUT of the commit, while the vendored core tarball (vendor/*.tgz)
365
- // and package-lock.json stay IN — `npm ci` on deploy resolves the pinned core from
366
- // the committed tarball (ADR 0108), so they MUST be tracked.
367
- //
368
- // docs/session-log-index.md is a WHOLE-FILE generated navigation doc (ADR 0110): every byte is
369
- // machine-owned, regenerated at deploy by regen-instance-docs.js (task 1002314) into the served
370
- // tree. Untracked so `git reset --hard origin/main` never wipes it and no branch can diff it —
371
- // the same reasoning the core's own .gitignore applies to it. (docs/repo-map.md is listed for
372
- // parity/forward-compat; it is core-scoped today so a standalone instance doesn't generate its
373
- // own — see regenerateNavDocs() in upgrade.js — but ignoring it costs nothing and prevents a
374
- // stray future write from entering the tree.)
375
- const INSTANCE_GITIGNORE = [
376
- 'node_modules/', 'dist/', '.env', '.env.local', '*.log',
377
- '# Whole-file generated navigation docs — regenerated at deploy (ADR 0110, task 1002314).',
378
- 'docs/session-log-index.md', 'docs/repo-map.md', '',
379
- ].join('\n');
380
-
381
- // A PRE-BUILT @bongos/core tarball this control plane can vendor into a NEW instance
382
- // WITHOUT rebuilding from source — the consumer-control-plane fix (task 1002275).
383
- //
384
- // The scaffold's default core-install path rebuilds the tarball with package-core.js,
385
- // which enumerates GIT-TRACKED files (`git -C REPO_ROOT ls-tree`). That works from a core
386
- // SOURCE checkout, but NOT when this runner is ITSELF a consumer instance whose core is an
387
- // installed npm dependency: provision.js then runs out of node_modules/@bongos/core, so
388
- // REPO_ROOT points INSIDE gitignored node_modules, git sees ZERO tracked files there, and
389
- // package-core aborts ("the core set is EMPTY") — the scaffold never pushes and the freshly
390
- // created repo is left EMPTY (seen live: trial/test on cloudbongos.com).
391
- //
392
- // But the exact pinned tarball this instance installed FROM is on disk, named by its own
393
- // package.json ("@bongos/core": "file:vendor/bongos-core-<v>.tgz"). Reuse it: byte-identical,
394
- // already proven, and the child pins the SAME core the hub runs. Returns an absolute .tgz
395
- // path, or null on a source checkout (no `file:` core pin) — where package-core.js is the
396
- // right, unchanged path. `instanceRoot` defaults to $BONGOS_INSTANCE_ROOT (what
397
- // infra/core-node.sh resolves against) else process.cwd() (the runner's WorkingDirectory).
398
- // fs injectable for tests. PURE-ish (fs reads only, no mutation → dry-run safe).
399
- function installedCoreTarball({ instanceRoot, fsImpl = fs } = {}) {
400
- const root = instanceRoot || process.env.BONGOS_INSTANCE_ROOT || process.cwd();
401
- let pkg;
402
- try { pkg = JSON.parse(fsImpl.readFileSync(path.join(root, 'package.json'), 'utf8')); }
403
- catch { return null; } // no/unreadable package.json → treat as a source checkout
404
- const deps = Object.assign({}, pkg.dependencies, pkg.devDependencies);
405
- // Both scope aliases the loader accepts (infra/core-node.sh checks the same two).
406
- for (const name of ['@bongos/core', '@cloudbongos/core']) {
407
- const m = /^file:(.+\.tgz)$/.exec(String(deps[name] || '').trim());
408
- if (m) {
409
- const abs = path.resolve(root, m[1]);
410
- if (fsImpl.existsSync(abs)) return abs; // a dangling pin (tarball pruned) → fall through to null
411
- }
412
- }
413
- return null;
414
- }
415
-
416
- // The REGISTRY-pinned sibling of installedCoreTarball (task 1002706). After a control
417
- // plane moves its own core pin off the vendored tarball onto the npm registry (the
418
- // cloudbongos.com cutover, task 1002621), its package.json holds a semver pin — no
419
- // `file:` — so installedCoreTarball returns null and the scaffold would fall into the
420
- // package-core.js source rebuild, which ABORTS on a consumer instance (the empty-set
421
- // abort of task 1002275, resurrected by the cutover). The installed package itself is
422
- // still the exact core this hub runs: return its node_modules dir + version + the
423
- // `npm pack` tarball name so the scaffold can pack it into a vendorable .tgz. The child
424
- // stays file:-pinned — its owner needs no npm token (the registry package is PRIVATE
425
- // until the ADR 0134 go-public step). Returns { dir, version, tarballName } or null
426
- // (a file: pin, an uninstalled dep, or a source checkout). fs reads only → dry-run safe.
427
- function installedCorePackDir({ instanceRoot, fsImpl = fs } = {}) {
428
- const root = instanceRoot || process.env.BONGOS_INSTANCE_ROOT || process.cwd();
429
- let pkg;
430
- try { pkg = JSON.parse(fsImpl.readFileSync(path.join(root, 'package.json'), 'utf8')); }
431
- catch { return null; } // no/unreadable package.json → a source checkout
432
- const deps = Object.assign({}, pkg.dependencies, pkg.devDependencies);
433
- for (const name of ['@bongos/core', '@cloudbongos/core']) {
434
- const spec = String(deps[name] || '').trim();
435
- if (!spec || spec.startsWith('file:')) continue; // file: pins belong to installedCoreTarball
436
- const dir = path.join(root, 'node_modules', ...name.split('/'));
437
- try {
438
- const installed = JSON.parse(fsImpl.readFileSync(path.join(dir, 'package.json'), 'utf8'));
439
- const version = installed && installed.version ? String(installed.version) : null;
440
- if (!version) continue;
441
- // npm pack names a scoped tarball <scope>-<name>-<version>.tgz (no @, / → -).
442
- const base = String(installed.name || name).replace(/^@/, '').replace(/\//g, '-');
443
- return { dir, version, tarballName: `${base}-${version}.tgz` };
444
- } catch { /* pinned but not installed → fall through */ }
445
- }
446
- return null;
447
- }
448
-
449
- // Resolve a vendorable @bongos/core tarball — the three-rung ladder, freshest-correct
450
- // first, shared by the fresh scaffold and the re-provision pin refresh (task 1003050):
451
- // 1. a `file:`-pinned control plane reuses its own pinned tarball (task 1002275 —
452
- // rebuilding via package-core.js aborts with an EMPTY core set when this runner
453
- // IS a consumer instance, leaving new repos empty);
454
- // 2. a REGISTRY-pinned consumer (the post-1002621 mothership shape) packs the core
455
- // it has INSTALLED — the child pins the SAME, CURRENT core the hub runs, ending
456
- // the frozen-old-tarball births (task 1002706, gap 13) without requiring the
457
- // child's owner to hold a token for the still-private registry package;
458
- // 3. a genuine core source checkout rebuilds fresh via package-core.js.
459
- // Returns { tgz, packDest } — packDest (set only on rung 2) is a temp dir the CALLER
460
- // must `rm -rf` once the tarball has been vendored.
461
- function resolveVendorableCoreTarball({ instanceRoot, exec, log, coreV, tag = '[core]' }) {
462
- let tgz = installedCoreTarball({ instanceRoot });
463
- let packDest = null;
464
- if (tgz) {
465
- log(` ${tag} reusing the pinned core tarball this control plane runs (${path.basename(tgz)}) — no source rebuild (task 1002275)`);
466
- } else {
467
- const packSrc = installedCorePackDir({ instanceRoot });
468
- if (packSrc) {
469
- // Keyed by VERSION, not slug: no request-derived value enters this command, and
470
- // two concurrent scaffolds of the same core pack identical bytes — collision-safe.
471
- packDest = path.join(os.tmpdir(), `bongos-core-pack-${packSrc.version}`);
472
- log(` ${tag} packing the registry-installed core this control plane runs (v${packSrc.version}) — no source rebuild (task 1002706)`);
473
- exec(`mkdir -p ${packDest}`);
474
- exec(`npm pack ${packSrc.dir} --pack-destination ${packDest}`);
475
- tgz = path.join(packDest, packSrc.tarballName);
476
- } else {
477
- log(` ${tag} building a fresh vendored tarball from source (package-core.js)`);
478
- exec(`node scripts/gds/package-core.js`); // cwd = REPO_ROOT → dist/bongos-core-<v>.tgz
479
- tgz = path.join(REPO_ROOT, 'dist', `bongos-core-${coreV}.tgz`);
480
- }
481
- }
482
- return { tgz, packDest };
483
- }
484
-
485
- // The onboard mode a provisioning row carries (migration provisioning_004, task 1002562).
486
- // 'adopt' means target_ref is a repo the owner ALREADY has, so the scaffold must LAYER
487
- // onto its history. Anything else — including a row written before the column existed, or
488
- // an unrecognized value — resolves to 'greenfield', deliberately the SAFE direction:
489
- // greenfield pushes a fresh tree and (carrying no --force) is REJECTED by a repo with
490
- // history, whereas wrongly choosing 'adopt' would start rewriting a repo nobody declared.
491
- function onboardMode(inst) {
492
- return String((inst && inst.onboard_mode) || '') === 'adopt' ? 'adopt' : 'greenfield';
493
- }
494
-
495
- // Has the scaffold already completed in `root`? The marker is MODE-DEPENDENT, and reusing
496
- // the greenfield one for adopt is a real bug: greenfield's marker is "package.json exists"
497
- // (a fresh empty repo has none until we scaffold it), but an adopted repo is an existing
498
- // project that almost certainly HAS a package.json already — so the greenfield check would
499
- // report "already scaffolded" on the very first run and the instance would never become
500
- // bootable. For adopt the honest marker is the thing the layering itself contributes: a
501
- // @bongos/core dependency (both scope aliases the loader accepts, matching
502
- // installedCoreTarball). fs injectable for tests; read-only, so dry-run safe.
503
- function alreadyScaffolded(root, mode, { fsImpl = fs } = {}) {
504
- const pkgPath = path.join(root, 'package.json');
505
- if (!fsImpl.existsSync(pkgPath)) return false;
506
- if (mode !== 'adopt') return true;
507
- let pkg;
508
- try { pkg = JSON.parse(fsImpl.readFileSync(pkgPath, 'utf8')); }
509
- catch { return false; } // unparseable → let the adopt layer report it, don't skip
510
- const deps = Object.assign({}, pkg.dependencies, pkg.devDependencies);
511
- return !!(deps['@bongos/core'] || deps['@cloudbongos/core']);
512
- }
513
-
514
- // Materialize + push the instance's own repo. TWO paths, chosen by onboard_mode:
515
- //
516
- // greenfield — the target_ref repo is EMPTY (github-repos.createUserRepo makes it with
517
- // auto_init:false), so there is nothing to clone: `git init` locally,
518
- // materialize, and push the first commit at main.
519
- // adopt — the target_ref repo is the owner's OWN existing project. CLONE it (so its
520
- // history and files come along), LAYER the instance config additively via
521
- // `bongos init --adopt --accept` (which never overwrites a pre-existing
522
- // file), and push the layer commit as an ordinary fast-forward on top.
523
- //
524
- // Both end at a repo carrying config/ + .claude/ + a file:-pinned @bongos/core + lockfile,
525
- // which is what makes it `npm ci --omit=dev`-installable by the rest of provisioning.
526
- //
527
- // NEITHER PATH EVER USES --force. That is a safety property, not an oversight: since ADR
528
- // 0155 the credential here can write to every repo the owner owns, so a force-push would
529
- // be a route to destroying a user's work. A rejected push is the correct, recoverable
530
- // outcome. Asserted by tests.
531
- //
532
- // Idempotent (see alreadyScaffolded) and dry-run safe (every mutation goes through the
533
- // injected exec/writeFile). ADR 0108 / 0121 / 0125.
534
- async function scaffoldStandaloneRepo(inst, deps) {
535
- // The scaffold BUILDS + pushes the instance's repo (git clone, package-core,
536
- // `bongos init`, git push) — control-plane work that needs the packaging tools +
537
- // the GitHub deploy key, so it runs LOCALLY even when box-mutating steps go
538
- // remote (task 2065). Falls back to exec/writeFile when a caller passes no
539
- // control executor (then all are the same local executor — unchanged behavior).
540
- const { db, log } = deps;
541
- const exec = deps.controlExec || deps.exec;
542
- const writeFile = deps.controlWriteFile || deps.writeFile;
543
- const root = standaloneRoot(inst);
544
- const repo = parseTargetRef(inst.target_ref);
545
- const mode = onboardMode(inst);
546
- if (!repo) {
547
- throw new Error(`standalone instance ${inst.slug}: target_ref "${inst.target_ref || '(unset)'}" is not an owner/repo — set it (e.g. <owner>/${inst.slug}) so the scaffold knows which repo to clone + push.`);
548
- }
549
- if (alreadyScaffolded(root, mode)) {
550
- log(` [scaffold] ${root} already scaffolded (mode=${mode}) — skipping`);
551
- return { already: true };
552
- }
553
- const coreV = coreVersionSafe();
554
- if (!coreV) {
555
- throw new Error('cannot resolve the core version (src/module-api CORE_VERSION) — run the scaffold from the control-plane checkout.');
556
- }
557
- const remote = instanceRepoRemote(repo);
558
- log(mode === 'adopt'
559
- ? ` [scaffold] ${repo.owner}/${repo.name} → ${root} (ADOPT: clone → layer additively → pin core ${coreV} → commit → fast-forward push)`
560
- : ` [scaffold] ${repo.owner}/${repo.name} → ${root} (clone → materialize → pin core ${coreV} → commit → push)`);
561
-
562
- // Owner login = the founding firstAdmin. Best-effort; the spec falls back to the repo owner.
563
- let ownerLogin = null;
564
- if (inst.owner_builder_id && db) {
565
- try {
566
- const r = await db.query('SELECT github_login FROM builders WHERE id = $1', [inst.owner_builder_id]);
567
- ownerLogin = r && r.rows && r.rows[0] ? r.rows[0].github_login : null;
568
- } catch { /* fall back to the repo owner */ }
569
- }
570
-
571
- // Resolve the instance owner's OAuth token BEFORE any git network op.
572
- //
573
- // Greenfield only needed it at PUSH time, so the lookup used to live down beside the
574
- // push. Adopt needs it EARLIER: its first act is to clone the owner's own repo, and a
575
- // PRIVATE one is unreadable by the control plane's shared deploy key (a deploy key is
576
- // per-repo). Since ADR 0155 this token carries the `repo` scope, so it can both read and
577
- // write the owner's private repos — which is what makes adopt possible at all.
578
- //
579
- // A GitHub DEPLOY KEY is per-repo, so the box's shared key also can't WRITE to a new
580
- // project repo — the second empty-repo cause (task 1002317) — hence preferring this
581
- // token on both paths. It rides a chmod-600 git credential-store file, never the logged
582
- // command or makeExec's env (makeExec surfaces both), and is removed in a `finally` so a
583
- // failed git op can't leave it on disk. No token (org-owned repo, or secret-box off) →
584
- // fall back to the SSH `origin` remote.
585
- const ownerToken = await resolveOwnerGithubToken(inst, deps, { tag: '[scaffold]' });
586
- // The credential-store file + the credential-LESS URL git resolves against it. The URL
587
- // carries only the `x-access-token` username (no secret), so it is safe in a logged argv.
588
- const credFile = ownerToken ? path.join(os.tmpdir(), `bongos-gitcred-${inst.slug}-${process.pid}`) : null;
589
- const httpsUrl = `https://x-access-token@github.com/${repo.owner}/${repo.name}.git`;
590
- const credOpt = credFile ? `-c credential.helper='store --file=${credFile}' ` : '';
591
- if (credFile) writeFile(credFile, `https://x-access-token:${ownerToken}@github.com\n`, { mode: 0o600 });
592
-
593
- try {
594
- // 1. Materialize the working tree.
595
- //
596
- // ADOPT — CLONE the owner's existing repo, so its history + files come along and the
597
- // layer commit lands as an ordinary descendant of its current tip. This is the whole
598
- // point: `git init`ing a fresh tree here produces an UNRELATED history, which the
599
- // remote rejects as a non-fast-forward (and, were it ever forced, would erase the
600
- // owner's work). Cloning over the token's HTTPS URL also covers the private case the
601
- // shared deploy key cannot read.
602
- //
603
- // GREENFIELD — a freshly-created instance repo is EMPTY (github-repos.createUserRepo,
604
- // auto_init:false), so there is NOTHING to clone; and the shared PROVISION_GIT_SSH_ALIAS
605
- // deploy key can't even READ a consumer's brand-new repo under their own account
606
- // (`git clone` fails "Repository not found" — the clone-side twin of the task-1002317
607
- // push bug). So `git init` locally and set `origin` to the SSH remote for the no-token
608
- // fallback push. Unchanged behavior.
609
- //
610
- // Either way a re-run after a partial attempt reuses the existing dir.
611
- const hasGit = fs.existsSync(path.join(root, '.git'));
612
- if (mode === 'adopt') {
613
- if (!hasGit) {
614
- exec(`mkdir -p ${root}`);
615
- exec(`git ${credOpt}clone ${credFile ? httpsUrl : remote} ${root}`);
616
- // Normalize `origin` to the SSH remote, matching what the greenfield path leaves
617
- // behind. Two reasons: the clone URL would otherwise persist as origin, and the
618
- // ongoing deploy (standalonePullCmd → `git pull --ff-only origin main`, and the box
619
- // timer's `git reset --hard origin/main`) reads origin — it must be a form that can
620
- // authenticate durably, which the one-shot token URL cannot (its credential file is
621
- // deleted below). Nothing durable ever holds the token either way; the pushes here
622
- // always pass an explicit URL.
623
- if (credFile) exec(`git -C ${root} remote set-url origin ${remote}`);
624
- } else {
625
- exec(`git -C ${root} ${credOpt}fetch ${credFile ? httpsUrl : 'origin'} main`);
626
- }
627
- // The rest of provisioning is main-only (standalonePullCmd is `git pull --ff-only
628
- // origin main`, and the box's deploy timer does `git reset --hard origin/main`), so a
629
- // repo whose default branch is not `main` would be layered and pushed somewhere the
630
- // deploy never reads. Fail loudly with the fix rather than silently half-working.
631
- const head = exec(`git -C ${root} rev-parse --abbrev-ref HEAD`, { allowFail: true });
632
- const branch = String((head && head.stdout) || '').trim();
633
- if (branch && branch !== 'main' && branch !== 'HEAD') {
634
- throw new Error(`standalone instance ${inst.slug}: adopting ${repo.owner}/${repo.name} but its default branch is "${branch}", not "main". The instance deploy only ever reads origin/main, so the layer would never be served. Rename the default branch to main (or point target_ref at a repo whose default is main) and re-run.`);
635
- }
636
- exec(`git -C ${root} checkout main`, { allowFail: true });
637
- } else {
638
- if (!hasGit) {
639
- exec(`mkdir -p ${root}`);
640
- exec(`git -C ${root} init -q`);
641
- exec(`git -C ${root} remote add origin ${remote}`);
642
- }
643
- exec(`git -C ${root} checkout -B main`);
644
- }
645
-
646
- // Core-install path (ADR 0134, task 2090). DEFAULT = vendor a tarball built from THIS
647
- // control-plane checkout (offline, proven — task 2053). OPT-IN = pull the PRIVATE
648
- // @bongos/core from the npm registry (PROVISION_CORE_FROM_REGISTRY=1). The registry
649
- // path needs NPM_TOKEN at `npm ci` time; if the flag is set but no token is present we
650
- // FAIL SOFT to the vendored path so a tokenless standup still works (never a broken
651
- // instance). The env is injectable for tests; the runner falls back to process.env.
652
- const env = deps.env || process.env;
653
- const wantRegistry = registryCoreRequested(env);
654
- const useRegistry = wantRegistry && hasNpmToken(env);
655
- if (wantRegistry && !useRegistry) {
656
- log(' [scaffold] PROVISION_CORE_FROM_REGISTRY set but no NPM_TOKEN in env — falling back to the vendored-tarball core (ADR 0134 fail-soft).');
657
- }
658
-
659
- // materialize config/ + .claude/ + a pinned package.json + lockfile via `bongos init`.
660
- // --no-seed: the instance's own DB is seeded by migrate, not here (and on the adopt path
661
- // the REAL-state seeding + backlog import happen when the owner runs `bongos init --adopt`
662
- // themselves, against the live instance — the step the wizard walks them through).
663
- //
664
- // MODE FLAGS. Greenfield passes --force (overwrite is safe on an empty repo). Adopt passes
665
- // --adopt --accept instead and NEVER --force: --adopt takes init's layering path, which
666
- // writes each config only where absent and leaves pre-existing files untouched, and
667
- // --accept clears the interactive pre-flight gate for this non-interactive runner (a
668
- // BLOCKING pre-flight finding still hard-stops — it cannot be waved through). init refuses
669
- // --adopt together with --force, so the two flag sets are mutually exclusive by design.
670
- //
671
- // Two mutually exclusive core-resolution shapes, orthogonal to the mode:
672
- // registry (--registry-core): caret pin + env-fed .npmrc; `npm ci` pulls from the
673
- // registry. init generates the lockfile (NPM_TOKEN is present — we gated on it).
674
- // vendored (--vendor-core, default): a tarball built from this checkout, file:-pinned.
675
- const modeFlags = mode === 'adopt' ? '--adopt --accept' : '--force';
676
- const spec = instanceInitSpec(inst, { ownerLogin });
677
- const specPath = path.join(os.tmpdir(), `bongos-init-${inst.slug}.json`);
678
- writeFile(specPath, JSON.stringify(spec, null, 2));
679
- if (useRegistry) {
680
- log(` [scaffold] core install: registry — PRIVATE @bongos/core@${coreV} via env-fed NPM_TOKEN (ADR 0134)`);
681
- exec(`node scripts/gds/init.js --from ${specPath} --dir ${root} --no-seed ${modeFlags} --registry-core`);
682
- } else {
683
- // Vendored tarball — resolveVendorableCoreTarball's three-rung ladder (shared
684
- // with the re-provision pin refresh, task 1003050).
685
- const { tgz, packDest } = resolveVendorableCoreTarball({
686
- instanceRoot: deps.instanceRoot, exec, log, coreV, tag: '[scaffold] core install:',
687
- });
688
- exec(`node scripts/gds/init.js --from ${specPath} --dir ${root} --no-seed ${modeFlags} --vendor-core ${tgz}`);
689
- // init --vendor-core COPIED the tarball into the child's vendor/ — the temp pack dir is spent.
690
- if (packDest) exec(`rm -rf ${packDest}`);
691
- }
692
-
693
- // 4. commit + push to the instance's own main (keep node_modules out).
694
- //
695
- // .gitignore: greenfield OWNS the file. Adopt must not clobber the owner's existing
696
- // ignores, so it only writes one where none exists — and where one does, it APPENDS the
697
- // entries we actually require (node_modules/, the vendored-core exceptions, the generated
698
- // nav docs) if they are missing, since `npm ci` + `git reset --hard` on the box depend on
699
- // them. Duplicate lines in a .gitignore are harmless, so the append is idempotent-safe.
700
- const giPath = path.join(root, '.gitignore');
701
- if (mode === 'adopt' && fs.existsSync(giPath)) {
702
- const existing = fs.readFileSync(giPath, 'utf8');
703
- const missing = INSTANCE_GITIGNORE.split('\n')
704
- .filter((l) => l.trim() && !l.startsWith('#'))
705
- .filter((l) => !existing.split('\n').some((e) => e.trim() === l.trim()));
706
- if (missing.length) {
707
- writeFile(giPath, `${existing.replace(/\n*$/, '\n')}\n# Added by Cloud Bongos (adopt) — required by the instance deploy.\n${missing.join('\n')}\n`);
708
- log(` [scaffold] appended ${missing.length} required .gitignore entr(ies) — existing rules left intact`);
709
- } else {
710
- log(' [scaffold] .gitignore already covers the required entries — left untouched');
711
- }
712
- } else {
713
- writeFile(giPath, INSTANCE_GITIGNORE);
714
- }
715
- exec(`git -C ${root} config user.name "Cloud Bongos"`);
716
- exec(`git -C ${root} config user.email "${provisionerBotEmail()}"`);
717
- exec(`git -C ${root} add -A`);
718
- const commitMsg = mode === 'adopt'
719
- ? `chore: adopt Cloud Bongos onto ${inst.slug} on a pinned @bongos/core ${coreV} (provision.js, ADR 0121)`
720
- : `chore: scaffold ${inst.slug} on a pinned @bongos/core ${coreV} (provision.js, ADR 0108)`;
721
- exec(`git -C ${root} commit -m "${commitMsg}"`, { allowFail: true });
722
-
723
- // Push. NO --force on either path, ever (see the function header): the ADR 0155 `repo`
724
- // credential can write to every repo the owner owns, so a force here would be a route to
725
- // destroying their work. Adopt's commit is a descendant of the tip it cloned, so an
726
- // ordinary push fast-forwards; if the remote moved underneath us the rejection is the
727
- // correct outcome and a re-run picks up the new tip.
728
- if (credFile) {
729
- exec(`git -C ${root} ${credOpt}push ${httpsUrl} HEAD:main`);
730
- log(` [scaffold] ${mode === 'adopt' ? 'pushed the adopt layer' : 'pushed'} to ${repo.owner}/${repo.name}@main via the owner's GitHub token (task 1002317)`);
731
- } else {
732
- exec(`git -C ${root} push -u origin main`);
733
- log(` [scaffold] pushed ${mode === 'adopt' ? 'the adopt layer' : 'initial scaffold'} to ${repo.owner}/${repo.name}@main (SSH origin)`);
734
- }
735
- return { ok: true, scaffolded: true, mode };
736
- } finally {
737
- // Always remove the credential file — a failed clone/push must not leave it on disk.
738
- if (credFile) exec(`rm -f ${credFile}`, { allowFail: true });
739
- }
740
- }
741
-
742
- // ── private-repo deploy key (ADR 0176, task 1002735; walk finding N4) ────────────
743
-
744
- // The instance owner's stored `repo`-scoped OAuth token (ADR 0155), or null (none
745
- // stored, org-owned repo, secret-box off). Shared by the scaffold push, the
746
- // private-repo deploy-key wiring, and the teardown revoke. `deps.getGithubToken` is
747
- // injectable for tests; production resolves it lazily against the core db slice
748
- // with the runner's pool.
749
- async function resolveOwnerGithubToken(inst, deps, { tag = '[repo-auth]' } = {}) {
750
- if (!inst.owner_builder_id) return null;
751
- try {
752
- const fetchTok = deps.getGithubToken
753
- || ((bid) => require(path.join(REPO_ROOT, 'src/bongos/db')).getGithubToken({ builderId: bid, p: deps.db }));
754
- const r = await fetchTok(inst.owner_builder_id);
755
- return r && r.token ? r.token : null;
756
- } catch (e) {
757
- (deps.log || (() => {}))(` ${tag} owner OAuth token lookup failed (${e && e.message})`);
758
- return null;
759
- }
760
- }
761
-
762
- function deployKeyTitle(inst) { return `cloudbongos-deploy-${inst.slug}`; }
763
- // Beside the checkout's own metadata: untracked by definition, survives the deploy's
764
- // `git reset --hard`, and dies with the checkout rather than accreting in ~/.ssh.
765
- function deployKeyPath(inst) { return path.posix.join(standaloneRoot(inst), '.git', 'cloudbongos-deploy-key'); }
766
-
767
- // Make a PRIVATE target_ref durably readable by the recurring deploy pull. The
768
- // owner's token covers only the ONE-SHOT clone/push (its credential file is deleted
769
- // after the scaffold), while `origin` rides the shared SSH alias — which GitHub
770
- // answers "Repository not found" for a private repo it isn't registered on. That
771
- // mismatch is walk finding N4: every private standup failed at `git pull --ff-only
772
- // origin main`, token or not (seen live: mercury, 2026-08-17).
773
- //
774
- // The fix (ADR 0176): mint the instance its OWN ed25519 deploy key, register it
775
- // READ-ONLY on the repo with the owner's token (idempotent by title; a stale
776
- // same-title key is replaced), then wire the CHECKOUT — origin re-pointed at plain
777
- // github.com and core.sshCommand pinned to the key with -F /dev/null so no
778
- // ~/.ssh/config identity can shadow it. Public repos return untouched; no token →
779
- // skip (the request-time refusal is task 1002909's preflight). Dry-run plans only.
780
- async function ensurePrivateRepoAccess(inst, deps) {
781
- const { log, apply } = deps;
782
- const exec = deps.controlExec || deps.exec;
783
- if (inst.hosting_shape !== 'standalone') return { skipped: 'shape' };
784
- const repo = parseTargetRef(inst.target_ref);
785
- if (!repo) return { skipped: 'no_repo' };
786
- // Dry-run stays OFFLINE like every other plan path here (the checkDnsTokenScope
787
- // preflight is apply-gated for the same reason): no token read, no GitHub call —
788
- // a plan run must stay a safe inspection tool for a snagged instance.
789
- if (!apply) {
790
- log(` [repo-auth] would check ${repo.owner}/${repo.name} visibility with the owner's credential and, if PRIVATE, mint + register a read-only deploy key and wire origin + core.sshCommand to it (ADR 0176)`);
791
- return { planned: true };
792
- }
793
- const ownerToken = await resolveOwnerGithubToken(inst, deps);
794
- if (!ownerToken) {
795
- log(' [repo-auth] no usable owner GitHub credential (none stored, or it could not be read) — a PRIVATE repo would fail at the deploy pull (public repos are unaffected)');
796
- return { skipped: 'no_token' };
797
- }
798
- const ghRepos = deps.githubRepos || require(path.join(REPO_ROOT, 'src/bongos/github-repos'));
799
- let info;
800
- try {
801
- info = await ghRepos.getUserRepo(ownerToken, { owner: repo.owner, name: repo.name, fetchImpl: deps.fetchImpl });
802
- } catch (e) {
803
- if (e && e.code === 'repo_not_found') {
804
- // Plain sentence: this rides error_note onto the owner's project card.
805
- throw new Error(`repository not found: GitHub answered 404 for ${repo.owner}/${repo.name} even with your granted credential — the repository may have been renamed or deleted, or the credential lost access. Check it on GitHub, then try again.`);
806
- }
807
- log(` [repo-auth] could not read ${repo.owner}/${repo.name} (${e && e.message}) — continuing; a PRIVATE repo would fail at the deploy pull`);
808
- return { skipped: 'github_unreachable' };
809
- }
810
-
811
- const root = standaloneRoot(inst);
812
- const keyPath = deployKeyPath(inst);
813
- const title = deployKeyTitle(inst);
814
- const fsImpl = deps.fsImpl || fs;
815
-
816
- if (!info.private) {
817
- // Flipped private → public since we wired it: un-wire, or the checkout stays
818
- // pinned (IdentitiesOnly + -F /dev/null admit no other identity) to a key the
819
- // owner is now free to delete from their repo settings. Keyed on our key file
820
- // so the common public path stays zero-mutation.
821
- if (fsImpl.existsSync(keyPath)) {
822
- log(` [repo-auth] ${repo.owner}/${repo.name} is public again — un-wiring its deploy key`);
823
- try {
824
- const keys = await ghRepos.listDeployKeys(ownerToken, { owner: repo.owner, name: repo.name, fetchImpl: deps.fetchImpl });
825
- const ours = keys.find((k) => k.title === title);
826
- if (ours) await ghRepos.deleteDeployKey(ownerToken, { owner: repo.owner, name: repo.name, keyId: ours.id, fetchImpl: deps.fetchImpl });
827
- } catch (e) { log(` [repo-auth] (remote key revoke skipped: ${e && e.message})`); }
828
- exec(`git -C ${root} remote set-url origin ${instanceRepoRemote(repo)}`);
829
- exec(`git -C ${root} config --unset core.sshCommand`, { allowFail: true });
830
- exec(`rm -f ${keyPath} ${keyPath}.pub`, { allowFail: true });
831
- return { private: false, unwired: true };
832
- }
833
- return { private: false }; // the shared-alias path reads public repos as-is
834
- }
835
-
836
- // Registering a deploy key needs repository ADMIN; getUserRepo already told us.
837
- // Refuse up front with the fix, instead of a raw 403 mid-standup.
838
- if (info.admin === false) {
839
- throw new Error(`repository access is not admin: your granted credential can read ${repo.owner}/${repo.name}, but registering its deploy key needs repository ADMIN. Ask a repository admin to raise your access (or make the repository public), then try again.`);
840
- }
841
-
842
- log(` [repo-auth] ${repo.owner}/${repo.name} is PRIVATE — wiring its own read-only deploy key (ADR 0176)`);
843
- if (!fsImpl.existsSync(keyPath) || !fsImpl.existsSync(`${keyPath}.pub`)) {
844
- exec(`rm -f ${keyPath} ${keyPath}.pub`, { allowFail: true });
845
- exec(`ssh-keygen -q -t ed25519 -N '' -C ${title} -f ${keyPath}`);
846
- exec(`chmod 600 ${keyPath}`); // ssh-keygen's 0600 default, made an explicit invariant
847
- }
848
- const pubKey = String(fsImpl.readFileSync(`${keyPath}.pub`, 'utf8')).trim();
849
- // Compare algorithm+key only — GitHub stores the key without the local comment.
850
- const keyBody = (k) => String(k || '').trim().split(/\s+/).slice(0, 2).join(' ');
851
- let minted = false;
852
- try {
853
- const keys = await ghRepos.listDeployKeys(ownerToken, { owner: repo.owner, name: repo.name, fetchImpl: deps.fetchImpl });
854
- const ours = keys.find((k) => k.title === title);
855
- const current = !!(ours && keyBody(ours.key) === keyBody(pubKey));
856
- if (ours && !current) {
857
- // GitHub 422s a duplicate title/key, and a stale registration is a dead credential —
858
- // replace, never accumulate.
859
- await ghRepos.deleteDeployKey(ownerToken, { owner: repo.owner, name: repo.name, keyId: ours.id, fetchImpl: deps.fetchImpl });
860
- log(` [repo-auth] removed the stale "${title}" key on ${repo.owner}/${repo.name} (local key changed)`);
861
- }
862
- if (!current) {
863
- try {
864
- await ghRepos.addDeployKey(ownerToken, { owner: repo.owner, name: repo.name, title, key: pubKey, readOnly: true, fetchImpl: deps.fetchImpl });
865
- } catch (e) {
866
- if (!e || e.code !== 'key_in_use') throw e;
867
- // The keypair is slug-keyed but the repo can change underneath it (renamed
868
- // then recreated, target_ref re-pointed): the key is registered SOMEWHERE
869
- // else, so retrying with this pair can never succeed. Mint fresh, try once.
870
- log(' [repo-auth] GitHub holds this key elsewhere (422) — minting a fresh keypair and retrying once');
871
- exec(`rm -f ${keyPath} ${keyPath}.pub`, { allowFail: true });
872
- exec(`ssh-keygen -q -t ed25519 -N '' -C ${title} -f ${keyPath}`);
873
- exec(`chmod 600 ${keyPath}`);
874
- const freshPub = String(fsImpl.readFileSync(`${keyPath}.pub`, 'utf8')).trim();
875
- await ghRepos.addDeployKey(ownerToken, { owner: repo.owner, name: repo.name, title, key: freshPub, readOnly: true, fetchImpl: deps.fetchImpl });
876
- }
877
- minted = true;
878
- log(` [repo-auth] registered read-only deploy key "${title}" on ${repo.owner}/${repo.name}`);
879
- } else {
880
- log(` [repo-auth] deploy key "${title}" already registered — nothing to mint`);
881
- }
882
- } catch (e) {
883
- if (e && e.code === 'not_repo_admin') {
884
- throw new Error(`repository access is not admin: your granted credential can read ${repo.owner}/${repo.name}, but registering its deploy key needs repository ADMIN. Ask a repository admin to raise your access (or make the repository public), then try again.`);
885
- }
886
- // Plain sentence for the owner card — a transient GitHub hiccup must not read as jargon.
887
- throw new Error(`deploy-key setup failed for ${repo.owner}/${repo.name}: ${e && e.message}. Nothing is wrong on your side — try again, and if it keeps failing make the repository public and retry.`);
888
- }
889
- exec(`git -C ${root} remote set-url origin git@github.com:${repo.owner}/${repo.name}.git`);
890
- exec(`git -C ${root} config core.sshCommand "ssh -F /dev/null -i ${keyPath} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new"`);
891
- return { private: true, minted };
892
- }
893
-
894
- // ── re-provision core-pin refresh (gap-list W3, task 1003050) ────────────────────
895
- //
896
- // The hazard: a standalone instance's RUNNING core can move ahead of its repo's
897
- // committed `file:` pin (an out-of-band `bongos upgrade`, a pre-fix re-provision that
898
- // refreshed the box without committing) — seen live on Spike Harbor, running a modern
899
- // core while the repo still pinned 1.19.2. The deploy path is repo-driven
900
- // (`git reset --hard origin/main` + `npm ci`), so the NEXT deploy silently rolls the
901
- // instance back against a DB the newer core has already migrated. ADR 0168 fixed
902
- // fresh scaffolds only; this closes the re-provision leg: every provision of an
903
- // already-scaffolded instance heals the committed pin to the core this control plane
904
- // runs, as a commit pushed to the instance repo.
905
-
906
- // The PURE decision half (tested in tests/provision.mjs). Registry pins are npm's to
907
- // resolve; an absent dep is not ours to invent; only `file:` pins are healed.
908
- function planCorePinRefresh({ pinnedDep, coreVersion }) {
909
- const dep = String(pinnedDep || '').trim();
910
- if (!dep) return { action: 'skip', reason: 'no @bongos/core dependency in the instance package.json' };
911
- if (!/^file:/.test(dep)) return { action: 'skip', reason: `registry pin (${dep}) — npm ci resolves it; nothing to vendor` };
912
- const upgrade = require('./upgrade');
913
- const pinned = upgrade.versionFromTgzPath(dep);
914
- if (pinned && pinned === coreVersion) return { action: 'current', pinned };
915
- return { action: 'refresh', pinned: pinned || null };
916
- }
917
-
918
- // Coerce a version label to a semver-safe charset BEFORE it is interpolated into any
919
- // shell string. The "from" label ultimately derives from the INSTANCE's own repo
920
- // package.json `file:` pin (lower trust than the shared control plane) via
921
- // versionFromTgzPath, whose regex admits any char but `/` — including `"`, backtick,
922
- // `$()`, `;`. A crafted pin (e.g. `file:vendor/bongos-core-1.0.0";touch x;echo".tgz`)
923
- // would otherwise break out of a `git commit -m "…"` argument and run as the
924
- // control-plane user. This chokepoint rejects anything outside semver's own alphabet
925
- // (task 1003050 grade — command-injection block). PURE.
926
- function safeVersionLabel(v) {
927
- const s = String(v == null ? '' : v).trim();
928
- return /^[0-9A-Za-z.+-]{1,64}$/.test(s) ? s : '(unversioned)';
929
- }
930
-
931
- // The commit + push command sequence for a pin refresh — PURE, so tests/provision.mjs
932
- // covers "the runner's commit step" (task 1003050 AC) deterministically. Version
933
- // labels pass through safeVersionLabel (the one interpolation-safety chokepoint); the
934
- // push prefers the owner's OAuth token URL (the shared deploy key can't write a
935
- // consumer repo — task 1002317) and falls back to the SSH origin.
936
- function buildCorePinRefreshCommit({ root, fromVersion, toVersion, repo, ownerToken, credFile }) {
937
- const from = safeVersionLabel(fromVersion);
938
- const to = safeVersionLabel(toVersion);
939
- const msg = `chore: re-provision refreshes the pinned @bongos/core ${from} -> ${to} (task 1003050)`;
940
- const push = (ownerToken && repo && credFile)
941
- ? `git -C ${root} -c credential.helper='store --file=${credFile}' push https://x-access-token@github.com/${repo.owner}/${repo.name}.git HEAD:main`
942
- : `git -C ${root} push origin main`;
943
- return {
944
- config: [
945
- `git -C ${root} config user.name "Cloud Bongos"`,
946
- `git -C ${root} config user.email "${provisionerBotEmail()}"`,
947
- ],
948
- add: `git -C ${root} add -A`,
949
- commit: `git -C ${root} commit -m "${msg}"`,
950
- push,
951
- };
952
- }
953
-
954
- async function refreshStandaloneCorePin(inst, deps) {
955
- const { log } = deps;
956
- const exec = deps.controlExec || deps.exec;
957
- const writeFile = deps.controlWriteFile || deps.writeFile;
958
- const upgrade = require('./upgrade');
959
- const root = standaloneRoot(inst);
960
- const coreV = coreVersionSafe();
961
- if (!coreV) { log(' [core-pin] cannot resolve the running core version — leaving the committed pin alone'); return { action: 'skip', reason: 'no core version' }; }
962
- let pkg = null;
963
- try { pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); } catch { /* unreadable → skip below */ }
964
- const pinnedDep = pkg && ((pkg.dependencies || {})['@bongos/core'] || (pkg.devDependencies || {})['@bongos/core']);
965
- const plan = planCorePinRefresh({ pinnedDep, coreVersion: coreV });
966
- if (plan.action === 'skip') { log(` [core-pin] ${plan.reason}`); return plan; }
967
- if (plan.action === 'current') { log(` [core-pin] committed pin already matches the running core (${coreV})`); return plan; }
968
- log(` [core-pin] committed pin ${safeVersionLabel(plan.pinned)} trails this control plane's core ${coreV} — refreshing so the next repo-driven deploy cannot roll the instance back (task 1003050)`);
969
-
970
- const { tgz, packDest } = resolveVendorableCoreTarball({
971
- instanceRoot: deps.instanceRoot, exec, log, coreV, tag: '[core-pin]',
972
- });
973
- try {
974
- const targetVersion = upgrade.versionFromTgzPath(path.basename(tgz)) || coreV;
975
- const vendorRel = upgrade.vendorTarball({ instanceDir: root, fromTgz: tgz, targetVersion });
976
- // Prune superseded vendored tarballs (+ manifests) so the repo carries exactly ONE
977
- // core. fs.rmSync, NOT exec — a filename comes off the instance's own (lower-trust)
978
- // vendor/ dir, so shelling `rm -f ${f}` would be the same injection vector as the
979
- // commit label (a committed file named `bongos-core-x";touch pwn;".tgz`). A direct
980
- // fs call takes the name as data, never as shell (task 1003050 grade).
981
- const vendorDir = path.join(root, 'vendor');
982
- const keep = new Set([`${upgrade.ARTIFACT}-${targetVersion}.tgz`, `${upgrade.ARTIFACT}-${targetVersion}.manifest.json`]);
983
- // vendorTarball just mkdirSync'd this, so it exists on the happy path; guard anyway
984
- // so an unexpectedly-absent dir prunes nothing instead of crashing the whole provision.
985
- if (fs.existsSync(vendorDir)) {
986
- for (const f of fs.readdirSync(vendorDir)) {
987
- if (f.startsWith(`${upgrade.ARTIFACT}-`) && !keep.has(f)) fs.rmSync(path.join(vendorDir, f), { force: true });
988
- }
989
- }
990
- upgrade.writePin(root, vendorRel);
991
- // `npm install` (NOT ci): the pin just changed, the lockfile must be regenerated.
992
- const instRes = upgrade.npmInstall(root);
993
- if (!instRes.ok) {
994
- throw new Error(`npm install after the core-pin refresh failed (${instRes.reason || `exit ${instRes.code}`}) — lockfile not regenerated; the pin refresh cannot be committed half-done.`);
995
- }
996
-
997
- // Commit + push the refresh to the instance repo — the shared deploy key cannot
998
- // write a consumer's repo (task 1002317), so prefer the owner's OAuth token,
999
- // exactly like the scaffold's push.
1000
- const ownerToken = await resolveOwnerGithubToken(inst, deps, { tag: '[core-pin]' });
1001
- const repo = parseTargetRef(inst.target_ref);
1002
- const credFile = ownerToken && repo ? path.join(os.tmpdir(), `bongos-gitcred-pin-${inst.slug}-${process.pid}`) : null;
1003
- const cmd = buildCorePinRefreshCommit({ root, fromVersion: plan.pinned, toVersion: targetVersion, repo, ownerToken, credFile });
1004
- try {
1005
- cmd.config.forEach((c) => exec(c));
1006
- exec(cmd.add);
1007
- exec(cmd.commit, { allowFail: true });
1008
- if (credFile) writeFile(credFile, `https://x-access-token:${ownerToken}@github.com\n`, { mode: 0o600 });
1009
- exec(cmd.push);
1010
- } finally {
1011
- if (credFile) exec(`rm -f ${credFile}`, { allowFail: true });
1012
- }
1013
- log(` [core-pin] refreshed + pushed: @bongos/core ${safeVersionLabel(plan.pinned)} → ${targetVersion}`);
1014
- return { ...plan, refreshed: true, toVersion: targetVersion };
1015
- } finally {
1016
- if (packDest) exec(`rm -rf ${packDest}`);
1017
- }
1018
- }
1019
-
1020
- // The per-instance systemd unit (the infra/cloudbongos.service shape): one Node process
1021
- // bound to the instance's port + DB + brand, secrets from its own env file. SHAPE-AWARE:
1022
- // a STANDALONE instance runs the CORE PACKAGE's platform-server from its OWN repo dir
1023
- // (WorkingDirectory=standaloneRoot → resolveInstanceRoot() resolves to the instance via
1024
- // cwd, deploy-instance.sh §4); co-tenant/dedicated run this control-plane checkout's src/.
1025
- function serviceUnit(inst, { etcBase = CONFIG.etcBase } = {}) {
1026
- const envFile = path.posix.join(etcBase, inst.slug, 'web.env');
1027
- const standalone = inst.hosting_shape === 'standalone';
1028
- const workDir = standalone ? standaloneRoot(inst) : REPO_ROOT;
1029
- const execStart = standalone
1030
- ? `/usr/bin/node ${CORE_PKG_DIR}/src/platform-server.js`
1031
- : `/usr/bin/node src/platform-server.js`;
1032
- return `[Unit]
1033
- Description=Cloud Bongos instance — ${inst.slug} (provisioned by provision.js, ADR 0111)
1034
- After=network-online.target postgresql.service
1035
- Wants=network-online.target
1036
- Requires=postgresql.service
1037
-
1038
- [Service]
1039
- Type=simple
1040
- User=lars
1041
- Group=lars
1042
- WorkingDirectory=${workDir}
1043
- EnvironmentFile=${envFile}
1044
- Environment=PGDATABASE=${dbName(inst)}
1045
- Environment=PORT=${inst.port}
1046
- ExecStart=${execStart}
1047
- Restart=always
1048
- RestartSec=3
1049
- StandardOutput=journal
1050
- StandardError=journal
1051
- NoNewPrivileges=true
1052
-
1053
- [Install]
1054
- WantedBy=multi-user.target
1055
- `;
1056
- }
1057
- function serviceUnitPath(inst) { return `/etc/systemd/system/${inst.slug}.service`; }
1058
-
1059
- // ── nightly DB backup: per-instance timer + service (task 2213) ─────────────
1060
- // PRODUCTIZES the manual task-2210 fix: a newly-provisioned instance was born with
1061
- // NO backups (dir absent, GDS_BACKUP_DB unset → the script fell back to the
1062
- // nonexistent `amazonprimea` default, and the repo's global gds-db-backup units
1063
- // hardcode /home/lars/amazonprimea — wrong for the consumer node_modules/@bongos/
1064
- // core layout). These generators emit a PER-INSTANCE unit pair that fixes all three:
1065
- // • name → `<slug>-db-backup.{service,timer}` so co-tenant instances on one box
1066
- // never collide (each dumps its OWN db; the script's retention prune is
1067
- // already `<db>-*.sql.gz`-scoped, so a shared backupDir is safe).
1068
- // • DB → Environment=GDS_BACKUP_DB=<instance db>, set on the unit (not read from
1069
- // the web.env) so the dump targets the right database with no fallback.
1070
- // • path → ExecStart resolves the canonical script INSIDE the instance's pinned
1071
- // @bongos/core (standalone) or this control-plane checkout (co-tenant),
1072
- // the SAME workDir/CORE_PKG_DIR split serviceUnit uses — never the old
1073
- // monorepo path.
1074
- function backupUnitName(inst) { return `${inst.slug}-db-backup`; }
1075
- function backupServicePath(inst) { return `/etc/systemd/system/${backupUnitName(inst)}.service`; }
1076
- function backupTimerPath(inst) { return `/etc/systemd/system/${backupUnitName(inst)}.timer`; }
1077
- // The consumer-layout-correct absolute path to db-backup-nightly.sh. Standalone runs
1078
- // it from the instance's own pinned core; co-tenant/dedicated from this checkout's
1079
- // scripts/gds/ — mirrors serviceUnit's execStart resolution so they can't diverge.
1080
- function backupScriptPath(inst) {
1081
- const rel = 'scripts/gds/db-backup-nightly.sh';
1082
- return inst.hosting_shape === 'standalone'
1083
- ? path.posix.join(standaloneRoot(inst), CORE_PKG_DIR, rel)
1084
- : path.posix.join(REPO_ROOT, rel);
1085
- }
1086
- function backupService(inst, { backupDir = CONFIG.backupDir } = {}) {
1087
- return `[Unit]
1088
- Description=Nightly pg_dump of the ${inst.slug} GDS DB (provisioned by provision.js, task 2213)
1089
- After=postgresql.service
1090
- Requires=postgresql.service
1091
-
1092
- [Service]
1093
- Type=oneshot
1094
- User=${APP_USER}
1095
- Group=${APP_USER}
1096
- # The DB to dump + where dumps land — set on the unit (not via the instance web.env)
1097
- # so the dump targets the right database with no fallback to a stale default (root
1098
- # cause of the task-2210 breakage). db-backup-nightly.sh reads both as env.
1099
- Environment=GDS_BACKUP_DB=${dbName(inst)}
1100
- Environment=GDS_BACKUP_DIR=${backupDir}
1101
- # Consumer-layout path: the canonical script inside this instance's pinned core.
1102
- ExecStart=/bin/bash ${backupScriptPath(inst)}
1103
- # Modest hardening — the script only needs read on the pg socket + write on the
1104
- # backup dir. Mirrors the repo's gds-db-backup.service.
1105
- NoNewPrivileges=true
1106
- ProtectSystem=strict
1107
- ReadWritePaths=${backupDir}
1108
- ProtectHome=read-only
1109
- PrivateTmp=true
1110
-
1111
- [Install]
1112
- # Not socket-activated; the timer drives this. The timer's WantedBy is what enables.
1113
- `;
1114
- }
1115
- function backupTimer(inst) {
1116
- return `[Unit]
1117
- Description=Daily timer for the ${inst.slug} GDS DB pg_dump (provisioned by provision.js, task 2213)
1118
-
1119
- [Timer]
1120
- # 04:00 UTC daily, mirroring the repo's gds-db-backup.timer (low-traffic window).
1121
- OnCalendar=*-*-* 04:00:00 UTC
1122
- # Catch up on next boot if the box was off when the timer was due.
1123
- Persistent=true
1124
- # Jitter so simultaneous timers don't dogpile the disk at exactly 04:00.
1125
- RandomizedDelaySec=300
1126
- Unit=${backupUnitName(inst)}.service
1127
-
1128
- [Install]
1129
- WantedBy=timers.target
1130
- `;
1131
- }
1132
-
1133
- // The branding pack a provisioned CO-TENANT instance loads: the shared, committed
1134
- // neutral starter (the co-tenant model shares ONE checkout, differentiated only by
1135
- // env — src/branding.js's GDS_BRANDING_FILE knob). Per-instance identity/domains
1136
- // therefore ride ENV OVERRIDES, not a divergent pack. Kept as a constant so the
1137
- // GDS_BRANDING_FILE line below and the env-var PREFIX (which must equal this pack's
1138
- // envPrefix, or src/branding.js's ENV_OVERRIDES never fire) can't drift apart.
1139
- const PROVISIONED_BRANDING_FILE = 'config/branding.neutral.json';
1140
-
1141
- // The env-var prefix a provisioned instance actually RESOLVES — i.e. the envPrefix
1142
- // of the pack it loads (PROVISIONED_BRANDING_FILE). src/instance-config.resolveEnv
1143
- // reads <envPrefix>_<SUFFIX>, and src/branding.resolveBranding applies
1144
- // <envPrefix>_{PUBLIC,BUILDERS,…}_ORIGIN overrides — so a web.env var only takes
1145
- // effect under THIS prefix. Read from the pack (fail-soft to the vanilla default)
1146
- // so it stays in lockstep instead of a magic string. (The prior slug-based prefix
1147
- // was a latent bug: it never matched the neutral pack's CLOUDBONGOS envPrefix, so
1148
- // the OAuth creds were silently unreadable — task 1972.)
1149
- function provisionedEnvPrefix() {
1150
- try {
1151
- const pack = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, PROVISIONED_BRANDING_FILE), 'utf8'));
1152
- return pack.envPrefix || 'CLOUDBONGOS';
1153
- } catch { return 'CLOUDBONGOS'; }
1154
- }
1155
-
1156
- // The per-instance secrets file body (the standup runbook's /etc/<inst>/web.env).
1157
- // The OAuth client id/secret are GUIDED/manual inputs (no GitHub create-API) — the
1158
- // runner writes placeholders the owner fills, unless provided via the intent later.
1159
- // A domain (bring-your-own or provisioned) drives the single-host origin overrides:
1160
- // with them set, src/branding.js resolves the instance's REAL buildersOrigin, so the
1161
- // core's single-host router (isBuildersHost === buildersOrigin host, task 1971) serves
1162
- // the hall on this host + OAuth uses the real callback origin — without them the pack
1163
- // stays at neutral localhost and the hall never loads (task 1972). No domain yet
1164
- // (bring-your-own deferred, ADR 0111 §4) ⇒ origins stay neutral until one is set.
1165
- function webEnvBody(inst, { clientId = '', clientSecret = '', appId = null, appPem = null, federation = null } = {}) {
1166
- const prefix = provisionedEnvPrefix(), { settingsEnvLines } = require(path.join(REPO_ROOT, 'modules/provisioning/provisioning')); // eslint-disable-line global-require
1167
- const origin = inst.domain ? `https://${inst.domain}` : '';
1168
- const originLines = origin
1169
- ? `# Single-host origins (task 1972): the hall serves on THIS host + OAuth/redirects
1170
- # use the real address. Resolved by src/branding.js ENV_OVERRIDES under the pack's
1171
- # ${prefix} envPrefix. A single-host instance has no dedicated builders./status.
1172
- # subdomain, so all four point at the one host.
1173
- ${prefix}_PUBLIC_ORIGIN=${origin}
1174
- ${prefix}_BUILDERS_ORIGIN=${origin}
1175
- ${prefix}_STATUS_ORIGIN=${origin}
1176
- ${prefix}_OAUTH_ORIGIN=${origin}
1177
- `
1178
- : `# No domain yet (bring-your-own-domain pending, ADR 0111 §4) — origins stay
1179
- # neutral (localhost); add ${prefix}_{PUBLIC,BUILDERS,OAUTH}_ORIGIN once a domain
1180
- # is set, or the hall won't load on the instance's host.
1181
- `;
1182
- // The OAuth app creds are filled by ONE of two paths (task 2080): the GitHub App
1183
- // Manifest flow (placeManifestCreds fetches them control-plane-side + re-writes this
1184
- // file with clientId/clientSecret set), or the manual oauth-secret.js placement. On
1185
- // the first provision write they are blank placeholders the flow fills later.
1186
- const credComment = federation
1187
- ? `# Provisioned by scripts/gds/provision.js (ADR 0111). This instance FEDERATES sign-in
1188
- # through the Cloud Bongos hub (ADR 0141 §4) — it needs no GitHub app of its own, so the
1189
- # GitHub client id/secret below stay blank; the ${prefix}_HUB_* block drives sign-in.`
1190
- : clientId || clientSecret
1191
- ? `# Provisioned by scripts/gds/provision.js (ADR 0111). OAuth app creds placed via the
1192
- # GitHub App Manifest flow (ADR 0133) — the owner clicked one link; no secret was pasted.`
1193
- : `# Provisioned by scripts/gds/provision.js (ADR 0111). Fill the OAuth app creds via the
1194
- # one-click GitHub App Manifest flow (ADR 0133), or manually (oauth-secret.js place):`;
1195
- // The founding owner (firstAdmin): the GitHub login src/bongos/auth.js seats as the
1196
- // instance's first Archon on first sign-in (maybeSeatFirstAdmin checks
1197
- // <prefix>_FIRST_ADMIN before branding.firstAdmin). WITHOUT it a provisioned instance
1198
- // has NO owner — first sign-in is refused / lands rankless and the owner can never
1199
- // claim their own instance (goal 35 / criterion 165 regression, caught live 2026-07-07:
1200
- // demo had every origin override but no FIRST_ADMIN, so the owner could not be seated).
1201
- // Derived from the instance repo owner (target_ref = "<owner>/<repo>"), the SAME source
1202
- // instanceInitSpec uses for the scaffold's branding.firstAdmin — so this env override and
1203
- // the scaffolded config/branding.json agree. (Org-owned repo: the org login is not a
1204
- // sign-in identity, so it is left unset; the owner sets <prefix>_FIRST_ADMIN by hand.)
1205
- const ownerRepo = parseTargetRef(inst.target_ref);
1206
- const firstAdminLine = ownerRepo && ownerRepo.owner
1207
- ? `${prefix}_FIRST_ADMIN=${ownerRepo.owner}\n`
1208
- : '';
1209
- // Cross-site identity federation block (ADR 0141 §4, task #1002109). Present only for
1210
- // a HOSTED (federated) instance: flips auth.idp ON via the env override branding.js
1211
- // reads (idpEnabled), and carries the hub origin, this instance's per-client secret
1212
- // (authenticates it TO the hub, server-to-server), and the hub's PUBLIC key (verifies
1213
- // the CLI's hub-signed assertion offline). All written straight to this chmod-600
1214
- // file — never through a browser or chat (the ADR 0133 secret-placement posture).
1215
- const fedLines = federation
1216
- ? `${prefix}_AUTH_IDP=true
1217
- ${prefix}_HUB_ORIGIN=${federation.hubOrigin}
1218
- ${prefix}_HUB_CLIENT_ID=${federation.clientId}
1219
- ${prefix}_HUB_CLIENT_SECRET=${federation.clientSecret}
1220
- ${prefix}_HUB_PUBLIC_KEY=${federation.hubPublicKey}
1221
- `
1222
- : '';
1223
- // The publish credential (task 1003049, ADR 0055): the manifest-born App's id +
1224
- // private key, read BARE (no prefix) by modules/lifecycle/github-push.js. The
1225
- // pem is env-file-flattened to a single line with literal \n (the ADR 0055
1226
- // rotation convention; normalizePem restores it). The installation id is NOT
1227
- // written — the server discovers it from the repo once the owner installs the
1228
- // App (the onboarding app-install step).
1229
- const appLines = appId && appPem
1230
- ? `# Publish credential (ADR 0055): the sign-in GitHub App doubles as it. The server
1231
- # mints short-lived installation tokens once the App is INSTALLED on the repo.
1232
- GITHUB_APP_ID=${appId}
1233
- GITHUB_APP_PRIVATE_KEY=${String(appPem).replace(/\r?\n/g, '\\n')}
1234
- `
1235
- : '';
1236
- return `# /etc/${inst.slug}/web.env — per-instance secrets (chmod 600, OUTSIDE the repo).
1237
- ${credComment}
1238
- ${prefix}_GITHUB_CLIENT_ID=${clientId || ''}
1239
- ${prefix}_GITHUB_CLIENT_SECRET=${clientSecret || ''}
1240
- ${firstAdminLine}GDS_BRANDING_FILE=${PROVISIONED_BRANDING_FILE}
1241
- ${originLines}${appLines}PORT=${inst.port}
1242
- PGDATABASE=${dbName(inst)}
1243
- ${fedLines}${settingsEnvLines(inst, prefix)}`;
1244
- }
1245
- function webEnvPath(inst, { etcBase = CONFIG.etcBase } = {}) {
1246
- return path.posix.join(etcBase, inst.slug, 'web.env');
1247
- }
1248
-
1249
- // ── Cross-site identity: federate a hosted instance's sign-in through the hub ───
1250
- // (ADR 0141 §4, task #1002109). The control plane IS the hub (cloudbongos.com), so
1251
- // registration is a direct in-process call against the hub pool — no HTTP hop.
1252
-
1253
- // The hub's own public origin, written into each federated instance as
1254
- // <PREFIX>_HUB_ORIGIN (what auth.js loadIdpConfig reads). Explicit PROVISION_HUB_ORIGIN
1255
- // wins; otherwise the control plane's OWN public origin (<PREFIX>_PUBLIC_ORIGIN, the
1256
- // env the hub already runs under). Null → not hub-configured → shouldFederate is false.
1257
- function federationHubOrigin() {
1258
- const explicit = process.env.PROVISION_HUB_ORIGIN;
1259
- if (explicit) return String(explicit).replace(/\/+$/, '');
1260
- const own = process.env[`${provisionedEnvPrefix()}_PUBLIC_ORIGIN`];
1261
- return own ? String(own).replace(/\/+$/, '') : null;
1262
- }
1263
-
1264
- // Whether provisioning should federate this instance's sign-in through the hub.
1265
- // Dormant-by-default: true ONLY when the control plane is hub-configured (the
1266
- // platform-identity module is present, a signing key is loadable, AND a hub origin
1267
- // resolves), the instance is a CO-TENANT (the mothership-hosted shape whose web.env is
1268
- // written locally — a `standalone` is owner-hosted and keeps its own GitHub app, the
1269
- // self-hosted path; a `dedicated` droplet has no local web.env write yet, same gap the
1270
- // ADR 0133 manifest flow leaves — see placeManifestCreds), and it has a public domain
1271
- // (the origin the hub binds the CLI mint audience to — the #1002108 account-takeover
1272
- // fix). Returns { federate, reason } so the caller can log WHY it skipped. PURE-ish
1273
- // (reads env + the injected module handles; no DB, no I/O).
1274
- function shouldFederate(inst, deps) {
1275
- const { platformIdentity, hubKeys } = deps;
1276
- if (!platformIdentity || !hubKeys) return { federate: false, reason: 'platform-identity module not present (control plane is not the hub)' };
1277
- if (!hubKeys.hubSigningConfigured()) return { federate: false, reason: 'hub signing key not configured (<PREFIX>_HUB_SIGNING_KEY unset)' };
1278
- if (!federationHubOrigin()) return { federate: false, reason: 'hub origin not resolvable (set PROVISION_HUB_ORIGIN or <PREFIX>_PUBLIC_ORIGIN)' };
1279
- if (inst.hosting_shape !== 'co-tenant') return { federate: false, reason: `${inst.hosting_shape} keeps its own GitHub app (not the co-tenant hosted path)` };
1280
- if (!inst.domain) return { federate: false, reason: 'no domain yet — federation needs the instance public origin' };
1281
- return { federate: true, reason: null };
1282
- }
1283
-
1284
- // Register this hosted instance as an sso_clients row on the HUB (fresh per-client
1285
- // secret) and return the federation block to bake into its web.env (ADR 0141 §4).
1286
- // SECURITY: `origin` MUST be the instance's EXACT public origin — the hub binds the
1287
- // CLI-federation mint audience to it (the #1002108 fix), so a mismatch would break
1288
- // device sign-in (and a wrong origin would be a takeover vector). client_id is the
1289
- // (unique, DNS-safe) slug, so a re-provision rotates the SAME row's secret via
1290
- // registerClient's ON CONFLICT rather than orphaning a client. redirect_uri lists both
1291
- // callback prefixes the core may emit (/api/gds is what auth.js builds today) so the
1292
- // hub's exact-match allow-list accepts the browser leg. --apply gates the real hub
1293
- // write; dry-run returns a placeholder block (so webEnvBody can still be composed) + logs.
1294
- async function federateInstance(inst, deps) {
1295
- const { db, platformIdentity, hubKeys, log, apply } = deps;
1296
- const origin = `https://${inst.domain}`;
1297
- const clientId = inst.slug;
1298
- const hubOrigin = federationHubOrigin();
1299
- const hubPublicKey = hubKeys.hubPublicKeyB64();
1300
- if (!hubPublicKey) throw new Error('hub signing key is set but unreadable — cannot derive HUB_PUBLIC_KEY');
1301
- const redirectUris = [
1302
- `${origin}/api/gds/auth/web/callback`,
1303
- `${origin}/api/bongos/auth/web/callback`,
1304
- ];
1305
- if (!apply) {
1306
- log(` [federate] would register client '${clientId}' on the hub (origin ${origin}) + write ${provisionedEnvPrefix()}_HUB_* + AUTH_IDP into web.env`);
1307
- return { hubOrigin, clientId, clientSecret: '0'.repeat(43), hubPublicKey };
1308
- }
1309
- const clientSecret = crypto.randomBytes(32).toString('base64url');
1310
- // Best-effort owner github_id — nice for the hub catalog's ownership link, but
1311
- // nullable in registerClient, so a lookup miss must never fail the provision.
1312
- let ownerGithubId = null;
1313
- try {
1314
- const { rows } = await db.query('SELECT github_id FROM builders WHERE id = $1', [inst.owner_builder_id]);
1315
- ownerGithubId = rows[0] ? rows[0].github_id : null;
1316
- } catch { /* non-fatal */ }
1317
- await platformIdentity.registerClient(
1318
- // type: the owner's declaration rides the instance row from the create
1319
- // wizard (task 1002772) and lands on the catalog row here — never guessed.
1320
- { clientId, secret: clientSecret, name: inst.slug, redirectUris, origin, ownerGithubId, type: inst.type },
1321
- { pool: db },
1322
- );
1323
- log(` [federate] registered client '${clientId}' on the hub (origin ${origin})`);
1324
- return { hubOrigin, clientId, clientSecret, hubPublicKey };
1325
- }
1326
-
1327
- // The Cloudflare zone a hostname's A record lives in. Cloudflare zones are
1328
- // registrable domains, which for the common case is the last two DNS labels
1329
- // (mercury.example.com → example.com; apex example.com → example.com). Multi-part
1330
- // public suffixes (foo.co.uk) are NOT special-cased here — set CLOUDFLARE_ZONE in
1331
- // the control-plane box.env to override (box-dns-cloudflare.sh honours it). PURE.
1332
- function zoneForDomain(domain) {
1333
- const d = String(domain || '').trim().toLowerCase().replace(/\.$/, '');
1334
- if (!d) return '';
1335
- const labels = d.split('.').filter(Boolean);
1336
- return labels.length <= 2 ? labels.join('.') : labels.slice(-2).join('.');
1337
- }
1338
-
1339
- // The env a DNS UPSERT invocation of infra/box-dns-cloudflare.sh needs for a
1340
- // PROVISIONED host: the hostname, the target public IP (the A-record content), the
1341
- // zone the record lives in, and whether Cloudflare proxies it.
1342
- // • proxied=false (default) is LOAD-BEARING for a CO-TENANT/dedicated host: it uses
1343
- // Caddy on-demand ACME (Let's Encrypt), whose challenge must reach Caddy directly —
1344
- // Cloudflare proxy ON would intercept :80/:443 at the edge and the challenge would
1345
- // silently fail (ADR 0003 / docs/recipes/ops-gotchas.md). Do NOT proxy those.
1346
- // • proxied=true for a STANDALONE host (task 2077): it runs control-plane-local under
1347
- // the *.cloudbongos.com wildcard Cloudflare Origin cert, so on-demand ACME never
1348
- // fires (a matching cert already exists) and proxied=false would serve that origin
1349
- // cert — untrusted to direct clients. Proxying lets the CF edge present a public cert
1350
- // (origin cert valid behind Full-strict) AND hides the control-plane IP.
1351
- // An explicit `zone` wins over the derived one (for a multi-part-suffix domain). PURE.
1352
- function dnsUpsertEnv(inst, { publicIp, zone, proxied = false } = {}) {
1353
- return {
1354
- BOX_HOSTNAME: inst.domain,
1355
- BOX_IP: publicIp,
1356
- CLOUDFLARE_ZONE: zone || zoneForDomain(inst.domain),
1357
- BOX_PROXIED: proxied ? '1' : '0',
1358
- };
1359
- }
1360
-
1361
- // ── DNS token-scope PREFLIGHT (task 1980) ────────────────────────────────────
1362
- // Onboarding a NEW project on a domain the control-plane's Cloudflare token does NOT
1363
- // cover fails LATE: the DNS UPSERT (step 5) errors mid-provision (the Emersonian
1364
- // standup — the token only covered the platform's original zone, so the new project's
1365
- // record couldn't be created until it was widened MID-FLOW). This preflight detects
1366
- // "the configured token can't reach the project's zone" UP FRONT, before the
1367
- // connect-address step, and GUIDES the fix — instead of a half-done provision. It is
1368
- // READ-ONLY (a CF `GET /zones?name=<zone>`); it never mutates DNS.
1369
- const CF_API_BASE = process.env.CLOUDFLARE_API_BASE || 'https://api.cloudflare.com/client/v4';
1370
-
1371
- // PURE: given the outcome of `GET /zones?name=<zone>` (HTTP status + parsed JSON) and
1372
- // the target zone, decide whether the token can MANAGE that zone, with actionable
1373
- // guidance when it can't. Exported + unit-tested with no network.
1374
- function classifyZoneScope({ status, body, zone }) {
1375
- const z = String(zone || '');
1376
- if (status === 401 || status === 403) {
1377
- return { reachable: false, reason: `Cloudflare rejected the token (HTTP ${status})`,
1378
- guidance: `The DNS token is invalid or lacks Zone:Read. Set CLOUDFLARE_API_TOKEN in the control-plane box.env to a token scoped to include ${z} (Zone:DNS:Edit + Zone:Read).` };
1379
- }
1380
- if (status !== 200 || !body || body.success !== true) {
1381
- const errs = body && Array.isArray(body.errors) ? body.errors.map((e) => e && e.message).filter(Boolean).join('; ') : '';
1382
- return { reachable: false, reason: `Cloudflare zone lookup failed (HTTP ${status}${errs ? `: ${errs}` : ''})`,
1383
- guidance: `Check CLOUDFLARE_API_TOKEN + network from the control plane, then retry.` };
1384
- }
1385
- const result = Array.isArray(body.result) ? body.result : [];
1386
- const match = result.find((r) => r && r.name === z);
1387
- if (!match) {
1388
- return { reachable: false, reason: `the token authenticated, but zone "${z}" is not in its scope`,
1389
- guidance: `Widen the Cloudflare API token to include ${z}: CF dashboard → My Profile → API Tokens → edit the token → Zone Resources → Include → ${z} (or use an account-scoped token). If ${z} is a multi-part public suffix (e.g. foo.co.uk), set CLOUDFLARE_ZONE explicitly in the control-plane box.env.` };
1390
- }
1391
- return { reachable: true, reason: `token can manage zone "${z}" (id ${match.id})`, zoneId: match.id };
1392
- }
1393
-
1394
- // Query Cloudflare for the domain's zone with the configured token. Injectable
1395
- // `token`/`fetchImpl` for tests. Returns { zone, reachable, reason, guidance[, zoneId] }.
1396
- // A missing token/fetch/domain is reported as not-reachable-with-guidance — it never
1397
- // throws for a config gap (the caller decides whether to hard-fail).
1398
- async function checkDnsTokenScope(domain, { token = process.env.CLOUDFLARE_API_TOKEN, zoneOverride = process.env.CLOUDFLARE_ZONE, fetchImpl } = {}) {
1399
- const zone = zoneOverride || zoneForDomain(domain);
1400
- const doFetch = fetchImpl || (typeof fetch !== 'undefined' ? fetch : null);
1401
- if (!zone) return { zone, reachable: false, reason: 'no domain given', guidance: 'provide a domain before the address step' };
1402
- if (!token) return { zone, reachable: false, reason: 'no CLOUDFLARE_API_TOKEN configured', guidance: `Set CLOUDFLARE_API_TOKEN (Zone:DNS:Edit on ${zone}) in the control-plane box.env before provisioning DNS for ${domain}.` };
1403
- if (!doFetch) return { zone, reachable: false, reason: 'no fetch available', guidance: 'run on a Node with global fetch, or inject fetchImpl' };
1404
- let status = 0; let bodyJson = null;
1405
- try {
1406
- const res = await doFetch(`${CF_API_BASE}/zones?name=${encodeURIComponent(zone)}`, {
1407
- headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
1408
- });
1409
- status = res.status;
1410
- bodyJson = await res.json().catch(() => null);
1411
- } catch (e) {
1412
- return { zone, reachable: false, reason: `Cloudflare request failed: ${e.message}`, guidance: 'check control-plane network + token, then retry' };
1413
- }
1414
- return { zone, ...classifyZoneScope({ status, body: bodyJson, zone }) };
1415
- }
1416
-
1417
- // A Caddy reverse-proxy site block for the instance's domain → its local port.
1418
- // TLS = on-demand ACME (P4, ADR 0111 §4): Caddy fetches a Let's Encrypt cert for
1419
- // this host LAZILY, on the first TLS handshake — no manual Cloudflare Origin-Cert
1420
- // console step (the least-automated leg). It is GATED by the global
1421
- // `on_demand_tls { ask … }` allow-list in infra/Caddyfile (→ /api/bongos/
1422
- // provisioning/tls-check), so Caddy only mints a cert for a host this module
1423
- // actually provisioned — never an arbitrary SNI. The A record is proxied=false
1424
- // (dnsUpsertEnv) so the challenge reaches Caddy. No domain → a comment
1425
- // (bring-your-own-domain is deferred per-instance, ADR 0111 §4).
1426
- function caddyBlock(inst) {
1427
- if (!inst.domain) {
1428
- return `# ${inst.slug}: no domain yet (bring-your-own-domain pending) — reachable on 127.0.0.1:${inst.port}\n`;
1429
- }
1430
- return `${inst.domain} {
1431
- tls {
1432
- on_demand
1433
- }
1434
- reverse_proxy 127.0.0.1:${inst.port}
1435
- }
1436
- `;
1437
- }
1438
- function caddySnippetPath(inst, { snippetDir = CONFIG.caddySnippetDir } = {}) {
1439
- return path.posix.join(snippetDir, `${inst.slug}.caddy`);
1440
- }
1441
- function healthzCmd(inst) {
1442
- return `curl -sS --max-time 5 http://127.0.0.1:${inst.port}/healthz`;
1443
- }
1444
-
1445
- // ---------------------------------------------------------------------------
1446
- // The injectable executor. dry-run → log-only; --apply → child_process.
1447
- // ---------------------------------------------------------------------------
1448
57
  function makeExec({ apply, log = console.log, remoteHost = null, remoteCwd = null, sshOpts = '' }) {
1449
58
  return function run(cmd, { allowFail = false, cwd, env } = {}) {
1450
59
  // Extra env is passed to the child (not interpolated into the command), so