@wtfalch/foundry 0.11.0 → 0.13.0

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.
@@ -1,17 +1,15 @@
1
- import { createHash, randomBytes } from 'node:crypto';
1
+ import { randomBytes } from 'node:crypto';
2
2
  import { URL } from 'node:url';
3
- import { isDeepStrictEqual } from 'node:util';
4
3
  import { CALLS_SFU_PRIVATE_URL, CALLS_SFU_PUBLIC_URL } from '../calls-sfu.js';
5
4
  import { APP_ENTRY_KEY, appEntry } from '../org.js';
6
- import { adoptedAppPatch, adoptedConnectorKey, adoptedPatchDetails, adoptedResources, adoptedTunnelKey, adoptionVaultRef, declaredAdoptUuids, foreignRoutes, isActiveSnapshot, mergeIngress, normalizeHostname, protectsKey, removedNames, replacedNames, restoreAppPatch, restoreIngress, snapshotAppSettings, } from './adopt.js';
7
- import { AGORA_CLIENT_ID_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, agoraHostnameFor, desiredAgoraEnv, entryClientIdScopeKey, } from './agora.js';
5
+ import { AGORA_CLIENT_ID_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CALLS_SERVICE_NAME_SCOPE_KEY, CALLS_SERVICE_PORT, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, agoraHostnameFor, callsServiceHasName, callsServiceInternalName, desiredAgoraEnv, entryClientIdScopeKey, } from './agora.js';
8
6
  import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
9
7
  import { MAS_IMAGE, MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
10
- import { ensureCloudflaredConnector, } from './cloudflared-connector.js';
8
+ import { ensureCloudflaredConnector } from './cloudflared-connector.js';
11
9
  import { appHostname, entryHostnames, tunnelDnsHostnames, tunnelIngressHostnames, } from './hostnames.js';
12
10
  import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
13
11
  import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
14
- import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, appDeployPendingScopeKey, appDomainsAppliedScopeKey, appEnvAppliedScopeKey, appNameFor, appSettingsAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, } from './platform-diff.js';
12
+ import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, appDomainsAppliedScopeKey, appEnvAppliedScopeKey, appNameFor, appSettingsAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, } from './platform-diff.js';
15
13
  /**
16
14
  * #274 fix round 4: bridges `@wtfalch/providers`' real `ZitadelClient.
17
15
  * listApps` -- a plain array, with no `loginBaseUri` or completeness signal
@@ -295,34 +293,6 @@ const CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY = 'cloudflare_tunnel_connector_a
295
293
  * already follows.
296
294
  */
297
295
  const CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY = 'cloudflare_tunnel_dns_domain';
298
- /**
299
- * #312: the ingress hostnames foundry wrote on an adopted tunnel, kept in the
300
- * tunnel row's `settings.written` for the life of the snapshot, so foundry's
301
- * routes are told from a person's by this record, never by guessing. A name is
302
- * added only after the write that carried it succeeded: a crash in between
303
- * leaves a route taken as foreign and kept.
304
- */
305
- function writtenIngressNames(row) {
306
- const written = row?.settings.written;
307
- return new Set(Array.isArray(written) ? written.filter((n) => typeof n === 'string') : []);
308
- }
309
- /** The names the row says a put may have carried (`writing`): restore treats one as written if its live route is exactly foundry's. */
310
- function writingIngressNames(row) {
311
- const writing = row?.settings.writing;
312
- return Array.isArray(writing) ? writing.filter((n) => typeof n === 'string') : [];
313
- }
314
- /** {@link CreatedDnsRecord}s of the tunnel row's `settings`, ignoring any entry of another shape. */
315
- function createdDnsRecords(settings) {
316
- const list = settings?.dnsRecords;
317
- return (Array.isArray(list) ? list : []).filter((r) => typeof r?.zoneId === 'string' && typeof r?.id === 'string' && typeof r?.hostname === 'string');
318
- }
319
- /** The hostnames the tunnel row says foundry is about to create a record for. */
320
- const dnsCreatingNames = (settings) => {
321
- const list = settings?.dnsCreating;
322
- return (Array.isArray(list) ? list : []).filter((n) => typeof n === 'string');
323
- };
324
- /** The live `TUNNEL_TOKEN` row of a connector app, if Coolify lists one (#312). */
325
- const tunnelTokenRows = (envs) => envs.filter((e) => e.key === 'TUNNEL_TOKEN' && !e.is_preview);
326
296
  /**
327
297
  * #236 round 3: every hostname {@link FoundryStepExecutor.publishTunnelDnsRecords}
328
298
  * last wrote a CNAME for, joined by `,` (hostnames carry no `,`, the same
@@ -538,8 +508,6 @@ const CALLS_SERVICE_DB_SCOPE_KEY = 'coolify_calls_service_database';
538
508
  const CALLS_SERVICE_REPO = 'wtfalch/calls';
539
509
  /** `packages/service/Dockerfile`, built from the repo root (that Dockerfile's own top comment). */
540
510
  const CALLS_SERVICE_DOCKERFILE = 'packages/service/Dockerfile';
541
- /** The port `calls/packages/service/Dockerfile` documents (`EXPOSE 8080`) -- `config.mjs`'s own `PORT` has no default, so this must be pushed explicitly. */
542
- const CALLS_SERVICE_PORT = '8080';
543
511
  /**
544
512
  * Whether an application's own Coolify `status` (`` `running:healthy`,
545
513
  * `exited:unhealthy` `` -- `@wtfalch/providers`' own doc comment) means it is
@@ -556,100 +524,6 @@ export function isRunningOrStarting(status) {
556
524
  return true;
557
525
  return !status.startsWith('exited') && !status.startsWith('stopped');
558
526
  }
559
- /**
560
- * The env names `pushAppEnv` writes from what the plan already holds, before
561
- * the cookie secret and the entry's declared `env`. `coolifyApp` and an
562
- * adoption's plan lines read the same list, so what an adopted app is told
563
- * "will change" is what the step writes (#311).
564
- */
565
- function ownedAppEnv(org, entry, recorded, authIssuer = null) {
566
- const envs = [
567
- // Only the `app` entry has a public name; the others are internal.
568
- ...(entry.key === APP_ENTRY_KEY
569
- ? [{ key: 'APP_URL', value: `https://${appHostname(org)}` }]
570
- : []),
571
- // #75: read back by the package-side pino sink (not this repo's job,
572
- // #75 "What belongs where") once `ensureLogVolume` has mounted the
573
- // matching volume.
574
- { key: 'LOG_DIR', value: LOG_DIR },
575
- ];
576
- if (entry.uses?.auth) {
577
- // F5: an entry that signs in has its own client, organisation and issuer.
578
- for (const [key, value] of Object.entries(entryAuthEnv(org, entry, recorded, authIssuer))) {
579
- envs.push({ key, value });
580
- }
581
- }
582
- else {
583
- const zitadelOrgId = recorded.zitadel_org;
584
- if (zitadelOrgId)
585
- envs.push({ key: 'AUTH_ORGANIZATION_ID', value: zitadelOrgId });
586
- const clientId = recorded[APP_CLIENT_ID_SCOPE_KEY];
587
- if (clientId)
588
- envs.push({ key: 'AUTH_CLIENT_ID', value: clientId });
589
- }
590
- // The invited owner's ZITADEL user id (gap P3): the app uses it to make
591
- // them its own owner in authz on first sign-in. `runSteps` records this
592
- // under the same `system:kind:key` shape every step's `resourceId` uses
593
- // (`zitadelOwner`'s own doc comment), not a scope id.
594
- const ownerUserId = recorded[`zitadel:owner:${org.id}`];
595
- if (ownerUserId)
596
- envs.push({ key: 'OWNER_USER_ID', value: ownerUserId });
597
- return envs;
598
- }
599
- /** Every env name foundry writes on an adopted app: what restore may touch (#311). */
600
- function ownedEnvNames(org, entry, recorded) {
601
- return new Set([
602
- ...ownedAppEnv(org, entry, recorded).map((e) => e.key),
603
- ...Object.keys(entry.env),
604
- 'AUTH_COOKIE_SECRET',
605
- // An entry that signs in also gets the issuer and a minted broker key.
606
- ...(entry.uses?.auth ? ['AUTH_ISSUER', 'AUTH_APP_KEY'] : []),
607
- ]);
608
- }
609
- /** Owned names whose live non-preview row Coolify hid: no value to snapshot (#311). */
610
- function hiddenOwnedEnv(owned, envs) {
611
- return envs
612
- .filter((e) => !e.is_preview && owned.has(e.key) && typeof e.value !== 'string')
613
- .map((e) => e.key);
614
- }
615
- /**
616
- * The app uuid and the env rows a snapshot's vault entry holds, for restore.
617
- * Refuses, naming the resource only, when the uuid or the entry is missing or
618
- * the entry is not a list of env rows (#311).
619
- */
620
- async function readEnvSnapshot(vault, row) {
621
- const { uuid } = row.settings;
622
- const { vaultRef } = row;
623
- const document = vaultRef && typeof uuid === 'string'
624
- ? await restoreStep(row.resourceKey, uuid, 'vault read', () => vault.resolve(vaultRef))
625
- : null;
626
- if (typeof uuid !== 'string' || document === null) {
627
- throw new Error(`restore ${row.resourceKey}: the snapshot's vault entry or app uuid is missing; nothing was changed`);
628
- }
629
- let snapshot;
630
- try {
631
- snapshot = JSON.parse(document);
632
- }
633
- catch {
634
- throw new Error(`restore ${row.resourceKey}: the vault entry is unreadable; nothing was changed`);
635
- }
636
- if (!Array.isArray(snapshot) ||
637
- !snapshot.every((r) => r !== null && typeof r === 'object' && typeof r.key === 'string')) {
638
- throw new Error(`restore ${row.resourceKey}: the vault entry is not a list of env rows; nothing was changed`);
639
- }
640
- return { uuid, envs: snapshot };
641
- }
642
- /** A sha256 hex digest: what the row keeps in place of a token. */
643
- const sha256Hex = (value) => createHash('sha256').update(value).digest('hex');
644
- /** Runs one restore call; a failure names the resource and the operation, never the cause (rule 6). */
645
- async function restoreStep(resourceKey, uuid, operation, call) {
646
- try {
647
- return await call();
648
- }
649
- catch {
650
- throw new Error(`restore ${resourceKey} (${uuid}): ${operation} failed`);
651
- }
652
- }
653
527
  // ---------------------------------------------------------------------------
