@bongos/core 1.19.401 → 1.19.402

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.
@@ -265,6 +265,17 @@ module.exports = function provisioningRoutes() {
265
265
  max: 30,
266
266
  key: (req) => (req.builder ? `b:${req.builder.id}` : clientIp(req)),
267
267
  });
268
+ // Limiter for the domain attach/re-point write (task 1003237). Signed-in and
269
+ // own-scoped, but since a re-point became possible each call can queue an
270
+ // address the RUNNER later releases with a call against the SHARED control-plane
271
+ // Cloudflare token — so an unthrottled owner can spend a platform-wide budget,
272
+ // not merely their own. Keyed on the BUILDER for the same reason as the slug
273
+ // check: per-IP would starve co-located builders while an attacker rotates IPs.
274
+ // A founder gives a project an address, or corrects it, a handful of times ever.
275
+ const domainWriteRateLimit = makeAskRateLimiter({
276
+ max: 10,
277
+ key: (req) => (req.builder ? `b:${req.builder.id}` : clientIp(req)),
278
+ });
268
279
 
269
280
  // Route ORDER is deliberate (the box.js precedent): the Archon-gated routes are
270
281
  // declared FIRST, then the any-builder routes. The route-rank auditor
@@ -859,9 +870,11 @@ module.exports = function provisioningRoutes() {
859
870
  // Validates + records the domain; for an already-ACTIVE instance it also enqueues
860
871
  // a 'domain-attach' intent the control-plane runner drains (web tier holds no
861
872
  // cloud tokens — ADR 0111 §2). A requested/error instance only records it: the
862
- // (re)provision run wires the domain itself. Re-pointing an existing address is
863
- // out of scope — refuse truthfully. Body: { domain (required) }.
864
- router.post('/provisioning/instances/:id/domain', auth.requireBuilder, async (req, res) => {
873
+ // (re)provision run wires the domain itself. A bring-your-own address may be
874
+ // RE-POINTED (task 1003237, ADR 0200): the same guards the first attach applies
875
+ // decide it, and the address it moved off is queued for release.
876
+ // Body: { domain (required) }.
877
+ router.post('/provisioning/instances/:id/domain', auth.requireBuilder, domainWriteRateLimit, async (req, res) => {
865
878
  const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
866
879
  if (!id) return;
867
880
  if (validateOrRespond(req, res, { domain: { required: true, type: 'string', maxLength: 253 } })) return;
@@ -919,15 +932,20 @@ module.exports = function provisioningRoutes() {
919
932
  return res.json({ ok: true, changed: false, queued: false, domain: inst.domain,
920
933
  message: 'This instance already has that address.' });
921
934
  }
922
- // A platform-ASSIGNED address may be upgraded away to a bring-your-own
923
- // domain — that upgrade path is half the task-1002704 decision (the old
924
- // record lingers in DNS, the idea-1000710 class). A genuine BYO address
925
- // stays un-re-pointable, as before.
926
- if (current && current !== assignedDefault) {
927
- return res.fail('domain_already_set', { status: 409,
928
- message: `This instance is already addressed as '${inst.domain}' — re-pointing an existing address is not supported here.`,
929
- details: { current: inst.domain } });
930
- }
935
+ // A mistyped bring-your-own address can be CORRECTED (task 1003237, owner
936
+ // decision 2026-08-28 / ADR 0200): the address is the founder's, and the
937
+ // guards above — reserved, in-apex squat — plus the uniqueness check below
938
+ // are exactly the ones the first attach applied, so a re-point needs no
939
+ // weaker rule and gets no stronger one. The address it moves OFF is queued
940
+ // for release (`releasing`): leaving a hostname UPSERTed at platform infra
941
+ // for a project that no longer answers there is the leak ADR 0181 §2 named.
942
+ // Only a BYO address is released, and only from a state that actually STOOD
943
+ // UP: a platform-ASSIGNED <slug>.<apex> is the platform's own name for this
944
+ // slug and the revive path re-uses it (ADR 0181 §2), while a 'requested'
945
+ // instance never had an A record written for the address it is leaving —
946
+ // parking that name would CREATE a junk record rather than release one.
947
+ const releasing = current && current !== assignedDefault
948
+ && provisioning.DOMAIN_RELEASE_FROM.has(inst.status) ? current : null;
931
949
  const holder = await provisioning.getInstanceByDomain(pool, domain);
932
950
  if (holder && String(holder.id) !== String(inst.id)) {
933
951
  return res.fail('domain_taken', { status: 409, message: 'That address is already in use by another instance.' });
@@ -938,8 +956,9 @@ module.exports = function provisioningRoutes() {
938
956
  details: { status: inst.status } });
939
957
  }
940
958
  // Record BEFORE enqueueing: the runner reads the row's domain, so the intent
941
- // must never race ahead of the column it wires.
942
- await provisioning.updateInstanceDomain(pool, id, domain);
959
+ // must never race ahead of the column it wires. The release queue rides the
960
+ // SAME statement — see updateInstanceDomain.
961
+ await provisioning.updateInstanceDomain(pool, id, domain, releasing);
943
962
  let queued = false;
944
963
  if (inst.status === 'active') {
945
964
  const { intent, created } = await provisioning.enqueueIntent(pool, id, 'domain-attach', 'api:self');
@@ -957,22 +976,30 @@ module.exports = function provisioningRoutes() {
957
976
  instanceId: id, ownerBuilderId: req.builder.id, event: 'domain',
958
977
  detail: `domain ${domain} recorded (attach already queued)`, actor: 'api:self',
959
978
  }).catch(() => {});
960
- return res.json({ ok: true, changed: true, queued: false, domain,
979
+ return res.json({ ok: true, changed: true, queued: false, domain, replaced: releasing,
961
980
  message: 'That address is already being wired for this instance.' });
962
981
  }
963
982
  }
983
+ const moved = releasing ? ` (moved off ${releasing}, queued for release)` : '';
964
984
  await provisioning.recordEvent(pool, {
965
985
  instanceId: id, ownerBuilderId: req.builder.id, event: 'domain',
966
- detail: queued ? `domain ${domain} recorded + attach queued` : `domain ${domain} recorded (wired at provision)`,
986
+ detail: (queued ? `domain ${domain} recorded + attach queued` : `domain ${domain} recorded (wired at provision)`) + moved,
967
987
  actor: 'api:self',
968
988
  }).catch(() => {});
969
- res.json({ ok: true, changed: true, queued, domain,
970
- // State-only sentence (task 1002720 / F16): the web tier holds no cloud
971
- // tokens and cannot know whether a DNS hook exists on the runner, so
972
- // "it will answer there shortly" was a promise it could not keep.
973
- message: queued
974
- ? 'Your instance is being wired to its new address — this card will show whether it answers.'
975
- : 'Address recorded — it will be wired when the instance is provisioned.' });
989
+ // State-only sentences (task 1002720 / F16): the web tier holds no cloud
990
+ // tokens and cannot know whether a DNS hook exists on the runner, so "it
991
+ // will answer there shortly" was a promise it could not keep. The re-point
992
+ // sentences name the old address in the same voice — what they say has
993
+ // ALREADY happened is the recording, not the unwiring. One lookup rather
994
+ // than a nested ternary, so each of the four states reads as its own line.
995
+ const MESSAGES = {
996
+ 'queued:repoint': `Your project is being wired to ${domain}; ${releasing} is being let go.`,
997
+ 'queued:first': 'Your instance is being wired to its new address — this card will show whether it answers.',
998
+ 'recorded:repoint': `Address changed to ${domain} — it will be wired, and ${releasing} let go, when the instance is provisioned.`,
999
+ 'recorded:first': 'Address recorded — it will be wired when the instance is provisioned.',
1000
+ };
1001
+ res.json({ ok: true, changed: true, queued, domain, replaced: releasing,
1002
+ message: MESSAGES[`${queued ? 'queued' : 'recorded'}:${releasing ? 'repoint' : 'first'}`] });
976
1003
  } catch (err) {
977
1004
  log.error('[provisioning] POST /provisioning/instances/:id/domain', err);
978
1005
  res.fail('domain_attach_failed', { status: 500, message: 'internal error' });
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.401",
3
+ "version": "1.19.402",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.401",
9
+ "version": "1.19.402",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.401",
3
+ "version": "1.19.402",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -404,6 +404,8 @@ a.orb{cursor:pointer;}
404
404
  .attachFold{margin-top:12px;}
405
405
  .attachFold summary{cursor:pointer;width:fit-content;font-size:13px;font-weight:700;color:var(--ink-faint);}
406
406
  .attachFold summary:hover,.attachFold[open] summary{color:var(--ink-soft);}
407
+ .attachNote{margin:8px 0 0;font-size:13px;line-height:1.45;color:var(--ink-faint);}
408
+ .attachNote code{font-family:var(--font-mono);}
407
409
  .projMsg{font-size:13.5px;font-weight:600;margin-top:10px;min-height:18px;color:var(--ink-soft);line-height:1.5;}
408
410
  .projMsg.ok{color:var(--ok);}
409
411
  .projMsg.err{color:var(--err);}
@@ -4022,8 +4022,12 @@ summary{min-height:24px;padding:3px 0;}
4022
4022
  (error_note, no intent in flight) can re-send its own domain — the route
4023
4023
  re-enqueues the attach for exactly this shape */
4024
4024
  if (inst.status === 'active' && inst.domain && inst.error_note && !inst.open_intent) return true;
4025
- return (!inst.domain || inst.domain === assignedDefaultFor(inst)) &&
4026
- (inst.status === 'requested' || inst.status === 'error' || inst.status === 'active');
4025
+ /* every address here is now correctable (task 1003237): none (give it one),
4026
+ the platform-assigned <slug>.<apex> (upgrade away from it), and — since the
4027
+ owner decision of 2026-08-28 — a bring-your-own address that was mistyped.
4028
+ The server's guards (reserved, in-apex squat, already held by another
4029
+ project) are unchanged and stay the authority; this is only the door. */
4030
+ return inst.status === 'requested' || inst.status === 'error' || inst.status === 'active';
4027
4031
  }
4028
4032
  /* re-enqueue doors: Try again on snagged, Bring it back on torn down —
4029
4033
  both re-POST the slug (the server's PROVISION_ENQUEUE_FROM gate) */
@@ -4237,13 +4241,30 @@ summary{min-height:24px;padding:3px 0;}
4237
4241
  be refused (intent_conflict), so the fold waits for the run to land */
4238
4242
  if (inst.open_intent && inst.open_intent.action === 'domain-attach') return '';
4239
4243
  var rekick = inst.status === 'active' && inst.domain && inst.error_note;
4244
+ /* the CORRECTION door (task 1003237): the project already answers on an
4245
+ address the founder brought, and it can be changed. The field carries the
4246
+ current one so a typo is fixed by editing it, not retyped from scratch —
4247
+ the re-kick door's shape, for the same reason. The platform-assigned
4248
+ <slug>.<apex> is NOT a correction: moving off it is the upgrade the fold
4249
+ already offered, and the field stays empty for the domain being brought. */
4250
+ var byo = inst.domain && inst.domain !== assignedDefaultFor(inst);
4251
+ var repoint = !rekick && byo;
4252
+ var prefill = rekick || repoint;
4253
+ var label = rekick ? 'Try the address again'
4254
+ : repoint ? 'Use a different domain'
4255
+ : inst.domain ? 'Use a domain I own' : 'Give it an address';
4240
4256
  return '<details class="attachFold"' + (rekick ? ' open' : '') + '>' +
4241
- '<summary>' + (rekick ? 'Try the address again' : inst.domain ? 'Use a domain I own' : 'Give it an address') + '</summary>' +
4257
+ '<summary>' + label + '</summary>' +
4242
4258
  '<div class="attach">' +
4243
4259
  '<input type="text" id="dom-' + iid + '" placeholder="myproject.example.com" autocomplete="off" spellcheck="false" maxlength="253" aria-label="A domain you own, for ' + esc(inst.slug) + '"' +
4244
- (rekick ? ' value="' + esc(inst.domain) + '"' : '') + '>' +
4260
+ (prefill ? ' value="' + esc(inst.domain) + '"' : '') + '>' +
4245
4261
  '<button type="button" class="btn small" data-attach="' + iid + '">' +
4246
- (rekick ? 'Try the address again' : 'Use this domain') + '</button></div></details>';
4262
+ (rekick ? 'Try the address again' : repoint ? 'Change the address' : 'Use this domain') + '</button></div>' +
4263
+ /* true in every state the fold renders in: a REQUESTED project never
4264
+ answered anywhere yet, so "stops answering" would be a claim about a
4265
+ thing that never happened (the F16 rule) */
4266
+ (repoint ? '<p class="attachNote">Changing it lets <code>' + esc(inst.domain) + '</code> go — this project won’t use that address any more.</p>' : '') +
4267
+ '</details>';
4247
4268
  }
4248
4269
 
4249
4270
  function renderCard(inst) {
@@ -5427,8 +5448,6 @@ summary{min-height:24px;padding:3px 0;}
5427
5448
  msg(id, 'That doesn’t look like a usable address — ' + esc(e.details.reason || 'use a real hostname like myproject.example.com') + '.', 'err');
5428
5449
  } else if (e.code === 'domain_taken') {
5429
5450
  msg(id, esc(e.message || 'That address is already in use by another project.'), 'err');
5430
- } else if (e.code === 'domain_already_set') {
5431
- msg(id, esc(e.message || 'This project already has an address — re-pointing isn’t supported here.'), 'err');
5432
5451
  } else if (e.code === 'domain_not_attachable') {
5433
5452
  msg(id, esc(e.message || 'An address can be attached once the instance settles.'), 'err');
5434
5453
  } else if (e.code === 'intent_conflict') {
@@ -118,6 +118,37 @@ function dnsUpsertEnv(inst, { publicIp, zone, proxied = false } = {}) {
118
118
  };
119
119
  }
120
120
 
121
+ // The RFC 5737 TEST-NET-1 sentinel a released hostname is parked at (task
122
+ // 1003237) — the shape scripts/gds/box-infra.js proved for a torn-down box
123
+ // (idea 309 / task 1234). 192.0.2.1 is reserved for documentation and is
124
+ // GUARANTEED never routable, so a name parked here can never dial platform
125
+ // infra or a recycled address.
126
+ const DNS_BLACKHOLE_IP = '192.0.2.1';
127
+
128
+ // The env a RELEASE invocation of the DNS hook needs: the abandoned hostname,
129
+ // parked at the blackhole sentinel. A re-point (task 1003237) leaves the old
130
+ // address UPSERTed at the platform's IP for a project that no longer answers
131
+ // there — the ADR 0181 §2 leak — and this is how the runner lets it go.
132
+ //
133
+ // REPOINT, not delete, for the reason box-infra.js records: deleting a name
134
+ // opens an NXDOMAIN window that resolvers negative-cache for the zone's SOA
135
+ // neg-TTL (~30 min, not editable), so a client that queried mid-gap stays
136
+ // broken long after. A name that always resolves — to a guaranteed-dead host —
137
+ // has nothing to negative-cache. It also needs only the UPSERT contract every
138
+ // BOX_DNS_HOOK implements, so no hook grows a new mode for this.
139
+ //
140
+ // proxied=0: a parked record must never ride the Cloudflare edge, which would
141
+ // answer for it. Takes a bare `hostname` rather than an instance — the whole
142
+ // point is that the row has already moved on. PURE.
143
+ function dnsReleaseEnv(hostname, { zone } = {}) {
144
+ return {
145
+ BOX_HOSTNAME: hostname,
146
+ BOX_IP: DNS_BLACKHOLE_IP,
147
+ CLOUDFLARE_ZONE: zone || zoneForDomain(hostname),
148
+ BOX_PROXIED: '0',
149
+ };
150
+ }
151
+
121
152
  // ── DNS token-scope PREFLIGHT (task 1980) ────────────────────────────────────
122
153
  // Onboarding a NEW project on a domain the control-plane's Cloudflare token does NOT
123
154
  // cover fails LATE: the DNS UPSERT (step 5) errors mid-provision (the Emersonian
@@ -208,10 +239,12 @@ function healthzCmd(inst) {
208
239
 
209
240
  module.exports = {
210
241
  CF_API_BASE,
242
+ DNS_BLACKHOLE_IP,
211
243
  caddyBlock,
212
244
  caddySnippetPath,
213
245
  checkDnsTokenScope,
214
246
  classifyZoneScope,
247
+ dnsReleaseEnv,
215
248
  dnsUpsertEnv,
216
249
  federateInstance,
217
250
  federationHubOrigin,
@@ -240,6 +240,23 @@ function webEnvPath(inst, { etcBase = CONFIG.etcBase } = {}) {
240
240
  // The settings env vars the runner pushes to a RUNNING instance (task 1003140):
241
241
  // the module's settingsEnvVars under the prefix the instance actually resolves —
242
242
  // the same pair webEnvBody writes at standup, so standup and push cannot drift.
243
+ // The four origin overrides web.env carries for an addressed instance — the
244
+ // domain-dependent half of webEnvBody, on its own so domain-attach can patch
245
+ // exactly those lines IN PLACE (task 1003237). A single-host instance has no
246
+ // dedicated builders./status. subdomain, so all four point at the one host.
247
+ // Empty for a domainless instance: there is no origin to assert, and writing
248
+ // `=` over a real one would take the hall down. PURE.
249
+ function originEnvVarsFor(inst) {
250
+ if (!inst || !inst.domain) return {};
251
+ const prefix = provisionedEnvPrefix(), origin = `https://${inst.domain}`;
252
+ return {
253
+ [`${prefix}_PUBLIC_ORIGIN`]: origin,
254
+ [`${prefix}_BUILDERS_ORIGIN`]: origin,
255
+ [`${prefix}_STATUS_ORIGIN`]: origin,
256
+ [`${prefix}_OAUTH_ORIGIN`]: origin,
257
+ };
258
+ }
259
+
243
260
  function settingsEnvVarsFor(inst) {
244
261
  const { settingsEnvVars } = require(path.join(REPO_ROOT, 'modules/provisioning/provisioning')); // eslint-disable-line global-require
245
262
  return settingsEnvVars(inst, provisionedEnvPrefix());
@@ -309,6 +326,7 @@ module.exports = {
309
326
  backupTimerPath,
310
327
  backupUnitName,
311
328
  instanceManifestCmd,
329
+ originEnvVarsFor,
312
330
  provisionedEnvPrefix,
313
331
  serviceUnit,
314
332
  serviceUnitPath,
@@ -51,8 +51,8 @@ const crypto = require('node:crypto');
51
51
 
52
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
53
  const { APP_USER, alreadyScaffolded, buildCorePinRefreshCommit, dbCreateCmd, dbName, 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');
54
- const { backupScriptPath, backupService, backupServicePath, backupTimer, backupTimerPath, backupUnitName, instanceManifestCmd, serviceUnit, serviceUnitPath, settingsConsumed, settingsEnvVarsFor, upsertEnvVars, webEnvBody, webEnvPath } = require('./provision-units.js');
55
- const { caddyBlock, caddySnippetPath, checkDnsTokenScope, classifyZoneScope, dnsUpsertEnv, federateInstance, federationHubOrigin, healthzCmd, shouldFederate, zoneForDomain } = require('./provision-net.js');
54
+ const { backupScriptPath, backupService, backupServicePath, backupTimer, backupTimerPath, backupUnitName, instanceManifestCmd, originEnvVarsFor, serviceUnit, serviceUnitPath, settingsConsumed, settingsEnvVarsFor, upsertEnvVars, webEnvBody, webEnvPath } = require('./provision-units.js');
55
+ const { caddyBlock, caddySnippetPath, checkDnsTokenScope, classifyZoneScope, dnsReleaseEnv, dnsUpsertEnv, federateInstance, federationHubOrigin, healthzCmd, shouldFederate, zoneForDomain } = require('./provision-net.js');
56
56
 
57
57
  function makeExec({ apply, log = console.log, remoteHost = null, remoteCwd = null, sshOpts = '' }) {
58
58
  // `shown` (task 1003140): what to LOG in place of the command text — for a command
@@ -464,6 +464,12 @@ async function provisionInstance(inst, deps) {
464
464
  }
465
465
  }
466
466
 
467
+ // A re-point on a PRE-ACTIVE instance records its release here and never
468
+ // reaches the attach leg (that one no-ops for anything but 'active'), so this
469
+ // run owns the drain — and an `error` row is frequently a partial standup that
470
+ // already UPSERTed the address it is now leaving (ADR 0181 §1, task 1003237).
471
+ await releaseAbandonedDomains(inst, { controlExec, log, apply, db, provisioning });
472
+
467
473
  // ── step 6: Caddy per-instance site snippet + reload ──────────────────────
468
474
  // Write the instance's OWN <slug>.caddy snippet (idempotent overwrite via the
469
475
  // injected writeFile — no read-modify-append to a shared Caddyfile), then reload.
@@ -557,6 +563,57 @@ runcmd:
557
563
  `;
558
564
  }
559
565
 
566
+ // The addresses a re-point left behind and this run still owes a release on
567
+ // (task 1003237). The one guard this tier can make on its own, and the one that
568
+ // matters: the address the instance is CURRENTLY on is never released, so a row
569
+ // that somehow queued its own domain can never blackhole a live project.
570
+ // Keeping a platform-assigned <slug>.<apex> off the queue is the ENQUEUE site's
571
+ // job — the web tier knows the apex, this runner does not (ADR 0181 §2). PURE.
572
+ function releasableDomains(inst) {
573
+ const here = String((inst && inst.domain) || '').toLowerCase();
574
+ return (Array.isArray(inst && inst.released_domains) ? inst.released_domains : [])
575
+ .map((d) => String(d || '').toLowerCase())
576
+ .filter((d) => d && d !== here);
577
+ }
578
+
579
+ // Park every address a re-point moved off at the blackhole sentinel, then empty
580
+ // the queue (task 1003237, ADR 0200). Called by BOTH legs that wire a domain —
581
+ // domain-attach for an ACTIVE instance, and the provision run that owns
582
+ // PRE-ACTIVE ones, since an `error` row is frequently a partial standup that
583
+ // already UPSERTed the address it is now leaving (ADR 0181 §1).
584
+ //
585
+ // BEST-EFFORT in the teardown sense: a hook that fails leaves the queue intact
586
+ // so the next run retries, and never fails the wiring that already landed. EVERY
587
+ // queued address is attempted — one host whose zone the token cannot reach must
588
+ // not strand the others behind it — and the queue is emptied only when all of
589
+ // them landed; a re-park of an address already at the sentinel is an idempotent
590
+ // UPSERT, so retrying the whole list costs nothing but the call. The list is
591
+ // capped at the enqueue site (MAX_RELEASED_DOMAINS), which is what bounds this
592
+ // loop. Returns the first host that failed, or null. Runs on the CONTROL PLANE —
593
+ // the hook needs the Cloudflare token, which never leaves it (ADR 0111 §2).
594
+ async function releaseAbandonedDomains(inst, { controlExec, log, apply, db, provisioning }) {
595
+ const hosts = releasableDomains(inst);
596
+ if (!hosts.length) return null;
597
+ if (!CONFIG.dnsHook) {
598
+ log(` [dns] cannot release ${hosts.join(', ')} (BOX_DNS_HOOK unset) — remove their A records manually`);
599
+ return null;
600
+ }
601
+ let firstFailed = null;
602
+ for (const host of hosts) {
603
+ const relEnv = dnsReleaseEnv(host);
604
+ log(` [dns] release ${host} -> ${relEnv.BOX_IP} (blackhole, zone ${relEnv.CLOUDFLARE_ZONE}) via ${CONFIG.dnsHook}`);
605
+ if (!apply) continue;
606
+ try { controlExec(CONFIG.dnsHook, { env: relEnv }); }
607
+ catch (e) {
608
+ log(` ⚠ could not release ${host}: ${(e && e.message) || e} — its A record still points here`);
609
+ if (!firstFailed) firstFailed = host;
610
+ }
611
+ }
612
+ if (firstFailed) return firstFailed;
613
+ if (apply && provisioning.clearReleasedDomains) await provisioning.clearReleasedDomains(db, inst.id).catch(() => {});
614
+ return null;
615
+ }
616
+
560
617
  // ---------------------------------------------------------------------------
561
618
  // Attach a late-arriving domain to an ALREADY-ACTIVE instance (task 1002692):
562
619
  // re-assert exactly the domain-dependent runbook legs — web.env origins (+ a
@@ -565,6 +622,18 @@ runcmd:
565
622
  // RETURN { ok:false } instead of throwing: the drain loop's catch flips the
566
623
  // instance to 'error', which must never happen to a healthy ACTIVE instance
567
624
  // over a domain hiccup.
625
+ //
626
+ // This leg ALSO carries a RE-POINT (task 1003237, ADR 0200) — the same intent,
627
+ // because the wiring is the same wiring. Two things follow from that:
628
+ // • The origin lines are patched IN PLACE (upsertEnvVars), never regenerated
629
+ // from the row. A template re-write via webEnvBody would blank the OAuth
630
+ // creds placeManifestCreds placed, the App private key and the federation
631
+ // block — none of which the row knows (ADR 0190 §2). A first attach was
632
+ // MASKED by wizard order (address first, GitHub App second); a re-point runs
633
+ // on a project the founder is already signed in to, so the same move would
634
+ // break sign-in on the exact project they just corrected (idea 1000842).
635
+ // • Every address on the row's release queue is parked at the blackhole
636
+ // sentinel AFTER the new one is wired — best-effort, never fatal.
568
637
  // ---------------------------------------------------------------------------
569
638
  async function domainAttachInstance(inst, deps) {
570
639
  const { db, provisioning, exec, writeFile, log, apply, privileged } = deps;
@@ -603,8 +672,22 @@ async function domainAttachInstance(inst, deps) {
603
672
  ? (CONFIG.controlPlaneIp || (CONFIG.remoteHost ? null : CONFIG.publicIp))
604
673
  : CONFIG.publicIp;
605
674
 
606
- log(' [config] re-write per-instance web.env (origins now carry the domain)');
607
- boxWriteFile(webEnvPath(inst), webEnvBody(inst, { federation: null, ownerLogin: await ownerLoginOf(inst, deps.db) }), { mode: 0o600, sudo: true });
675
+ // Patch ONLY the origin lines (the settingsApplyInstance shape, ADR 0190 §2).
676
+ // Dry-run has no file to read, so it PLANS the patch rather than writing a
677
+ // synthetic body. An unreadable or empty file is refused, never "patched":
678
+ // writing four origins over a missing PORT/PGDATABASE would take the instance
679
+ // down — and this leg runs against instances that are ALREADY serving.
680
+ const origins = originEnvVarsFor(inst);
681
+ const read = boxExec(`${sudoP}cat ${webEnvPath(inst)}`, { allowFail: true });
682
+ const currentEnv = String((read && read.stdout) || '');
683
+ if (!apply) {
684
+ log(` [config] would patch ${Object.keys(origins).join(', ')} in ${webEnvPath(inst)} in place (creds + federation untouched)`);
685
+ } else if (!read || read.softFailed || !currentEnv.trim()) {
686
+ return { ok: false, error: `cannot read ${webEnvPath(inst)} — nothing to patch (was this instance provisioned by the runner?)` };
687
+ } else {
688
+ log(' [config] patch the origin lines of web.env in place (creds + federation untouched)');
689
+ boxWriteFile(webEnvPath(inst), upsertEnvVars(currentEnv, origins), { mode: 0o600, sudo: true });
690
+ }
608
691
  log(' [service] restart to pick up the new origins');
609
692
  boxExec(`${sudoP}systemctl restart ${inst.slug}`);
610
693
 
@@ -629,10 +712,19 @@ async function domainAttachInstance(inst, deps) {
629
712
  log(` [dns] skipped (BOX_DNS_HOOK unset) — point ${inst.domain}'s A record at ${recordIp || 'this server'} manually`);
630
713
  }
631
714
 
715
+ // The snippet is per-SLUG and always carries the CURRENT domain, so a re-point
716
+ // takes the old vhost away in the same write — Caddy stops serving the old host
717
+ // (and tls-check stops vouching for it) with no separate un-wire step.
632
718
  log(' [caddy] write site snippet + reload');
633
719
  boxWriteFile(caddySnippetPath(inst), caddyBlock(inst), { mode: 0o644, sudo: true });
634
720
  boxExec(`${sudoP}systemctl reload caddy`, { allowFail: true });
635
721
 
722
+ // Release what the re-point moved off, AFTER the new address is wired: the DNS
723
+ // record is the only half of an abandoned address the platform still holds, and
724
+ // it points at platform infra.
725
+ const releaseFailed = await releaseAbandonedDomains(inst, { controlExec, log, apply, db, provisioning });
726
+ if (releaseFailed) return { ok: true, releaseFailed };
727
+
636
728
  log(' [healthz] verify the instance answers');
637
729
  boxExec(healthzCmd(inst), { allowFail: true });
638
730
 
@@ -1337,7 +1429,7 @@ module.exports = {
1337
1429
  caddyBlock, caddySnippetPath, healthzCmd, dedicatedUserData, nextFreePort,
1338
1430
  // executors + orchestration (injectable deps for tests)
1339
1431
  makeExec, makeWriteFile, allocatePort, boundLocalPorts, provisionInstance, teardownInstance,
1340
- domainAttachInstance, cmdRunIntents, cmdReconcile, cmdStatus,
1432
+ domainAttachInstance, releasableDomains, cmdRunIntents, cmdReconcile, cmdStatus,
1341
1433
  // live settings push (task 1003140) + the in-place env patch + read-back it rides on
1342
1434
  settingsApplyInstance, upsertEnvVars, settingsEnvVarsFor, settingsConsumed, instanceManifestCmd,
1343
1435
  // restart an unreachable ACTIVE instance (task 1003238) — the settings leg minus the env patch
package/src/module-api.js CHANGED
@@ -1242,8 +1242,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
1242
1242
  // landed since 1.19.399 with no explicit bump. run 33201979306. (task 1002620)
1243
1243
  // 1.19.401 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1244
1244
  // landed since 1.19.400 with no explicit bump. run 33205015717. (task 1002620)
1245
+ // 1.19.402 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1246
+ // landed since 1.19.401 with no explicit bump. run 33206226800. (task 1002620)
1245
1247
  // ---------------------------------------------------------------------------
1246
- const CORE_VERSION = '1.19.401'; // CI auto-patch carrier (ADR 0161); see changelog above
1248
+ const CORE_VERSION = '1.19.402'; // CI auto-patch carrier (ADR 0161); see changelog above
1247
1249
 
1248
1250
  // A namespaced logger so a module's log lines are attributable + consistent.
1249
1251
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -335,11 +335,37 @@ test('the F19 re-kick door survives the fold: open, pre-filled, one button', ()
335
335
  const fold = fn('attachFold');
336
336
  assert.match(fold, /var rekick = inst\.status === 'active' && inst\.domain && inst\.error_note;/);
337
337
  assert.match(fold, /\(rekick \? ' open' : ''\)/, 'the fold opens on its own for a stalled address');
338
- assert.match(fold, /\(rekick \? ' value="' \+ esc\(inst\.domain\) \+ '"' : ''\)/, 'pre-filled with the address that stalled');
339
- assert.match(fold, /rekick \? 'Try the address again' : 'Use this domain'/, 'and the button says what it does');
338
+ assert.match(fold, /\(prefill \? ' value="' \+ esc\(inst\.domain\) \+ '"' : ''\)/, 'pre-filled with the address in play');
339
+ assert.match(fold, /var prefill = rekick \|\| repoint;/, 'a stalled address and a correctable one both arrive in the field');
340
+ assert.match(fold, /rekick \? 'Try the address again' : repoint \? 'Change the address' : 'Use this domain'/, 'and the button says what it does');
340
341
  assert.match(fn('canAttachDomain'), /inst\.status === 'active' && inst\.domain && inst\.error_note && !inst\.open_intent\) return true;/, 'the route-side re-kick gate is untouched');
341
342
  });
342
343
 
344
+ test('the correction door: a bring-your-own address can be changed, and the fold says what that costs (task 1003237)', () => {
345
+ const fold = fn('attachFold');
346
+ // the three doors are told apart by what the project is actually addressed as
347
+ assert.match(fold, /var byo = inst\.domain && inst\.domain !== assignedDefaultFor\(inst\);/,
348
+ 'the platform-assigned <slug>.<apex> is NOT a correction — moving off it is the upgrade the fold already offered');
349
+ assert.match(fold, /var repoint = !rekick && byo;/, 'a stalled address keeps the F19 door, which is louder');
350
+ assert.match(fold, /'Use a different domain'/, 'the summary names the correction');
351
+ assert.match(fold, /attachNote/, 'and the consequence is stated where the button is');
352
+ assert.match(fold, /won’t use that address any more/, 'letting the old address go is SAID, not discovered');
353
+ // the door itself: an addressed, live project is no longer excluded
354
+ const gate = fn('canAttachDomain');
355
+ assert.doesNotMatch(gate, /!inst\.domain \|\| inst\.domain === assignedDefaultFor\(inst\)/,
356
+ 'the "only a domainless or platform-addressed project may attach" clause is what the re-point removes');
357
+ assert.match(gate, /inst\.status === 'requested' \|\| inst\.status === 'error' \|\| inst\.status === 'active'/,
358
+ 'state is still the gate — a mid-flight or torn-down project has nothing to point a domain at');
359
+ assert.match(COSMOS, /\.attachNote\{/, 'the note is styled');
360
+ assert.doesNotMatch((COSMOS.match(/\.attachNote[^\n]*/g) || []).join('\n'), /#[0-9a-f]{3,8}\b/i,
361
+ 'and paints from tokens, so dark mode needs no twin');
362
+ });
363
+
364
+ test('the retired refusal is gone from the card: domain_already_set can no longer be returned', () => {
365
+ assert.doesNotMatch(HUB, /domain_already_set/,
366
+ 'the server stopped sending it (task 1003237) — a branch for it would be unreachable copy');
367
+ });
368
+
343
369
  test('a heartbeat re-render keeps an open fold open and a half-typed domain typed', () => {
344
370
  const card = fn('renderCard');
345
371
  assert.match(card, /var keepOpen = !!\(oldFold && oldFold\.open\);/);