@bongos/core 1.20.15 → 1.20.17

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.
@@ -87,7 +87,7 @@ function backupUnitName(inst) { return `${inst.slug}-db-backup`; }
87
87
  function backupServicePath(inst) { return `/etc/systemd/system/${backupUnitName(inst)}.service`; }
88
88
  function backupTimerPath(inst) { return `/etc/systemd/system/${backupUnitName(inst)}.timer`; }
89
89
  // The consumer-layout-correct absolute path to db-backup-nightly.sh. Standalone runs
90
- // it from the instance's own pinned core; co-tenant/dedicated from this checkout's
90
+ // it from the instance's own pinned core; co-tenant from this checkout's
91
91
  // scripts/gds/ — mirrors serviceUnit's execStart resolution so they can't diverge.
92
92
  function backupScriptPath(inst) {
93
93
  const rel = 'scripts/gds/db-backup-nightly.sh';
@@ -269,9 +269,8 @@ GITHUB_APP_PRIVATE_KEY=${String(appPem).replace(/\r?\n/g, '\\n')}
269
269
  : '';
270
270
  // The pool cap (task 1003928, ADR 0285 §3): a co-located instance shares the box's
271
271
  // Postgres with every sibling and the control plane, so its pool may not take pg's
272
- // default 10 of the server's 97. src/bongos/pool.js reads it bare. A dedicated
273
- // droplet has its own server and keeps the default — the line is not written.
274
- const poolLine = inst.hosting_shape === 'dedicated' ? '' : `PGPOOL_MAX=${CONFIG.instancePoolMax}\n`;
272
+ // default 10 of the server's 97. src/bongos/pool.js reads it bare.
273
+ const poolLine = `PGPOOL_MAX=${CONFIG.instancePoolMax}\n`;
275
274
  return `# /etc/${inst.slug}/web.env — per-instance secrets (chmod 600, OUTSIDE the repo).
276
275
  ${credComment}
277
276
  ${prefix}_GITHUB_CLIENT_ID=${clientId || ''}
@@ -13,7 +13,7 @@
13
13
  // composes the docs/recipes/cloudbongos-standup.md runbook into ONE idempotent,
14
14
  // dry-run-default, drift-reconciling flow (ADR 0016 / ADR 0031 §9.4 trust boundary):
15
15
  //
16
- // server (co-tenant process OR dedicated droplet)
16
+ // server (a process on an allocated port — co-tenant or cloud-host)
17
17
  // → createdb + GDS-only migrate (MEDUSA_GDS_ONLY=1)
18
18
  // → write config + per-instance /etc/<inst>/web.env
19
19
  // → install + enable a per-instance systemd unit (+ allocate a port)
@@ -31,16 +31,14 @@
31
31
  // default OFF = bare commands (root / test / co-tenant-legacy contexts unchanged).
32
32
  // • Injectable executor. `makeExec` returns a `run(cmd)` that is a pure logger
33
33
  // in dry-run and child_process in --apply; tests pass a recording fake, so the
34
- // whole runbook composition is unit-tested with no droplet / DB / network.
34
+ // whole runbook composition is unit-tested with no box / DB / network.
35
35
  // • Idempotent. Re-running a half-done provision is safe (createdb tolerates
36
36
  // "already exists"; unit/Caddy writes are overwrites; healthz is a poll).
37
- // • The step-command generators are PURE functions (exported) → the SAME command
38
- // strings drive local exec (co-tenant) AND the dedicated droplet's cloud-init.
37
+ // • The step-command generators are PURE functions (exported), tested directly.
39
38
  //
40
39
  // This is control-plane ops tooling (scripts/), so it may require the core + a
41
40
  // module's data slice directly (a module may not, but a CLI may). It
42
- // reuses modules/provisioning/provisioning.js (the intent queue + state) and
43
- // scripts/gds/do-api.js (the shared DO client extracted in task #1943).
41
+ // reuses modules/provisioning/provisioning.js (the intent queue + state).
44
42
 
45
43
  const fs = require('node:fs');
46
44
  const os = require('node:os');
@@ -49,7 +47,7 @@ const cp = require('node:child_process');
49
47
  const crypto = require('node:crypto');
50
48
 
51
49
  const { CONFIG, MANIFEST_UA, softFailResult, MANIFEST_VERIFY_INTERVAL_MS, MANIFEST_VERIFY_TRIES, MAX_INTENT_ATTEMPTS, REPO_ROOT, coreVersionSafe, hasFlag, loadDeps, oauthSecret, provisionerBotEmail } = require('./provision-config.js');
52
- const { accountPreflight, appGroupExpr, appUser, alreadyScaffolded, buildCorePinRefreshCommit, coreCheckoutHasGit, dbCreateCmd, dbName, dbRoleCmd, dbRoleCmdShown, dbRoleGrantsCmd, ensureInstanceUserCmd, generateDbPassword, grantInstanceRepoReadCmd, instanceDbRole, instanceStateDir, instanceUser, deployKeyPath, deployKeyTitle, ensurePrivateRepoAccess, installedCorePackDir, installedCoreTarball, instanceInitSpec, instanceRepoRemote, migrateCmd, onboardMode, ownerLoginOf, parseTargetRef, planCorePinRefresh, refreshStandaloneCorePin, resolveOwnerGithubToken, resolveVendorableCoreTarball, safeVersionLabel, scaffoldStandaloneRepo, seedFirstVersionCmd, standaloneInstallCmd, standaloneMigrateCmd, standalonePullCmd, standaloneRegenDocsCmd, standaloneRoot } = require('./provision-repo.js');
50
+ const { accountPreflight, appUser, alreadyScaffolded, buildCorePinRefreshCommit, coreCheckoutHasGit, dbCreateCmd, dbName, dbRoleCmd, dbRoleCmdShown, dbRoleGrantsCmd, ensureInstanceUserCmd, generateDbPassword, grantInstanceRepoReadCmd, instanceDbRole, instanceStateDir, instanceUser, deployKeyPath, deployKeyTitle, ensurePrivateRepoAccess, installedCorePackDir, installedCoreTarball, instanceInitSpec, instanceRepoRemote, migrateCmd, onboardMode, ownerLoginOf, parseTargetRef, planCorePinRefresh, refreshStandaloneCorePin, resolveOwnerGithubToken, resolveVendorableCoreTarball, safeVersionLabel, scaffoldStandaloneRepo, seedFirstVersionCmd, standaloneInstallCmd, standaloneMigrateCmd, standalonePullCmd, standaloneRegenDocsCmd, standaloneRoot } = require('./provision-repo.js');
53
51
  const { backupScriptPath, backupService, backupServicePath, backupTimer, backupTimerPath, backupUnitName, instanceManifestCmd, originEnvVarsFor, serviceUnit, serviceUnitPath, settingsConsumed, settingsEnvVarsFor, renamedEnvCarry, upsertEnvVars, webEnvBody, webEnvPath } = require('./provision-units.js');
54
52
  const { caddyBlock, caddySnippetPath, checkCaddyWiring, checkDnsTokenScope, classifyZoneScope, dnsReleaseEnv, dnsUpsertEnv, federateInstance, federationHubOrigin, healthzCmd, identityCmd, identityVerdict, ingressPlaneGap, ingressPreflight, shouldFederate, zoneForDomain } = require('./provision-net.js');
55
53
  const { resolveProcessManager } = require('./provision-process.js');
@@ -222,7 +220,7 @@ async function allocatePort(db, provisioning, { exec } = {}) {
222
220
 
223
221
  // ---------------------------------------------------------------------------
224
222
  // Compose + run the standup runbook for ONE instance (idempotent, dry-run-safe).
225
- // deps: { db, provisioning, doApi, exec, writeFile, log, apply, privileged }
223
+ // deps: { db, provisioning, exec, writeFile, log, apply, privileged }
226
224
  // ---------------------------------------------------------------------------
227
225
  async function provisionInstance(inst, deps) {
228
226
  const { db, provisioning, exec, writeFile, log, apply, privileged } = deps;
@@ -235,7 +233,7 @@ async function provisionInstance(inst, deps) {
235
233
  // (code sync, migrate, /etc writes, systemd, Caddy, healthz) must run on the SAME
236
234
  // box — NOT the remote co-hosting box that PROVISION_REMOTE_HOST routes co-tenant
237
235
  // steps to (that box never had the dir; ADR 0130's remote-exec was co-tenant-only).
238
- // So a standalone uses the control-plane executors; co-tenant + dedicated are
236
+ // So a standalone uses the control-plane executors; co-tenant is
239
237
  // unchanged (boxExec === exec, boxWriteFile === writeFile). When no remoteHost is
240
238
  // set, controlExec === exec anyway, so this is a no-op for the all-local default.
241
239
  const standalone = inst.hosting_shape === 'cloud-host';
@@ -252,7 +250,7 @@ async function provisionInstance(inst, deps) {
252
250
 
253
251
  if (isByoHost(inst.hosting_shape)) return recordByoHost(inst, deps); // stand up NOTHING, ABOVE every preflight — reasoning in provision-byo-host.js's header (ADR 0323)
254
252
  // ── preflight: DNS token scope (task 1980) ─────────────────────────────────
255
- // BEFORE committing to the address (allocating a port / creating a droplet / the
253
+ // BEFORE committing to the address (allocating a port / the
256
254
  // step-5 DNS UPSERT), verify the control-plane Cloudflare token can actually reach
257
255
  // the domain's zone. Turns the Emersonian standup's confusing mid-flow step-5 DNS
258
256
  // failure into an up-front, actionable stop with no half-done provision. Read-only.
@@ -275,13 +273,13 @@ async function provisionInstance(inst, deps) {
275
273
  // before any mutation: ingress asks whether this plane has a proxy AT ALL, 0303 below whether
276
274
  // that proxy is wired for THIS domain. Deliberately NOT domain-gated — a domainless child
277
275
  // skipped 0303 entirely, reached step 6 and died on the snippet write.
278
- const ingressGap = ingressPreflight({ exec: boxExec, apply, log, shape: inst.hosting_shape, probe: deps.ingressPlaneGap });
276
+ const ingressGap = ingressPreflight({ exec: boxExec, apply, log, probe: deps.ingressPlaneGap });
279
277
  if (ingressGap) return { ok: false, error: ingressGap };
280
278
  // Account preflight (task 1004018, ADR 0317). The third and last PLANE question — can this
281
279
  // box give the child its own unix account? — before 0303 below asks the per-DOMAIN one. A
282
280
  // plane that cannot would run every child as one uid, the ADR 0281 vector, and step 1c's
283
281
  // useradd would crash mid-provision instead of refusing.
284
- const accountGap = accountPreflight({ exec: boxExec, apply, log, shape: inst.hosting_shape, privileged, probe: deps.accountPlaneGap });
282
+ const accountGap = accountPreflight({ exec: boxExec, apply, log, privileged, probe: deps.accountPlaneGap });
285
283
  if (accountGap) return { ok: false, error: accountGap };
286
284
  // App-user preflight (task 1002719): steps below name the app user, and appUser() refuses
287
285
  // root or an unusable name. Refuse HERE, before the status flip, not midway through.
@@ -289,7 +287,7 @@ async function provisionInstance(inst, deps) {
289
287
  // Caddy preflight (task 1002860, ADR 0303): a box whose base Caddyfile has no tls-check ask
290
288
  // can never certify this domain, so refuse before touching it. Injectable like the DNS check.
291
289
  const caddyCheck = deps.checkCaddyWiring || checkCaddyWiring;
292
- if (inst.domain && inst.hosting_shape !== 'dedicated' && apply) {
290
+ if (inst.domain && apply) {
293
291
  const gap = caddyCheck(boxExec, { domain: inst.domain });
294
292
  if (gap) return { ok: false, error: `Caddy preflight failed: ${gap}` };
295
293
  }
@@ -328,68 +326,39 @@ async function provisionInstance(inst, deps) {
328
326
  // else PROVISION_PUBLIC_IP (all-local: one and the same machine).
329
327
  // Remote-exec set but no control-plane IP configured → null, which
330
328
  // makes step 5 fail loud rather than point the domain at the wrong box.
331
- // • dedicated → the droplet's own IP (captured once it settles, below).
332
- let recordIp;
333
- if (inst.hosting_shape === 'dedicated') recordIp = null;
334
- else if (standalone) recordIp = CONFIG.controlPlaneIp || (CONFIG.remoteHost ? null : CONFIG.publicIp);
335
- else recordIp = CONFIG.publicIp;
336
- if (inst.hosting_shape === 'dedicated') {
337
- // Dedicated droplet: create it + settle its public IP via the shared DO client
338
- // (do-api, dry-run-default). The on-droplet app standup rides cloud-init
339
- // user-data built from the SAME step commands below. For the
340
- // dry-run plan we surface the intent; --apply creates the droplet.
341
- const doApi = deps.doApi;
342
- log(' [server] dedicated droplet');
343
- if (apply && doApi) {
344
- const { droplet } = await doApi.createDroplet({
345
- name: `cb-${inst.slug}`, region: CONFIG.region, size: inst.tier || 's-2vcpu-4gb',
346
- image: CONFIG.baseImage, tags: [CONFIG.tag], sshKeys: CONFIG.sshKeyIds,
347
- userData: dedicatedUserData(inst),
348
- });
349
- const settled = await doApi.settleDroplet(droplet.id);
350
- patch.error_note = null;
351
- recordIp = settled.ip || null; // the droplet's own IP is this instance's A-record target
352
- log(` droplet ${droplet.id} @ ${settled.ip || '(pending)'} — app standup rides cloud-init`);
353
- } else {
354
- log(' would createDroplet + settle IP, baking cloud-init that runs the standup steps');
355
- }
329
+ const recordIp = standalone ? CONFIG.controlPlaneIp || (CONFIG.remoteHost ? null : CONFIG.publicIp) : CONFIG.publicIp;
330
+ // Co-tenant OR standalone: a process bound to an allocated port on the box it runs
331
+ // on. Co-tenant shares that box's checkout ($0, cloudbongos.com model — under
332
+ // remote-exec, the co-hosting box); standalone (ADR 0108) runs from its OWN repo
333
+ // dir with a pinned @bongos/core (synced in step 2) on the CONTROL PLANE.
334
+ // Allocate against the ports of whichever box it runs on (boxExec: remote for a
335
+ // co-tenant, local for a standalone — task 2074), then run the rest there.
336
+ if (!inst.port) {
337
+ const port = await allocatePort(db, provisioning, { exec: boxExec });
338
+ patch.port = port;
339
+ inst = { ...inst, port };
340
+ log(` [server] ${inst.hosting_shape} — allocated port ${port}`);
356
341
  } else {
357
- // Co-tenant OR standalone: a process bound to an allocated port on the box it runs
358
- // on. Co-tenant shares that box's checkout ($0, cloudbongos.com model — under
359
- // remote-exec, the co-hosting box); standalone (ADR 0108) runs from its OWN repo
360
- // dir with a pinned @bongos/core (synced in step 2) on the CONTROL PLANE.
361
- // Allocate against the ports of whichever box it runs on (boxExec: remote for a
362
- // co-tenant, local for a standalone — task 2074), then run the rest there.
363
- if (!inst.port) {
364
- const port = await allocatePort(db, provisioning, { exec: boxExec });
365
- patch.port = port;
366
- inst = { ...inst, port };
367
- log(` [server] ${inst.hosting_shape} — allocated port ${port}`);
368
- } else {
369
- log(` [server] ${inst.hosting_shape} — port ${inst.port}`);
370
- }
371
- // Standalone: SCAFFOLD its own repo if absent — clone the (empty) target_ref →
372
- // materialize config/ + .claude/ + file:-pinned core + lockfile → commit + push
373
- // (ADR 0108 / 0125, task 2055). Replaces the old "repo must already be cloned"
374
- // throw: the greenfield flow now has no manual clone step. Idempotent + dry-run-safe.
375
- if (inst.hosting_shape === 'cloud-host') {
376
- await scaffoldStandaloneRepo(inst, deps);
377
- // A PRIVATE target_ref gets its own read-only deploy key BEFORE step 2's `git pull
378
- // --ff-only origin main`, which failed unauthenticated on every private standup (N4; ADR 0176).
379
- await ensurePrivateRepoAccess(inst, deps);
380
- await require('./provision-pin-key.js').ensurePinWriteKey(inst, deps); // the hub's WRITE key for later pin pushes, public repos too (task 1004291, ADR 0350)
381
- }
342
+ log(` [server] ${inst.hosting_shape} — port ${inst.port}`);
343
+ }
344
+ // Standalone: SCAFFOLD its own repo if absent — clone the (empty) target_ref →
345
+ // materialize config/ + .claude/ + file:-pinned core + lockfile → commit + push
346
+ // (ADR 0108 / 0125, task 2055). Replaces the old "repo must already be cloned"
347
+ // throw: the greenfield flow now has no manual clone step. Idempotent + dry-run-safe.
348
+ if (inst.hosting_shape === 'cloud-host') {
349
+ await scaffoldStandaloneRepo(inst, deps);
350
+ // A PRIVATE target_ref gets its own read-only deploy key BEFORE step 2's `git pull
351
+ // --ff-only origin main`, which failed unauthenticated on every private standup (N4; ADR 0176).
352
+ await ensurePrivateRepoAccess(inst, deps);
353
+ await require('./provision-pin-key.js').ensurePinWriteKey(inst, deps); // the hub's WRITE key for later pin pushes, public repos too (task 1004291, ADR 0350)
382
354
  }