654
528
  // Executor
655
529
  // ---------------------------------------------------------------------------
@@ -671,8 +545,6 @@ export class FoundryStepExecutor {
671
545
  return this.zitadelApp(action, org, context);
672
546
  case 'zitadel:customers':
673
547
  return this.zitadelCustomers(org, context);
674
- case 'coolify:adopt':
675
- return this.coolifyAdopt(action, org, context.recordedIds());
676
548
  case 'coolify:app':
677
549
  return this.coolifyApp(action, org, context);
678
550
  case 'coolify:broker-entry':
@@ -1214,521 +1086,29 @@ export class FoundryStepExecutor {
1214
1086
  throw new Error('no usable Coolify server is registered on this instance');
1215
1087
  return server;
1216
1088
  }
1217
- /** The live app Coolify holds under `uuid`. The error names the id only (rule 6). */
1218
- async readLiveApp(uuid) {
1219
- try {
1220
- return (await this.ports.coolify.raw('GET', `/applications/${uuid}`));
1221
- }
1222
- catch {
1223
- throw new Error(`adopt: the live app ${uuid} could not be read`);
1224
- }
1225
- }
1226
- /** The live ingress routes of the adopted tunnel `id`. The error names the id only (rule 6). */
1227
- async readAdoptedIngress(id) {
1228
- try {
1229
- const { config } = await this.ports.cloudflare.getTunnelConfig(id);
1230
- const ingress = config?.ingress ?? [];
1231
- if (!Array.isArray(ingress))
1232
- throw new Error('not a list');
1233
- return ingress;
1234
- }
1235
- catch {
1236
- throw new Error(`adopt: the live tunnel ${id} could not be read`);
1237
- }
1238
- }
1239
- /**
1240
- * #311: for each adopted resource, the lines its `coolify:adopt` step shows
1241
- * (a setting with old value beside new; an env var by name only) and what
1242
- * is blocked. Read from the live app on every plan. No value of an env var
1243
- * leaves this method (rule 6).
1244
- */
1245
- async adoptionReport(org, recorded) {
1246
- const details = {};
1247
- const blocked = [];
1248
- const withheld = [];
1249
- for (const resource of adoptedResources(org)) {
1250
- // A uuid that cannot be read blocks that resource, not the whole plan (rule 2).
1251
- let live = {};
1252
- let envs = [];
1253
- let routes = [];
1254
- try {
1255
- if (resource.kind === 'database')
1256
- await this.ports.coolify.getDatabase(resource.uuid);
1257
- else if (resource.kind === 'tunnel')
1258
- routes = await this.readAdoptedIngress(resource.uuid);
1259
- else {
1260
- live = await this.readLiveApp(resource.uuid);
1261
- envs = await this.ports.coolify.listEnvs(resource.uuid);
1262
- }
1263
- }
1264
- catch {
1265
- withheld.push(resource.resourceKey);
1266
- blocked.push({
1267
- description: `${org.slug}'s adoption of ${resource.resourceKey} is blocked, not planned: the live resource ${resource.uuid} could not be read`,
1268
- key: `coolify:adopt:${resource.resourceKey}`,
1269
- });
1270
- continue;
1271
- }
1272
- if (resource.kind === 'database') {
1273
- details[resource.resourceKey] = [
1274
- 'the database is taken over as it is; nothing in it changes',
1275
- ];
1276
- continue;
1277
- }
1278
- if (resource.kind === 'tunnel') {
1279
- // Hostnames only: a service address may carry credentials (rule 6).
1280
- const declared = tunnelIngressHostnames(org);
1281
- const row = await this.ports.adoptionStore?.get(org.id, adoptedTunnelKey(org));
1282
- const written = new Set([...writtenIngressNames(row), ...writingIngressNames(row)]);
1283
- const kept = foreignRoutes(routes, declared, COOLIFY_PROXY_ORIGIN, written);
1284
- details[resource.resourceKey] = [
1285
- ...kept
1286
- .filter((r) => r.hostname !== undefined)
1287
- .map((r) => `kept, not managed: ${r.hostname}`),
1288
- ...(kept.some((r) => r.hostname === undefined)
1289
- ? ['kept, not managed: (no hostname)']
1290
- : []),
1291
- ...replacedNames(routes, declared, COOLIFY_PROXY_ORIGIN).map((h) => `will replace: ${h}`),
1292
- ...removedNames(routes, declared, COOLIFY_PROXY_ORIGIN, written).map((h) => `will remove: ${h}`),
1293
- ];
1294
- continue;
1295
- }
1296
- if (resource.kind === 'connector') {
1297
- if (hiddenOwnedEnv(new Set(['TUNNEL_TOKEN']), envs).length > 0) {
1298
- withheld.push(resource.resourceKey);
1299
- blocked.push({
1300
- description: `${org.slug}'s adoption of ${resource.resourceKey} is blocked, not planned: Coolify hides the value of TUNNEL_TOKEN, so it cannot be snapshotted`,
1301
- key: `coolify:adopt:${resource.resourceKey}`,
1302
- });
1303
- continue;
1304
- }
1305
- details[resource.resourceKey] = [];
1306
- // The token is compared and never shown; a failed read adds no line.
1307
- try {
1308
- const token = await this.ports.cloudflare.getTunnelToken(org.hosting.adopt?.tunnel ?? '');
1309
- if (tunnelTokenRows(envs).every((e) => e.value !== token)) {
1310
- details[resource.resourceKey]?.push('will change: TUNNEL_TOKEN');
1311
- }
1312
- }
1313
- catch { }
1314
- continue;
1315
- }
1316
- const { entry } = resource;
1317
- const hidden = hiddenOwnedEnv(ownedEnvNames(org, entry, recorded), envs);
1318
- if (hidden.length > 0) {
1319
- withheld.push(resource.resourceKey);
1320
- blocked.push({
1321
- description: `${org.slug}'s adoption of ${resource.resourceKey} is blocked, not planned: Coolify hides the value of ${hidden.sort().join(', ')}, so it cannot be snapshotted`,
1322
- key: `coolify:adopt:${resource.resourceKey}`,
1323
- });
1324
- continue;
1325
- }
1326
- const liveEnv = new Map(envs.filter((e) => !e.is_preview).map((e) => [e.key, e.value]));
1327
- const owned = new Map([
1328
- ...ownedAppEnv(org, entry, recorded).map((e) => [e.key, e.value]),
1329
- ...Object.entries(resolveAppEnv(org, entry, recorded).values),
1330
- ]);
1331
- const lines = adoptedPatchDetails(adoptedAppPatch(entry, entryHostnames(org, entry), live), live);
1332
- for (const [key, value] of owned) {
1333
- if (!liveEnv.has(key))
1334
- lines.push(`will add: ${key}`);
1335
- else if (liveEnv.get(key) !== value)
1336
- lines.push(`will change: ${key}`);
1337
- }
1338
- // The issuer's value is read at apply time, so only its presence shows here.
1339
- if (entry.uses?.auth) {
1340
- for (const key of ['AUTH_ISSUER', 'AUTH_APP_KEY']) {
1341
- owned.set(key, liveEnv.get(key) ?? '');
1342
- if (!liveEnv.has(key))
1343
- lines.push(`will add: ${key}`);
1344
- }
1345
- }
1346
- if (!liveEnv.has('AUTH_COOKIE_SECRET') && !owned.has('AUTH_COOKIE_SECRET')) {
1347
- lines.push('will add: AUTH_COOKIE_SECRET');
1348
- }
1349
- for (const key of [...liveEnv.keys()].sort()) {
1350
- if (!owned.has(key) && key !== 'AUTH_COOKIE_SECRET')
1351
- lines.push(`not managed: ${key}`);
1352
- }
1353
- details[resource.resourceKey] = lines;
1354
- // Choice A of #311's "Decide": an adopted app with no runtime role
1355
- // address is reported, never given one.
1356
- if (entry.database) {
1357
- const address = liveEnv.get('DATABASE_URL');
1358
- const ownerAddresses = [liveEnv.get('DATABASE_URL_OWNER')];
1359
- const databaseUuid = org.hosting.adopt?.database;
1360
- if (databaseUuid) {
1361
- ownerAddresses.push(await this.ports.coolify.getDatabase(databaseUuid).then((db) => db.internal_db_url, () => undefined));
1362
- }
1363
- if (!address || ownerAddresses.includes(address)) {
1364
- withheld.push(resource.resourceKey);
1365
- const label = entry.key === APP_ENTRY_KEY ? 'app' : `${entry.key} app`;
1366
- blocked.push({
1367
- description: `${org.slug}'s ${label} runtime database address is blocked, not planned: DATABASE_URL is missing or is the owner address, so set DATABASE_URL on the app; foundry leaves its env alone`,
1368
- key: entry.key === APP_ENTRY_KEY
1369
- ? 'coolify:app:database-url'
1370
- : `coolify:app:${entry.key}:database-url`,
1371
- });
1372
- }
1373
- }
1374
- }
1375
- return { details, blocked, withheld };
1376
- }
1377
- /**
1378
- * #311: puts each adopted app back as its snapshot recorded it, touching
1379
- * only what foundry writes: the settings it patches and the env names it
1380
- * owns (an edit a person made to anything else stays). Owned names the
1381
- * snapshot has are written back with its value and flags; owned names it
1382
- * lacks are deleted; preview rows are left alone. Every vault entry and
1383
- * live read happens first, so a missing entry or a row that cannot be
1384
- * deleted refuses before any Coolify write. Errors name ids only, and the
1385
- * vault's env values go to `bulkUpsertEnvs` and nowhere else (rule 6).
1386
- */
1387
- async restoreAdoption(org, recorded, rows) {
1388
- const vault = this.ports.adoptionVault;
1389
- if (!vault)
1390
- throw new Error(`restore for ${org.slug}: no adoption vault is wired`);
1391
- const apps = [];
1392
- for (const row of rows.filter((r) => r.kind === 'app')) {
1393
- // The row names the app: a declaration that changed or went away since
1394
- // does not move the restore onto another app.
1395
- const { uuid, envs: snapshot } = await readEnvSnapshot(vault, row);
1396
- const entry = org.hosting.entries.find((e) => `coolify:app:${appStepKey(org, e.key)}` === row.resourceKey);
1397
- // Without an entry, the names foundry writes with no declaration.
1398
- const owned = ownedEnvNames(org, entry ?? {
1399
- key: row.resourceKey.slice(`coolify:app:${org.id}:`.length),
1400
- env: {},
1401
- uses: undefined,
1402
- }, recorded);
1403
- const old = snapshot.filter((e) => !e.is_preview);
1404
- const oldNames = new Set(old.map((e) => e.key));
1405
- const live = (await restoreStep(row.resourceKey, uuid, 'env read', () => this.ports.coolify.listEnvs(uuid))).filter((e) => !e.is_preview);
1406
- const liveApp = await restoreStep(row.resourceKey, uuid, 'settings read', () => this.readLiveApp(uuid));
1407
- const patch = restoreAppPatch(entry, entry ? entryHostnames(org, entry) : [], row.settings, liveApp);
1408
- const upserts = [];
1409
- for (const e of old.filter((r) => owned.has(r.key))) {
1410
- if (typeof e.value !== 'string') {
1411
- throw new Error(`restore ${row.resourceKey}: the snapshot has no value for ${e.key}; nothing was changed`);
1412
- }
1413
- upserts.push({
1414
- key: e.key,
1415
- value: e.value,
1416
- buildTime: Boolean(e.is_buildtime),
1417
- literal: Boolean(e.is_literal),
1418
- multiline: Boolean(e.is_multiline),
1419
- });
1420
- }
1421
- const deletes = [];
1422
- for (const e of live.filter((l) => owned.has(l.key) && !oldNames.has(l.key))) {
1423
- if (!e.uuid) {
1424
- throw new Error(`restore ${row.resourceKey}: the live env ${e.key} has no uuid, so it is not deleted; nothing was changed`);
1425
- }
1426
- deletes.push({ key: e.key, uuid: e.uuid });
1427
- }
1428
- apps.push({ row, uuid, patch, upserts, deletes });
1429
- }
1430
- // #312: the tunnel's ingress with foundry's routes swapped for the snapshot's,
1431
- // read live now so a route a person added since stays.
1432
- const cf = this.ports.cloudflare;
1433
- const tunnels = [];
1434
- for (const row of rows.filter((r) => r.kind === 'tunnel')) {
1435
- const { uuid: id, ingress } = row.settings;
1436
- if (typeof id !== 'string' || !Array.isArray(ingress)) {
1437
- throw new Error(`restore ${row.resourceKey}: the snapshot's tunnel id or ingress is missing; nothing was changed`);
1438
- }
1439
- const { config } = await restoreStep(row.resourceKey, id, 'ingress read', () => cf.getTunnelConfig(id));
1440
- const live = config?.ingress ?? [];
1441
- // The names the row says foundry wrote: the declaration may have changed or gone since.
1442
- const written = new Set([...writtenIngressNames(row), ...writingIngressNames(row)]);
1443
- const restored = restoreIngress(live, ingress, COOLIFY_PROXY_ORIGIN, written);
1444
- const swapped = removedNames(live, [], COOLIFY_PROXY_ORIGIN, written).map(normalizeHostname);
1445
- // A name with a live route that is not exactly foundry's keeps its CNAME, or that route stops resolving.
1446
- const leftAlone = (hostname) => live.filter((r) => r.hostname !== undefined &&
1447
- normalizeHostname(r.hostname) === normalizeHostname(hostname)).length > (swapped.includes(normalizeHostname(hostname)) ? 1 : 0);
1448
- tunnels.push({
1449
- row,
1450
- id,
1451
- config: isDeepStrictEqual(restored, live) ? null : { ...config, ingress: restored },
1452
- dnsRecords: createdDnsRecords(row.settings).filter((r) => !leftAlone(r.hostname)),
1453
- });
1454
- }
1455
- // #312: the old TUNNEL_TOKEN back on the connector; a token the snapshot lacks is deleted.
1456
- // Only a live token that is the one foundry wrote (its digest is in the row) is changed.
1457
- const tokenLeft = [];
1458
- const connectors = [];
1459
- for (const row of rows.filter((r) => r.kind === 'connector')) {
1460
- const { uuid, envs: snapshot } = await readEnvSnapshot(vault, row);
1461
- const [old] = tunnelTokenRows(snapshot);
1462
- const [live] = tunnelTokenRows(await restoreStep(row.resourceKey, uuid, 'env read', () => this.ports.coolify.listEnvs(uuid)));
1463
- if (old && typeof old.value !== 'string') {
1464
- throw new Error(`restore ${row.resourceKey}: the snapshot has no value for TUNNEL_TOKEN; nothing was changed`);
1465
- }
1466
- const foundrys = row.settings.tokenWritten === true &&
1467
- typeof live?.value === 'string' &&
1468
- row.settings.tokenDigest === sha256Hex(live.value);
1469
- if (old && live?.value !== old.value && foundrys) {
1470
- connectors.push({
1471
- row,
1472
- uuid,
1473
- upsert: {
1474
- key: 'TUNNEL_TOKEN',
1475
- value: old.value ?? '',
1476
- buildTime: Boolean(old.is_buildtime),
1477
- literal: Boolean(old.is_literal),
1478
- multiline: Boolean(old.is_multiline),
1479
- },
1480
- });
1481
- }
1482
- else if (!old && live && foundrys) {
1483
- if (!live.uuid) {
1484
- throw new Error(`restore ${row.resourceKey}: the live TUNNEL_TOKEN has no uuid, so it is not deleted; nothing was changed`);
1485
- }
1486
- connectors.push({ row, uuid, del: { uuid: live.uuid } });
1487
- }
1488
- else if (live &&
1489
- !foundrys &&
1490
- row.settings.tokenWritten === true &&
1491
- (!old || live.value !== old.value)) {
1492
- tokenLeft.push(row.resourceKey);
1493
- }
1494
- else if (old && !live && row.settings.tokenWritten === true) {
1495
- // A person deleted the token: a redeploy would start the connector with none.
1496
- tokenLeft.push(row.resourceKey);
1497
- }
1498
- else if (row.settings.tokenWritten === true) {
1499
- // Foundry rewrote the token: a retry after a failed deploy still deploys.
1500
- connectors.push({ row, uuid });
1501
- }
1502
- }
1503
- const envDeleted = [];
1504
- for (const { row, uuid, patch, upserts, deletes } of apps) {
1505
- const key = row.resourceKey;
1506
- if (Object.keys(patch).length > 0) {
1507
- await restoreStep(key, uuid, 'settings update', () => this.ports.coolify.updateApplication(uuid, patch));
1508
- }
1509
- if (upserts.length > 0) {
1510
- await restoreStep(key, uuid, 'env write', () => this.ports.coolify.bulkUpsertEnvs(uuid, upserts));
1511
- }
1512
- for (const del of deletes) {
1513
- await restoreStep(key, uuid, 'env delete', () => this.ports.coolify.raw('DELETE', `/applications/${uuid}/envs/${del.uuid}`));
1514
- if (!envDeleted.includes(del.key))
1515
- envDeleted.push(del.key);
1516
- }
1517
- // A container reads env and domains only at start, a folder only at build (rule 4).
1518
- // Decided from the snapshot, not from live: a retry after a failed deploy
1519
- // finds live already written and must still deploy.
1520
- if (Object.keys(patch).length > 0) {
1521
- await restoreStep(key, uuid, 'deploy', () => this.ports.coolify.deploy(uuid));
1522
- }
1523
- else {
1524
- await restoreStep(key, uuid, 'restart', () => this.ports.coolify.restart(uuid));
1525
- }
1526
- }
1527
- for (const { row, id, config, dnsRecords } of tunnels) {
1528
- if (config) {
1529
- await restoreStep(row.resourceKey, id, 'ingress write', () => cf.putTunnelConfig(id, config));
1530
- }
1531
- // Only the records foundry created, and only while each is still a CNAME
1532
- // to this tunnel: one a person repointed, or one already gone, is left.
1533
- for (const record of dnsRecords) {
1534
- await restoreStep(row.resourceKey, id, 'dns delete', async () => {
1535
- const live = (await cf.findDnsRecord(record.zoneId, record.hostname, 'CNAME')).find((r) => r.id === record.id);
1536
- if (live?.content !== `${id}.cfargotunnel.com`)
1537
- return;
1538
- try {
1539
- await cf.deleteDnsRecord(record.zoneId, record.id);
1540
- }
1541
- catch (error) {
1542
- if (!isNotFoundError(error))
1543
- throw error;
1544
- }
1545
- });
1546
- }
1547
- }
1548
- for (const { row, uuid, upsert, del } of connectors) {
1549
- const key = row.resourceKey;
1550
- if (upsert) {
1551
- await restoreStep(key, uuid, 'env write', () => this.ports.coolify.bulkUpsertEnvs(uuid, [upsert]));
1552
- }
1553
- if (del) {
1554
- await restoreStep(key, uuid, 'env delete', () => this.ports.coolify.raw('DELETE', `/applications/${uuid}/envs/${del.uuid}`));
1555
- }
1556
- // The container reads its token only at start.
1557
- await restoreStep(key, uuid, 'deploy', () => this.ports.coolify.deploy(uuid));
1558
- }
1559
- return { envDeleted, tokenLeft };
1560
- }
1561
- /**
1562
- * #311: records what a live app or database looked like before foundry
1563
- * first writes to it, in this order: the env values to the vault, the
1564
- * non-secret settings to the row, then both read back and compared. The
1565
- * step writes nothing to Coolify, so a snapshot that fails stops the apply
1566
- * before any later step does. Every error names ids only (rule 6).
1567
- */
1568
- async coolifyAdopt(action, org, recorded) {
1569
- const { adoptionVault: vault, adoptionStore: store } = this.ports;
1570
- if (!vault || !store) {
1571
- throw new Error(`coolify:adopt for ${org.slug}: no adoption vault is wired`);
1572
- }
1573
- const resource = adoptedResources(org).find((r) => r.resourceKey === action.key);
1574
- if (!resource)
1575
- throw new Error(`coolify:adopt for ${org.slug}: nothing declared for ${action.key}`);
1576
- // Snapshotting another uuid now would record foundry's own earlier writes.
1577
- const held = await store.get(org.id, action.key);
1578
- if (held?.state === 'adopted' && held.settings.uuid !== resource.uuid) {
1579
- throw new Error(`coolify:adopt ${action.key}: restore the adopted ${resource.kind} first (${String(held.settings.uuid)} is adopted, not ${resource.uuid}); nothing was changed`);
1580
- }
1581
- // Another key's snapshot of this uuid holds the original; a second one would record foundry's own writes.
1582
- const holder = (await store.list(org.id)).find((r) => r.resourceKey !== action.key && protectsKey(r) && r.settings.uuid === resource.uuid);
1583
- if (holder) {
1584
- throw new Error(`coolify:adopt ${action.key}: ${resource.uuid} is already adopted under ${holder.resourceKey}; nothing was changed`);
1585
- }
1586
- let settings = { uuid: resource.uuid };
1587
- let vaultRef = null;
1588
- let envDocument = '';
1589
- if (resource.kind === 'app') {
1590
- settings = {
1591
- uuid: resource.uuid,
1592
- ...snapshotAppSettings(await this.readLiveApp(resource.uuid)),
1593
- };
1594
- vaultRef = adoptionVaultRef(org, resource);
1595
- let envs;
1596
- try {
1597
- envs = await this.ports.coolify.listEnvs(resource.uuid);
1598
- }
1599
- catch {
1600
- throw new Error(`adopt: the env of the live app ${resource.uuid} could not be read`);
1601
- }
1602
- const hidden = hiddenOwnedEnv(ownedEnvNames(org, resource.entry, recorded), envs);
1603
- if (hidden.length > 0) {
1604
- throw new Error(`coolify:adopt ${action.key}: Coolify hides the value of ${hidden.sort().join(', ')}; nothing was changed`);
1605
- }
1606
- envDocument = JSON.stringify(envs);
1607
- }
1608
- else if (resource.kind === 'tunnel') {
1609
- settings = {
1610
- uuid: resource.uuid,
1611
- ingress: await this.readAdoptedIngress(resource.uuid),
1612
- written: [],
1613
- };
1614
- }
1615
- else if (resource.kind === 'connector') {
1616
- await this.readLiveApp(resource.uuid);
1617
- vaultRef = adoptionVaultRef(org, resource);
1618
- let envs;
1619
- try {
1620
- envs = await this.ports.coolify.listEnvs(resource.uuid);
1621
- }
1622
- catch {
1623
- throw new Error(`adopt: the env of the live app ${resource.uuid} could not be read`);
1624
- }
1625
- if (hiddenOwnedEnv(new Set(['TUNNEL_TOKEN']), envs).length > 0) {
1626
- throw new Error(`coolify:adopt ${action.key}: Coolify hides the value of TUNNEL_TOKEN; nothing was changed`);
1627
- }
1628
- // The old token is a secret: it goes to the vault, never to `settings`.
1629
- envDocument = JSON.stringify(tunnelTokenRows(envs));
1630
- }
1631
- let sealed = false;
1632
- let written = false;
1633
- try {
1634
- if (vaultRef)
1635
- await vault.put(vaultRef, envDocument);
1636
- await store.put({
1637
- orgId: org.id,
1638
- resourceKey: action.key,
1639
- kind: resource.kind,
1640
- takenAt: (this.ports.now?.() ?? new Date()).toISOString(),
1641
- settings,
1642
- vaultRef,
1643
- });
1644
- written = true;
1645
- const row = await store.get(org.id, action.key);
1646
- sealed =
1647
- row !== null &&
1648
- row.state === 'adopted' &&
1649
- row.vaultRef === vaultRef &&
1650
- isDeepStrictEqual(row.settings, settings) &&
1651
- (vaultRef === null || (await vault.resolve(vaultRef)) === envDocument);
1652
- }
1653
- catch {
1654
- // The cause may quote a value; the message below names ids only.
1655
- }
1656
- if (!sealed) {
1657
- // A row left as `adopted` would make the retry skip the snapshot (#311).
1658
- if (written) {
1659
- try {
1660
- await store.setState(org.id, action.key, 'released');
1661
- }
1662
- catch {
1663
- throw new Error(`coolify:adopt ${action.key}: the snapshot could not be read back and its row could not be released; the row may still be active`);
1664
- }
1665
- }
1666
- throw new Error(`coolify:adopt ${action.key}: the snapshot could not be written and read back; nothing was changed`);
1667
- }
1668
- return undefined;
1669
- }
1670
- /** #311: whether the resource was adopted: its row is `adopted` or `confirmed`, or `id` is a declared adopt uuid. */
1671
- async isAdopted(org, resourceKey, id) {
1672
- const row = await this.ports.adoptionStore?.get(org.id, resourceKey);
1673
- return protectsKey(row) || declaredAdoptUuids(org).has(id);
1674
- }
1675
- /** #311: refuses to manage as foundry's own a resource whose row protects its key and whose declaration has no `adopt`. */
1676
- async refuseTakeover(org, resourceKey) {
1677
- if (protectsKey(await this.ports.adoptionStore?.get(org.id, resourceKey))) {
1678
- throw new Error(`${resourceKey}: adopted, so it needs adopt declared; nothing was changed`);
1679
- }
1680
- }
1681
- /** #311: refuses to write to a declared adopt uuid that has no active snapshot. */
1682
- async requireSnapshot(org, resourceKey, uuid) {
1683
- const row = await this.ports.adoptionStore?.get(org.id, resourceKey);
1684
- if (!isActiveSnapshot(row, uuid)) {
1685
- throw new Error(`${resourceKey}: no active adoption snapshot; nothing was changed`);
1686
- }
1687
- }
1688
1089
  async coolifyApp(action, org, context) {
1689
1090
  const recordedAppId = context.recordedIds()[`coolify:app:${action.key}`];
1690
1091
  if (action.desired.enabled === false) {
1691
1092
  if (!recordedAppId) {
1692
1093
  throw new Error(`coolify:app for ${org.slug}: no live app recorded, nothing to park`);
1693
1094
  }
1694
- if (await this.isAdopted(org, `coolify:app:${action.key}`, recordedAppId)) {
1695
- throw new Error(`coolify:app ${action.key}: adopted, not stopped`);
1696
- }
1697
1095
  await this.ports.coolify.raw('POST', `/applications/${recordedAppId}/stop`);
1698
1096
  return { resourceId: recordedAppId };
1699
1097
  }
1700
1098
  const entry = org.hosting.entries.find((e) => appStepKey(org, e.key) === action.key);
1701
1099
  if (!entry)
1702
1100
  throw new Error(`coolify:app for ${org.slug}: no hosting entry for ${action.key}`);
1703
- if (entry.adopt)
1704
- await this.requireSnapshot(org, `coolify:app:${action.key}`, entry.adopt.app);
1705
- else
1706
- await this.refuseTakeover(org, `coolify:app:${action.key}`);
1707
1101
  // A level-1 org with no bespoke app of its own gets the shared standard
1708
1102
  // app instead (gap P1, `.plans/2026-09-26-level-1-org-map.md`): an entry
1709
1103
  // with no `repo`.
1710
1104
  const appRepo = entry.repo ?? DEFAULT_APP_REPO;
1711
- // Read only by the name lookup and by create, so an adopted entry, which
1712
- // does neither, makes no project (#311).
1713
- let scoped;
1714
- const scope = async () => {
1715
- if (!scoped) {
1716
- const projectUuid = await this.ensureCoolifyProject(org, context);
1717
- const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
1718
- scoped = { projectUuid, environment };
1719
- }
1720
- return scoped;
1721
- };
1105
+ const projectUuid = await this.ensureCoolifyProject(org, context);
1106
+ const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
1722
1107
  // The recorded id first; the name when nothing is recorded, or when the
1723
1108
  // recorded app is gone.
1724
1109
  let existing = null;
1725
1110
  let live;
1726
- // An adopted entry is the app it declares by uuid: never found by name,
1727
- // never created (#311).
1728
- if (entry.adopt) {
1729
- existing = { uuid: entry.adopt.app };
1730
- }
1731
- else if (recordedAppId) {
1111
+ if (recordedAppId) {
1732
1112
  try {
1733
1113
  live = await this.ports.coolify.getApplication(recordedAppId);
1734
1114
  existing = { uuid: recordedAppId };
@@ -1738,9 +1118,9 @@ export class FoundryStepExecutor {
1738
1118
  throw error;
1739
1119
  }
1740
1120
  }
1741
- if (!existing && !entry.adopt) {
1121
+ if (!existing) {
1742
1122
  existing = await this.ports.coolify.findApplicationByName(appNameFor(org, entry), {
1743
- environmentIds: [(await scope()).environment.id],
1123
+ environmentIds: [environment.id],
1744
1124
  });
1745
1125
  }
1746
1126
  // An entry with no names is internal: the other entries reach it as
@@ -1750,7 +1130,6 @@ export class FoundryStepExecutor {
1750
1130
  const folderField = isStatic ? 'publish_directory' : 'base_directory';
1751
1131
  const settingsKey = appSettingsAppliedScopeKey(entry.key);
1752
1132
  const domainsKey = appDomainsAppliedScopeKey(entry.key);
1753
- const pendingKey = appDeployPendingScopeKey(entry.key);
1754
1133
  let haveNames;
1755
1134
  let have;
1756
1135
  let appId;
@@ -1762,18 +1141,14 @@ export class FoundryStepExecutor {
1762
1141
  // stops the app without deleting it, so re-enabling found it here,
1763
1142
  // still stopped, and it stayed stopped forever despite `apply`
1764
1143
  // reporting success.
1765
- // An adopted app is left as it runs: starting it is not declared.
1766
- if (!entry.adopt) {
1767
- live ??= await this.ports.coolify.getApplication(existing.uuid);
1768
- if (!isRunningOrStarting(live.status)) {
1769
- await this.ports.coolify.raw('POST', `/applications/${existing.uuid}/start`);
1770
- }
1144
+ live ??= await this.ports.coolify.getApplication(existing.uuid);
1145
+ if (!isRunningOrStarting(live.status)) {
1146
+ await this.ports.coolify.raw('POST', `/applications/${existing.uuid}/start`);
1771
1147
  }
1772
1148
  have = appliedAppSettings(context.recordedIds(), entry.key);
1773
1149
  haveNames = appliedAppDomains(org, context.recordedIds(), entry.key);
1774
1150
  }
1775
1151
  else {
1776
- const { projectUuid, environment } = await scope();
1777
1152
  const server = await this.resolveServer();
1778
1153
  const githubApp = await this.ports.coolify.requireGithubApp();
1779
1154
  const created = await this.ports.coolify.createGithubAppApplication({
@@ -1805,39 +1180,26 @@ export class FoundryStepExecutor {
1805
1180
  // recorded below leaves the old one, so the retry patches the same
1806
1181
  // difference again.
1807
1182
  const want = appSettingsOf(entry);
1808
- let patch = {};
1809
- if (entry.adopt) {
1810
- patch = adoptedAppPatch(entry, names, await this.readLiveApp(entry.adopt.app));
1811
- }
1812
- else {
1813
- if (want.folder !== have.folder)
1814
- patch[folderField] = want.folder ? `/${want.folder}` : '/';
1815
- if (want.port !== have.port && want.port !== null && !isStatic) {
1816
- patch.ports_exposes = String(want.port);
1817
- }
1818
- if (want.autoDeploy !== have.autoDeploy)
1819
- patch.is_auto_deploy_enabled = want.autoDeploy;
1820
- // Create names the app after `domains`; a changed name reaches a live app
1821
- // here (rule 3). The internal name is set after create, as the calls
1822
- // stack's apps do.
1823
- if (JSON.stringify(names) !== JSON.stringify(haveNames))
1824
- patch.domains = names.join(',');
1825
- if (names.length === 0 && (!existing || patch.domains !== undefined)) {
1826
- patch.custom_internal_name = appNameFor(org, entry);
1827
- }
1828
- }
1829
- if (Object.keys(patch).length > 0) {
1830
- // An adopted app's patch is live from here, so a failed deploy must
1831
- // replan the app step (#311).
1832
- if (entry.adopt) {
1833
- context.recordScopeId(pendingKey, '1');
1834
- await context.persistScopeIds();
1835
- }
1183
+ const patch = {};
1184
+ if (want.folder !== have.folder)
1185
+ patch[folderField] = want.folder ? `/${want.folder}` : '/';
1186
+ if (want.port !== have.port && want.port !== null && !isStatic) {
1187
+ patch.ports_exposes = String(want.port);
1188
+ }
1189
+ if (want.autoDeploy !== have.autoDeploy)
1190
+ patch.is_auto_deploy_enabled = want.autoDeploy;
1191
+ // Create names the app after `domains`; a changed name reaches a live app
1192
+ // here (rule 3). The internal name is set after create, as the calls
1193
+ // stack's apps do.
1194
+ if (JSON.stringify(names) !== JSON.stringify(haveNames))
1195
+ patch.domains = names.join(',');
1196
+ if (names.length === 0 && (!existing || patch.domains !== undefined)) {
1197
+ patch.custom_internal_name = appNameFor(org, entry);
1198
+ }
1199
+ if (Object.keys(patch).length > 0)
1836
1200
  await this.ports.coolify.updateApplication(appId, patch);
1837
- }
1838
1201
  // The container builds and listens from these only at its next deploy.
1839
- const settingsNeedDeploy = Boolean((entry.adopt && context.recordedIds()[pendingKey]) ||
1840
- patch.base_directory ||
1202
+ const settingsNeedDeploy = Boolean(patch.base_directory ||
1841
1203
  patch.publish_directory ||
1842
1204
  patch.ports_exposes ||
1843
1205
  patch.domains !== undefined ||
@@ -1848,8 +1210,7 @@ export class FoundryStepExecutor {
1848
1210
  // covers the database being turned on after the app already exists (the
1849
1211
  // app step still ran this apply, found the app by name, and lands here).
1850
1212
  let dbPush = { pushed: false, changed: false };
1851
- // An adopted app keeps the database addresses it has (#311).
1852
- if (entry.database && !entry.adopt && !org.hosting.adopt?.database) {
1213
+ if (entry.database) {
1853
1214
  const dbId = context.recordedIds()[`coolify:database:${org.id}`];
1854
1215
  if (dbId) {
1855
1216
  dbPush = await this.pushDatabaseUrl(appId, dbId, org.slug.replace(/-/g, '_'));
@@ -1866,10 +1227,7 @@ export class FoundryStepExecutor {
1866
1227
  // `ensureLogVolume` whether this app was already running before this
1867
1228
  // call -- see its own doc comment for why that decides whether to
1868
1229
  // restart it.
1869
- // An adopted app gets no volume: the snapshot cannot undo one (#311).
1870
- if (!entry.adopt) {
1871
- await this.ensureLogVolume(appId, org, Boolean(existing), appNameFor(org, entry));
1872
- }
1230
+ await this.ensureLogVolume(appId, org, Boolean(existing), appNameFor(org, entry));
1873
1231
  // The app receives whatever it needs to sign in (gap P2). Unconditional,
1874
1232
  // unlike the database push above: every app has a hostname, and an
1875
1233
  // upsert of the same value on a later apply is a no-op, not a change.
@@ -1889,8 +1247,6 @@ export class FoundryStepExecutor {
1889
1247
  // one and `planApp` reports it instead of planning it.
1890
1248
  context.recordScopeId(settingsKey, JSON.stringify({ ...want, port: want.port ?? have.port }));
1891
1249
  context.recordScopeId(domainsKey, JSON.stringify(names));
1892
- if (entry.adopt)
1893
- context.recordScopeId(pendingKey, '');
1894
1250
  context.recordScopeId(appEnvAppliedScopeKey(entry.key), appEnv.applied);
1895
1251
  if (entry.uses?.auth) {
1896
1252
  context.recordScopeId(entryAuthEnvAppliedScopeKey(entry.key), appEnv.authApplied);
@@ -1904,7 +1260,7 @@ export class FoundryStepExecutor {
1904
1260
  // actually resolved a mail domain for this org.
1905
1261
  // One Lettermint project, `<slug>-app`, belongs to the `app` entry; a
1906
1262
  // second entry sharing the name would delete the first's live token.
1907
- if (entry.key === APP_ENTRY_KEY && !entry.adopt) {
1263
+ if (entry.key === APP_ENTRY_KEY) {
1908
1264
  await this.ensureAppLettermintMail(appId, org, context, Boolean(existing));
1909
1265
  }
1910
1266
  return { resourceId: appId };
@@ -1949,24 +1305,12 @@ export class FoundryStepExecutor {
1949
1305
  if (!dbId) {
1950
1306
  throw new Error(`coolify:database for ${org.slug}: no live database recorded, nothing to park`);
1951
1307
  }
1952
- if (await this.isAdopted(org, `coolify:database:${action.key}`, dbId)) {
1953
- throw new Error(`coolify:database ${action.key}: adopted, not stopped`);
1954
- }
1955
1308
  await this.ports.coolify.raw('POST', `/databases/${dbId}/stop`);
1956
1309
  return { resourceId: dbId };
1957
1310
  }
1958
1311
  const existing = context.recordedIds()[`coolify:database:${action.key}`];
1959
1312
  let dbId;
1960
- if (!org.hosting.adopt?.database) {
1961
- await this.refuseTakeover(org, `coolify:database:${action.key}`);
1962
- }
1963
- if (org.hosting.adopt?.database) {
1964
- // The declared uuid is the database, over any recorded id: never found by
1965
- // name or created (#311).
1966
- dbId = org.hosting.adopt.database;
1967
- await this.requireSnapshot(org, `coolify:database:${action.key}`, dbId);
1968
- }
1969
- else if (existing) {
1313
+ if (existing) {
1970
1314
  dbId = existing;
1971
1315
  }
1972
1316
  else {
@@ -1997,7 +1341,7 @@ export class FoundryStepExecutor {
1997
1341
  // before app on the same apply -- `diff.ts`'s `computePlanDetail`).
1998
1342
  const dbEntry = org.hosting.entries.find((e) => e.database);
1999
1343
  const appId = dbEntry && context.recordedIds()[`coolify:app:${appStepKey(org, dbEntry.key)}`];
2000
- if (dbEntry && appId && !dbEntry.adopt && !org.hosting.adopt?.database) {
1344
+ if (dbEntry && appId) {
2001
1345
  const markerKey = dbEnvAppliedScopeKey(dbEntry.key);
2002
1346
  const push = await this.pushDatabaseUrl(appId, dbId, org.slug.replace(/-/g, '_'));
2003
1347
  if (push.pushed) {
@@ -2096,7 +1440,16 @@ export class FoundryStepExecutor {
2096
1440
  */
2097
1441
  async pushAppEnv(appId, org, entry, context, authIssuer) {
2098
1442
  const recorded = context.recordedIds();
2099
- const envs = ownedAppEnv(org, entry, recorded, authIssuer);
1443
+ const envs = [
1444
+ // Only the `app` entry has a public name; the others are internal.
1445
+ ...(entry.key === APP_ENTRY_KEY
1446
+ ? [{ key: 'APP_URL', value: `https://${appHostname(org)}` }]
1447
+ : []),
1448
+ // #75: read back by the package-side pino sink (not this repo's job,
1449
+ // #75 "What belongs where") once `ensureLogVolume` has mounted the
1450
+ // matching volume.
1451
+ { key: 'LOG_DIR', value: LOG_DIR },
1452
+ ];
2100
1453
  // An entry with `uses.auth` signs in one organisation, with its own client
2101
1454
  // and the issuer; any other entry keeps the org's staff organisation and
2102
1455
  // the one hosted-app client.
@@ -2106,10 +1459,27 @@ export class FoundryStepExecutor {
2106
1459
  if (!authEnv.AUTH_ORGANIZATION_ID) {
2107
1460
  throw new Error(`coolify:app for ${org.slug}: entry '${entry.key}' signs in ${entry.uses.auth.users}, whose organisation id is not recorded yet`);
2108
1461
  }
1462
+ for (const [key, value] of Object.entries(authEnv))
1463
+ envs.push({ key, value });
2109
1464
  const appKey = await this.ensureAuthAppKey(appId);
2110
1465
  if (appKey)
2111
1466
  envs.push({ key: 'AUTH_APP_KEY', value: appKey });
2112
1467
  }
1468
+ else {
1469
+ const zitadelOrgId = recorded.zitadel_org;
1470
+ if (zitadelOrgId)
1471
+ envs.push({ key: 'AUTH_ORGANIZATION_ID', value: zitadelOrgId });
1472
+ const clientId = recorded[APP_CLIENT_ID_SCOPE_KEY];
1473
+ if (clientId)
1474
+ envs.push({ key: 'AUTH_CLIENT_ID', value: clientId });
1475
+ }
1476
+ // The invited owner's ZITADEL user id (gap P3): the app uses it to make
1477
+ // them its own owner in authz on first sign-in. `runSteps` records this
1478
+ // under the same `system:kind:key` shape every step's `resourceId` uses
1479
+ // (`zitadelOwner`'s own doc comment), not a scope id.
1480
+ const ownerUserId = recorded[`zitadel:owner:${org.id}`];
1481
+ if (ownerUserId)
1482
+ envs.push({ key: 'OWNER_USER_ID', value: ownerUserId });
2113
1483
  // Idempotent the same way `pushDatabaseUrl`'s credential is treated:
2114
1484
  // generated once, handed straight to `bulkUpsertEnvs` and nowhere else in
2115
1485
  // this class -- never returned, recorded, logged or interpolated into a
@@ -2126,22 +1496,7 @@ export class FoundryStepExecutor {
2126
1496
  const appEnv = resolveAppEnv(org, entry, recorded).values;
2127
1497
  for (const [key, value] of Object.entries(appEnv))
2128
1498
  envs.push({ key, value });
2129
- // An omitted flag is written as false, so on an adopted app a name that
2130
- // exists live keeps the flags it has (#311).
2131
- const live = entry.adopt
2132
- ? (await this.ports.coolify.listEnvs(appId)).filter((e) => !e.is_preview)
2133
- : [];
2134
- await this.ports.coolify.bulkUpsertEnvs(appId, envs.map((env) => {
2135
- const row = live.find((e) => e.key === env.key);
2136
- return row
2137
- ? {
2138
- ...env,
2139
- buildTime: Boolean(row.is_buildtime),
2140
- literal: Boolean(row.is_literal),
2141
- multiline: Boolean(row.is_multiline),
2142
- }
2143
- : env;
2144
- }));
1499
+ await this.ports.coolify.bulkUpsertEnvs(appId, envs);
2145
1500
  // `coolifyApp` records `applied` once the restart succeeded, so `planApp`
2146
1501
  // replans this step when the declared map changes (rule 3). Ids and a
2147
1502
  // domain, never a secret.
@@ -2343,9 +1698,13 @@ export class FoundryStepExecutor {
2343
1698
  */
2344
1699
  async ensureOrgLettermintRelay(org, mailOrg, mailDomain, context) {
2345
1700
  const relayName = `${org.slug}-relay`;
1701
+ // #345: `mailOrg.domain` is absent for a level-1 org -- `mailDomain` is
1702
+ // `applyOrgMail`'s own resolved result, the one domain `MailPort`'s
1703
+ // relay methods ever need.
1704
+ const relayOrg = { ...mailOrg, domain: mailDomain };
2346
1705
  const [projects, hasRelay] = await Promise.all([
2347
1706
  this.ports.lettermint.listProjects(),
2348
- this.ports.mail.hasOrgRelay(mailOrg),
1707
+ this.ports.mail.hasOrgRelay(relayOrg),
2349
1708
  ]);
2350
1709
  // The recorded project, not "the only one": a leftover duplicate whose
2351
1710
  // delete keeps failing must not force a fresh mint on every apply.
@@ -2368,7 +1727,7 @@ export class FoundryStepExecutor {
2368
1727
  // The restriction covers old and new projects, so the old one keeps
2369
1728
  // sending until the route switches.
2370
1729
  await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects);
2371
- await this.ports.mail.applyOrgRelay(mailOrg, created.token);
1730
+ await this.ports.mail.applyOrgRelay(relayOrg, created.token);
2372
1731
  }
2373
1732
  catch (err) {
2374
1733
  await this.discardLettermintProject(created.project.id);
@@ -3045,7 +2404,7 @@ export class FoundryStepExecutor {
3045
2404
  }
3046
2405
  const projectUuid = await this.ensureCoolifyProject(org, context);
3047
2406
  const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
3048
- const name = `${org.slug}-calls`;
2407
+ const name = callsServiceInternalName(org);
3049
2408
  const synapseHost = `${org.slug}-calls-synapse`;
3050
2409
  const masHost = `${org.slug}-calls-mas`;
3051
2410
  const dbName = `${name}-db`;
@@ -3070,6 +2429,13 @@ export class FoundryStepExecutor {
3070
2429
  domains: callsHostnameFor(org),
3071
2430
  dockerfile_location: CALLS_SERVICE_DOCKERFILE,
3072
2431
  }));
2432
+ // A fixed internal name, like Synapse and MAS above, so the org's Agora
2433
+ // reaches this app as `http://<name>:8080` (`agora.ts`'s `AGORA_CALLS_UPSTREAM`).
2434
+ // Set on every run, not only on create: `planCallsService` plans this
2435
+ // step again for a live app whose marker is missing or stale, so an app
2436
+ // made before this existed gets its name here. The marker, recorded last,
2437
+ // tells `desiredAgoraEnv` that the name and the env are both in place.
2438
+ await this.ports.coolify.updateApplication(app.appId, { custom_internal_name: name });
3073
2439
  // Every app foundry deploys keeps its own logs (#75), reused as-is here.
3074
2440
  await this.ensureLogVolume(app.appId, org, app.existed);
3075
2441
  const bindingSecret = await this.ensureCallsBindingSecret(app.appId);
@@ -3084,7 +2450,7 @@ export class FoundryStepExecutor {
3084
2450
  { key: 'MAS_CLIENT_ID', value: masCredentials.clientId },
3085
2451
  { key: 'MAS_CLIENT_SECRET', value: masCredentials.clientSecret },
3086
2452
  { key: 'SYNAPSE_URL', value: `http://${synapseHost}:8008` },
3087
- { key: 'CALLS_ORIGIN', value: `https://${callsHostnameFor(org)}` },
2453
+ { key: 'CALLS_ORIGIN', value: `https://${agoraHostnameFor(org)}` },
3088
2454
  { key: 'CALLS_HOST_ADAPTER', value: 'production' },
3089
2455
  { key: 'CALLS_TRUSTED_IP_HEADER', value: 'cf-connecting-ip' },
3090
2456
  { key: 'PORT', value: CALLS_SERVICE_PORT },
@@ -3092,6 +2458,13 @@ export class FoundryStepExecutor {
3092
2458
  if (bindingSecret)
3093
2459
  envs.push({ key: 'CALLS_BINDING_SECRET', value: bindingSecret });
3094
2460
  await this.ports.coolify.bulkUpsertEnvs(app.appId, envs);
2461
+ // A container reads its name and env only at start, so a running app is
2462
+ // restarted once, when this run is the one that gave it its name (the same
2463
+ // rule `coolifyApp` follows when an existing app's env changed).
2464
+ if (app.existed && !callsServiceHasName(org, context.recordedIds())) {
2465
+ await this.ports.coolify.restart(app.appId);
2466
+ }
2467
+ context.recordScopeId(CALLS_SERVICE_NAME_SCOPE_KEY, name);
3095
2468
  return { resourceId: app.appId };
3096
2469
  }
3097
2470
  /**
@@ -3151,7 +2524,10 @@ export class FoundryStepExecutor {
3151
2524
  * `agora` port overrides it) -- absent otherwise. The chat upstreams are
3152
2525
  * still never pushed: chat has no admin API yet
3153
2526
  * (https://github.com/wtfalch/chat/issues/40). Agora answers 502 on those
3154
- * routes rather than serving anything wrong.
2527
+ * routes rather than serving anything wrong. `AGORA_CALLS_UPSTREAM` is
2528
+ * pushed once calls are on and this org's calls service app carries its
2529
+ * internal name (`desiredAgoraEnv` reads that marker from the recorded ids,
2530
+ * not the host values).
3155
2531
  */
3156
2532
  async coolifyAgora(action, org, context) {
3157
2533
  // The host values the plan read, carried in the step: never read again
@@ -3279,17 +2655,11 @@ export class FoundryStepExecutor {
3279
2655
  }
3280
2656
  // ---- cloudflare --------------------------------------------------------
3281
2657
  async cloudflareTunnel(action, org, context) {
3282
- const adoptedTunnel = org.hosting.adopt?.tunnel;
3283
- const connectorApp = org.hosting.adopt?.connectorApp;
3284
2658
  if (action.desired.enabled === false) {
3285
2659
  const tunnelId = context.recordedIds()[`cloudflare:tunnel:${action.key}`];
3286
2660
  if (!tunnelId) {
3287
2661
  throw new Error(`cloudflare:tunnel for ${org.slug}: no live tunnel recorded, nothing to park`);
3288
2662
  }
3289
- // Parking clears the ingress and stops the connector: never for what was adopted (#312).
3290
- if (await this.isAdopted(org, adoptedTunnelKey(org), tunnelId)) {
3291
- throw new Error(`cloudflare:tunnel ${action.key}: adopted, not stopped`);
3292
- }
3293
2663
  // Cleared, not deleted (#7 corrections 1): an ingress with only the
3294
2664
  // mandatory catch-all routes nothing.
3295
2665
  await this.ports.cloudflare.putTunnelConfig(tunnelId, {
@@ -3310,17 +2680,6 @@ export class FoundryStepExecutor {
3310
2680
  }
3311
2681
  return { resourceId: tunnelId };
3312
2682
  }
3313
- // An adopted tunnel and connector are written to only under an active
3314
- // snapshot; one that was adopted and is no longer declared is not taken as
3315
- // foundry's own (#312).
3316
- if (adoptedTunnel)
3317
- await this.requireSnapshot(org, adoptedTunnelKey(org), adoptedTunnel);
3318
- else
3319
- await this.refuseTakeover(org, adoptedTunnelKey(org));
3320
- if (connectorApp)
3321
- await this.requireSnapshot(org, adoptedConnectorKey(org), connectorApp);
3322
- else
3323
- await this.refuseTakeover(org, adoptedConnectorKey(org));
3324
2683
  const tunnelName = `${org.slug}-tunnel`;
3325
2684
  // #236 round 5 finding 2 / round 6 finding 2: read, and (if it is about
3326
2685
  // to change) added to the accumulated replaced-id set, before
@@ -3329,11 +2688,8 @@ export class FoundryStepExecutor {
3329
2688
  // domain declared still leaves `destroy` a way to recognise the old
3330
2689
  // id's CNAMEs as this tunnel's own rather than a foreign record.
3331
2690
  const recordedTunnelId = context.recordedIds()[`cloudflare:tunnel:${action.key}`];
3332
- // An adopted tunnel is the one declared by id: never found by name, never created (#312).
3333
- const tunnel = adoptedTunnel
3334
- ? { id: adoptedTunnel }
3335
- : ((await this.ports.cloudflare.findTunnelByName(tunnelName)) ??
3336
- (await this.ports.cloudflare.createTunnel(tunnelName)));
2691
+ const existing = await this.ports.cloudflare.findTunnelByName(tunnelName);
2692
+ const tunnel = existing ?? (await this.ports.cloudflare.createTunnel(tunnelName));
3337
2693
  const previousIds = parseTunnelPreviousIds(context.recordedIds());
3338
2694
  if (recordedTunnelId && recordedTunnelId !== tunnel.id) {
3339
2695
  previousIds.add(recordedTunnelId);
@@ -3356,18 +2712,13 @@ export class FoundryStepExecutor {
3356
2712
  // (#115) gets one ingress entry per hostname `tunnelIngressHostnames`
3357
2713
  // returns, all routed to the same proxy -- Traefik itself, not this list,
3358
2714
  // is what tells the public site apart from the cms studio.
3359
- if (adoptedTunnel) {
3360
- await this.mergeAdoptedIngress(org, tunnel.id);
3361
- }
3362
- else {
3363
- const ingress = tunnelIngressHostnames(org).map((hostname) => ({
3364
- hostname,
3365
- service: COOLIFY_PROXY_ORIGIN,
3366
- }));
3367
- await this.ports.cloudflare.putTunnelConfig(tunnel.id, {
3368
- ingress: [...ingress, { service: 'http_status:404' }],
3369
- });
3370
- }
2715
+ const ingress = tunnelIngressHostnames(org).map((hostname) => ({
2716
+ hostname,
2717
+ service: COOLIFY_PROXY_ORIGIN,
2718
+ }));
2719
+ await this.ports.cloudflare.putTunnelConfig(tunnel.id, {
2720
+ ingress: [...ingress, { service: 'http_status:404' }],
2721
+ });
3371
2722
  // #235: a connector for this org's own tunnel, the same call every
3372
2723
  // platform tunnel step already makes (Decided, William, 2026-10-01:
3373
2724
  // one connector per org tunnel, never a shared tunnel with appended
@@ -3375,40 +2726,22 @@ export class FoundryStepExecutor {
3375
2726
  // nothing is dialed into it, so `hostname` answers with a Cloudflare
3376
2727
  // error -- #197's own gap, for an org's tunnel rather than a platform
3377
2728
  // one.
3378
- // An adopted connector is the app declared by uuid: it needs no project to
3379
- // be found in or created under (#312).
3380
- let place;
3381
- if (!connectorApp) {
3382
- const projectUuid = await this.ensureCoolifyProject(org, context);
3383
- const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
3384
- place = { projectUuid, environment };
3385
- }
3386
- else {
3387
- context.recordScopeId(CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY, connectorApp);
3388
- }
3389
- await this.guarded(Boolean(adoptedTunnel), `cloudflare:tunnel for ${org.slug}: updating the connector of tunnel ${tunnel.id}`, () => ensureCloudflaredConnector({
2729
+ const projectUuid = await this.ensureCoolifyProject(org, context);
2730
+ const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
2731
+ await ensureCloudflaredConnector({
3390
2732
  coolify: this.ports.coolify,
3391
2733
  cloudflare: this.ports.cloudflare,
3392
2734
  tunnelId: tunnel.id,
3393
2735
  appName: `${org.slug}-cloudflared`,
3394
- ...place,
3395
- adopted: Boolean(connectorApp),
3396
- // Restore redeploys a connector whose token foundry rewrote, so this is recorded first.
3397
- // The digest tells restore the live token is foundry's; the token itself is never stored.
3398
- beforeTokenWrite: connectorApp
3399
- ? (token) => this.updateAdoptionSettings(org, adoptedConnectorKey(org), (settings) => ({
3400
- ...settings,
3401
- tokenWritten: true,
3402
- tokenDigest: sha256Hex(token),
3403
- }))
3404
- : undefined,
2736
+ projectUuid,
2737
+ environment,
3405
2738
  resolveServer: () => this.resolveServer(),
3406
2739
  recordedConnectorAppId: context.recordedIds()[CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY] ?? null,
3407
2740
  recordAppId: async (appId) => {
3408
2741
  context.recordScopeId(CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY, appId);
3409
2742
  await context.persistScopeIds();
3410
2743
  },
3411
- }));
2744
+ });
3412
2745
  // #236: an org with no domain (a level-1 org whose app still serves off
3413
2746
  // the shared `*.oikos.host` wildcard tunnel, `scripts/shared-ingress.mjs`)
3414
2747
  // has no zone of its own and nothing here to publish -- `org.domain` is
@@ -3441,14 +2774,12 @@ export class FoundryStepExecutor {
3441
2774
  // failure leaves the *old* domain recorded, so a retry sees the same
3442
2775
  // domain change again and tries cleanup again.
3443
2776
  if (org.domain) {
3444
- const { domain } = org;
3445
2777
  const recordedBefore = context.recordedIds();
3446
2778
  const previousDomain = recordedBefore[CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY];
3447
2779
  const previousHostnamesRaw = recordedBefore[CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY];
3448
2780
  const previousHostnames = previousHostnamesRaw ? previousHostnamesRaw.split(',') : [];
3449
2781
  const domainChanged = previousDomain !== undefined && previousDomain !== org.domain;
3450
- const dnsFailed = `cloudflare:tunnel for ${org.slug}: publishing DNS for tunnel ${tunnel.id}`;
3451
- const publishedHostnames = await this.guarded(Boolean(adoptedTunnel), dnsFailed, () => this.publishTunnelDnsRecords(org, domain, tunnel.id,
2782
+ const publishedHostnames = await this.publishTunnelDnsRecords(org, org.domain, tunnel.id,
3452
2783
  // Round 6 finding 2: every id this step has ever replaced, not only
3453
2784
  // the one `recordedTunnelId` captured above -- see the scope key's
3454
2785
  // own doc comment.
@@ -3458,13 +2789,9 @@ export class FoundryStepExecutor {
3458
2789
  // needs the domain's own previous hostname list for that. A domain
3459
2790
  // change instead cleans the *whole* old domain below, so it gets an
3460
2791
  // empty list here rather than a second, redundant pass over it.
3461
- domainChanged ? [] : previousHostnames, context));
2792
+ domainChanged ? [] : previousHostnames, context);
3462
2793
  if (domainChanged) {
3463
- await this.guarded(Boolean(adoptedTunnel), dnsFailed, async () => this.deleteStaleTunnelDnsRecords(tunnel.id, previousIds, previousDomain, previousHostnames, context,
3464
- // An adopted tunnel's CNAME that foundry did not create is never deleted.
3465
- adoptedTunnel
3466
- ? new Set(createdDnsRecords((await this.ports.adoptionStore?.get(org.id, adoptedTunnelKey(org)))?.settings).map((r) => r.id))
3467
- : undefined));
2794
+ await this.deleteStaleTunnelDnsRecords(tunnel.id, previousIds, previousDomain, previousHostnames, context);
3468
2795
  }
3469
2796
  context.recordScopeId(CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY, org.domain);
3470
2797
  context.recordScopeId(CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY, publishedHostnames.join(','));
@@ -3492,80 +2819,6 @@ export class FoundryStepExecutor {
3492
2819
  }
3493
2820
  return { resourceId: tunnel.id };
3494
2821
  }
3495
- /**
3496
- * Runs `call`; for an adopted resource a failure that is not one of this
3497
- * file's own messages (which name hostnames and record types only) is
3498
- * replaced by `label`, because a client's error text carries response
3499
- * bodies (rule 6, #312).
3500
- */
3501
- async guarded(adopted, label, call) {
3502
- if (!adopted)
3503
- return call();
3504
- try {
3505
- return await call();
3506
- }
3507
- catch (error) {
3508
- if (error instanceof Error && error.message.startsWith('cloudflare:tunnel for '))
3509
- throw error;
3510
- throw new Error(`${label} failed`);
3511
- }
3512
- }
3513
- /**
3514
- * #312: writes the org's routes into the adopted tunnel's LIVE ingress list
3515
- * (`mergeIngress`), not the snapshot, so a route a person added since is
3516
- * kept too. The declared names go into the tunnel row's `written` only after
3517
- * the write that carried them succeeded, so a crash in between leaves a
3518
- * route taken as foreign and kept. Writes nothing when the merged list is the
3519
- * live one. Errors name the tunnel id and the operation only.
3520
- */
3521
- async mergeAdoptedIngress(org, tunnelId) {
3522
- const cf = this.ports.cloudflare;
3523
- const failed = (operation) => new Error(`cloudflare:tunnel for ${org.slug}: ${operation} of tunnel ${tunnelId} failed`);
3524
- let live;
3525
- try {
3526
- live = (await cf.getTunnelConfig(tunnelId)).config;
3527
- if (live && !Array.isArray(live.ingress))
3528
- throw new Error('not a list');
3529
- }
3530
- catch {
3531
- throw failed('reading the ingress');
3532
- }
3533
- const declared = tunnelIngressHostnames(org);
3534
- const row = await this.ports.adoptionStore?.get(org.id, adoptedTunnelKey(org));
3535
- // A name in `writing` may have been carried by a put whose row write never landed.
3536
- const written = new Set([...writtenIngressNames(row), ...writingIngressNames(row)]);
3537
- const snapshot = Array.isArray(row?.settings.ingress) ? row.settings.ingress : [];
3538
- const merged = mergeIngress(live?.ingress ?? [], declared, COOLIFY_PROXY_ORIGIN, written, snapshot);
3539
- try {
3540
- const put = !isDeepStrictEqual(merged, live?.ingress ?? []);
3541
- if (put) {
3542
- // Recorded before the put: a failed row write after it must not strand foundry's routes.
3543
- await this.updateAdoptionSettings(org, adoptedTunnelKey(org), (settings) => ({
3544
- ...settings,
3545
- writing: [...new Set([...writingIngressNames(row), ...declared])],
3546
- }));
3547
- await cf.putTunnelConfig(tunnelId, { ...live, ingress: merged });
3548
- }
3549
- if (put || declared.some((hostname) => !written.has(hostname))) {
3550
- await this.updateAdoptionSettings(org, adoptedTunnelKey(org), (settings) => ({
3551
- ...settings,
3552
- written: [...new Set([...written, ...declared])],
3553
- writing: [],
3554
- }));
3555
- }
3556
- }
3557
- catch {
3558
- throw failed('writing the ingress');
3559
- }
3560
- }
3561
- /** #312: replaces the `settings` of an adoption row, read fresh so no other key is lost. The state and `takenAt` stay. */
3562
- async updateAdoptionSettings(org, resourceKey, update) {
3563
- const { adoptionStore: store } = this.ports;
3564
- const row = await store?.get(org.id, resourceKey);
3565
- if (!store || !row)
3566
- throw new Error('no adoption row to record in');
3567
- await store.setSettings(org.id, resourceKey, update(row.settings));
3568
- }
3569
2822
  /**
3570
2823
  * #236: when this tunnel's DNS was last published under a domain that
3571
2824
  * differs from the one `org` now declares, deletes every CNAME in the
@@ -3607,7 +2860,7 @@ export class FoundryStepExecutor {
3607
2860
  * hostnames) never added entries of its own to begin with, so there is
3608
2861
  * nothing of this domain's left in that set either way.
3609
2862
  */
3610
- async deleteStaleTunnelDnsRecords(tunnelId, previousIds, previousDomain, previousHostnames, context, onlyIds) {
2863
+ async deleteStaleTunnelDnsRecords(tunnelId, previousIds, previousDomain, previousHostnames, context) {
3611
2864
  const zone = await this.ports.cloudflare.findZoneByApex(previousDomain);
3612
2865
  if (!zone)
3613
2866
  return;
@@ -3621,7 +2874,7 @@ export class FoundryStepExecutor {
3621
2874
  ? (await Promise.all(previousHostnames.map((hostname) => cf.findDnsRecord(zone.id, hostname, 'CNAME')))).flat()
3622
2875
  : await cf.listDnsRecords(zone.id, { type: 'CNAME' });
3623
2876
  for (const record of records) {
3624
- if (dnsTargets.has(record.content) && (!onlyIds || onlyIds.has(record.id))) {
2877
+ if (dnsTargets.has(record.content)) {
3625
2878
  await cf.deleteDnsRecord(zone.id, record.id);
3626
2879
  }
3627
2880
  }
@@ -3695,15 +2948,12 @@ export class FoundryStepExecutor {
3695
2948
  if (!zone) {
3696
2949
  throw new Error(`cloudflare:tunnel for ${org.slug}: no Cloudflare zone found for ${domain} to point its hostnames at -- cloudflare:zone must provision it first`);
3697
2950
  }
3698
- const adopted = Boolean(org.hosting.adopt?.tunnel);
3699
- const tunnelRow = () => this.ports.adoptionStore?.get(org.id, adoptedTunnelKey(org));
3700
2951
  const dnsTarget = `${tunnelId}.cfargotunnel.com`;
3701
2952
  const previousTargets = new Set([...previousIds].filter((id) => id !== tunnelId).map((id) => `${id}.cfargotunnel.com`));
3702
2953
  const hostnames = tunnelDnsHostnames(org, domain);
3703
2954
  const droppedHostnames = previousHostnames.filter((hostname) => !hostnames.includes(hostname));
3704
2955
  const conflicts = [];
3705
2956
  const toCreate = [];
3706
- const toClaim = [];
3707
2957
  const toRepoint = [];
3708
2958
  for (const hostname of hostnames) {
3709
2959
  const [a] = await cf.findDnsRecord(zone.id, hostname, 'A');
@@ -3724,11 +2974,6 @@ export class FoundryStepExecutor {
3724
2974
  }
3725
2975
  if (!cname)
3726
2976
  toCreate.push(hostname);
3727
- // A crash after the create left a CNAME to the tunnel that is foundry's.
3728
- else if (adopted &&
3729
- dnsCreatingNames((await tunnelRow())?.settings).some((n) => normalizeHostname(n) === normalizeHostname(hostname))) {
3730
- toClaim.push({ hostname, recordId: cname.id });
3731
- }
3732
2977
  }
3733
2978
  if (conflicts.length > 0) {
3734
2979
  throw new Error(`cloudflare:tunnel for ${org.slug}: will not point its tunnel at every hostname -- ${conflicts.join('; ')}; remove the conflicting record first`);
@@ -3749,43 +2994,13 @@ export class FoundryStepExecutor {
3749
2994
  };
3750
2995
  for (const hostname of settled)
3751
2996
  recordTracked(hostname);
3752
- // The row learns of a record foundry made: `dnsCreating` first, then `dnsRecords` with its id.
3753
- const recordCreated = (hostname, recordId) => this.updateAdoptionSettings(org, adoptedTunnelKey(org), (settings) => ({
3754
- ...settings,
3755
- dnsRecords: [...createdDnsRecords(settings), { zoneId: zone.id, id: recordId, hostname }],
3756
- dnsCreating: dnsCreatingNames(settings).filter((n) => normalizeHostname(n) !== normalizeHostname(hostname)),
3757
- }));
3758
- for (const { hostname, recordId } of toClaim)
3759
- await recordCreated(hostname, recordId);
3760
2997
  for (const hostname of toCreate) {
3761
- if (adopted) {
3762
- await this.updateAdoptionSettings(org, adoptedTunnelKey(org), (settings) => ({
3763
- ...settings,
3764
- dnsCreating: [...new Set([...dnsCreatingNames(settings), hostname])],
3765
- }));
3766
- }
3767
- let created;
3768
- try {
3769
- created = await cf.createDnsRecord(zone.id, {
3770
- type: 'CNAME',
3771
- name: hostname,
3772
- content: dnsTarget,
3773
- proxied: true,
3774
- });
3775
- }
3776
- catch (error) {
3777
- // The call answered with an error: no record of foundry's, so a later one is a person's.
3778
- // A timeout or network failure may have created it: `dnsCreating` stays for the claim.
3779
- if (adopted && isApiError(error)) {
3780
- await this.updateAdoptionSettings(org, adoptedTunnelKey(org), (settings) => ({
3781
- ...settings,
3782
- dnsCreating: dnsCreatingNames(settings).filter((n) => normalizeHostname(n) !== normalizeHostname(hostname)),
3783
- })).catch(() => { });
3784
- }
3785
- throw error;
3786
- }
3787
- if (adopted)
3788
- await recordCreated(hostname, created.id);
2998
+ await cf.createDnsRecord(zone.id, {
2999
+ type: 'CNAME',
3000
+ name: hostname,
3001
+ content: dnsTarget,
3002
+ proxied: true,
3003
+ });
3789
3004
  recordTracked(hostname);
3790
3005
  }
3791
3006
  for (const { hostname, recordId } of toRepoint) {
@@ -3809,10 +3024,7 @@ export class FoundryStepExecutor {
3809
3024
  // way.
3810
3025
  for (const hostname of droppedHostnames) {
3811
3026
  const [cname] = await cf.findDnsRecord(zone.id, hostname, 'CNAME');
3812
- // An adopted tunnel's CNAME is deleted only if foundry created it.
3813
- const mine = !adopted ||
3814
- createdDnsRecords((await tunnelRow())?.settings).some((r) => r.id === cname?.id);
3815
- if (cname && mine && (cname.content === dnsTarget || previousTargets.has(cname.content))) {
3027
+ if (cname && (cname.content === dnsTarget || previousTargets.has(cname.content))) {
3816
3028
  await cf.deleteDnsRecord(zone.id, cname.id);
3817
3029
  }
3818
3030
  tracked.delete(trackedTunnelDnsEntry(domain, hostname));
@@ -4061,7 +3273,9 @@ export class FoundryStepExecutor {
4061
3273
  const mailDomain = context.recordedIds()[MAIL_DOMAIN_SCOPE_KEY];
4062
3274
  if (!mailDomain)
4063
3275
  return;
4064
- await this.ports.mail.removeOrgRelay(mailOrg);
3276
+ // #345: `mailOrg.domain` is absent for a level-1 org -- the previously
3277
+ // recorded mail domain is the one `MailPort.removeOrgRelay` needs.
3278
+ await this.ports.mail.removeOrgRelay({ ...mailOrg, domain: mailDomain });
4065
3279
  const names = new Set([`${org.slug}-app`, `${org.slug}-relay`]);
4066
3280
  const projects = await this.ports.lettermint.listProjects();
4067
3281
  for (const project of projects) {
@@ -4252,16 +3466,12 @@ export class FoundryStepExecutor {
4252
3466
  return;
4253
3467
  }
4254
3468
  case 'cloudflare:tunnel': {
4255
- // An adopted tunnel and connector are never deleted (#312).
4256
- if (await this.isAdopted(org, adoptedTunnelKey(org), target.id))
4257
- return;
4258
3469
  // #235: the connector app first, its own 404 handled locally --
4259
3470
  // a retry where this already succeeded must still reach
4260
3471
  // `deleteTunnel` below, which the outer catch's own 404 handling
4261
3472
  // would otherwise short-circuit before it ran.
4262
3473
  const connectorAppId = recordedIds[CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY];
4263
- if (connectorAppId &&
4264
- !(await this.isAdopted(org, adoptedConnectorKey(org), connectorAppId))) {
3474
+ if (connectorAppId) {
4265
3475
  try {
4266
3476
  await this.ports.coolify.deleteApplication(connectorAppId);
4267
3477
  }
@@ -4346,13 +3556,9 @@ export class FoundryStepExecutor {
4346
3556
  await this.ports.cloudflare.deleteAccessApp(target.id);
4347
3557
  return;
4348
3558
  case 'coolify:app':
4349
- if (declaredAdoptUuids(org).has(target.id))
4350
- return;
4351
3559
  await this.ports.coolify.deleteApplication(target.id);
4352
3560
  return;
4353
3561
  case 'coolify:database':
4354
- if (declaredAdoptUuids(org).has(target.id))
4355
- return;
4356
3562
  await this.ports.coolify.deleteDatabase(target.id);
4357
3563
  return;
4358
3564
  case 'zitadel:app': {
@@ -4413,10 +3619,3 @@ export function isNotFoundError(error) {
4413
3619
  const message = error instanceof Error ? error.message : String(error);
4414
3620
  return /^\S+ \S+ \S+ → 404:/.test(message);
4415
3621
  }
4416
- /** Whether the API answered with an error (a status), as opposed to a timeout or network failure. */
4417
- function isApiError(error) {
4418
- if (typeof error?.status === 'number')
4419
- return true;
4420
- const message = error instanceof Error ? error.message : String(error);
4421
- return /^\S+ \S+ \S+ → \d{3}:/.test(message);
4422
- }