@bongos/core 1.19.950 → 1.19.952

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,13 @@ const KNOWN_MODULES = ['game', 'art-pipeline', 'discord', 'dev-box'];
87
87
  // request (POST /provisioning/instances validates slug/shape, and returns 404 when
88
88
  // the module isn't enabled on the target instance — init then falls back to filing
89
89
  // the manual production-topology task). ADR 0111 §3.
90
- const HOSTING_SHAPES = ['co-tenant', 'dedicated', 'standalone'];
90
+ // ADR 0323: 'byo-host' is the free front door and the default; 'co-tenant' is RETIRED
91
+ // and the server refuses it outright, so it is absent here DELIBERATELY. Leaving it in
92
+ // would let init write a spec the create route then 403s — a local list that accepts
93
+ // more than the server does is worse than no list, because the refusal arrives a step
94
+ // later with no useful message. Dropping it means `--shape co-tenant` fails here, naming
95
+ // the shapes that work.
96
+ const HOSTING_SHAPES = ['byo-host', 'dedicated', 'standalone'];
91
97
 
92
98
  // The Cloud Bongos core npm package an instance pins as a dependency (ADR 0108).
93
99
  // `bongos init --adopt` LAYERS this onto an existing repo's package.json; `bongos
@@ -205,11 +211,11 @@ function provisioningSlug(spec) {
205
211
  // block). THE ADR 0111 §3 ARM: infra answers become a real provisioning REQUEST (a
206
212
  // provisioning_intents row the control-plane runner drains) instead of only a manual
207
213
  // runbook task. Pure — no I/O; the caller POSTs it. Shape defaults to the cheapest
208
- // co-tenant ($0) per the budget DNA (ADR 0111 §6).
214
+ // byo-host ($0 to us — the owner supplies the host) per the budget DNA (ADR 0323).
209
215
  function provisioningRequest(spec) {
210
216
  const p = spec && spec.provisioning;
211
217
  if (!isObj(p)) return null;
212
- const shape = HOSTING_SHAPES.includes(p.shape) ? p.shape : 'co-tenant';
218
+ const shape = HOSTING_SHAPES.includes(p.shape) ? p.shape : 'byo-host';
213
219
  const body = { slug: provisioningSlug(spec), hosting_shape: shape };
214
220
  if (p.domain) body.domain = String(p.domain).trim();
215
221
  if (p.targetRef) body.target_ref = String(p.targetRef).trim();
@@ -334,12 +340,12 @@ function normalizeSpec(raw = {}) {
334
340
  spec.modules = Array.isArray(spec.modules) ? spec.modules.filter((m) => KNOWN_MODULES.includes(m)) : [];
335
341
  if (!spec.version.status) spec.version.status = 'building';
336
342
  // Provisioning (ADR 0111 §3) — a block ⇒ the owner opted in. Coerce the shape to a
337
- // known value (default the cheapest co-tenant) and trim the free-text refs; drop the
343
+ // known value (default byo-host, the free front door) and trim the free-text refs; drop the
338
344
  // block entirely when absent so provisioningRequest() reads it as "not requested".
339
345
  if (isObj(spec.provisioning)) {
340
346
  const p = spec.provisioning;
341
347
  spec.provisioning = {
342
- shape: HOSTING_SHAPES.includes(p.shape) ? p.shape : 'co-tenant',
348
+ shape: HOSTING_SHAPES.includes(p.shape) ? p.shape : 'byo-host',
343
349
  ...(p.slug ? { slug: String(p.slug).trim() } : {}),
344
350
  ...(p.domain ? { domain: String(p.domain).trim() } : {}),
345
351
  ...(p.targetRef ? { targetRef: String(p.targetRef).trim() } : {}),
@@ -446,13 +452,20 @@ async function interview({ adopt = false } = {}) {
446
452
  let provisioning;
447
453
  const wantProv = (await ask(rl, ' Provision production hosting now? (y/N)', 'N')).toLowerCase();
448
454
  if (wantProv === 'y' || wantProv === 'yes') {
449
- const shape = (await ask(rl, ' Hosting shape? (co-tenant = share an existing box, $0 / dedicated = its own droplet / standalone = its own repo + pinned @bongos/core, ADR 0108)', 'co-tenant')).toLowerCase();
455
+ // Describes each shape by WHAT IT DOES and never by its owner-facing product
456
+ // name. The stored value → owner-facing label translation lives in exactly one
457
+ // place — SHAPE_LABEL in modules/public-landing/public/projects.html (ADR 0323,
458
+ // and this module's CLAUDE.md says so) — so glossing '"cloud host"' here would
459
+ // be a second copy of it, in a file no designer renaming the product would
460
+ // think to open. A CLI prompt naming the stored value it is about to write is
461
+ // the right register anyway: this asks for a column value, not for a purchase.
462
+ const shape = (await ask(rl, ' Hosting shape? (byo-host = you run it on your own host / standalone = its own repo + pinned @bongos/core on the platform\'s box / dedicated = the same on its own droplet, priced by tier)', 'byo-host')).toLowerCase();
450
463
  const domain = await ask(rl, ' Domain you own — bring-your-own (blank = the platform assigns <slug>.<its apex> when configured)?');
451
464
  const targetRef = await ask(rl, ' Target credential ref? (which control-plane creds the runner should use; optional)');
452
465
  let tier = '';
453
466
  if (shape === 'dedicated') tier = await ask(rl, ' Droplet tier? (e.g. s-1vcpu-2gb; optional)');
454
467
  provisioning = {
455
- shape: HOSTING_SHAPES.includes(shape) ? shape : 'co-tenant',
468
+ shape: HOSTING_SHAPES.includes(shape) ? shape : 'byo-host',
456
469
  domain: domain || undefined,
457
470
  targetRef: targetRef || undefined,
458
471
  tier: tier || undefined,
@@ -484,7 +497,8 @@ async function interview({ adopt = false } = {}) {
484
497
  // ---------------------------------------------------------------------------
485
498
 
486
499
  // PURE (exported for tests) — the root START-HERE guide (task 1002404, goal
487
- // 1000052). A freshly-CLONED instance must tell a human what to run FIRST: the
500
+ // 1000052; task 1003881 made it LAYER onto an existing README instead of
501
+ // skipping one — see layerReadme below). A freshly-CLONED instance must tell a human what to run FIRST: the
488
502
  // `bongos` CLI ships INSIDE the not-yet-installed @bongos/core package, so
489
503
  // `npm install` has to come before any `bongos` command (including `bongos dev`).
490
504
  // `bongos init` runs npm install during scaffolding, but a later `git clone` of
@@ -624,8 +638,9 @@ function writeConfigs(spec, { dir, force, dryRun, coreDep, log = console.log })
624
638
  log(` ✓ wrote ${path.relative(dir, modulesPath)}`);
625
639
  log(` ✓ wrote ${path.relative(dir, hierarchyPath)}`);
626
640
  log(` ✓ wrote ${path.relative(dir, pkgPath)} (pins ${CORE_PKG} → ${pinNote})`);
627
- // README START-HERE (task 1002404): mint it when absent, else APPEND the marked
628
- // section. Never a blind overwrite — an owner's own README content always survives.
641
+ // README START-HERE (task 1002404, layering added by task 1003881): mint it when
642
+ // absent, else APPEND the marked section. Never a blind overwrite — an owner's own
643
+ // README content always survives, which is why the guide stopped being skipped.
629
644
  layerReadme(branding, { dir, dryRun: false, log });
630
645
  return { brandingPath, modulesPath, hierarchyPath, pkgPath, readmePath, wrote: true };
631
646
  }
@@ -23,7 +23,7 @@
23
23
  const readline = require('node:readline');
24
24
  const { spawnSync } = require('node:child_process');
25
25
  const { saveSession, loadStoredSession, listStoredSessions } = require('./cli-lib');
26
- // The device-flow refusal detector + its plain-language cure (task 1003400). A leaf
26
+ // The device-flow refusal detector + its plain-language cure (task 1003881). A leaf
27
27
  // module, NOT setup.js: setup requires this file for runDeviceFlow, so requiring it
28
28
  // back would close a require cycle. Both callers depend on the leaf instead.
29
29
  const { isDeviceFlowDisabled, deviceFlowFallbackLines } = require('./device-flow-help');
@@ -0,0 +1,68 @@
1
+ 'use strict';
2
+
3
+ // scripts/gds/provision-byo-host.js — the runner's answer for a 'byo-host' instance:
4
+ // stand up NOTHING (ADR 0323, task 1004141).
5
+ //
6
+ // "Bring your own host" means the OWNER runs the instance — their machine, their VPS,
7
+ // their own platform account. The platform's only job is to hold the record, so the
8
+ // runner's job here is defined ENTIRELY BY WHAT IT MUST NOT DO: no port allocation, no
9
+ // database or role, no `web.env` under /etc, no systemd unit, no Caddy snippet, no DNS
10
+ // record, no cloud API call, no shell on anybody's box.
11
+ //
12
+ // WHY IT IS A GUARD AND NOT A CASE IN THE SWITCH. `provisionInstance` runs four
13
+ // preflights before it mutates anything — box capacity, ingress, unix accounts, Caddy
14
+ // wiring — and every one of them asks a question about a machine the platform owns. On
15
+ // a byo-host row the honest answer to each is not "pass" or "fail" but "not ours to
16
+ // ask", so the shape has to return ABOVE them, not be handled after them. That is why
17
+ // the caller's line sits immediately under the already-active idempotency check.
18
+ //
19
+ // WHAT IT PREVENTED. Before this existed the shape fell through to the shared
20
+ // co-tenant/standalone standup. `standalone` is false for it, so boxExec was the
21
+ // CONTROL PLANE's own shell: the runner would have allocated a port, run createdb,
22
+ // written env under /etc, installed a unit and a Caddy snippet and reloaded the proxy —
23
+ // standing up, on our box, the one shape whose entire promise is that we never do.
24
+ // Nothing would have errored. A silent success is the failure mode, which is why the
25
+ // proof (`tests/provision_byo_host.mjs`) asserts ABSENCE — no exec, no write, no port,
26
+ // no database — instead of asserting that the right steps ran.
27
+ //
28
+ // WHY THE ROW STILL GOES 'active'. `active` records that the platform has nothing left
29
+ // to do, not that the platform is running it. Liveness for this shape is the ADR 0252
30
+ // pull against the owner-supplied origin: the ~5-minute liveness sweep probes it by
31
+ // domain and only ever writes the overlay columns, never `status`, so the card earns its
32
+ // verdict from an observation rather than from our runner's say-so.
33
+ //
34
+ // Its own file because scripts/gds/provision.js sits at the 1500-line oversized-file
35
+ // ratchet — the same reason provision-net.js, provision-units.js and
36
+ // provision-core-upgrade.js were carved out.
37
+
38
+ // The stored value. A literal, not a regex or a prefix match: a near-miss must reach the
39
+ // standup's own validation rather than be quietly treated as the shape we skip.
40
+ const BYO_HOST_SHAPE = 'byo-host';
41
+
42
+ function isByoHost(hostingShape) {
43
+ return hostingShape === BYO_HOST_SHAPE;
44
+ }
45
+
46
+ // Record a byo-host instance as provisioned without touching any box. Reaches the
47
+ // database ONLY through the injected `provisioning` port (the status flip + the audit
48
+ // event) — it takes no executor and no writeFile, so there is nothing here that could
49
+ // mutate a machine even by mistake.
50
+ async function recordByoHost(inst, { db, provisioning, log, apply }) {
51
+ log(' [byo-host] the owner runs this one — no port, no database, no unit, no Caddy snippet, no DNS');
52
+ if (!apply) {
53
+ log(' [byo-host] would mark it active without touching any box');
54
+ return { ok: true, byoHost: true, stoodUp: false };
55
+ }
56
+ // error_note: null — a stale note from an earlier failed run must not ride into the
57
+ // active state (the same clearing the co-tenant/standalone tail does).
58
+ await provisioning.setInstanceStatus(db, inst.id, 'active', { error_note: null, provisioned_at: new Date().toISOString() });
59
+ await provisioning.recordEvent(db, {
60
+ instanceId: inst.id, ownerBuilderId: inst.owner_builder_id, event: 'active',
61
+ detail: 'byo-host — the owner supplies the host; the platform stood up nothing',
62
+ actor: 'runner',
63
+ });
64
+ log(` ✓ ${inst.slug} recorded (byo-host) — nothing was stood up`);
65
+ return { ok: true, byoHost: true, stoodUp: false };
66
+ }
67
+
68
+ module.exports = { BYO_HOST_SHAPE, isByoHost, recordByoHost };
@@ -59,6 +59,7 @@ const { installedCoreVersion, runnerBanner } = require('./runner-drift.js');
59
59
  const { teardownInstance } = require('./provision-teardown.js');
60
60
  const { disconnectInstance } = require('./provision-disconnect.js');
61
61
  const { coreUpgradeInstance, preflightCoreUpgrade, snapshotAllCoreVersions } = require('./provision-core-upgrade.js'); // the owner's deploy door, ADR 0293
62
+ const { isByoHost, recordByoHost } = require('./provision-byo-host.js'); // stand up NOTHING for a byo-host row, ADR 0323
62
63
 
63
64
  // WHAT supervises an instance's service (ADR 0306). systemd on every box we run — the
64
65
  // seam exists so a plane without systemd is not a dead end. Injectable for tests.
@@ -250,6 +251,7 @@ async function provisionInstance(inst, deps) {
250
251
  // Idempotency: an already-active instance is a no-op success (re-drained intent).
251
252
  if (inst.status === 'active') { log(' already active — nothing to do'); return { ok: true, already: true }; }
252
253
 
254
+ 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)
253
255
  // ── preflight: DNS token scope (task 1980) ─────────────────────────────────
254
256
  // BEFORE committing to the address (allocating a port / creating a droplet / the
255
257
  // step-5 DNS UPSERT), verify the control-plane Cloudflare token can actually reach
@@ -274,7 +274,7 @@ async function promptOnboardingConsent() {
274
274
  }
275
275
 
276
276
  // ── W1: the manifest-born App has Device Flow OFF (task 1003051) ─────────────
277
- // The detector + the guidance moved to ./device-flow-help (task 1003400) so
277
+ // The detector + the guidance moved to ./device-flow-help (task 1003881) so
278
278
  // `bongos login` can use the same words without closing a require cycle back
279
279
  // through this file (setup requires login for runDeviceFlow). Re-exported here
280
280
  // because both are part of setup.js's tested public surface.
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.950'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.952'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -101,7 +101,7 @@ t('preflightAdopt: a clean repo → no blocking; every category reports', () =>
101
101
  } finally { rmSync(dir, { recursive: true, force: true }); }
102
102
  });
103
103
 
104
- // ---- ignored-incoming: the check that was missing (task 1003400) ----------
104
+ // ---- ignored-incoming: the check that was missing (task 1003881) ----------
105
105
  //
106
106
  // The failure it exists to stop: adopt onto a repo whose .gitignore already carries
107
107
  // `.claude/`. The skills/hooks/settings are written to disk, `git add` drops them, the
@@ -45,7 +45,7 @@ test('pollDecision maps the /access-requests/status body to a loop action', () =
45
45
  assert.equal(pollDecision({}), 'wait');
46
46
  });
47
47
 
48
- // ---- the Device-Flow dead end (task 1003400) -------------------------------
48
+ // ---- the Device-Flow dead end (task 1003881) -------------------------------
49
49
  //
50
50
  // Device Flow is OFF on every GitHub App created from a manifest and GitHub offers no
51
51
  // API to switch it on, so terminal sign-in is broken from birth on a fresh instance —
@@ -170,7 +170,7 @@ await t('a DB error returns {error}, never throws', async () => {
170
170
  assert.equal(r.outcome, 'error');
171
171
  });
172
172
 
173
- // ---- the outbound half must not outlive the inbound half (task 1003400) ----
173
+ // ---- the outbound half must not outlive the inbound half (task 1003881) ----
174
174
  //
175
175
  // The 👍 this line invites is applied ONLY by discord-approvals.js, which lives in the
176
176
  // `discord` module. onboarding (which posts) is default-ON; discord is default-OFF; and
package/tests/init.mjs CHANGED
@@ -239,14 +239,16 @@ test('provisioningSlug: derives from productName, clamps to 40, strips trailing
239
239
  test('provisioningRequest: null unless opted in; builds the POST body when opted in', () => {
240
240
  assert.equal(init.provisioningRequest(fullSpec()), null); // no block → not requested
241
241
  const co = init.provisioningRequest(provSpec({})); // empty block = opted in, defaults
242
- assert.deepEqual(co, { slug: 'cloud-bongos', hosting_shape: 'co-tenant' });
242
+ assert.deepEqual(co, { slug: 'cloud-bongos', hosting_shape: 'byo-host' });
243
243
  const full = init.provisioningRequest(provSpec({ shape: 'dedicated', domain: 'example.com', targetRef: 'do:main', tier: 's-1vcpu-2gb' }));
244
244
  assert.deepEqual(full, { slug: 'cloud-bongos', hosting_shape: 'dedicated', domain: 'example.com', target_ref: 'do:main', tier: 's-1vcpu-2gb' });
245
245
  });
246
246
 
247
- test('provisioningRequest: unknown shape falls back to the cheapest co-tenant', () => {
248
- // normalizeSpec coerces a bad shape → co-tenant (the budget default, ADR 0111 §6).
249
- assert.equal(init.provisioningRequest(provSpec({ shape: 'kubernetes' })).hosting_shape, 'co-tenant');
247
+ test('provisioningRequest: unknown shape falls back to byo-host, the free front door', () => {
248
+ // normalizeSpec coerces a bad shape → byo-host (ADR 0323; was co-tenant, which that
249
+ // decision retired — the server now refuses it, so falling back to it would write a
250
+ // spec the create route rejects a step later).
251
+ assert.equal(init.provisioningRequest(provSpec({ shape: 'kubernetes' })).hosting_shape, 'byo-host');
250
252
  });
251
253
 
252
254
  test('kickoffTasks: provisioning requested → production-topology is AUTOMATED (dropped), manual steps stay', () => {
@@ -264,7 +266,7 @@ test('kickoffTasks: provisioning requested → production-topology is AUTOMATED
264
266
  test('normalizeSpec: coerces the provisioning block; drops it when absent', () => {
265
267
  assert.equal(init.normalizeSpec({}).provisioning, undefined);
266
268
  const n = init.normalizeSpec({ provisioning: { shape: 'DEDICATED', domain: ' Example.com ', junk: 1 } });
267
- assert.equal(n.provisioning.shape, 'co-tenant'); // unknown shape (case) → default
269
+ assert.equal(n.provisioning.shape, 'byo-host'); // unknown shape (case) → default
268
270
  assert.equal(n.provisioning.domain, 'Example.com'); // trimmed
269
271
  assert.equal(n.provisioning.junk, undefined); // stray field dropped
270
272
  });
@@ -651,14 +653,14 @@ async function runSeedTests() {
651
653
 
652
654
  await at('seedViaApi: provisioning 404 (module off) → falls back to the manual production-topology task', async () => {
653
655
  const api = fakeApi([['/provisioning/instances', { ok: false, status: 404, data: { error: 'not_found' } }]]);
654
- const out = await init.seedViaApi(provSpec({ shape: 'co-tenant' }), { log: () => {}, api });
656
+ const out = await init.seedViaApi(provSpec({ shape: 'byo-host' }), { log: () => {}, api });
655
657
  assert.equal(out.provisioned, false);
656
658
  assert.ok(findTaskPost(api, /Production topology/), 'filed the manual fallback task');
657
659
  });
658
660
 
659
661
  await at('seedViaApi: provisioning error → also files the fallback task', async () => {
660
662
  const api = fakeApi([['/provisioning/instances', { ok: false, status: 500, data: {} }]]);
661
- const out = await init.seedViaApi(provSpec({ shape: 'co-tenant' }), { log: () => {}, api });
663
+ const out = await init.seedViaApi(provSpec({ shape: 'byo-host' }), { log: () => {}, api });
662
664
  assert.equal(out.provisioned, false);
663
665
  assert.ok(findTaskPost(api, /Production topology/), 'filed the manual fallback task on error');
664
666
  });
@@ -48,7 +48,7 @@ const PUBLISHED_SURFACE = [
48
48
  'artistGate', // added by task 1003575 (ADR 0241): the artist gate — off | advisory | strict, plus the pure verdict helpers over it. Two modules ask it two different questions ("file this review?" and "does an open one hold the deploy?") and both must resolve the knob through this one reader, never branding().project
49
49
  'readApplicantProfileFromHub', // added by task 1002972 (privacy spec D7): the reviewer queue's LIVE per-render read of one applicant's hub profile view. NARROW by design — the underlying call is authenticated with this instance's hub client secret, and loadIdpConfig stays OFF the doorway so no module ever holds the credential that speaks for the whole project
50
50
  'enabledDisciplines', // BV1.R81: instance offered-disciplines (1.9.0; onboarding restock backstop)
51
- 'isModuleEnabled', // task 1003400: is ANOTHER module present? onboarding's Discord approve/decline control is only actionable when the `discord` module's reaction handler is loaded, so the posting half has to be able to ask
51
+ 'isModuleEnabled', // task 1003881: is ANOTHER module present? onboarding's Discord approve/decline control is only actionable when the `discord` module's reaction handler is loaded, so the posting half has to be able to ask
52
52
  'registerProvider', 'hasProvider', 'resolve', 'resolveOptional', 'listPorts', 'verifyPortsSatisfied',
53
53
  'on', 'emit', 'emitAsync', 'listEvents', 'seamSnapshot',
54
54
  'contribute', 'contributions', 'collectContributions', 'listContributionPoints',
@@ -237,7 +237,11 @@ test('no wall-clock promises, no operator vocabulary on the stranger’s path',
237
237
  assert.doesNotMatch(HUB, /about five minutes/, 'the empty state');
238
238
  assert.doesNotMatch(HUB, /a runner does the real work/, 'the mine fine print');
239
239
  assert.doesNotMatch(SCRIPT, /pinned platform core/, 'the review Shape row');
240
- assert.match(SCRIPT, /\['Shape', 'Its own server, running the platform’s current release'\]/);
240
+ /* The wizard creates hosting_shape 'standalone', whose owner-facing name became
241
+ "cloud host" at ADR 0323 — "its own server" was ambiguous between that shape and
242
+ a dedicated one, and said nothing about WHO runs it. The assertion still guards the
243
+ same property this test is about: owner vocabulary, and no wall-clock promise. */
244
+ assert.match(SCRIPT, /\['Shape', 'Cloud host — we stand it up and run it for you, on the platform’s current release'\]/);
241
245
  assert.doesNotMatch(HUB, /\/new-project<\/code> assistant/, 'the done panel’s closing line');
242
246
  assert.match(HUB, /Sign-in switches on after the GitHub steps|switch sign-in on/, 'the address step does not promise sign-in "just works"');
243
247
  assert.doesNotMatch(HUB, /Sign-in, sharing, everything just works/);
@@ -0,0 +1,122 @@
1
+ // tests/provision_byo_host.mjs — the runner stands up NOTHING for a 'byo-host'
2
+ // instance (ADR 0323, task 1004141).
3
+ //
4
+ // PROVES ABSENCE, not that the right steps ran, and that is the whole point. The
5
+ // shape's promise is negative — the owner supplies the host, so the platform must
6
+ // allocate no port, create no database, write no file, install no unit, touch no
7
+ // proxy and publish no DNS record. Asserting "the byo-host path ran" would pass
8
+ // just as happily if the co-tenant standup ran underneath it, which is exactly the
9
+ // bug this file exists to catch: before the branch, 'byo-host' fell through to the
10
+ // shared standup on the CONTROL PLANE's own shell and nothing errored.
11
+ //
12
+ // Hermetic: every executor is a spy that RECORDS instead of acting, so a single
13
+ // stray command shows up as a failed assertion rather than as a mutated box.
14
+ import assert from 'node:assert/strict';
15
+ import { test } from 'node:test';
16
+ import { createRequire } from 'node:module';
17
+
18
+ const require = createRequire(import.meta.url);
19
+ const P = require('../scripts/gds/provision.js');
20
+
21
+ const byoHost = {
22
+ id: 42, slug: 'mercury', hosting_shape: 'byo-host', status: 'requested',
23
+ owner_builder_id: 7, port: null, db_name: null, domain: 'mercury.example.com',
24
+ };
25
+
26
+ // Spies for every way provisionInstance can reach a machine or a record. `exec`
27
+ // and `writeFile` THROW rather than return, so a leaked step fails loudly with the
28
+ // command in the message instead of being silently collected and forgotten.
29
+ function harness({ apply = true } = {}) {
30
+ const queries = [], statuses = [], events = [], logs = [];
31
+ const boom = (what) => (arg) => { throw new Error(`byo-host must not ${what}: ${String(arg).slice(0, 200)}`); };
32
+ const deps = {
33
+ apply, log: (m) => logs.push(String(m)), sleep: async () => {},
34
+ db: { query: async (sql, params) => { queries.push({ sql, params }); return { rows: [] }; } },
35
+ provisioning: {
36
+ setInstanceStatus: async (_db, id, status, patch = {}) => { statuses.push({ id, status, patch }); },
37
+ recordEvent: async (_db, e) => { events.push(e); },
38
+ recordLivenessProbe: async () => { throw new Error('byo-host must not record a loopback liveness probe'); },
39
+ recordDnsNote: async () => { throw new Error('byo-host must not write a DNS note'); },
40
+ clearReleasedDomains: async () => { throw new Error('byo-host must not release domains'); },
41
+ allocatePort: async () => { throw new Error('byo-host must not allocate a port'); },
42
+ },
43
+ exec: boom('run a command'),
44
+ controlExec: boom('run a control-plane command'),
45
+ writeFile: boom('write a file'),
46
+ controlWriteFile: boom('write a control-plane file'),
47
+ doApi: () => { throw new Error('byo-host must not call a cloud API'); },
48
+ checkDnsTokenScope: async () => { throw new Error('byo-host must not preflight a DNS zone'); },
49
+ checkCaddyWiring: () => { throw new Error('byo-host must not inspect Caddy'); },
50
+ ingressPlaneGap: () => { throw new Error('byo-host must not probe this plane for ingress'); },
51
+ accountPlaneGap: () => { throw new Error('byo-host must not probe this plane for unix accounts'); },
52
+ getGithubToken: async () => { throw new Error('byo-host must not need a repo credential'); },
53
+ };
54
+ return { deps, queries, statuses, events, logs };
55
+ }
56
+
57
+ test('a byo-host provision touches no box: no exec, no file, no port, no database, no DNS', async () => {
58
+ const h = harness();
59
+ const r = await P.provisionInstance(byoHost, h.deps);
60
+ assert.equal(r.ok, true);
61
+ assert.equal(r.stoodUp, false, 'the result says plainly that nothing was stood up');
62
+ assert.equal(r.byoHost, true);
63
+ // Absence: the only DB work is the status flip + the event, both through the
64
+ // provisioning port. No raw query — a port allocation, a createdb or a
65
+ // capacity count would each have to go through db.query to happen at all.
66
+ assert.deepEqual(h.queries, [], 'no direct SQL — capacity counting, port allocation and the DB steps all query');
67
+ assert.equal(r.port, undefined, 'no port is reported because none is allocated');
68
+ });
69
+
70
+ test('the row reaches active with provisioned_at set, and nothing else is patched', async () => {
71
+ const h = harness();
72
+ await P.provisionInstance(byoHost, h.deps);
73
+ assert.equal(h.statuses.length, 1);
74
+ const [{ id, status, patch }] = h.statuses;
75
+ assert.equal(id, 42);
76
+ assert.equal(status, 'active', "'active' means the platform has nothing left to do, not that it runs it");
77
+ assert.equal(patch.error_note, null, 'a stale note from an earlier failed run must not ride into active');
78
+ assert.ok(patch.provisioned_at, 'the record carries when the platform finished with it');
79
+ assert.equal(patch.port, undefined, 'no port is stored');
80
+ assert.equal(patch.db_name, undefined, 'no database is named');
81
+ assert.equal(h.events.length, 1);
82
+ assert.equal(h.events[0].event, 'active');
83
+ assert.match(h.events[0].detail, /byo-host/, 'the audit trail says which shape, so the absence is readable later');
84
+ assert.equal(h.events[0].actor, 'runner');
85
+ });
86
+
87
+ test('a dry run writes nothing at all — not even the status', async () => {
88
+ const h = harness({ apply: false });
89
+ const r = await P.provisionInstance(byoHost, h.deps);
90
+ assert.equal(r.ok, true);
91
+ assert.equal(r.stoodUp, false);
92
+ assert.deepEqual(h.statuses, []);
93
+ assert.deepEqual(h.events, []);
94
+ assert.ok(h.logs.some((l) => /would mark it active/.test(l)), 'a dry run still says what it would do');
95
+ });
96
+
97
+ test('the branch is keyed on the shape, not on a missing port — a domainless row behaves the same', async () => {
98
+ const h = harness();
99
+ const r = await P.provisionInstance({ ...byoHost, domain: null }, h.deps);
100
+ assert.equal(r.ok, true);
101
+ assert.equal(r.stoodUp, false);
102
+ assert.equal(h.statuses[0].status, 'active');
103
+ });
104
+
105
+ test('an already-active byo-host row stays a plain idempotent no-op', async () => {
106
+ const h = harness();
107
+ const r = await P.provisionInstance({ ...byoHost, status: 'active' }, h.deps);
108
+ assert.deepEqual(r, { ok: true, already: true });
109
+ assert.deepEqual(h.statuses, [], 're-draining an intent must not re-stamp provisioned_at');
110
+ });
111
+
112
+ test('the shape is absent from the runner shapes that DO get stood up', () => {
113
+ // The guard is above every preflight, so the proof that it cannot be reordered
114
+ // by accident is that the co-tenant/standalone standup is unreachable for it.
115
+ // Any other shape still reaches the first executor — that is the control.
116
+ const h = harness();
117
+ return assert.rejects(
118
+ P.provisionInstance({ ...byoHost, hosting_shape: 'standalone', domain: null }, h.deps),
119
+ /byo-host must not/,
120
+ 'a non-byo-host shape still reaches the box steps — the branch is shape-scoped, not a global off switch',
121
+ );
122
+ });
@@ -84,6 +84,11 @@ t('placementBox: standalone → control plane always; co-tenant → the remote b
84
84
  assert.deepEqual(placementBox('co-tenant', {}), { box: 'control-plane', shapes: ['standalone', 'co-tenant'], fixedMb: 750 });
85
85
  assert.deepEqual(placementBox('co-tenant', { remoteHost: 'cohost' }), { box: 'overflow', shapes: ['co-tenant'], fixedMb: 630 });
86
86
  assert.equal(placementBox('dedicated', {}), null);
87
+ // ADR 0323: a byo-host runs on the owner's own machine, so it has no box of ours and
88
+ // no shared ceiling — null for a STRONGER reason than dedicated's (dedicated is our
89
+ // droplet, just not a shared one). remoteHost must not change that answer.
90
+ assert.equal(placementBox('byo-host', {}), null);
91
+ assert.equal(placementBox('byo-host', { remoteHost: 'cohost' }), null);
87
92
  });
88
93
 
89
94
  t('placementVerdict: the ceiling refuses at N of N, the floor refuses under 256 MB free, an unmeasured floor cannot refuse', () => {
@@ -122,11 +122,21 @@ await ta('a box with one slot left admits the create', async () => {
122
122
  assert.equal(lastCreateArgs && lastCreateArgs.slug, 'lantern');
123
123
  });
124
124
 
125
- await ta('an absent shape (the co-tenant default) on a local box is counted too', async () => {
125
+ await ta('an absent shape takes the byo-host default, so a full box does NOT refuse it', async () => {
126
+ // This assertion INVERTED at task 1004141 (ADR 0323), and the inversion is the point.
127
+ // The default used to be 'co-tenant', which lands on the control plane, so a shapeless
128
+ // create was counted against the ceiling and could be refused box_full. The default is
129
+ // now 'byo-host', which runs on the owner's own machine and consumes nothing here — so
130
+ // refusing it for OUR capacity would be refusing a project that costs us no memory.
131
+ //
132
+ // The real trap this pins: THREE places resolve an absent shape — createInstance's
133
+ // coercion, paid-shape-gate's authority lookup, and this gate's placement lookup. If
134
+ // they disagree, the gate prices a request against a shape the row will not take, and
135
+ // nothing errors; you just get a 409 on a create that should have succeeded.
126
136
  reset({ count: 15 });
127
137
  const res = await post({ slug: 'lantern', no_address: true });
128
- assert.equal(res.status, 409, res.raw);
129
- assert.equal(res.body.error.code, 'box_full');
138
+ assert.equal(res.status, 201, res.raw);
139
+ assert.equal(capacityReads, 0, 'a shape with no box of ours must not even read the count');
130
140
  });
131
141
 
132
142
  // ── boxes the web tier cannot count are the runner's ──────────────────────────────
@@ -138,15 +148,25 @@ await ta('dedicated is its own box: never refused here, and the count is never r
138
148
  assert.equal(capacityReads, 0);
139
149
  });
140
150
 
141
- await ta('a co-tenant under remote-exec lands on a box this host cannot measure: admitted', async () => {
151
+ await ta('byo-host runs on the OWNER\'s machine: never refused here, and the count is never read', async () => {
152
+ // ADR 0323's new shape, and the strongest form of "not our box": there is no machine
153
+ // of ours to place it on at all, so a full control plane must not refuse it and the
154
+ // count must not even be read. A full box turning away the one shape that costs us
155
+ // nothing would be the worst possible refusal.
142
156
  reset({ count: 15 });
143
- process.env.PROVISION_REMOTE_HOST = 'overflow.example';
144
- const res = await post({ slug: 'lantern', hosting_shape: 'co-tenant', no_address: true });
157
+ const res = await post({ slug: 'lantern', hosting_shape: 'byo-host', no_address: true });
145
158
  assert.equal(res.status, 201, res.raw);
146
159
  assert.equal(capacityReads, 0);
147
- delete process.env.PROVISION_REMOTE_HOST;
148
160
  });
149
161
 
162
+ // The co-tenant-under-remote-exec case this file used to assert HERE is now unreachable
163
+ // through the route: ADR 0323 retires that shape and paid-shape-gate.js refuses it
164
+ // before the capacity gate runs, so a route-level assertion would only ever be proving
165
+ // the 403. The placement rule itself is still live — the runner reads it for the rows
166
+ // that already carry the shape — and stays pinned at unit level in
167
+ // tests/provisioning_capacity.mjs ('placementBox: … co-tenant → the remote box only
168
+ // under remote-exec'). Moved rather than deleted, per the live-code rule.
169
+
150
170
  await ta('the live-memory floor is left to the runner: slots free + memory tight still admits', async () => {
151
171
  reset({ count: 3, mem: MEMINFO_TIGHT });
152
172
  const res = await post(WIZARD);
@@ -235,7 +235,7 @@ await ta('POST /instances: the picker\'s ids ride the create request through to
235
235
 
236
236
  // Act — the body the wizard files
237
237
  const res = await post('/provisioning/instances', {
238
- slug: 'mercury', hosting_shape: 'co-tenant', type: 'game', modules: ['agents', 'discord'],
238
+ slug: 'mercury', hosting_shape: 'byo-host', type: 'game', modules: ['agents', 'discord'],
239
239
  });
240
240
 
241
241
  // Assert
@@ -245,7 +245,7 @@ await ta('POST /instances: the picker\'s ids ride the create request through to
245
245
 
246
246
  await ta('POST /instances: an omitted modules key is still a valid create — skipping is supported, not an error', async () => {
247
247
  lastCreateArgs = null; existingRow = null;
248
- const res = await post('/provisioning/instances', { slug: 'mercury', hosting_shape: 'co-tenant', type: 'game' });
248
+ const res = await post('/provisioning/instances', { slug: 'mercury', hosting_shape: 'byo-host', type: 'game' });
249
249
  assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
250
250
  assert.equal(lastCreateArgs.modules, undefined, 'nothing is invented on the owner\'s behalf');
251
251
  // …and the receipt already carries the set they will actually get.
@@ -255,7 +255,7 @@ await ta('POST /instances: an omitted modules key is still a valid create — sk
255
255
 
256
256
  await ta('POST /instances: an explicit empty selection is accepted as an answer', async () => {
257
257
  lastCreateArgs = null; existingRow = null;
258
- const res = await post('/provisioning/instances', { slug: 'mercury', hosting_shape: 'co-tenant', type: 'game', modules: [] });
258
+ const res = await post('/provisioning/instances', { slug: 'mercury', hosting_shape: 'byo-host', type: 'game', modules: [] });
259
259
  assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
260
260
  assert.deepEqual(lastCreateArgs.modules, []);
261
261
  });
@@ -266,7 +266,7 @@ await ta('POST /instances: an unknown module is REFUSED and named, never silentl
266
266
  // prevent, and the reason badProjectDetail was written beside this.
267
267
  existingRow = null;
268
268
  const res = await post('/provisioning/instances', {
269
- slug: 'mercury', hosting_shape: 'co-tenant', modules: ['discrod'],
269
+ slug: 'mercury', hosting_shape: 'byo-host', modules: ['discrod'],
270
270
  });
271
271
  assert.equal(res.status, 400, res.raw);
272
272
  assert.equal(res.body.error.code, 'bad_modules');
@@ -277,7 +277,7 @@ await ta('POST /instances: an unknown module is REFUSED and named, never silentl
277
277
  await ta('POST /instances: naming an always-on module is refused with the reason, not the generic miss', async () => {
278
278
  existingRow = null;
279
279
  const res = await post('/provisioning/instances', {
280
- slug: 'mercury', hosting_shape: 'co-tenant', modules: ['memory'],
280
+ slug: 'mercury', hosting_shape: 'byo-host', modules: ['memory'],
281
281
  });
282
282
  assert.equal(res.status, 400, res.raw);
283
283
  assert.equal(res.body.error.code, 'bad_modules');
@@ -286,11 +286,11 @@ await ta('POST /instances: naming an always-on module is refused with the reason
286
286
 
287
287
  await ta('POST /instances: modules must be an array of strings', async () => {
288
288
  existingRow = null;
289
- const notArray = await post('/provisioning/instances', { slug: 'mercury', hosting_shape: 'co-tenant', modules: 'discord' });
289
+ const notArray = await post('/provisioning/instances', { slug: 'mercury', hosting_shape: 'byo-host', modules: 'discord' });
290
290
  assert.equal(notArray.status, 400, notArray.raw);
291
291
  assert.equal(notArray.body.error.code, 'validation_failed');
292
292
 
293
- const notStrings = await post('/provisioning/instances', { slug: 'mercury', hosting_shape: 'co-tenant', modules: [7] });
293
+ const notStrings = await post('/provisioning/instances', { slug: 'mercury', hosting_shape: 'byo-host', modules: [7] });
294
294
  assert.equal(notStrings.status, 400, notStrings.raw);
295
295
  assert.equal(notStrings.body.error.code, 'validation_failed');
296
296
  });
@@ -301,7 +301,7 @@ await ta('POST /instances: a re-request never changes an existing project\'s mod
301
301
  // to hunt for one.
302
302
  existingRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: ['agents'], detail: {} };
303
303
  const res = await post('/provisioning/instances', {
304
- slug: 'mercury', hosting_shape: 'co-tenant', modules: ['discord'],
304
+ slug: 'mercury', hosting_shape: 'byo-host', modules: ['discord'],
305
305
  });
306
306
  assert.equal(res.status, 200, res.raw);
307
307
  assert.ok(res.body.modules_ignored, 'the dropped answer is reported, not swallowed');
@@ -589,7 +589,7 @@ await ta('POST /instances: a null answer at CREATION is accepted and stores noth
589
589
  lastCreateArgs = null; existingRow = null;
590
590
 
591
591
  const res = await post('/provisioning/instances', {
592
- slug: 'mercury', hosting_shape: 'co-tenant', type: 'game', detail: { team_shape: null },
592
+ slug: 'mercury', hosting_shape: 'byo-host', type: 'game', detail: { team_shape: null },
593
593
  });
594
594
 
595
595
  assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
@@ -150,7 +150,7 @@ function post(path, body) {
150
150
  await ta('POST /instances: onboard_mode=adopt is threaded through to createInstance', async () => {
151
151
  lastCreateArgs = null;
152
152
  const res = await post('/provisioning/instances', {
153
- slug: 'demo', hosting_shape: 'co-tenant', target_ref: 'example-owner/demo', onboard_mode: 'adopt',
153
+ slug: 'demo', hosting_shape: 'byo-host', target_ref: 'example-owner/demo', onboard_mode: 'adopt',
154
154
  });
155
155
  assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
156
156
  assert.equal(lastCreateArgs && lastCreateArgs.onboardMode, 'adopt');
@@ -158,7 +158,7 @@ await ta('POST /instances: onboard_mode=adopt is threaded through to createInsta
158
158
 
159
159
  await ta('POST /instances: onboard_mode absent → greenfield (unchanged behavior for every existing caller)', async () => {
160
160
  lastCreateArgs = null;
161
- const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'co-tenant' });
161
+ const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'byo-host' });
162
162
  assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
163
163
  assert.equal(lastCreateArgs && lastCreateArgs.onboardMode, 'greenfield');
164
164
  });
@@ -167,7 +167,7 @@ await ta('POST /instances: an unknown onboard_mode is REFUSED (never silently co
167
167
  // Coercing here would be worse than refusing: the caller believes it asked for one thing
168
168
  // and the runner does another. The route names the valid set so the client can fix itself.
169
169
  const res = await post('/provisioning/instances', {
170
- slug: 'demo', hosting_shape: 'co-tenant', target_ref: 'a/b', onboard_mode: 'brownfield',
170
+ slug: 'demo', hosting_shape: 'byo-host', target_ref: 'a/b', onboard_mode: 'brownfield',
171
171
  });
172
172
  assert.equal(res.status, 400);
173
173
  assert.equal(res.body.error.code, 'bad_onboard_mode');
@@ -204,8 +204,8 @@ await ta('POST /instances: adopt WITHOUT target_ref is refused — there is no r
204
204
  // Without target_ref the runner would fall back to greenfield behavior on a row labelled
205
205
  // adopt. Refuse at the door rather than store a lie.
206
206
  for (const body of [
207
- { slug: 'demo', hosting_shape: 'co-tenant', onboard_mode: 'adopt' },
208
- { slug: 'demo', hosting_shape: 'co-tenant', onboard_mode: 'adopt', target_ref: ' ' },
207
+ { slug: 'demo', hosting_shape: 'byo-host', onboard_mode: 'adopt' },
208
+ { slug: 'demo', hosting_shape: 'byo-host', onboard_mode: 'adopt', target_ref: ' ' },
209
209
  ]) {
210
210
  const res = await post('/provisioning/instances', body);
211
211
  assert.equal(res.status, 400, `expected 400 for ${JSON.stringify(body)}`);