383
355
  if (!inst.db_name) { patch.db_name = inst.slug; inst = { ...inst, db_name: inst.slug }; }
384
356
 
385
- // The remaining steps run on the instance's box for co-tenant + standalone (boxExec/
386
- // boxWriteFile — remote for co-tenant under remote-exec, control-plane-local for
387
- // standalone; task 2074); for dedicated they are baked into cloud-init (above) and
388
- // re-asserted here as idempotent no-ops on the box.
389
- const local = inst.hosting_shape !== 'dedicated';
357
+ // The remaining steps run on the instance's box (boxExec/boxWriteFile — remote for
358
+ // co-tenant under remote-exec, control-plane-local for standalone; task 2074).
390
359
 
391
360
  // ── step 2: code sync (standalone) + database ─────────────────────────────
392
- if (local && standalone) {
361
+ if (standalone) {
393
362
  // Standalone (ADR 0108 / deploy-instance.sh steps 1-2): sync the instance's own
394
363
  // repo + install the pinned @bongos/core, so the migrate + service below run
395
364
  // from the INSTANCE root, not this control-plane checkout. cwd = standaloneRoot.
@@ -412,86 +381,74 @@ async function provisionInstance(inst, deps) {
412
381
  // ── step 1c: the instance's OWN unix account (task 1003369, audit B3) ─────
413
382
  // Idempotent; the group-add is what lets a fresh system account READ the checkout it runs
414
383
  // from — without it the unit fails at exec with EACCES. Rationale: provision-repo.js.
415
- if (local) {
416
- log(` [user] ensure unix account ${instanceUser(inst)} + its state dir`);
417
- boxExec(ensureInstanceUserCmd(inst, { privileged }));
418
- boxExec(grantInstanceRepoReadCmd(inst, { privileged }));
419
- } else { log(' (instance unix account created via cloud-init on the droplet)'); }
384
+ log(` [user] ensure unix account ${instanceUser(inst)} + its state dir`);
385
+ boxExec(ensureInstanceUserCmd(inst, { privileged }));
386
+ boxExec(grantInstanceRepoReadCmd(inst, { privileged }));
420
387
 
421
388
  log(' [db] create + GDS-only migrate');
422
- if (local) {
423
- boxExec(dbCreateCmd(inst, { privileged }), { allowFail: true }); // "already exists" is fine
424
- if (standalone) boxExec(standaloneMigrateCmd(inst, { privileged }), { cwd: standaloneRoot(inst) }); // instance-root migrate
425
- else boxExec(migrateCmd(inst, { privileged }));
426
- // Seed the first version so the instance can take tasks from minute one (task 2119,
427
- // goal 35). Only fires if the versions table is empty — idempotent, never disturbs an
428
- // existing project. NOT allowFail: a re-run is a clean 0-row no-op, so a real failure
429
- // here should surface. Dedicated bakes the same step into cloud-init (dedicatedUserData).
430
- log(' [seed] first version (only if versions is empty)');
431
- boxExec(seedFirstVersionCmd(inst, { privileged }));
432
- // Regenerate the whole-file navigation docs from the pinned core so a fresh/redeployed
433
- // standalone instance's docs/ stays live (ADR 0110 §2, task 1002314) — the deploy-regen the
434
- // bespoke pull scripts were missing. Best-effort: regen-instance-docs.js exits 0 even on a
435
- // generator hiccup (LOUD ::ERROR::), so a stale nav doc never fails a provision.
436
- if (standalone) {
437
- log(' [docs] regenerate whole-file navigation docs (session-log index)');
438
- boxExec(standaloneRegenDocsCmd(), { cwd: standaloneRoot(inst), allowFail: true });
439
- }
440
- } else { log(' (db create + migrate + first-version seed run via cloud-init on the droplet)'); }
389
+ boxExec(dbCreateCmd(inst, { privileged }), { allowFail: true }); // "already exists" is fine
390
+ if (standalone) boxExec(standaloneMigrateCmd(inst, { privileged }), { cwd: standaloneRoot(inst) }); // instance-root migrate
391
+ else boxExec(migrateCmd(inst, { privileged }));
392
+ // Seed the first version so the instance can take tasks from minute one (task 2119,
393
+ // goal 35). Only fires if the versions table is empty — idempotent, never disturbs an
394
+ // existing project. NOT allowFail: a re-run is a clean 0-row no-op, so a real failure
395
+ // here should surface.
396
+ log(' [seed] first version (only if versions is empty)');
397
+ boxExec(seedFirstVersionCmd(inst, { privileged }));
398
+ // Regenerate the whole-file navigation docs from the pinned core so a fresh/redeployed
399
+ // standalone instance's docs/ stays live (ADR 0110 §2, task 1002314) — the deploy-regen the
400
+ // bespoke pull scripts were missing. Best-effort: regen-instance-docs.js exits 0 even on a
401
+ // generator hiccup (LOUD ::ERROR::), so a stale nav doc never fails a provision.
402
+ if (standalone) {
403
+ log(' [docs] regenerate whole-file navigation docs (session-log index)');
404
+ boxExec(standaloneRegenDocsCmd(), { cwd: standaloneRoot(inst), allowFail: true });
405
+ }
441
406
 
442
407
  // ── step 2b: the instance's OWN password-authed PG role (task 1003369, audit B3) ──
443
408
  // AFTER the migrate: it runs as `postgres`, which therefore OWNS the tables it creates.
444
- if (local) {
445
- log(' [db] create/rotate the per-instance role + revoke PUBLIC connect');
446
- // `shown` is MANDATORY: dbRoleCmd embeds the password, and run() logs the raw command
447
- // on BOTH the dry-run and success paths — the exact leak this task exists to prevent
448
- // (makeWriteFile passes `shown` for the web.env body for the same reason, and a `shown`
449
- // call site also suppresses stderr). The GRANTS carry no secret and log in full.
450
- boxExec(dbRoleCmd(inst, { password: dbPassword, privileged }), { shown: dbRoleCmdShown(inst) });
451
- boxExec(dbRoleGrantsCmd(inst, { privileged }));
452
- } else { log(' (per-instance DB role created via cloud-init on the droplet)'); }
409
+ log(' [db] create/rotate the per-instance role + revoke PUBLIC connect');
410
+ // `shown` is MANDATORY: dbRoleCmd embeds the password, and run() logs the raw command
411
+ // on BOTH the dry-run and success paths — the exact leak this task exists to prevent
412
+ // (makeWriteFile passes `shown` for the web.env body for the same reason, and a `shown`
413
+ // call site also suppresses stderr). The GRANTS carry no secret and log in full.
414
+ boxExec(dbRoleCmd(inst, { password: dbPassword, privileged }), { shown: dbRoleCmdShown(inst) });
415
+ boxExec(dbRoleGrantsCmd(inst, { privileged }));
453
416
 
454
417
  // ── step 3: config + per-instance secrets ─────────────────────────────────
455
418
  log(' [config] per-instance web.env');
456
- if (local) boxWriteFile(webEnvPath(inst), webEnvBody(inst, { federation, ownerLogin: await ownerLoginOf(inst, deps.db), dbPassword }), { mode: 0o600, sudo: true });
457
- else log(' (baked into cloud-init)');
419
+ boxWriteFile(webEnvPath(inst), webEnvBody(inst, { federation, ownerLogin: await ownerLoginOf(inst, deps.db), dbPassword }), { mode: 0o600, sudo: true });
458
420
 
459
421
  // ── step 4: install + enable the instance's service ───────────────────────
460
422
  const pm = pmFor(deps);
461
423
  log(` [service] install + enable the ${pm.key} service`);
462
- if (local) {
463
- pm.installService(inst, { exec: boxExec, writeFile: boxWriteFile, sudoP, log });
464
- } else { log(' (unit baked into cloud-init)'); }
424
+ pm.installService(inst, { exec: boxExec, writeFile: boxWriteFile, sudoP, log });
465
425
 
466
426
  // ── step 4b: nightly DB backup — dir + consumer-layout timer/service (task 2213) ──
467
427
  // Every instance must be born WITH backups (the task-2210 gap: cloudbongos was
468
428
  // provisioned without any). Create the app-user-owned backup dir, install the
469
429
  // per-instance timer/service (correct db + consumer-layout script path), enable
470
430
  // the timer. Idempotent: `install -d` no-ops on an existing dir, unit writes are
471
- // overwrites, `enable --now` is idempotent. Dedicated bakes the same into cloud-init.
431
+ // overwrites, `enable --now` is idempotent.
472
432
  log(' [backup] create backup dir + install + enable nightly DB backup timer');
473
- if (local) {
474
- pm.installBackup(inst, { exec: boxExec, writeFile: boxWriteFile, sudoP, log });
475
- } else { log(' (backup dir + units baked into cloud-init)'); }
433
+ pm.installBackup(inst, { exec: boxExec, writeFile: boxWriteFile, sudoP, log });
476
434
 
477
435
  // ── step 5: DNS — Cloudflare A-record UPSERT for the instance's domain (P4) ──
478
436
  // Formalized in P4 (#1945): the shared box-dns-cloudflare.sh hook UPSERTs an A
479
437
  // record pointing the domain at its target IP (recordIp), in the domain's own
480
438
  // zone, proxied=false so Caddy's on-demand ACME challenge (step 6) reaches the
481
- // origin (dnsUpsertEnv). It runs on the CONTROL PLANE for BOTH shapes: the hook
482
- // needs the Cloudflare token, which never leaves the control plane, so a dedicated
483
- // instance's DNS is upserted from here (not baked into the droplet's cloud-init).
439
+ // origin (dnsUpsertEnv). It runs on the CONTROL PLANE for every shape: the hook
440
+ // needs the Cloudflare token, which never leaves the control plane.
484
441
  // Unlike P3's best-effort allowFail, a DNS FAILURE now SURFACES (the task-1180
485
442
  // lesson: never report provision success while the A record silently didn't land).
486
443
  if (inst.domain && CONFIG.dnsHook) {
487
444
  if (!recordIp) {
488
445
  const ipEnvHint = standalone ? 'PROVISION_CONTROL_PLANE_IP (standalone)' : 'PROVISION_PUBLIC_IP (co-tenant)';
489
- if (apply) throw new Error(`cannot UPSERT DNS for ${inst.domain}: no target IP — set ${ipEnvHint} in the control-plane box.env, or the droplet must settle an IP (dedicated)`);
446
+ if (apply) throw new Error(`cannot UPSERT DNS for ${inst.domain}: no target IP — set ${ipEnvHint} in the control-plane box.env`);
490
447
  log(` [dns] would upsert A ${inst.domain} once the target IP is known (set ${ipEnvHint})`);
491
448
  } else {
492
449
  // Standalone runs control-plane-local under the *.cloudbongos.com wildcard origin
493
- // cert (task 2074/2077) → proxied=true (CF edge fronts it, IP hidden); co-tenant +
494
- // dedicated stay proxied=false so their on-demand ACME challenge reaches the origin.
450
+ // cert (task 2074/2077) → proxied=true (CF edge fronts it, IP hidden); co-tenant
451
+ // stays proxied=false so their on-demand ACME challenge reaches the origin.
495
452
  const dnsEnv = dnsUpsertEnv(inst, { publicIp: recordIp, proxied: standalone });
496
453
  log(` [dns] upsert A ${inst.domain} -> ${recordIp} (zone ${dnsEnv.CLOUDFLARE_ZONE}, proxied=${dnsEnv.BOX_PROXIED === '1'}) via ${CONFIG.dnsHook}`);
497
454
  // controlExec, NOT exec: the DNS hook needs the Cloudflare token, which lives
@@ -528,12 +485,10 @@ async function provisionInstance(inst, deps) {
528
485
  // setup) — verified after the write, since only a written snippet proves the import
529
486
  // reaches it. The reload is loud: Caddy keeps its old config when it rejects the new one.
530
487
  log(' [caddy] write site snippet + reload');
531
- if (local) {
532
- boxWriteFile(caddySnippetPath(inst), caddyBlock(inst), { mode: 0o644, sudo: true });
533
- const gap = inst.domain && apply ? caddyCheck(boxExec, { domain: inst.domain, requireHost: true }) : null;
534
- if (gap) return { ok: false, error: `Caddy wiring failed: ${gap}` };
535
- boxExec(`${sudoP}systemctl reload caddy`);
536
- } else { log(' (Caddy on the droplet via cloud-init)'); }
488
+ boxWriteFile(caddySnippetPath(inst), caddyBlock(inst), { mode: 0o644, sudo: true });
489
+ const gap = inst.domain && apply ? caddyCheck(boxExec, { domain: inst.domain, requireHost: true }) : null;
490
+ if (gap) return { ok: false, error: `Caddy wiring failed: ${gap}` };
491
+ boxExec(`${sudoP}systemctl reload caddy`);
537
492
 
538
493
  // ── step 7: healthz ───────────────────────────────────────────────────────
539
494
  // The probe RESULT is recorded, not discarded (task 1002720 / QA-walk F15: a
@@ -545,17 +500,15 @@ async function provisionInstance(inst, deps) {
545
500
  // what tells the owner the truth.
546
501
  log(' [healthz] verify the instance answers');
547
502
  const sleep = deps.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
548
- let healthy = null; // null = not observed (dry-run / dedicated)
549
- if (local) {
550
- const tries = apply ? 10 : 1;
551
- for (let i = 0; i < tries; i++) {
552
- const res = boxExec(healthzCmd(inst), { allowFail: true });
553
- if (!apply) break; // dry-run: exactly one plan line
554
- if (res && !res.softFailed) { healthy = true; break; }
555
- healthy = false;
556
- if (i < tries - 1) await sleep(1000);
557
- }
558
- } else log(' (verified post-boot)');
503
+ let healthy = null; // null = not observed (dry-run)
504
+ const tries = apply ? 10 : 1;
505
+ for (let i = 0; i < tries; i++) {
506
+ const res = boxExec(healthzCmd(inst), { allowFail: true });
507
+ if (!apply) break; // dry-run: exactly one plan line
508
+ if (res && !res.softFailed) { healthy = true; break; }
509
+ healthy = false;
510
+ if (i < tries - 1) await sleep(1000);
511
+ }
559
512
 
560
513
  // ── step 7b: identity ─────────────────────────────────────────────────────
561
514
  // Does it answer as ITSELF? A live instance that serves the platform's name instead of its own
@@ -563,7 +516,7 @@ async function provisionInstance(inst, deps) {
563
516
  // (task 1003740, which went unnoticed across every hosted instance for months). A WARNING, never
564
517
  // a failure: an owner may legitimately name a project after the platform, and a name must not
565
518
  // fail a working standup.
566
- if (local && apply && healthy !== false) {
519
+ if (apply && healthy !== false) {
567
520
  const res = boxExec(identityCmd(inst), { allowFail: true });
568
521
  const raw = res && !res.softFailed ? (res.stdout || '') : '';
569
522
  let manifest = null;
@@ -613,42 +566,6 @@ async function provisionInstance(inst, deps) {
613
566
  return { ok: true, port: inst.port, healthy };
614
567
  }
615
568
 
616
- // The cloud-init user-data for a dedicated droplet: run the co-tenant standup
617
- // steps on first boot. Kept small (well under DO's 64 KiB cap). Built from the
618
- // SAME pure command generators so local + dedicated never diverge.
619
- function dedicatedUserData(inst) {
620
- const i = { ...inst, db_name: inst.db_name || inst.slug };
621
- // task 2213: a dedicated droplet gets the same nightly backup as local shapes —
622
- // write the per-instance unit pair (base64-encoded to sidestep YAML block-scalar
623
- // escaping of the multi-line bodies), create the app-user-owned backup dir, and
624
- // enable the timer alongside the web unit. Mirrors provisionInstance's step 4b.
625
- const svcB64 = Buffer.from(backupService(i), 'utf8').toString('base64');
626
- const timerB64 = Buffer.from(backupTimer(i), 'utf8').toString('base64');
627
- // task 1003369: the same role the local leg creates at step 2b, same position around the
628
- // migrate, but PASSWORD-LESS (peerOnly) — DO retains user-data and serves it back, and a
629
- // dedicated droplet is single-tenant. Whole argument on dbRoleCmd.
630
- const roleLines = ` - [ bash, -lc, "${dbRoleCmd(i, { peerOnly: true })}" ]\n - [ bash, -lc, "${dbRoleGrantsCmd(i)}" ]\n`;
631
- return `#cloud-config
632
- write_files:
633
- - path: ${backupServicePath(i)}
634
- encoding: b64
635
- permissions: '0644'
636
- content: ${svcB64}
637
- - path: ${backupTimerPath(i)}
638
- encoding: b64
639
- permissions: '0644'
640
- content: ${timerB64}
641
- runcmd:
642
- - [ bash, -lc, "${ensureInstanceUserCmd(i)}" ]
643
- - [ bash, -lc, "${grantInstanceRepoReadCmd(i)}" ]
644
- - [ bash, -lc, "${dbCreateCmd(i)} || true" ]
645
- - [ bash, -lc, "cd ${REPO_ROOT} && ${migrateCmd(i)}" ]
646
- - [ bash, -lc, "${seedFirstVersionCmd(i)}" ]
647
- ${roleLines} - [ bash, -lc, "install -d -o ${appUser()} -g ${appGroupExpr()} -m 0750 ${CONFIG.backupDir}" ]
648
- - [ bash, -lc, "systemctl daemon-reload && systemctl enable --now ${i.slug} ${backupUnitName(i)}.timer" ]
649
- `;
650
- }
651
-
652
569
  // The addresses a re-point left behind and this run still owes a release on
653
570
  // (task 1003237). The one guard this tier can make on its own, and the one that
654
571
  // matters: the address the instance is CURRENTLY on is never released, so a row
@@ -754,10 +671,6 @@ async function domainAttachInstance(inst, deps) {
754
671
  log(` instance is '${inst.status}' — nothing to wire now (provision owns pre-active domains)`);
755
672
  return { ok: true, noop: true };
756
673
  }
757
- if (inst.hosting_shape === 'dedicated') {
758
- return { ok: false, error: `domain-attach for a dedicated instance is not automated yet — point an A record for ${inst.domain} at the droplet's IP and reload its Caddy` };
759
- }
760
-
761
674
  // DNS preflight (the task-1980 stance): verify token reach BEFORE touching the box.
762
675
  if (CONFIG.dnsHook && apply) {
763
676
  const scope = await (deps.checkDnsTokenScope || checkDnsTokenScope)(inst.domain);
@@ -766,7 +679,7 @@ async function domainAttachInstance(inst, deps) {
766
679
  }
767
680
  log(` [preflight] DNS token can reach zone ${scope.zone} ✓`);
768
681
  }
769
- const ingressGap = ingressPreflight({ exec: boxExec, apply, log, shape: inst.hosting_shape, probe: deps.ingressPlaneGap, before: 're-writing the Caddy snippet' });
682
+ const ingressGap = ingressPreflight({ exec: boxExec, apply, log, probe: deps.ingressPlaneGap, before: 're-writing the Caddy snippet' });
770
683
  if (ingressGap) return { ok: false, error: ingressGap }; // Ingress preflight (task 1004017, ADR 0315)
771
684
  const caddyCheck = deps.checkCaddyWiring || checkCaddyWiring; // Caddy preflight (task 1002860, ADR 0303)
772
685
  if (apply) {
@@ -891,13 +804,6 @@ async function settingsApplyInstance(inst, deps) {
891
804
  log(` instance is '${inst.status}' — nothing to push now (standup writes the same values)`);
892
805
  return { ok: true, noop: true };
893
806
  }
894
- // A dedicated droplet is stood up by cloud-init and the runner has no post-hoc
895
- // exec path to it (the placeManifestCreds refusal) — say so rather than report a
896
- // push that never landed. The route refuses to enqueue for this shape too.
897
- if (inst.hosting_shape === 'dedicated') {
898
- return { ok: false, error: `settings push is not automated for a dedicated droplet — set ${shown} in its ${webEnvPath(inst)} and restart the ${inst.slug} service there` };
899
- }
900
-
901
807
  // Read the file as it stands so ONLY the settings lines change. Dry-run runs no
902
808
  // command, so it has no file to patch — it PLANS the patch instead of writing a
903
809
  // synthetic two-line body (which would read as the exact overwrite this leg
@@ -1003,11 +909,6 @@ async function restartInstance(inst, deps) {
1003
909
  log(` instance is '${inst.status}' — nothing running to restart`);
1004
910
  return { ok: true, noop: true };
1005
911
  }
1006
- // A dedicated droplet has no runner exec path (the placeManifestCreds refusal).
1007
- // The route refuses to enqueue for this shape too; this is the second gate.
1008
- if (inst.hosting_shape === 'dedicated') {
1009
- return { ok: false, error: `restart is not automated for a dedicated droplet — run 'systemctl restart ${inst.slug}' on that machine` };
1010
- }
1011
912
  if (!apply) {
1012
913
  pmFor(deps).restartService(inst, { exec: boxExec, sudoP });
1013
914
  boxExec(healthzCmd(inst), { allowFail: true });
@@ -1115,14 +1016,9 @@ async function convertManifestCode(code, { fetchImpl = globalThis.fetch, userAge
1115
1016
 
1116
1017
  // Convert one manifest row's code → creds and PLACE them (rewrite web.env + restart +
1117
1018
  // verify /healthz). Uses the SAME executors provisionInstance does, so co-tenant (local
1118
- // or remote-cohost) and standalone (control-plane-local) are handled identically. A
1119
- // dedicated droplet bakes web.env into cloud-init and has no post-hoc exec path here, so
1120
- // it is refused with guidance. Returns { ok, appId, appSlug } | { ok:false, error }.
1019
+ // or remote-cohost) and standalone (control-plane-local) are handled identically. Returns { ok, appId, appSlug } | { ok:false, error }.
1121
1020
  async function placeManifestCreds(inst, mrow, deps) {
1122
1021
  const { log, apply } = deps;
1123
- if (inst.hosting_shape === 'dedicated') {
1124
- return { ok: false, error: 'GitHub App Manifest cred placement is not supported for a dedicated droplet yet — run oauth-secret.js on the droplet instead' };
1125
- }
1126
1022
  if (!mrow.github_code) return { ok: false, error: 'manifest row carries no github_code to convert' };
1127
1023
  const standalone = inst.hosting_shape === 'cloud-host';
1128
1024
  const boxExec = standalone ? (deps.controlExec || deps.exec) : deps.exec;
@@ -1324,7 +1220,7 @@ async function cmdRunIntents(deps) {
1324
1220
 
1325
1221
  // A light drift reconcile: surface instances stuck mid-flight (provisioning /
1326
1222
  // tearing_down) with no open intent — a runner died mid-op. Reports; --apply
1327
- // re-enqueues a fresh intent so the next tick retries. (Full droplet-vs-DB drift
1223
+ // re-enqueues a fresh intent so the next tick retries. (Full box-vs-DB drift
1328
1224
  // is a later hardening.)
1329
1225
  async function cmdReconcile(deps) {
1330
1226
  const { db, provisioning, log, apply } = deps;
@@ -1421,8 +1317,8 @@ async function main() {
1421
1317
  const slug = args[1] && !args[1].startsWith('--') ? args[1] : null;
1422
1318
  if ((cmd === 'provision' || cmd === 'teardown') && !slug) { console.error(`${cmd} needs a <slug>`); process.exit(2); }
1423
1319
 
1424
- let pool, provisioning, DoApi, platformIdentity, hubKeys, projectCatalog;
1425
- try { ({ pool, provisioning, DoApi, platformIdentity, hubKeys, projectCatalog } = loadDeps()); }
1320
+ let pool, provisioning, platformIdentity, hubKeys, projectCatalog;
1321
+ try { ({ pool, provisioning, platformIdentity, hubKeys, projectCatalog } = loadDeps()); }
1426
1322
  catch (e) { console.error(`cannot load DB layer (run this on the control plane): ${e.message}`); process.exit(1); }
1427
1323
 
1428
1324
  // Box-target executor (remote over SSH when PROVISION_REMOTE_HOST is set, else
@@ -1433,14 +1329,11 @@ async function main() {
1433
1329
  if (CONFIG.remoteHost && !CONFIG.remoteCwd) { console.error('PROVISION_REMOTE_HOST is set but PROVISION_REMOTE_CHECKOUT is not — name the co-hosting box\'s checkout dir (the old hardcoded default was removed, task 1002756).'); process.exit(2); }
1434
1330
  const exec = makeExec({ apply, remoteHost: CONFIG.remoteHost, remoteCwd: CONFIG.remoteCwd, sshOpts: CONFIG.sshOpts });
1435
1331
  const controlExec = makeExec({ apply });
1436
- let doApiClient = null;
1437
1332
  const deps = {
1438
1333
  db: pool, provisioning, apply, privileged: CONFIG.privileged, log: console.log,
1439
1334
  exec, controlExec,
1440
1335
  writeFile: makeWriteFile({ apply, privileged: CONFIG.privileged, exec, remoteHost: CONFIG.remoteHost }),
1441
1336
  controlWriteFile: makeWriteFile({ apply, privileged: CONFIG.privileged, exec: controlExec }),
1442
- // Built on first READ (task 1002718 F6): an --apply tick whose intents never call DO needs no DO_API_TOKEN.
1443
- get doApi() { return DoApi ? (doApiClient = doApiClient || new DoApi({ dryRun: !apply, token: CONFIG.doToken, log: (m) => console.log(' ' + m) })) : null; },
1444
1337
  // Cross-site identity (ADR 0141 §4). Null on a non-hub control plane → federation
1445
1338
  // dormant; shouldFederate() then skips and the instance owns its auth (as today).
1446
1339
  platformIdentity, hubKeys,
@@ -1484,7 +1377,7 @@ module.exports = {
1484
1377
  // private-repo deploy key (ADR 0176, task 1002735)
1485
1378
  resolveOwnerGithubToken, deployKeyTitle, deployKeyPath, ensurePrivateRepoAccess,
1486
1379
  zoneForDomain, dnsUpsertEnv, classifyZoneScope, checkDnsTokenScope,
1487
- caddyBlock, caddySnippetPath, healthzCmd, dedicatedUserData, nextFreePort,
1380
+ caddyBlock, caddySnippetPath, healthzCmd, nextFreePort,
1488
1381
  // executors + orchestration (injectable deps for tests)
1489
1382
  makeExec, makeWriteFile, allocatePort, boundLocalPorts, provisionInstance, teardownInstance,
1490
1383
  domainAttachInstance, releasableDomains, cmdRunIntents, cmdReconcile, cmdStatus,
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.20.15'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.20.17'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');