@wtfalch/foundry 0.8.0 → 0.10.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,12 +1,15 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { URL } from 'node:url';
3
3
  import { CALLS_SFU_PRIVATE_URL, CALLS_SFU_PUBLIC_URL } from '../calls-sfu.js';
4
- import { AGORA_CLIENT_ID_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, agoraHostnameFor, desiredAgoraEnv, } from './agora.js';
4
+ import { APP_ENTRY_KEY, appEntry } from '../org.js';
5
+ 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';
6
+ import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
5
7
  import { MAS_IMAGE, MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
6
8
  import { ensureCloudflaredConnector } from './cloudflared-connector.js';
9
+ import { appHostname, entryHostnames, tunnelDnsHostnames, tunnelIngressHostnames, } from './hostnames.js';
7
10
  import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
8
11
  import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
9
- import { APP_ENV_APPLIED_SCOPE_KEY, CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, DB_ENV_APPLIED_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, 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';
10
13
  /**
11
14
  * #274 fix round 4: bridges `@wtfalch/providers`' real `ZitadelClient.
12
15
  * listApps` -- a plain array, with no `loginBaseUri` or completeness signal
@@ -47,17 +50,6 @@ export class NotBuiltError extends Error {
47
50
  // ---------------------------------------------------------------------------
48
51
  // Hostnames
49
52
  // ---------------------------------------------------------------------------
50
- /**
51
- * `<slug>.oikos.host` (`../slug.ts`), or `DeclaredOrg.domain` once
52
- * `hosting.usesDomain` is true — the same rule `diff.ts`'s `planApp` and
53
- * `planSite` document, reused here for the org's own hosted app, its
54
- * Cloudflare tunnel ingress and its ZITADEL issuer apps' redirect URIs
55
- * (task brief: "Hostnames: `<slug>.oikos.host` ... or `DeclaredOrg.domain`
56
- * when `hosting.usesDomain`").
57
- */
58
- function hostnameFor(org) {
59
- return org.hosting.usesDomain && org.domain ? org.domain : `${org.slug}.oikos.host`;
60
- }
61
53
  /**
62
54
  * #274 fix round 3 point 4: `zitadelLoginHost`'s own comparison between a
63
55
  * live app's ZITADEL-reported login base URI and the one this step itself
@@ -95,36 +87,6 @@ function callsHostnameFor(org) {
95
87
  ? `calls.${org.domain}`
96
88
  : `${org.slug}-calls.oikos.host`;
97
89
  }
98
- /**
99
- * The hostnames `cloudflareTunnel` routes to `COOLIFY_PROXY_ORIGIN`
100
- * (https://github.com/wtfalch/foundry/issues/115). A site-enabled org
101
- * (`org.ts`'s `SiteConfig.enabled`) has no single app hostname to reuse -- it
102
- * needs both its public site at `org.domain` and its cms studio (the sign-in
103
- * and editing surface, `@wtfalch/cms`) at `cms.<domain>`, so both are named
104
- * here; `org.domain` is non-null in that branch, guaranteed by `org.ts`'s
105
- * `validateSite` ("site.enabled requires domain"). Every other org --
106
- * concretely a hosting org, whose one app hostname this served before #115
107
- * ever existed -- keeps exactly the prior single-hostname behavior
108
- * ({@link hostnameFor}), unchanged.
109
- *
110
- * #145: an own-domain calls service adds `calls.<domain>` on top of whichever
111
- * of those a site/hosting org already gets, since calls can run alongside
112
- * either (or neither).
113
- */
114
- function tunnelIngressHostnames(org) {
115
- const hostnames = org.site.enabled && org.domain ? [org.domain, `cms.${org.domain}`] : [hostnameFor(org)];
116
- if (org.calls.enabled && org.calls.usesDomain && org.domain) {
117
- hostnames.push(`calls.${org.domain}`);
118
- }
119
- // #164: Agora always moves onto `agora.<domain>` once `org.domain` is set
120
- // -- no toggle to gate this on. A level-1 org (no domain) adds nothing
121
- // here: `<slug>-agora.oikos.host` rides the shared `*.oikos.host` wildcard
122
- // tunnel (`scripts/shared-ingress.mjs`), not a per-org tunnel.
123
- if (org.domain) {
124
- hostnames.push(agoraHostnameFor(org));
125
- }
126
- return hostnames;
127
- }
128
90
  /**
129
91
  * The health URLs an outside prober should call for this org (#43): one per
130
92
  * app foundry runs for it. A hosting org's app answers at its own hostname,
@@ -134,8 +96,17 @@ function tunnelIngressHostnames(org) {
134
96
  * this file's hostname rules.
135
97
  */
136
98
  export function healthUrls(org) {
137
- if (org.hosting.enabled)
138
- return [`https://${hostnameFor(org)}/api/health`];
99
+ // One URL per entry that has a public name; an internal entry has none.
100
+ if (org.hosting.enabled && appEntry(org.hosting)) {
101
+ return org.hosting.entries.flatMap((entry) => {
102
+ const [hostname] = entryHostnames(org, entry);
103
+ if (!hostname)
104
+ return [];
105
+ return [
106
+ `https://${hostname}${entry.healthPath ?? (entry.buildPack === 'static' ? '/' : '/api/health')}`,
107
+ ];
108
+ });
109
+ }
139
110
  if (org.site.enabled && org.domain) {
140
111
  return [`https://${org.domain}/api/health`, `https://${cmsHostnameFor(org)}/api/health`];
141
112
  }
@@ -157,7 +128,7 @@ function mailOrgFor(org, usesDomain, mode) {
157
128
  /**
158
129
  * Every app on the Coolify box shares one Traefik reverse proxy, and Coolify
159
130
  * itself points that proxy's routing at whatever `domains` an application
160
- * declares (`coolifyApp`'s `hostnameFor(org)`) -- so a tunnel's ingress only
131
+ * declares (`coolifyApp`'s `appHostname(org)`) -- so a tunnel's ingress only
161
132
  * has to reach the proxy, never a specific app container.
162
133
  * `wtfalch/app-template`'s bootstrap (`bootstrap/steps/cf-tunnel.ts`, prior
163
134
  * art for the exact org this file's `cloudflareTunnel` implements, cited by
@@ -338,36 +309,94 @@ const CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY = 'cloudflare_tunnel_dns_domain';
338
309
  */
339
310
  const CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY = 'cloudflare_tunnel_dns_hostnames';
340
311
  /**
341
- * #236 round 5 finding 1: the domain, and the hostnames under it,
342
- * {@link FoundryStepExecutor.publishTunnelDnsRecords} is in the middle of
343
- * writing CNAMEs for -- recorded after *each* individual CNAME write
344
- * succeeds, well before the step as a whole returns and
345
- * {@link CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY}/
346
- * {@link CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY} themselves are updated
347
- * (which, on a domain change, waits for the *old* domain's cleanup to
348
- * succeed too -- round 4 finding 3). Without this, a `createDnsRecord`
349
- * failure partway through a hostname list, or an old-domain cleanup failure
350
- * right after a successful publish, left an already-created CNAME out of
351
- * every recorded list, so `destroy` had no way to find and remove it.
352
- * `destroy` checks this pair in addition to the authoritative one above.
353
- * Never read by `cloudflareTunnel` itself to decide anything -- it is
354
- * destroy's (and a retried apply's own idempotent re-check's) safety net,
355
- * not a second source of truth to branch planning on.
312
+ * #236 round 6 finding 1: every `${domain}|${hostname}` pair
313
+ * {@link FoundryStepExecutor.publishTunnelDnsRecords} or
314
+ * {@link FoundryStepExecutor.deleteStaleTunnelDnsRecords} has written (or
315
+ * is in the middle of writing) a CNAME for -- across every apply, and
316
+ * across however many domains an org has moved through without a clean
317
+ * resolution, not only the most recent one. Replaces round 5's single
318
+ * pending domain/hostnames pair (`cloudflare_tunnel_dns_pending_domain`/
319
+ * `_hostnames`), which tracked one domain at a time and was overwritten
320
+ * whole on every call: apply 1 moves domain A to B, creates `h1.B`, and
321
+ * old-domain cleanup throws, leaving pending at `B/[h1]`; the org then
322
+ * declares C, and apply 2's own pending write overwrites it with
323
+ * `C/[...]` -- `h1.B` is now in no recorded list at all, so `destroy`
324
+ * leaves it live forever. This key instead accumulates entries (adds,
325
+ * never replaces the set) via {@link parseTrackedTunnelDns}/
326
+ * {@link trackedTunnelDnsState}, and an entry is dropped only once its own
327
+ * CNAME is deleted, confirmed absent, or confirmed not this tunnel's.
328
+ * `destroy` and {@link FoundryStepExecutor.deleteStaleTunnelDnsRecords}
329
+ * both walk every domain this set still names, not only the org's current
330
+ * or last-recorded one. Neither a domain nor a DNS hostname can contain
331
+ * `,` or `|`, so the join is unambiguous; sorted (like
332
+ * {@link LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY}, `login-host.ts`) so the
333
+ * recorded value is stable regardless of write order.
356
334
  */
357
- const CLOUDFLARE_TUNNEL_DNS_PENDING_DOMAIN_SCOPE_KEY = 'cloudflare_tunnel_dns_pending_domain';
358
- const CLOUDFLARE_TUNNEL_DNS_PENDING_HOSTNAMES_SCOPE_KEY = 'cloudflare_tunnel_dns_pending_hostnames';
335
+ const CLOUDFLARE_TUNNEL_DNS_TRACKED_SCOPE_KEY = 'cloudflare_tunnel_dns_tracked';
336
+ /** {@link CLOUDFLARE_TUNNEL_DNS_TRACKED_SCOPE_KEY}'s entries, parsed back out. */
337
+ function parseTrackedTunnelDns(recorded) {
338
+ const raw = recorded[CLOUDFLARE_TUNNEL_DNS_TRACKED_SCOPE_KEY];
339
+ return new Set(raw ? raw.split(',').filter((entry) => entry.length > 0) : []);
340
+ }
341
+ /** One {@link CLOUDFLARE_TUNNEL_DNS_TRACKED_SCOPE_KEY} entry for `domain`'s `hostname`. */
342
+ function trackedTunnelDnsEntry(domain, hostname) {
343
+ return `${domain}|${hostname}`;
344
+ }
345
+ /** {@link CLOUDFLARE_TUNNEL_DNS_TRACKED_SCOPE_KEY}'s own serialisation, sorted so the recorded value is stable regardless of write order. */
346
+ function trackedTunnelDnsState(entries) {
347
+ return [...entries].sort().join(',');
348
+ }
349
+ /** {@link parseTrackedTunnelDns}'s entries, grouped by domain so cleanup can walk one zone at a time. */
350
+ function trackedTunnelDnsByDomain(entries) {
351
+ const byDomain = new Map();
352
+ for (const entry of entries) {
353
+ const separator = entry.indexOf('|');
354
+ if (separator === -1)
355
+ continue;
356
+ const domain = entry.slice(0, separator);
357
+ const hostname = entry.slice(separator + 1);
358
+ const hostnames = byDomain.get(domain) ?? [];
359
+ hostnames.push(hostname);
360
+ byDomain.set(domain, hostnames);
361
+ }
362
+ return byDomain;
363
+ }
359
364
  /**
360
- * #236 round 5 finding 2: the tunnel id this step most recently replaced
361
- * with a new one -- a recreate, `findTunnelByName` returning a different id
362
- * for the same name -- recorded unconditionally, even on an apply where
363
- * `org.domain` is unset and the DNS-publish block below never runs. A CNAME
364
- * published while a domain *was* declared can still be live under that old
365
- * id after the domain is later cleared, so `destroy` needs this even then.
366
- * `publishTunnelDnsRecords`'s own repoint logic already accepts either the
367
- * current tunnel id or this one when matching a CNAME as this org's own
368
- * rather than a foreign conflict (round 3); `destroy` now does the same.
365
+ * #236 round 6 finding 2: every tunnel id this step has ever replaced with
366
+ * a new one, not only the immediately prior one. Round 5's singular
367
+ * `cloudflare_tunnel_previous_id` was overwritten on each recreate, so two
368
+ * recreates with no successful DNS resolution in between (an apply with
369
+ * no domain declared, or one that keeps failing) left the CNAMEs from the
370
+ * *first* recreate matched by nothing -- `destroy` and this step's own
371
+ * repoint logic only ever compared against the latest id. Recorded
372
+ * unconditionally, even on an apply where `org.domain` is unset and the
373
+ * DNS-publish block in {@link FoundryStepExecutor.cloudflareTunnel} never
374
+ * runs: a CNAME published while a domain *was* declared can still be live
375
+ * under an old id after the domain is later cleared. Comma-joined, sorted,
376
+ * the same shape {@link CLOUDFLARE_TUNNEL_DNS_TRACKED_SCOPE_KEY} above
377
+ * uses (tunnel ids carry no `,`). Cleared once that DNS-publish block
378
+ * completes *and* (round 7) the tracked set names no domain but the one
379
+ * just published -- only then has every tracked CNAME that could have
380
+ * pointed at any of them been repointed to the current id or cleaned up.
381
+ * A tracked entry left under some other domain by an earlier apply whose
382
+ * own cleanup never ran, or itself threw, can still point at an old id, so
383
+ * clearing unconditionally (round 6) left `destroy` unable to recognise
384
+ * that CNAME as this tunnel's own once a later apply resolved a different
385
+ * domain change and moved on. An apply with no domain declared never
386
+ * reaches that point either, so the set is left to accumulate across such
387
+ * applies, and across an unresolved domain change, until one apply
388
+ * publishes with nothing tracked outside its own domain.
369
389
  */
370
- const CLOUDFLARE_TUNNEL_PREVIOUS_ID_SCOPE_KEY = 'cloudflare_tunnel_previous_id';
390
+ const CLOUDFLARE_TUNNEL_PREVIOUS_IDS_SCOPE_KEY = 'cloudflare_tunnel_previous_ids';
391
+ /** {@link CLOUDFLARE_TUNNEL_PREVIOUS_IDS_SCOPE_KEY}'s entries, parsed back out. */
392
+ function parseTunnelPreviousIds(recorded) {
393
+ const raw = recorded[CLOUDFLARE_TUNNEL_PREVIOUS_IDS_SCOPE_KEY];
394
+ return new Set(raw ? raw.split(',').filter((id) => id.length > 0) : []);
395
+ }
396
+ /** {@link CLOUDFLARE_TUNNEL_PREVIOUS_IDS_SCOPE_KEY}'s own serialisation, sorted so the recorded value is stable regardless of write order. */
397
+ function tunnelPreviousIdsState(ids) {
398
+ return [...ids].sort().join(',');
399
+ }
371
400
  /**
372
401
  * #162: the owner's own mailbox, created once inside `coolify:mail` after
373
402
  * `applyOrgMail` succeeds, alongside {@link MAIL_DOMAIN_SCOPE_KEY} -- another
@@ -520,6 +549,8 @@ export class FoundryStepExecutor {
520
549
  return this.zitadelCustomers(org, context);
521
550
  case 'coolify:app':
522
551
  return this.coolifyApp(action, org, context);
552
+ case 'coolify:broker-entry':
553
+ return this.coolifyBrokerEntry(action, org, context);
523
554
  case 'coolify:database':
524
555
  return this.coolifyDatabase(action, org, context);
525
556
  case 'coolify:calls':
@@ -562,7 +593,7 @@ export class FoundryStepExecutor {
562
593
  }
563
594
  /**
564
595
  * `plan/diff.ts`'s `planAccess` (#84): puts the org's app hostname behind a
565
- * Cloudflare Access app that lets only `hosting.accessEmails` through.
596
+ * Cloudflare Access app that lets only the `app` entry's `accessEmails` through.
566
597
  * foundry names its Access app `foundry:<orgId>` and reuses only an app with
567
598
  * that name, so a re-run after a crash does not duplicate it. It refuses,
568
599
  * rather than adopt, when another Access app already holds the hostname:
@@ -570,7 +601,12 @@ export class FoundryStepExecutor {
570
601
  * any policy but foundry's, since that policy could let others in.
571
602
  */
572
603
  async cloudflareAccess(org) {
573
- const domain = hostnameFor(org);
604
+ // Only the `app` entry has a public name (`planAccess`); per-entry
605
+ // hostnames are issue 309.
606
+ const entry = appEntry(org.hosting);
607
+ if (!entry)
608
+ throw new Error(`cloudflare:access for ${org.slug}: no '${APP_ENTRY_KEY}' hosting entry`);
609
+ const domain = appHostname(org);
574
610
  const name = `foundry:${org.id}`;
575
611
  const cf = this.ports.cloudflare;
576
612
  const onDomain = (await cf.listAccessApps()).filter((a) => a.domain === domain);
@@ -588,7 +624,7 @@ export class FoundryStepExecutor {
588
624
  await cf.createAccessPolicy(app.id, {
589
625
  name: ACCESS_POLICY_NAME,
590
626
  decision: 'allow',
591
- include: org.hosting.accessEmails.map((email) => ({ email: { email } })),
627
+ include: entry.accessEmails.map((email) => ({ email: { email } })),
592
628
  });
593
629
  }
594
630
  return { resourceId: app.id };
@@ -775,6 +811,9 @@ export class FoundryStepExecutor {
775
811
  ...(recorded[APP_CLIENT_ID_SCOPE_KEY]
776
812
  ? org.hosting.issuerApps.filter((a) => a.kind === 'web').map((a) => a.name)
777
813
  : []),
814
+ ...Object.keys(recorded)
815
+ .filter((key) => key.startsWith(ENTRY_CLIENT_ID_SCOPE_PREFIX))
816
+ .map((key) => key.slice(ENTRY_CLIENT_ID_SCOPE_PREFIX.length)),
778
817
  ...(recorded[CMS_CLIENT_ID_SCOPE_KEY] ? ['cms'] : []),
779
818
  ...(recorded[AGORA_CLIENT_ID_SCOPE_KEY] ? ['agora'] : []),
780
819
  ].map((name) => recorded[`zitadel:app:${name}`]));
@@ -958,7 +997,7 @@ export class FoundryStepExecutor {
958
997
  ? cmsHostnameFor(org)
959
998
  : isAgoraClient
960
999
  ? agoraHostnameFor(org)
961
- : hostnameFor(org);
1000
+ : appHostname(org);
962
1001
  // Native apps sign in via device code, with no redirect of their own
963
1002
  // (auth/scripts/provision.mjs:475-497, prior art cited in the decisions
964
1003
  // file) -- empty arrays rather than a web app's callback/logout URLs.
@@ -970,11 +1009,23 @@ export class FoundryStepExecutor {
970
1009
  const redirectUri = isAgoraClient
971
1010
  ? `https://${hostname}/callback`
972
1011
  : `https://${hostname}/auth/callback`;
1012
+ // An entry with `uses.auth` signs in at its own exact hostnames, one pair
1013
+ // of addresses each; a `*` adds none (`entryHostnames`).
1014
+ const authEntry = org.hosting.entries.find((e) => e.key === action.key && e.uses?.auth);
1015
+ const hosts = authEntry ? entryHostnames(org, authEntry) : null;
973
1016
  const created = await this.ports.zitadel.createOidcApp(zitadelOrgId, projectId, {
974
1017
  name: action.key,
975
1018
  appType,
976
- redirectUris: appType === 'native' ? [] : [redirectUri],
977
- postLogoutRedirectUris: appType === 'native' ? [] : [`https://${hostname}/`],
1019
+ redirectUris: appType === 'native'
1020
+ ? []
1021
+ : hosts
1022
+ ? hosts.map((host) => `https://${host}/auth/callback`)
1023
+ : [redirectUri],
1024
+ postLogoutRedirectUris: appType === 'native'
1025
+ ? []
1026
+ : hosts
1027
+ ? hosts.map((host) => `https://${host}/`)
1028
+ : [`https://${hostname}/`],
978
1029
  });
979
1030
  if (appType === 'web') {
980
1031
  // `coolifyApp`'s own `pushAppEnv` reads `APP_CLIENT_ID_SCOPE_KEY` to
@@ -989,7 +1040,9 @@ export class FoundryStepExecutor {
989
1040
  ? CMS_CLIENT_ID_SCOPE_KEY
990
1041
  : isAgoraClient
991
1042
  ? AGORA_CLIENT_ID_SCOPE_KEY
992
- : APP_CLIENT_ID_SCOPE_KEY, created.clientId);
1043
+ : authEntry
1044
+ ? entryClientIdScopeKey(action.key)
1045
+ : APP_CLIENT_ID_SCOPE_KEY, created.clientId);
993
1046
  }
994
1047
  return { resourceId: created.appId };
995
1048
  }
@@ -1036,23 +1089,51 @@ export class FoundryStepExecutor {
1036
1089
  return server;
1037
1090
  }
1038
1091
  async coolifyApp(action, org, context) {
1092
+ const recordedAppId = context.recordedIds()[`coolify:app:${action.key}`];
1039
1093
  if (action.desired.enabled === false) {
1040
- const appId = context.recordedIds()[`coolify:app:${action.key}`];
1041
- if (!appId) {
1094
+ if (!recordedAppId) {
1042
1095
  throw new Error(`coolify:app for ${org.slug}: no live app recorded, nothing to park`);
1043
1096
  }
1044
- await this.ports.coolify.raw('POST', `/applications/${appId}/stop`);
1045
- return { resourceId: appId };
1097
+ await this.ports.coolify.raw('POST', `/applications/${recordedAppId}/stop`);
1098
+ return { resourceId: recordedAppId };
1046
1099
  }
1100
+ const entry = org.hosting.entries.find((e) => appStepKey(org, e.key) === action.key);
1101
+ if (!entry)
1102
+ throw new Error(`coolify:app for ${org.slug}: no hosting entry for ${action.key}`);
1047
1103
  // A level-1 org with no bespoke app of its own gets the shared standard
1048
- // app instead (gap P1, `.plans/2026-09-26-level-1-org-map.md`), in the
1049
- // same repo shape `bespokeAppRepo` itself uses.
1050
- const appRepo = org.bespokeAppRepo ?? DEFAULT_APP_REPO;
1104
+ // app instead (gap P1, `.plans/2026-09-26-level-1-org-map.md`): an entry
1105
+ // with no `repo`.
1106
+ const appRepo = entry.repo ?? DEFAULT_APP_REPO;
1051
1107
  const projectUuid = await this.ensureCoolifyProject(org, context);
1052
1108
  const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
1053
- const existing = await this.ports.coolify.findApplicationByName(org.slug, {
1054
- environmentIds: [environment.id],
1055
- });
1109
+ // The recorded id first; the name when nothing is recorded, or when the
1110
+ // recorded app is gone.
1111
+ let existing = null;
1112
+ let live;
1113
+ if (recordedAppId) {
1114
+ try {
1115
+ live = await this.ports.coolify.getApplication(recordedAppId);
1116
+ existing = { uuid: recordedAppId };
1117
+ }
1118
+ catch (error) {
1119
+ if (!isNotFoundError(error))
1120
+ throw error;
1121
+ }
1122
+ }
1123
+ if (!existing) {
1124
+ existing = await this.ports.coolify.findApplicationByName(appNameFor(org, entry), {
1125
+ environmentIds: [environment.id],
1126
+ });
1127
+ }
1128
+ // An entry with no names is internal: the other entries reach it as
1129
+ // `<slug>-<key>`, and it gets no domain, route or DNS record.
1130
+ const names = entryHostnames(org, entry);
1131
+ const isStatic = entry.buildPack === 'static';
1132
+ const folderField = isStatic ? 'publish_directory' : 'base_directory';
1133
+ const settingsKey = appSettingsAppliedScopeKey(entry.key);
1134
+ const domainsKey = appDomainsAppliedScopeKey(entry.key);
1135
+ let haveNames;
1136
+ let have;
1056
1137
  let appId;
1057
1138
  if (existing) {
1058
1139
  appId = existing.uuid;
@@ -1062,10 +1143,12 @@ export class FoundryStepExecutor {
1062
1143
  // stops the app without deleting it, so re-enabling found it here,
1063
1144
  // still stopped, and it stayed stopped forever despite `apply`
1064
1145
  // reporting success.
1065
- const live = await this.ports.coolify.getApplication(existing.uuid);
1146
+ live ??= await this.ports.coolify.getApplication(existing.uuid);
1066
1147
  if (!isRunningOrStarting(live.status)) {
1067
1148
  await this.ports.coolify.raw('POST', `/applications/${existing.uuid}/start`);
1068
1149
  }
1150
+ have = appliedAppSettings(context.recordedIds(), entry.key);
1151
+ haveNames = appliedAppDomains(org, context.recordedIds(), entry.key);
1069
1152
  }
1070
1153
  else {
1071
1154
  const server = await this.resolveServer();
@@ -1077,24 +1160,60 @@ export class FoundryStepExecutor {
1077
1160
  environment_uuid: environment.uuid,
1078
1161
  github_app_uuid: githubApp.uuid,
1079
1162
  git_repository: appRepo,
1080
- git_branch: org.hosting.gitBranch,
1081
- build_pack: org.hosting.buildPack,
1082
- name: org.slug,
1083
- domains: hostnameFor(org),
1163
+ git_branch: entry.branch,
1164
+ build_pack: entry.buildPack,
1165
+ name: appNameFor(org, entry),
1166
+ domains: names.length > 0 ? names.join(',') : undefined,
1167
+ autogenerate_domain: names.length > 0 ? undefined : false,
1168
+ // A static site is served by Coolify's own web server on port 80.
1169
+ ports_exposes: isStatic ? '80' : entry.port === null ? undefined : String(entry.port),
1170
+ is_auto_deploy_enabled: entry.autoDeploy,
1171
+ [folderField]: entry.folder === null ? undefined : `/${entry.folder}`,
1084
1172
  // Only the shared standard app builds from this repo; a bespoke
1085
1173
  // app's Dockerfile is at its own repo's root.
1086
- dockerfile_location: org.bespokeAppRepo ? undefined : DEFAULT_APP_DOCKERFILE,
1174
+ dockerfile_location: entry.repo ? undefined : DEFAULT_APP_DOCKERFILE,
1087
1175
  });
1088
1176
  appId = created.uuid;
1089
- }
1177
+ // Create sent every setting, so nothing is left to patch.
1178
+ have = appSettingsOf(entry);
1179
+ haveNames = names;
1180
+ }
1181
+ // Patch what differs from what the app has. A crash before the marker is
1182
+ // recorded below leaves the old one, so the retry patches the same
1183
+ // difference again.
1184
+ const want = appSettingsOf(entry);
1185
+ const patch = {};
1186
+ if (want.folder !== have.folder)
1187
+ patch[folderField] = want.folder ? `/${want.folder}` : '/';
1188
+ if (want.port !== have.port && want.port !== null && !isStatic) {
1189
+ patch.ports_exposes = String(want.port);
1190
+ }
1191
+ if (want.autoDeploy !== have.autoDeploy)
1192
+ patch.is_auto_deploy_enabled = want.autoDeploy;
1193
+ // Create names the app after `domains`; a changed name reaches a live app
1194
+ // here (rule 3). The internal name is set after create, as the calls
1195
+ // stack's apps do.
1196
+ if (JSON.stringify(names) !== JSON.stringify(haveNames))
1197
+ patch.domains = names.join(',');
1198
+ if (names.length === 0 && (!existing || patch.domains !== undefined)) {
1199
+ patch.custom_internal_name = appNameFor(org, entry);
1200
+ }
1201
+ if (Object.keys(patch).length > 0)
1202
+ await this.ports.coolify.updateApplication(appId, patch);
1203
+ // The container builds and listens from these only at its next deploy.
1204
+ const settingsNeedDeploy = Boolean(patch.base_directory ||
1205
+ patch.publish_directory ||
1206
+ patch.ports_exposes ||
1207
+ patch.domains !== undefined ||
1208
+ patch.custom_internal_name);
1090
1209
  // The database plans before the app (`diff.ts`'s `computePlanDetail`,
1091
1210
  // hosting-wiring campaign) so that, on the same apply, the database's
1092
1211
  // resource id is already recorded by the time this runs -- but this also
1093
1212
  // covers the database being turned on after the app already exists (the
1094
1213
  // app step still ran this apply, found the app by name, and lands here).
1095
1214
  let dbPush = { pushed: false, changed: false };
1096
- if (org.hosting.database) {
1097
- const dbId = context.recordedIds()[`coolify:database:${action.key}`];
1215
+ if (entry.database) {
1216
+ const dbId = context.recordedIds()[`coolify:database:${org.id}`];
1098
1217
  if (dbId) {
1099
1218
  dbPush = await this.pushDatabaseUrl(appId, dbId, org.slug.replace(/-/g, '_'));
1100
1219
  }
@@ -1102,7 +1221,7 @@ export class FoundryStepExecutor {
1102
1221
  // Restart when the push changed something, or when no earlier run got as
1103
1222
  // far as restarting after one (marker missing): a crash between the push
1104
1223
  // and the restart leaves the env right and the container stale.
1105
- const dbNeedsRestart = dbPush.pushed && (dbPush.changed || !context.recordedIds()[DB_ENV_APPLIED_SCOPE_KEY]);
1224
+ const dbNeedsRestart = dbPush.pushed && (dbPush.changed || !context.recordedIds()[dbEnvAppliedScopeKey(entry.key)]);
1106
1225
  // Every app keeps its own logs across a redeploy (#75). Unconditional,
1107
1226
  // like the env push below: found by mount path before create, so a
1108
1227
  // re-run (or the drift step `diff.ts`'s `planApp` plans for an app that
@@ -1110,27 +1229,42 @@ export class FoundryStepExecutor {
1110
1229
  // `ensureLogVolume` whether this app was already running before this
1111
1230
  // call -- see its own doc comment for why that decides whether to
1112
1231
  // restart it.
1113
- await this.ensureLogVolume(appId, org, Boolean(existing));
1232
+ await this.ensureLogVolume(appId, org, Boolean(existing), appNameFor(org, entry));
1114
1233
  // The app receives whatever it needs to sign in (gap P2). Unconditional,
1115
1234
  // unlike the database push above: every app has a hostname, and an
1116
1235
  // upsert of the same value on a later apply is a no-op, not a change.
1117
- const appEnv = await this.pushAppEnv(appId, org, context);
1236
+ const appEnv = await this.pushAppEnv(appId, org, entry, context, action.desired.authIssuer ?? null);
1118
1237
  // Rule 4 (#239, #243): a container reads its env only at start, so an app
1119
1238
  // that was already there is restarted, once, when its database URLs or its
1120
1239
  // declared appEnv changed.
1121
- if ((appEnv.changed || dbNeedsRestart) && existing)
1240
+ // A new folder needs a build, which a restart does not do, so a settings
1241
+ // change deploys instead (rule 3), and that carries the env too.
1242
+ if (settingsNeedDeploy)
1243
+ await this.ports.coolify.deploy(appId);
1244
+ else if ((appEnv.changed || dbNeedsRestart) && existing)
1122
1245
  await this.ports.coolify.restart(appId);
1123
- // Recorded only after the restart succeeded: a failed restart leaves the
1124
- // old value, so the next plan replans (rule 3). The db marker is not a URL.
1125
- context.recordScopeId(APP_ENV_APPLIED_SCOPE_KEY, appEnv.applied);
1246
+ // Recorded only after the redeploy succeeded: a failed one leaves the old
1247
+ // value, so the next plan replans (rule 3). The db marker is not a URL.
1248
+ // A port taken back to null was not patched, so the marker keeps the old
1249
+ // one and `planApp` reports it instead of planning it.
1250
+ context.recordScopeId(settingsKey, JSON.stringify({ ...want, port: want.port ?? have.port }));
1251
+ context.recordScopeId(domainsKey, JSON.stringify(names));
1252
+ context.recordScopeId(appEnvAppliedScopeKey(entry.key), appEnv.applied);
1253
+ if (entry.uses?.auth) {
1254
+ context.recordScopeId(entryAuthEnvAppliedScopeKey(entry.key), appEnv.authApplied);
1255
+ }
1126
1256
  if (dbPush.pushed)
1127
- context.recordScopeId(DB_ENV_APPLIED_SCOPE_KEY, '1');
1257
+ context.recordScopeId(dbEnvAppliedScopeKey(entry.key), '1');
1128
1258
  // #140 rework (2026-09-28 lettermint-org-mail swap): the hosted app's own
1129
1259
  // Lettermint project, domain restriction and `MAIL_FROM` (only the app
1130
1260
  // started by this same step, per the design's "only apps that send mail
1131
1261
  // get it" -- cms and calls do not). A no-op until `coolify:mail` has
1132
1262
  // actually resolved a mail domain for this org.
1133
- await this.ensureAppLettermintMail(appId, org, context, Boolean(existing));
1263
+ // One Lettermint project, `<slug>-app`, belongs to the `app` entry; a
1264
+ // second entry sharing the name would delete the first's live token.
1265
+ if (entry.key === APP_ENTRY_KEY) {
1266
+ await this.ensureAppLettermintMail(appId, org, context, Boolean(existing));
1267
+ }
1134
1268
  return { resourceId: appId };
1135
1269
  }
1136
1270
  /**
@@ -1154,13 +1288,13 @@ export class FoundryStepExecutor {
1154
1288
  * `createGithubAppApplication`'s own instant-deploy path, so a restart
1155
1289
  * here would only be a redundant extra one).
1156
1290
  */
1157
- async ensureLogVolume(appId, org, wasAlreadyRunning) {
1291
+ async ensureLogVolume(appId, org, wasAlreadyRunning, appName = org.slug) {
1158
1292
  const storages = await this.ports.coolify.listStorages(appId);
1159
1293
  const already = (storages.persistent_storages ?? []).some((s) => s.mount_path === LOG_DIR);
1160
1294
  if (already)
1161
1295
  return;
1162
1296
  await this.ports.coolify.createPersistentStorage(appId, {
1163
- name: `${org.slug}-logs`,
1297
+ name: `${appName}-logs`,
1164
1298
  mount_path: LOG_DIR,
1165
1299
  });
1166
1300
  if (wasAlreadyRunning) {
@@ -1207,14 +1341,16 @@ export class FoundryStepExecutor {
1207
1341
  // Handles the database being provisioned after the app already exists
1208
1342
  // (`coolifyApp`'s own push handles the more common order, database
1209
1343
  // before app on the same apply -- `diff.ts`'s `computePlanDetail`).
1210
- const appId = context.recordedIds()[`coolify:app:${action.key}`];
1211
- if (appId) {
1344
+ const dbEntry = org.hosting.entries.find((e) => e.database);
1345
+ const appId = dbEntry && context.recordedIds()[`coolify:app:${appStepKey(org, dbEntry.key)}`];
1346
+ if (dbEntry && appId) {
1347
+ const markerKey = dbEnvAppliedScopeKey(dbEntry.key);
1212
1348
  const push = await this.pushDatabaseUrl(appId, dbId, org.slug.replace(/-/g, '_'));
1213
1349
  if (push.pushed) {
1214
- if (push.changed || !context.recordedIds()[DB_ENV_APPLIED_SCOPE_KEY]) {
1350
+ if (push.changed || !context.recordedIds()[markerKey]) {
1215
1351
  await this.ports.coolify.restart(appId);
1216
1352
  }
1217
- context.recordScopeId(DB_ENV_APPLIED_SCOPE_KEY, '1');
1353
+ context.recordScopeId(markerKey, '1');
1218
1354
  }
1219
1355
  }
1220
1356
  return { resourceId: dbId };
@@ -1288,7 +1424,7 @@ export class FoundryStepExecutor {
1288
1424
  * P2, `.plans/2026-09-26-level-1-org-map.md`) -- a second `bulkUpsertEnvs`
1289
1425
  * call rather than folding into `pushDatabaseUrl`'s, since this one runs
1290
1426
  * unconditionally (`coolifyApp`) where that one runs only once
1291
- * `hosting.database` is declared and provisioned.
1427
+ * the `app` entry's `database` is declared and provisioned.
1292
1428
  *
1293
1429
  * `APP_URL` and `LOG_DIR` (#75) are pushed unconditionally; every other
1294
1430
  * value is pushed only once its own source step has actually recorded it
@@ -1301,24 +1437,44 @@ export class FoundryStepExecutor {
1301
1437
  * is its own design question, left out of #204's own "How" rather than
1302
1438
  * guessed) is out of scope here (task brief). `DATABASE_URL_OWNER`/
1303
1439
  * `DATABASE_URL` are pushed by `pushDatabaseUrl` instead, not here --
1304
- * that call runs only once `hosting.database` is declared and
1440
+ * that call runs only once the `app` entry's `database` is declared and
1305
1441
  * provisioned, where this one is unconditional (#239).
1306
1442
  */
1307
- async pushAppEnv(appId, org, context) {
1443
+ async pushAppEnv(appId, org, entry, context, authIssuer) {
1308
1444
  const recorded = context.recordedIds();
1309
1445
  const envs = [
1310
- { key: 'APP_URL', value: `https://${hostnameFor(org)}` },
1446
+ // Only the `app` entry has a public name; the others are internal.
1447
+ ...(entry.key === APP_ENTRY_KEY
1448
+ ? [{ key: 'APP_URL', value: `https://${appHostname(org)}` }]
1449
+ : []),
1311
1450
  // #75: read back by the package-side pino sink (not this repo's job,
1312
1451
  // #75 "What belongs where") once `ensureLogVolume` has mounted the
1313
1452
  // matching volume.
1314
1453
  { key: 'LOG_DIR', value: LOG_DIR },
1315
1454
  ];
1316
- const zitadelOrgId = recorded.zitadel_org;
1317
- if (zitadelOrgId)
1318
- envs.push({ key: 'AUTH_ORGANIZATION_ID', value: zitadelOrgId });
1319
- const clientId = recorded[APP_CLIENT_ID_SCOPE_KEY];
1320
- if (clientId)
1321
- envs.push({ key: 'AUTH_CLIENT_ID', value: clientId });
1455
+ // An entry with `uses.auth` signs in one organisation, with its own client
1456
+ // and the issuer; any other entry keeps the org's staff organisation and
1457
+ // the one hosted-app client.
1458
+ const authEnv = entryAuthEnv(org, entry, recorded, authIssuer);
1459
+ if (entry.uses?.auth) {
1460
+ // A customers entry never falls back to the staff organisation's id.
1461
+ if (!authEnv.AUTH_ORGANIZATION_ID) {
1462
+ throw new Error(`coolify:app for ${org.slug}: entry '${entry.key}' signs in ${entry.uses.auth.users}, whose organisation id is not recorded yet`);
1463
+ }
1464
+ for (const [key, value] of Object.entries(authEnv))
1465
+ envs.push({ key, value });
1466
+ const appKey = await this.ensureAuthAppKey(appId);
1467
+ if (appKey)
1468
+ envs.push({ key: 'AUTH_APP_KEY', value: appKey });
1469
+ }
1470
+ else {
1471
+ const zitadelOrgId = recorded.zitadel_org;
1472
+ if (zitadelOrgId)
1473
+ envs.push({ key: 'AUTH_ORGANIZATION_ID', value: zitadelOrgId });
1474
+ const clientId = recorded[APP_CLIENT_ID_SCOPE_KEY];
1475
+ if (clientId)
1476
+ envs.push({ key: 'AUTH_CLIENT_ID', value: clientId });
1477
+ }
1322
1478
  // The invited owner's ZITADEL user id (gap P3): the app uses it to make
1323
1479
  // them its own owner in authz on first sign-in. `runSteps` records this
1324
1480
  // under the same `system:kind:key` shape every step's `resourceId` uses
@@ -1339,7 +1495,7 @@ export class FoundryStepExecutor {
1339
1495
  // minted here; a source with no value yet (its own step has not run,
1340
1496
  // or has not run for this apply) is simply omitted, the same way every
1341
1497
  // other optional entry above is.
1342
- const appEnv = resolveAppEnv(org, recorded).values;
1498
+ const appEnv = resolveAppEnv(org, entry, recorded).values;
1343
1499
  for (const [key, value] of Object.entries(appEnv))
1344
1500
  envs.push({ key, value });
1345
1501
  await this.ports.coolify.bulkUpsertEnvs(appId, envs);
@@ -1347,7 +1503,66 @@ export class FoundryStepExecutor {
1347
1503
  // replans this step when the declared map changes (rule 3). Ids and a
1348
1504
  // domain, never a secret.
1349
1505
  const applied = JSON.stringify(appEnv);
1350
- return { changed: applied !== (recorded[APP_ENV_APPLIED_SCOPE_KEY] ?? '{}'), applied };
1506
+ // The sign-in values, recorded by `coolifyApp` the same way; the key is
1507
+ // never part of them.
1508
+ const authApplied = JSON.stringify(authEnv);
1509
+ return {
1510
+ changed: applied !== (recorded[appEnvAppliedScopeKey(entry.key)] ?? '{}') ||
1511
+ (Boolean(entry.uses?.auth) &&
1512
+ authApplied !== (recorded[entryAuthEnvAppliedScopeKey(entry.key)] ?? '{}')),
1513
+ applied,
1514
+ authApplied,
1515
+ };
1516
+ }
1517
+ /**
1518
+ * F5: the app's `AUTH_APP_KEY`, minted once and only when the app has none
1519
+ * of at least 32 characters (the broker refuses a shorter one). `null` once
1520
+ * it has one: the app's env is where the key lives, and
1521
+ * `coolifyBrokerEntry` reads it back from there. It goes to this env and
1522
+ * the broker's, nowhere else.
1523
+ */
1524
+ async ensureAuthAppKey(appId) {
1525
+ const envs = await this.ports.coolify.listEnvs(appId);
1526
+ const already = envs.some((e) => e.key === 'AUTH_APP_KEY' && !e.is_preview && (e.value ?? '').length >= 32);
1527
+ return already ? null : randomBytes(32).toString('base64url');
1528
+ }
1529
+ /**
1530
+ * `plan/diff.ts`'s `planBrokerEntries` (F5): the entry's `AUTH_APPS` entry,
1531
+ * written by `writeBrokerEntry` with the key `pushAppEnv` put in the app's
1532
+ * env. Records what it wrote (never the key) and any foreign entry the
1533
+ * broker would drop.
1534
+ */
1535
+ async coolifyBrokerEntry(action, org, context) {
1536
+ const broker = this.ports.broker;
1537
+ if (!broker)
1538
+ throw new Error(`coolify:broker-entry for ${org.slug}: the broker port is not wired`);
1539
+ const recorded = context.recordedIds();
1540
+ const entry = org.hosting.entries.find((e) => appStepKey(org, e.key) === action.key);
1541
+ if (!entry?.uses?.auth) {
1542
+ throw new Error(`coolify:broker-entry for ${org.slug}: no uses.auth entry for ${action.key}`);
1543
+ }
1544
+ const wanted = wantedBrokerEntry(org, entry, recorded);
1545
+ if ('missing' in wanted) {
1546
+ throw new Error(`coolify:broker-entry for ${org.slug}: ${wanted.missing.map((m) => m.what).join(', ')} not recorded yet`);
1547
+ }
1548
+ const appId = recorded[`coolify:app:${action.key}`];
1549
+ if (!appId) {
1550
+ throw new Error(`coolify:broker-entry for ${org.slug}: app ${action.key} is not recorded yet`);
1551
+ }
1552
+ const appKey = (await this.ports.coolify.listEnvs(appId)).find((e) => e.key === 'AUTH_APP_KEY' && !e.is_preview)?.value;
1553
+ if (!appKey || appKey.length < 32) {
1554
+ throw new Error(`coolify:broker-entry for ${org.slug}: app ${appId} has no AUTH_APP_KEY yet`);
1555
+ }
1556
+ const previousRaw = recorded[brokerEntryAppliedScopeKey(entry.key)];
1557
+ const result = await writeBrokerEntry(broker, {
1558
+ want: wanted.want,
1559
+ appKey,
1560
+ previous: previousRaw ? JSON.parse(previousRaw) : null,
1561
+ });
1562
+ context.recordScopeId(brokerEntryWarningsScopeKey(entry.key), JSON.stringify(result.invalidForeign));
1563
+ // Last: a run that failed above plans the step again.
1564
+ context.recordScopeId(brokerEntryAppliedScopeKey(entry.key), JSON.stringify(wanted.want));
1565
+ return {};
1351
1566
  }
1352
1567
  /**
1353
1568
  * `null` once the app already has one -- `AUTH_COOKIE_SECRET` is generated
@@ -2447,16 +2662,19 @@ export class FoundryStepExecutor {
2447
2662
  return { resourceId: tunnelId };
2448
2663
  }
2449
2664
  const tunnelName = `${org.slug}-tunnel`;
2450
- // #236 round 5 finding 2: read, and (if it is about to change) recorded,
2451
- // before `createTunnel` can replace it -- unconditionally, not only
2452
- // inside the `if (org.domain)` block below, so a recreate on an apply
2453
- // with no domain declared still leaves `destroy` a way to recognise the
2454
- // old id's CNAMEs as this tunnel's own rather than a foreign record.
2665
+ // #236 round 5 finding 2 / round 6 finding 2: read, and (if it is about
2666
+ // to change) added to the accumulated replaced-id set, before
2667
+ // `createTunnel` can replace it -- unconditionally, not only inside the
2668
+ // `if (org.domain)` block below, so a recreate on an apply with no
2669
+ // domain declared still leaves `destroy` a way to recognise the old
2670
+ // id's CNAMEs as this tunnel's own rather than a foreign record.
2455
2671
  const recordedTunnelId = context.recordedIds()[`cloudflare:tunnel:${action.key}`];
2456
2672
  const existing = await this.ports.cloudflare.findTunnelByName(tunnelName);
2457
2673
  const tunnel = existing ?? (await this.ports.cloudflare.createTunnel(tunnelName));
2674
+ const previousIds = parseTunnelPreviousIds(context.recordedIds());
2458
2675
  if (recordedTunnelId && recordedTunnelId !== tunnel.id) {
2459
- context.recordScopeId(CLOUDFLARE_TUNNEL_PREVIOUS_ID_SCOPE_KEY, recordedTunnelId);
2676
+ previousIds.add(recordedTunnelId);
2677
+ context.recordScopeId(CLOUDFLARE_TUNNEL_PREVIOUS_IDS_SCOPE_KEY, tunnelPreviousIdsState(previousIds));
2460
2678
  }
2461
2679
  // Routes each of the org's hostnames (`tunnelIngressHostnames`) to
2462
2680
  // Coolify's own Traefik reverse proxy, not a per-app container address --
@@ -2465,7 +2683,7 @@ export class FoundryStepExecutor {
2465
2683
  // offline.tf's bootstrap, cited by
2466
2684
  // https://github.com/wtfalch/foundry/issues/71): every app on the
2467
2685
  // Coolify box already gets a Traefik route from the `domains` its
2468
- // `coolify:app` step declared (`hostnameFor(org)`, this file's
2686
+ // `coolify:app` step declared (`appHostname(org)`, this file's
2469
2687
  // `coolifyApp`), so a cloudflared connector joined to the same Docker
2470
2688
  // network only has to reach Traefik and let it route by Host header --
2471
2689
  // the connector never needs to know the app's own container name or
@@ -2541,12 +2759,12 @@ export class FoundryStepExecutor {
2541
2759
  const previousDomain = recordedBefore[CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY];
2542
2760
  const previousHostnamesRaw = recordedBefore[CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY];
2543
2761
  const previousHostnames = previousHostnamesRaw ? previousHostnamesRaw.split(',') : [];
2544
- // Same value `recordedTunnelId` captured above, before `createTunnel`
2545
- // could replace it -- not re-read here, since this block's own
2546
- // `context.recordScopeId` calls never touch that key.
2547
- const previousTunnelId = recordedTunnelId;
2548
2762
  const domainChanged = previousDomain !== undefined && previousDomain !== org.domain;
2549
- const publishedHostnames = await this.publishTunnelDnsRecords(org, org.domain, tunnel.id, previousTunnelId,
2763
+ const publishedHostnames = await this.publishTunnelDnsRecords(org, org.domain, tunnel.id,
2764
+ // Round 6 finding 2: every id this step has ever replaced, not only
2765
+ // the one `recordedTunnelId` captured above -- see the scope key's
2766
+ // own doc comment.
2767
+ previousIds,
2550
2768
  // Round 4 finding 2: a hostname this domain itself dropped (no domain
2551
2769
  // change) is cleaned up inline by `publishTunnelDnsRecords`, which
2552
2770
  // needs the domain's own previous hostname list for that. A domain
@@ -2554,17 +2772,31 @@ export class FoundryStepExecutor {
2554
2772
  // empty list here rather than a second, redundant pass over it.
2555
2773
  domainChanged ? [] : previousHostnames, context);
2556
2774
  if (domainChanged) {
2557
- await this.deleteStaleTunnelDnsRecords(tunnel.id, previousTunnelId, previousDomain, previousHostnames);
2775
+ await this.deleteStaleTunnelDnsRecords(tunnel.id, previousIds, previousDomain, previousHostnames, context);
2558
2776
  }
2559
2777
  context.recordScopeId(CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY, org.domain);
2560
2778
  context.recordScopeId(CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY, publishedHostnames.join(','));
2561
- // #236 round 5 finding 1: the authoritative pair above now carries
2562
- // everything the pending pair was tracking mid-write -- clear it so a
2563
- // later destroy (or crash-recovery apply) never reads a stale pending
2564
- // domain that no longer matches reality. A no-op the first time this
2565
- // step ever runs (both keys already absent).
2566
- context.recordScopeId(CLOUDFLARE_TUNNEL_DNS_PENDING_DOMAIN_SCOPE_KEY, '');
2567
- context.recordScopeId(CLOUDFLARE_TUNNEL_DNS_PENDING_HOSTNAMES_SCOPE_KEY, '');
2779
+ // #236 round 7: every previous id's CNAMEs *under `org.domain`* have
2780
+ // now been repointed to `tunnel.id` or cleaned up by publish/cleanup
2781
+ // above (current-domain hostnames by the write loop inside
2782
+ // `publishTunnelDnsRecords`, the old domain's by
2783
+ // `deleteStaleTunnelDnsRecords` above) -- but a tracked entry left
2784
+ // under some *other* domain by an earlier apply whose own cleanup
2785
+ // never ran, or itself threw, can still point at one of `previousIds`.
2786
+ // Round 6 finding 2's unconditional clear missed exactly that: apply 1
2787
+ // moves domain A to B, creates `h1.B` on tunnel T1, and A's cleanup
2788
+ // throws; the tunnel is then recreated as T2 out of band and the org
2789
+ // declares domain C; apply 2 publishes C and cleans up only A, leaving
2790
+ // `h1.B` tracked and still pointed at T1 -- clearing the id set here
2791
+ // would make `destroy` and the next `deleteStaleTunnelDnsRecords`
2792
+ // match only T2 and leave `h1.B` live forever. So the set is cleared
2793
+ // only once the tracked set names no domain but the one just
2794
+ // published: the one case where nothing tracked can still reference
2795
+ // an old id. A no-op the first time this step ever runs.
2796
+ const trackedDomains = trackedTunnelDnsByDomain(parseTrackedTunnelDns(context.recordedIds())).keys();
2797
+ if ([...trackedDomains].every((domain) => domain === org.domain)) {
2798
+ context.recordScopeId(CLOUDFLARE_TUNNEL_PREVIOUS_IDS_SCOPE_KEY, '');
2799
+ }
2568
2800
  }
2569
2801
  return { resourceId: tunnel.id };
2570
2802
  }
@@ -2587,23 +2819,36 @@ export class FoundryStepExecutor {
2587
2819
  * existed falls back to the old zone-wide pass, the one case where no
2588
2820
  * hostname list was ever written to look up instead.
2589
2821
  *
2590
- * `previousTunnelId` (round 4 finding 1): the old domain's CNAMEs were
2591
- * published under whatever tunnel id was live the *last* time this step
2592
- * recorded that domain, which is `previousTunnelId`, not necessarily
2593
- * `tunnelId` -- a tunnel recreated under the same name
2594
- * (`findTunnelByName` returning a new id) in the same apply that also
2595
- * changes the domain leaves the old domain's records pointed at the old
2596
- * id. Matching only `tunnelId`'s target left them live forever. Both
2597
- * targets are checked so this stays a no-op once a retry has already
2598
- * moved the old domain onto the new id.
2822
+ * `previousIds` (round 4 finding 1, round 6 finding 2): the old domain's
2823
+ * CNAMEs were published under whatever tunnel id was live the *last*
2824
+ * time this step recorded that domain, which may be any id this step has
2825
+ * ever replaced, not necessarily `tunnelId` -- a tunnel recreated under
2826
+ * the same name (`findTunnelByName` returning a new id), possibly more
2827
+ * than once with no successful publish in between, in the same apply
2828
+ * that also changes the domain leaves the old domain's records pointed
2829
+ * at an old id. Matching only `tunnelId`'s target left them live
2830
+ * forever; matching only the single most recent previous id (round 4)
2831
+ * still missed an older one two unresolved recreates back. Every target
2832
+ * is checked so this stays a no-op once a retry has already moved the
2833
+ * old domain onto the current id.
2834
+ *
2835
+ * Round 6 finding 1: once `previousHostnames` is non-empty, every name in
2836
+ * it has now been looked up -- deleted (its CNAME matched one of
2837
+ * `dnsTargets`), left alone (found but foreign), or never found at all
2838
+ * (already gone) -- so none of them needs tracking under `previousDomain`
2839
+ * in {@link CLOUDFLARE_TUNNEL_DNS_TRACKED_SCOPE_KEY} any longer; dropped
2840
+ * from that set accordingly. The zone-wide fallback path (no recorded
2841
+ * hostnames) never added entries of its own to begin with, so there is
2842
+ * nothing of this domain's left in that set either way.
2599
2843
  */
2600
- async deleteStaleTunnelDnsRecords(tunnelId, previousTunnelId, previousDomain, previousHostnames) {
2844
+ async deleteStaleTunnelDnsRecords(tunnelId, previousIds, previousDomain, previousHostnames, context) {
2601
2845
  const zone = await this.ports.cloudflare.findZoneByApex(previousDomain);
2602
2846
  if (!zone)
2603
2847
  return;
2604
2848
  const dnsTargets = new Set([`${tunnelId}.cfargotunnel.com`]);
2605
- if (previousTunnelId && previousTunnelId !== tunnelId) {
2606
- dnsTargets.add(`${previousTunnelId}.cfargotunnel.com`);
2849
+ for (const id of previousIds) {
2850
+ if (id !== tunnelId)
2851
+ dnsTargets.add(`${id}.cfargotunnel.com`);
2607
2852
  }
2608
2853
  const cf = this.ports.cloudflare;
2609
2854
  const records = previousHostnames.length > 0
@@ -2614,6 +2859,13 @@ export class FoundryStepExecutor {
2614
2859
  await cf.deleteDnsRecord(zone.id, record.id);
2615
2860
  }
2616
2861
  }
2862
+ if (previousHostnames.length > 0) {
2863
+ const tracked = parseTrackedTunnelDns(context.recordedIds());
2864
+ for (const hostname of previousHostnames) {
2865
+ tracked.delete(trackedTunnelDnsEntry(previousDomain, hostname));
2866
+ }
2867
+ context.recordScopeId(CLOUDFLARE_TUNNEL_DNS_TRACKED_SCOPE_KEY, trackedTunnelDnsState(tracked));
2868
+ }
2617
2869
  }
2618
2870
  /**
2619
2871
  * #236: upserts a proxied CNAME to this tunnel for each of
@@ -2639,13 +2891,15 @@ export class FoundryStepExecutor {
2639
2891
  * (round 4 finding 3), so a crash mid-write is recovered by re-running
2640
2892
  * this same idempotent step, never silently skipped.
2641
2893
  *
2642
- * Round 3: `previousTunnelId` is the tunnel id this same step recorded
2643
- * last time it ran (`cloudflareTunnel`'s own `cloudflare:tunnel:<key>`
2644
- * resource id, read before `tunnel` above is computed for this run) -- a
2645
- * tunnel Cloudflare shows as recreated under the same name
2646
- * (`findTunnelByName` returning a new id for `<slug>-tunnel`) leaves every
2647
- * CNAME still pointed at the *old* id. That is this org's own stale
2648
- * record, not a foreign one, so it is repointed rather than refused.
2894
+ * Round 3, round 6 finding 2: `previousIds` is every tunnel id this same
2895
+ * step has ever replaced (`cloudflareTunnel`'s own accumulated set,
2896
+ * captured before `tunnel` above is computed for this run) -- a tunnel
2897
+ * Cloudflare shows as recreated under the same name (`findTunnelByName`
2898
+ * returning a new id for `<slug>-tunnel`), possibly more than once with
2899
+ * no successful publish in between, leaves every CNAME still pointed at
2900
+ * one of those old ids. That is this org's own stale record, not a
2901
+ * foreign one, so it is repointed rather than refused, whichever of
2902
+ * `previousIds` it still names.
2649
2903
  *
2650
2904
  * Round 4 finding 2: `previousHostnames` is this domain's own previously
2651
2905
  * recorded hostname list (empty when the domain just changed --
@@ -2655,29 +2909,29 @@ export class FoundryStepExecutor {
2655
2909
  * `hostnames` below but was never cleaned up before this round: its CNAME
2656
2910
  * stayed live and its name stayed in the recorded list forever, because
2657
2911
  * only a domain *change* ever ran cleanup. Each dropped name's CNAME is
2658
- * deleted here when it still points at this tunnel or its immediately
2659
- * prior id; a name already gone, or now pointed at something foreign, is
2660
- * simply left out of the returned list -- nothing here is this method's to
2661
- * reclaim or report as a conflict.
2912
+ * deleted here when it still points at this tunnel or any of
2913
+ * `previousIds`; a name already gone, or now pointed at something
2914
+ * foreign, is simply left out of the returned list -- nothing here is
2915
+ * this method's to reclaim or report as a conflict.
2662
2916
  *
2663
- * Round 5 finding 1: `context` is used only to record
2664
- * {@link CLOUDFLARE_TUNNEL_DNS_PENDING_DOMAIN_SCOPE_KEY}/
2665
- * {@link CLOUDFLARE_TUNNEL_DNS_PENDING_HOSTNAMES_SCOPE_KEY} after each
2666
- * individual CNAME write below succeeds, so a crash or a thrown conflict
2667
- * partway through never leaves an already-created CNAME out of every
2668
- * recorded list -- `destroy` checks this pending pair too.
2917
+ * Round 5 finding 1, round 6 finding 1: `context` is used to add every
2918
+ * hostname this call confirms correct (settled, created or repointed) to
2919
+ * {@link CLOUDFLARE_TUNNEL_DNS_TRACKED_SCOPE_KEY} as soon as it is
2920
+ * confirmed, and to drop a dropped hostname from it once that hostname's
2921
+ * own CNAME is resolved one way or another -- additively, alongside
2922
+ * whatever other domains that shared set already names, never
2923
+ * overwriting them. `destroy` and {@link deleteStaleTunnelDnsRecords}
2924
+ * both read this set.
2669
2925
  */
2670
- async publishTunnelDnsRecords(org, domain, tunnelId, previousTunnelId, previousHostnames, context) {
2926
+ async publishTunnelDnsRecords(org, domain, tunnelId, previousIds, previousHostnames, context) {
2671
2927
  const cf = this.ports.cloudflare;
2672
2928
  const zone = await cf.findZoneByApex(domain);
2673
2929
  if (!zone) {
2674
2930
  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`);
2675
2931
  }
2676
2932
  const dnsTarget = `${tunnelId}.cfargotunnel.com`;
2677
- const previousDnsTarget = previousTunnelId && previousTunnelId !== tunnelId
2678
- ? `${previousTunnelId}.cfargotunnel.com`
2679
- : undefined;
2680
- const hostnames = tunnelIngressHostnames(org).filter((hostname) => hostname === domain || hostname.endsWith(`.${domain}`));
2933
+ const previousTargets = new Set([...previousIds].filter((id) => id !== tunnelId).map((id) => `${id}.cfargotunnel.com`));
2934
+ const hostnames = tunnelDnsHostnames(org, domain);
2681
2935
  const droppedHostnames = previousHostnames.filter((hostname) => !hostnames.includes(hostname));
2682
2936
  const conflicts = [];
2683
2937
  const toCreate = [];
@@ -2692,7 +2946,7 @@ export class FoundryStepExecutor {
2692
2946
  }
2693
2947
  const [cname] = await cf.findDnsRecord(zone.id, hostname, 'CNAME');
2694
2948
  if (cname && cname.content !== dnsTarget) {
2695
- if (previousDnsTarget && cname.content === previousDnsTarget) {
2949
+ if (previousTargets.has(cname.content)) {
2696
2950
  toRepoint.push({ hostname, recordId: cname.id });
2697
2951
  continue;
2698
2952
  }
@@ -2705,20 +2959,22 @@ export class FoundryStepExecutor {
2705
2959
  if (conflicts.length > 0) {
2706
2960
  throw new Error(`cloudflare:tunnel for ${org.slug}: will not point its tunnel at every hostname -- ${conflicts.join('; ')}; remove the conflicting record first`);
2707
2961
  }
2708
- // Round 5 finding 1: `settled` tracks every hostname under `domain`
2709
- // confirmed to have the right CNAME so far -- starting with the ones
2710
- // that needed no write at all, then one more per successful create or
2711
- // repoint below -- and is persisted to the pending scope ids after each
2712
- // addition, well before this method returns. A failure on any later
2713
- // hostname still leaves everything settled up to that point recorded,
2714
- // rather than only in this function's own local state.
2962
+ // Round 5 finding 1, round 6 finding 1: `settled` is every hostname
2963
+ // under `domain` confirmed to have the right CNAME so far -- starting
2964
+ // with the ones that needed no write at all, then one more per
2965
+ // successful create or repoint below -- and each is added to the
2966
+ // shared tracked set as soon as it is confirmed, well before this
2967
+ // method returns. A failure on any later hostname still leaves
2968
+ // everything settled up to that point recorded, rather than only in
2969
+ // this function's own local state.
2715
2970
  const settled = hostnames.filter((hostname) => !toCreate.includes(hostname) && !toRepoint.some((r) => r.hostname === hostname));
2716
- const recordPending = () => {
2717
- context.recordScopeId(CLOUDFLARE_TUNNEL_DNS_PENDING_DOMAIN_SCOPE_KEY, domain);
2718
- context.recordScopeId(CLOUDFLARE_TUNNEL_DNS_PENDING_HOSTNAMES_SCOPE_KEY, settled.join(','));
2971
+ const tracked = parseTrackedTunnelDns(context.recordedIds());
2972
+ const recordTracked = (hostname) => {
2973
+ tracked.add(trackedTunnelDnsEntry(domain, hostname));
2974
+ context.recordScopeId(CLOUDFLARE_TUNNEL_DNS_TRACKED_SCOPE_KEY, trackedTunnelDnsState(tracked));
2719
2975
  };
2720
- if (settled.length > 0)
2721
- recordPending();
2976
+ for (const hostname of settled)
2977
+ recordTracked(hostname);
2722
2978
  for (const hostname of toCreate) {
2723
2979
  await cf.createDnsRecord(zone.id, {
2724
2980
  type: 'CNAME',
@@ -2726,8 +2982,7 @@ export class FoundryStepExecutor {
2726
2982
  content: dnsTarget,
2727
2983
  proxied: true,
2728
2984
  });
2729
- settled.push(hostname);
2730
- recordPending();
2985
+ recordTracked(hostname);
2731
2986
  }
2732
2987
  for (const { hostname, recordId } of toRepoint) {
2733
2988
  await cf.updateDnsRecord(zone.id, recordId, {
@@ -2736,18 +2991,25 @@ export class FoundryStepExecutor {
2736
2991
  content: dnsTarget,
2737
2992
  proxied: true,
2738
2993
  });
2739
- settled.push(hostname);
2740
- recordPending();
2994
+ recordTracked(hostname);
2741
2995
  }
2742
2996
  // Round 4 finding 2: clean up a hostname this domain itself dropped.
2743
2997
  // Not a conflict check -- there is nothing to create or repoint here,
2744
2998
  // only a possible stale CNAME of this tunnel's own to remove. Never in
2745
- // `hostnames`/`settled` to begin with, so nothing to update there.
2999
+ // `hostnames`/`settled` to begin with, so nothing to track-add there.
3000
+ //
3001
+ // Round 6 finding 1: whatever happens -- deleted (its CNAME matched),
3002
+ // left alone (found but foreign), or never found at all (already gone)
3003
+ // -- this hostname's own entry (if it had one) no longer needs
3004
+ // tracking under `domain`, so it is dropped from the shared set either
3005
+ // way.
2746
3006
  for (const hostname of droppedHostnames) {
2747
3007
  const [cname] = await cf.findDnsRecord(zone.id, hostname, 'CNAME');
2748
- if (cname && (cname.content === dnsTarget || cname.content === previousDnsTarget)) {
3008
+ if (cname && (cname.content === dnsTarget || previousTargets.has(cname.content))) {
2749
3009
  await cf.deleteDnsRecord(zone.id, cname.id);
2750
3010
  }
3011
+ tracked.delete(trackedTunnelDnsEntry(domain, hostname));
3012
+ context.recordScopeId(CLOUDFLARE_TUNNEL_DNS_TRACKED_SCOPE_KEY, trackedTunnelDnsState(tracked));
2751
3013
  }
2752
3014
  return hostnames;
2753
3015
  }
@@ -3213,46 +3475,46 @@ export class FoundryStepExecutor {
3213
3475
  // look up instead. 404 handled locally, same reason as the
3214
3476
  // connector above.
3215
3477
  //
3216
- // Round 5 finding 2: `dnsTargets` accepts this tunnel's current id
3217
- // *or* {@link CLOUDFLARE_TUNNEL_PREVIOUS_ID_SCOPE_KEY} -- the id a
3218
- // recreate most recently replaced -- the same two-target match
3219
- // `publishTunnelDnsRecords`'s own repoint logic already uses, so a
3220
- // CNAME a domain-less apply's recreate left pointed at the old id
3221
- // (never repointed, since that apply never ran the DNS-publish
3222
- // block at all) still gets removed here.
3223
- //
3224
- // Round 5 finding 1: `dnsSources` also checks the *pending*
3225
- // domain/hostnames pair alongside the authoritative one, so a
3226
- // CNAME `publishTunnelDnsRecords` created but never got to record
3227
- // in the authoritative pair (a later hostname's write failing, or
3228
- // an old-domain cleanup failure right after a successful publish)
3229
- // is still found and removed.
3230
- const previousTunnelId = recordedIds[CLOUDFLARE_TUNNEL_PREVIOUS_ID_SCOPE_KEY];
3478
+ // Round 5 finding 2, round 6 finding 2: `dnsTargets` accepts this
3479
+ // tunnel's current id *or* any id
3480
+ // {@link CLOUDFLARE_TUNNEL_PREVIOUS_IDS_SCOPE_KEY} still names --
3481
+ // every id this step has ever replaced, not only the latest -- the
3482
+ // same match `publishTunnelDnsRecords`'s own repoint logic already
3483
+ // uses, so a CNAME a domain-less apply's recreate left pointed at
3484
+ // an old id (never repointed, since that apply never ran the
3485
+ // DNS-publish block at all), possibly more than one recreate back,
3486
+ // still gets removed here.
3487
+ const previousTunnelIds = parseTunnelPreviousIds(recordedIds);
3231
3488
  const dnsTargets = new Set([`${target.id}.cfargotunnel.com`]);
3232
- if (previousTunnelId && previousTunnelId !== target.id) {
3233
- dnsTargets.add(`${previousTunnelId}.cfargotunnel.com`);
3489
+ for (const id of previousTunnelIds) {
3490
+ if (id !== target.id)
3491
+ dnsTargets.add(`${id}.cfargotunnel.com`);
3492
+ }
3493
+ // Round 6 finding 1: every domain the shared tracked set still
3494
+ // names, folded together with the authoritative domain/hostnames
3495
+ // pair (an org that predates that set has only the latter). A
3496
+ // domain the tracked set names but the authoritative pair does
3497
+ // not is exactly the case this round exists for -- a hostname
3498
+ // published under a domain the org has since moved away from
3499
+ // twice over, never caught by any single "previous domain" field.
3500
+ const hostnamesByDomain = trackedTunnelDnsByDomain(parseTrackedTunnelDns(recordedIds));
3501
+ const authoritativeDomain = recordedIds[CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY];
3502
+ if (authoritativeDomain) {
3503
+ const authoritativeHostnamesRaw = recordedIds[CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY];
3504
+ const merged = new Set(hostnamesByDomain.get(authoritativeDomain) ?? []);
3505
+ if (authoritativeHostnamesRaw) {
3506
+ for (const hostname of authoritativeHostnamesRaw.split(','))
3507
+ merged.add(hostname);
3508
+ }
3509
+ hostnamesByDomain.set(authoritativeDomain, [...merged]);
3234
3510
  }
3235
- const dnsSources = [
3236
- {
3237
- domain: recordedIds[CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY],
3238
- hostnamesRaw: recordedIds[CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY],
3239
- },
3240
- {
3241
- domain: recordedIds[CLOUDFLARE_TUNNEL_DNS_PENDING_DOMAIN_SCOPE_KEY],
3242
- hostnamesRaw: recordedIds[CLOUDFLARE_TUNNEL_DNS_PENDING_HOSTNAMES_SCOPE_KEY],
3243
- },
3244
- ];
3245
- for (const { domain: dnsDomain, hostnamesRaw: dnsHostnamesRaw } of dnsSources) {
3246
- if (!dnsDomain)
3247
- continue;
3511
+ for (const [dnsDomain, dnsHostnames] of hostnamesByDomain) {
3248
3512
  try {
3249
3513
  const zone = await this.ports.cloudflare.findZoneByApex(dnsDomain);
3250
3514
  if (zone) {
3251
3515
  const cf = this.ports.cloudflare;
3252
- const records = dnsHostnamesRaw
3253
- ? (await Promise.all(dnsHostnamesRaw
3254
- .split(',')
3255
- .map((hostname) => cf.findDnsRecord(zone.id, hostname, 'CNAME')))).flat()
3516
+ const records = dnsHostnames.length > 0
3517
+ ? (await Promise.all(dnsHostnames.map((hostname) => cf.findDnsRecord(zone.id, hostname, 'CNAME')))).flat()
3256
3518
  : await cf.listDnsRecords(zone.id, { type: 'CNAME' });
3257
3519
  for (const record of records) {
3258
3520
  if (dnsTargets.has(record.content)) {