@wtfalch/foundry 0.7.1 → 0.9.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.
Files changed (54) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +12 -8
  2. package/dist/bootstrap/bootstrap.js +4 -12
  3. package/dist/bootstrap/declare.js +1 -5
  4. package/dist/bootstrap/versions.d.ts +8 -0
  5. package/dist/bootstrap/versions.js +31 -0
  6. package/dist/calls-sfu-deploy-support.d.ts +1 -1
  7. package/dist/calls-sfu-deploy-support.js +1 -1
  8. package/dist/calls-sfu-ssh-adapter.d.ts +1 -1
  9. package/dist/calls-sfu-ssh-adapter.js +1 -1
  10. package/dist/calls-sfu.d.ts +5 -4
  11. package/dist/calls-sfu.js +5 -4
  12. package/dist/index.d.ts +4 -5
  13. package/dist/index.js +6 -3
  14. package/dist/migrations/0024_audit_events.sql +1 -1
  15. package/dist/migrations/0025_audit_display.sql +1 -1
  16. package/dist/migrations/0026_audit_erase_email_guard.sql +1 -1
  17. package/dist/migrations/0027_audit_chain.sql +1 -1
  18. package/dist/migrations/0028_audit_rls.sql +2 -2
  19. package/dist/migrations/0029_hosting_entries.sql +109 -0
  20. package/dist/migrations-dir.d.ts +9 -0
  21. package/dist/migrations-dir.js +10 -0
  22. package/dist/org.d.ts +59 -27
  23. package/dist/org.js +98 -26
  24. package/dist/plan/calls-matrix.d.ts +2 -0
  25. package/dist/plan/calls-matrix.js +2 -0
  26. package/dist/plan/diff.d.ts +3 -1
  27. package/dist/plan/diff.js +100 -32
  28. package/dist/plan/executor.d.ts +324 -19
  29. package/dist/plan/executor.js +764 -34
  30. package/dist/plan/index.d.ts +1 -1
  31. package/dist/plan/index.js +1 -1
  32. package/dist/plan/login-host.d.ts +24 -0
  33. package/dist/plan/login-host.js +21 -0
  34. package/dist/plan/plan-apply.d.ts +2 -0
  35. package/dist/plan/plan-apply.js +26 -10
  36. package/dist/plan/platform-diff.d.ts +102 -12
  37. package/dist/plan/platform-diff.js +236 -15
  38. package/dist/plan/platform-providers.d.ts +43 -1
  39. package/dist/plan/platform-providers.js +245 -33
  40. package/dist/plan/zitadel-system-api.d.ts +64 -0
  41. package/dist/plan/zitadel-system-api.js +125 -0
  42. package/dist/plan.d.ts +43 -0
  43. package/dist/plan.js +16 -1
  44. package/dist/platform.d.ts +1 -1
  45. package/dist/store/audit.d.ts +13 -17
  46. package/dist/store/audit.js +14 -18
  47. package/dist/store/postgres.d.ts +14 -0
  48. package/dist/store/postgres.js +15 -18
  49. package/dist/store/schema.d.ts +20 -1
  50. package/dist/store/schema.js +7 -1
  51. package/package.json +6 -3
  52. package/versions.json +1 -1
  53. package/dist/migrate.d.ts +0 -38
  54. package/dist/migrate.js +0 -47
@@ -1,12 +1,34 @@
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 { APP_ENTRY_KEY, appEntry } from '../org.js';
4
5
  import { AGORA_CLIENT_ID_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, agoraHostnameFor, desiredAgoraEnv, } from './agora.js';
5
- import { MAS_IMAGE, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
6
+ import { MAS_IMAGE, MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
6
7
  import { ensureCloudflaredConnector } from './cloudflared-connector.js';
7
- import { LOGIN_BINDING_SCOPE_KEY, loginBindingState, loginHostClientIds, } from './login-host.js';
8
+ import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
8
9
  import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
9
- import { APP_ENV_APPLIED_SCOPE_KEY, DB_ENV_APPLIED_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, resolveAppEnv, } from './platform-diff.js';
10
+ 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, appStepKey, resolveAppEnv, } from './platform-diff.js';
11
+ /**
12
+ * #274 fix round 4: bridges `@wtfalch/providers`' real `ZitadelClient.
13
+ * listApps` -- a plain array, with no `loginBaseUri` or completeness signal
14
+ * -- into {@link ZitadelPort.listApps}'s own shape, so a host wiring that
15
+ * real client in satisfies this port without reaching into `executor.ts`
16
+ * itself to build the wrapper by hand. `complete: false` on every call:
17
+ * the real client's own `_search` request has no pagination on it yet
18
+ * (https://github.com/wtfalch/providers/issues/29), so this adapter cannot
19
+ * promise it read more than one page. `loginBaseUri` comes back `undefined`
20
+ * for every app for the identical reason -- the real client does not read
21
+ * it back at all yet, not "read it back as absent" -- which is exactly the
22
+ * state `zitadelLoginHost`'s fail-closed rule exists for: every pending
23
+ * host stays pending until the tracked change above lands and this adapter
24
+ * (or its replacement) can start reporting `string | null` for real.
25
+ */
26
+ export function adaptZitadelListApps(client) {
27
+ return async (orgId, projectId) => ({
28
+ apps: await client.listApps(orgId, projectId),
29
+ complete: false,
30
+ });
31
+ }
10
32
  // ---------------------------------------------------------------------------
11
33
  // Errors
12
34
  // ---------------------------------------------------------------------------
@@ -26,6 +48,14 @@ export class NotBuiltError extends Error {
26
48
  // ---------------------------------------------------------------------------
27
49
  // Hostnames
28
50
  // ---------------------------------------------------------------------------
51
+ /**
52
+ * The Coolify name of a hosting entry's app: `<slug>` for the `app` entry, so
53
+ * the move to a list finds the app that runs today, `<slug>-<key>` for any
54
+ * other.
55
+ */
56
+ function appNameFor(org, entry) {
57
+ return entry.key === APP_ENTRY_KEY ? org.slug : `${org.slug}-${entry.key}`;
58
+ }
29
59
  /**
30
60
  * `<slug>.oikos.host` (`../slug.ts`), or `DeclaredOrg.domain` once
31
61
  * `hosting.usesDomain` is true — the same rule `diff.ts`'s `planApp` and
@@ -37,6 +67,17 @@ export class NotBuiltError extends Error {
37
67
  function hostnameFor(org) {
38
68
  return org.hosting.usesDomain && org.domain ? org.domain : `${org.slug}.oikos.host`;
39
69
  }
70
+ /**
71
+ * #274 fix round 3 point 4: `zitadelLoginHost`'s own comparison between a
72
+ * live app's ZITADEL-reported login base URI and the one this step itself
73
+ * constructs (`https://<host>/`) -- lowercased and with its trailing `/`
74
+ * (there is always exactly one, a URI's path component, never more) trimmed,
75
+ * so a host is never read as "not in use" purely because ZITADEL's own
76
+ * casing or slashing differs from this step's.
77
+ */
78
+ function normalizedLoginBaseUri(uri) {
79
+ return uri.toLowerCase().replace(/\/$/, '');
80
+ }
40
81
  /**
41
82
  * `cms.<domain>` -- the cms studio's own hostname (`cmsSite` below), apart
42
83
  * from the public site itself at `org.domain` (used directly there). Called
@@ -278,6 +319,122 @@ const MAIL_DOMAIN_SCOPE_KEY = 'mail_domain';
278
319
  * (`platform-providers.ts`), never a shared tunnel with appended ingress.
279
320
  */
280
321
  const CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY = 'cloudflare_tunnel_connector_app';
322
+ /**
323
+ * #236: the domain `publishTunnelDnsRecords` last published this org's
324
+ * tunnel's hostnames under -- compared against the org's current `domain`
325
+ * so `plan/diff.ts`'s `planTunnel` can tell a stale publish (never done at
326
+ * all, or the org's domain has since changed) from a steady state with
327
+ * nothing left to do, and so `destroy` and `deleteStaleTunnelDnsRecords`
328
+ * know which zone to clean up even once the org's declared domain has moved
329
+ * on. `diff.ts` keeps its own copy of this exact string, the same
330
+ * duplication {@link CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY} above
331
+ * already follows.
332
+ */
333
+ const CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY = 'cloudflare_tunnel_dns_domain';
334
+ /**
335
+ * #236 round 3: every hostname {@link FoundryStepExecutor.publishTunnelDnsRecords}
336
+ * last wrote a CNAME for, joined by `,` (hostnames carry no `,`, the same
337
+ * join {@link MAIL_ROLE_MAILBOXES_SCOPE_KEY} already relies on) -- so
338
+ * cleanup (`destroy`'s `cloudflare:tunnel` case and
339
+ * {@link FoundryStepExecutor.deleteStaleTunnelDnsRecords}) can look each one
340
+ * up by name instead of listing the zone. The real adapter's
341
+ * `listDnsRecords` (`@wtfalch/providers`' `cloudflare.ts`) asks for one page
342
+ * of 100 and never walks `result_info.total_pages`, so a zone with more
343
+ * records than that silently lost everything past the first page. A record
344
+ * from before this key existed has no entry here yet: cleanup falls back to
345
+ * the old `listDnsRecords` zone-wide pass for that one run, and the next
346
+ * successful publish leaves this key set so later cleanups never need to.
347
+ */
348
+ const CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY = 'cloudflare_tunnel_dns_hostnames';
349
+ /**
350
+ * #236 round 6 finding 1: every `${domain}|${hostname}` pair
351
+ * {@link FoundryStepExecutor.publishTunnelDnsRecords} or
352
+ * {@link FoundryStepExecutor.deleteStaleTunnelDnsRecords} has written (or
353
+ * is in the middle of writing) a CNAME for -- across every apply, and
354
+ * across however many domains an org has moved through without a clean
355
+ * resolution, not only the most recent one. Replaces round 5's single
356
+ * pending domain/hostnames pair (`cloudflare_tunnel_dns_pending_domain`/
357
+ * `_hostnames`), which tracked one domain at a time and was overwritten
358
+ * whole on every call: apply 1 moves domain A to B, creates `h1.B`, and
359
+ * old-domain cleanup throws, leaving pending at `B/[h1]`; the org then
360
+ * declares C, and apply 2's own pending write overwrites it with
361
+ * `C/[...]` -- `h1.B` is now in no recorded list at all, so `destroy`
362
+ * leaves it live forever. This key instead accumulates entries (adds,
363
+ * never replaces the set) via {@link parseTrackedTunnelDns}/
364
+ * {@link trackedTunnelDnsState}, and an entry is dropped only once its own
365
+ * CNAME is deleted, confirmed absent, or confirmed not this tunnel's.
366
+ * `destroy` and {@link FoundryStepExecutor.deleteStaleTunnelDnsRecords}
367
+ * both walk every domain this set still names, not only the org's current
368
+ * or last-recorded one. Neither a domain nor a DNS hostname can contain
369
+ * `,` or `|`, so the join is unambiguous; sorted (like
370
+ * {@link LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY}, `login-host.ts`) so the
371
+ * recorded value is stable regardless of write order.
372
+ */
373
+ const CLOUDFLARE_TUNNEL_DNS_TRACKED_SCOPE_KEY = 'cloudflare_tunnel_dns_tracked';
374
+ /** {@link CLOUDFLARE_TUNNEL_DNS_TRACKED_SCOPE_KEY}'s entries, parsed back out. */
375
+ function parseTrackedTunnelDns(recorded) {
376
+ const raw = recorded[CLOUDFLARE_TUNNEL_DNS_TRACKED_SCOPE_KEY];
377
+ return new Set(raw ? raw.split(',').filter((entry) => entry.length > 0) : []);
378
+ }
379
+ /** One {@link CLOUDFLARE_TUNNEL_DNS_TRACKED_SCOPE_KEY} entry for `domain`'s `hostname`. */
380
+ function trackedTunnelDnsEntry(domain, hostname) {
381
+ return `${domain}|${hostname}`;
382
+ }
383
+ /** {@link CLOUDFLARE_TUNNEL_DNS_TRACKED_SCOPE_KEY}'s own serialisation, sorted so the recorded value is stable regardless of write order. */
384
+ function trackedTunnelDnsState(entries) {
385
+ return [...entries].sort().join(',');
386
+ }
387
+ /** {@link parseTrackedTunnelDns}'s entries, grouped by domain so cleanup can walk one zone at a time. */
388
+ function trackedTunnelDnsByDomain(entries) {
389
+ const byDomain = new Map();
390
+ for (const entry of entries) {
391
+ const separator = entry.indexOf('|');
392
+ if (separator === -1)
393
+ continue;
394
+ const domain = entry.slice(0, separator);
395
+ const hostname = entry.slice(separator + 1);
396
+ const hostnames = byDomain.get(domain) ?? [];
397
+ hostnames.push(hostname);
398
+ byDomain.set(domain, hostnames);
399
+ }
400
+ return byDomain;
401
+ }
402
+ /**
403
+ * #236 round 6 finding 2: every tunnel id this step has ever replaced with
404
+ * a new one, not only the immediately prior one. Round 5's singular
405
+ * `cloudflare_tunnel_previous_id` was overwritten on each recreate, so two
406
+ * recreates with no successful DNS resolution in between (an apply with
407
+ * no domain declared, or one that keeps failing) left the CNAMEs from the
408
+ * *first* recreate matched by nothing -- `destroy` and this step's own
409
+ * repoint logic only ever compared against the latest id. Recorded
410
+ * unconditionally, even on an apply where `org.domain` is unset and the
411
+ * DNS-publish block in {@link FoundryStepExecutor.cloudflareTunnel} never
412
+ * runs: a CNAME published while a domain *was* declared can still be live
413
+ * under an old id after the domain is later cleared. Comma-joined, sorted,
414
+ * the same shape {@link CLOUDFLARE_TUNNEL_DNS_TRACKED_SCOPE_KEY} above
415
+ * uses (tunnel ids carry no `,`). Cleared once that DNS-publish block
416
+ * completes *and* (round 7) the tracked set names no domain but the one
417
+ * just published -- only then has every tracked CNAME that could have
418
+ * pointed at any of them been repointed to the current id or cleaned up.
419
+ * A tracked entry left under some other domain by an earlier apply whose
420
+ * own cleanup never ran, or itself threw, can still point at an old id, so
421
+ * clearing unconditionally (round 6) left `destroy` unable to recognise
422
+ * that CNAME as this tunnel's own once a later apply resolved a different
423
+ * domain change and moved on. An apply with no domain declared never
424
+ * reaches that point either, so the set is left to accumulate across such
425
+ * applies, and across an unresolved domain change, until one apply
426
+ * publishes with nothing tracked outside its own domain.
427
+ */
428
+ const CLOUDFLARE_TUNNEL_PREVIOUS_IDS_SCOPE_KEY = 'cloudflare_tunnel_previous_ids';
429
+ /** {@link CLOUDFLARE_TUNNEL_PREVIOUS_IDS_SCOPE_KEY}'s entries, parsed back out. */
430
+ function parseTunnelPreviousIds(recorded) {
431
+ const raw = recorded[CLOUDFLARE_TUNNEL_PREVIOUS_IDS_SCOPE_KEY];
432
+ return new Set(raw ? raw.split(',').filter((id) => id.length > 0) : []);
433
+ }
434
+ /** {@link CLOUDFLARE_TUNNEL_PREVIOUS_IDS_SCOPE_KEY}'s own serialisation, sorted so the recorded value is stable regardless of write order. */
435
+ function tunnelPreviousIdsState(ids) {
436
+ return [...ids].sort().join(',');
437
+ }
281
438
  /**
282
439
  * #162: the owner's own mailbox, created once inside `coolify:mail` after
283
440
  * `applyOrgMail` succeeds, alongside {@link MAIL_DOMAIN_SCOPE_KEY} -- another
@@ -472,7 +629,7 @@ export class FoundryStepExecutor {
472
629
  }
473
630
  /**
474
631
  * `plan/diff.ts`'s `planAccess` (#84): puts the org's app hostname behind a
475
- * Cloudflare Access app that lets only `hosting.accessEmails` through.
632
+ * Cloudflare Access app that lets only the `app` entry's `accessEmails` through.
476
633
  * foundry names its Access app `foundry:<orgId>` and reuses only an app with
477
634
  * that name, so a re-run after a crash does not duplicate it. It refuses,
478
635
  * rather than adopt, when another Access app already holds the hostname:
@@ -498,7 +655,7 @@ export class FoundryStepExecutor {
498
655
  await cf.createAccessPolicy(app.id, {
499
656
  name: ACCESS_POLICY_NAME,
500
657
  decision: 'allow',
501
- include: org.hosting.accessEmails.map((email) => ({ email: { email } })),
658
+ include: (appEntry(org.hosting)?.accessEmails ?? []).map((email) => ({ email: { email } })),
502
659
  });
503
660
  }
504
661
  return { resourceId: app.id };
@@ -599,9 +756,70 @@ export class FoundryStepExecutor {
599
756
  * domains, writes zitadel-login's binding, then repoints the apps whose
600
757
  * client id that binding holds. Apps created later are not in it yet:
601
758
  * the next run replans (`diff.ts`'s `planLoginHost`) and moves them.
759
+ *
760
+ * #274: when the org's host has moved (`recorded[key]`, the *previous*
761
+ * run's resourceId, differs from this run's `host`), also removes every
762
+ * host that is no longer the org's current one from both places --
763
+ * `ensureTrustedDomain` and `upsertBinding` above only ever add, so
764
+ * without this an old host stays trusted and bound forever, and a later
765
+ * org that reuses the old slug inherits it unasked.
766
+ *
767
+ * #274 fix round 2: this always returns `host` -- the *current* desired
768
+ * host -- as its `resourceId`, never an old one, however many of its
769
+ * removals are still outstanding. Fix round 1 returned `previousHost`
770
+ * while a removal was skipped, banking on `planLoginHost` replanning the
771
+ * whole step off that mismatch alone; that loses track of the *new* host
772
+ * the moment a second host change moves `previousHost` on again (A -> B
773
+ * skipped, then B -> C: A's own removal has nothing left recording it was
774
+ * ever owed). `LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY`
775
+ * (`pendingRemovalHosts`/`pendingRemovalState`, `login-host.ts`) is the
776
+ * durable record instead -- every host still awaiting removal, independent
777
+ * of which one is current -- and `planLoginHost` replans the step whenever
778
+ * it is non-empty, not only when the current host itself differs.
779
+ *
780
+ * Whether a host is still "in use" is decided from each live app's actual
781
+ * ZITADEL Login V2 base URI (`ZitadelPort.listApps`' `loginBaseUri`), not
782
+ * from `boundAppIds` membership: fix round 1 skipped removal only while an
783
+ * app outside `boundAppIds` existed, but `boundAppIds` is deliberately
784
+ * narrower than "every live app" (it excludes `native`/`api` apps and any
785
+ * `web` app without a recorded client id yet) and a host can safely be
786
+ * removed even while such an app exists, so long as nothing is actually
787
+ * pointed at it. Removing by the wrong signal either breaks sign-in (an
788
+ * app repointed is not the same fact as an app with no client id yet) or
789
+ * strands a host forever (an org with a `native`/`api` app would never
790
+ * satisfy "every live app bound").
791
+ *
792
+ * #274 fix round 3, four more defects fix round 2 left:
793
+ *
794
+ * 1. The *current* host is excluded from the candidates for removal.
795
+ * Without this, A -> B (A pending, not yet safe) -> A deletes the host
796
+ * this very call just (re-)trusted and bound: a host already in
797
+ * `LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY` from an earlier run can be
798
+ * exactly the one the org's host is moving back to.
799
+ * 2. Fail closed on missing information, rather than ever reading
800
+ * "nothing uses it" off of one: an app whose `loginBaseUri` cannot be
801
+ * read at all (`undefined` -- which, until a real adapter is wired per
802
+ * `ZitadelPort.listApps`'s own doc comment, is every app, every call)
803
+ * means every pending host stays pending this run, not just the ones a
804
+ * narrower check happened to miss.
805
+ * 3. Same fail-closed rule for `listApps`' own `complete` flag: an
806
+ * incomplete read (today, always, for the identical reason) means the
807
+ * set of apps this call saw could be missing one still pointed at a
808
+ * pending host, so nothing is removed off of it either.
809
+ * 4. A base URI compares after lowercasing and trimming its trailing
810
+ * `/` (`normalizedLoginBaseUri`) -- ZITADEL's own value and the one
811
+ * this step constructs are not guaranteed to agree on case or a
812
+ * trailing slash, and a comparison that is case- or slash-sensitive
813
+ * both ways can report "in use" as "not" by accident.
814
+ * 5. Each live app's base URI is read as of *after* this call's own
815
+ * repoint loop above, not the stale value `listApps` returned before
816
+ * it: a bound app this very call just repointed away from a pending
817
+ * host must not hold that host pending for one more run only because
818
+ * the snapshot taken before the repoint still named it.
602
819
  */
603
820
  async zitadelLoginHost(action, org, context) {
604
821
  const recorded = context.recordedIds();
822
+ const previousHost = recorded[`zitadel:login-host:${action.key}`] ?? null;
605
823
  const zitadelOrgId = recorded.zitadel_org;
606
824
  if (!zitadelOrgId) {
607
825
  throw new Error(`zitadel:login-host for ${org.slug}: no ZITADEL org recorded yet -- the issuer must be provisioned first`);
@@ -628,17 +846,93 @@ export class FoundryStepExecutor {
628
846
  ...(recorded[AGORA_CLIENT_ID_SCOPE_KEY] ? ['agora'] : []),
629
847
  ].map((name) => recorded[`zitadel:app:${name}`]));
630
848
  const projectId = recorded.zitadel_project;
849
+ // No project yet means no apps yet: vacuously safe to decide from (fix
850
+ // round 3 point 2/3 apply only once there is something to fail closed
851
+ // on), and nothing to repoint either.
852
+ let liveApps = [];
853
+ let listingComplete = true;
631
854
  if (projectId) {
632
- const apps = await this.ports.zitadel.listApps(zitadelOrgId, projectId);
633
- for (const app of apps) {
634
- if (app.state === 'APP_STATE_REMOVED' || !boundAppIds.has(app.id))
855
+ const listed = await this.ports.zitadel.listApps(zitadelOrgId, projectId);
856
+ listingComplete = listed.complete;
857
+ liveApps = listed.apps.filter((app) => app.state !== 'APP_STATE_REMOVED');
858
+ for (const app of liveApps) {
859
+ if (!boundAppIds.has(app.id))
635
860
  continue;
636
861
  await this.ports.zitadel.updateOidcAppLoginBaseUri(zitadelOrgId, projectId, app.id, `https://${host}/`);
637
862
  }
638
863
  }
639
- // Last, so a crash above reruns the whole step. A client id recorded
640
- // after this point differs from it and replans the step next run.
864
+ // Fix round 3 point 5: a bound app's current base URI is `host` for
865
+ // certain, just set above, whatever `listApps` read before that --
866
+ // never its stale pre-repoint snapshot. Every other live app's is
867
+ // exactly what `listApps` reported, unmodified by this call. `null`
868
+ // (fix round 4 point 1: read, and confirmed this app has never had one)
869
+ // is deliberately not added -- it names no host, so there is nothing to
870
+ // compare against, same as an app this loop never visits at all.
871
+ const currentLoginBaseUris = new Set();
872
+ for (const app of liveApps) {
873
+ const uri = boundAppIds.has(app.id) ? `https://${host}/` : app.loginBaseUri;
874
+ if (typeof uri === 'string')
875
+ currentLoginBaseUris.add(normalizedLoginBaseUri(uri));
876
+ }
877
+ // Fix round 3 points 2/3, fix round 4 point 1: an unbound app's base
878
+ // URI this call could not read *at all* (`undefined`, never `null` --
879
+ // {@link ZitadelPort.listApps}'s own doc comment has the three-state
880
+ // distinction), or a listing this call cannot be sure is complete,
881
+ // means there is no way to tell whether a pending host is still in use
882
+ // -- fail closed for every pending host this run rather than trust a
883
+ // partial picture. A bound app always has a known-good URI as of just
884
+ // above, so it is never the reason to fail closed; nor is an unbound
885
+ // app `listApps` confirmed has no base URI at all (`null`) -- fail
886
+ // round 3 collapsed that into "unreadable" too, which would have kept
887
+ // a host pending forever against an org with such an app, even once
888
+ // the real client can tell the two apart.
889
+ const canDecideSafely = listingComplete &&
890
+ liveApps.every((app) => boundAppIds.has(app.id) || app.loginBaseUri !== undefined);
891
+ // Every host up for removal this run: whatever was already pending,
892
+ // plus the host this run is moving away from (if any) -- deduplicated,
893
+ // since a host can arrive here twice across two runs that both saw it
894
+ // pending -- and, fix round 3 point 1, never the host this very call
895
+ // just (re-)trusted and bound, however it got into either set.
896
+ const candidates = new Set(pendingRemovalHosts(recorded));
897
+ candidates.delete(host);
898
+ if (previousHost && previousHost !== host)
899
+ candidates.add(previousHost);
900
+ // #274: a removal, once the new host is fully live (trusted, bound, and
901
+ // every repointable app repointed) -- never the other way round, so an
902
+ // org is never left with no trusted host at all if this throws partway
903
+ // through. Each removal catches its own not-found separately (`destroy`'s
904
+ // own `cloudflare:tunnel` case below takes the identical precaution, for
905
+ // its connector app and its tunnel): a domain already gone must not skip
906
+ // the binding delete that follows it.
907
+ const stillPending = [];
908
+ for (const candidate of candidates) {
909
+ if (!canDecideSafely ||
910
+ currentLoginBaseUris.has(normalizedLoginBaseUri(`https://${candidate}/`))) {
911
+ stillPending.push(candidate);
912
+ continue;
913
+ }
914
+ try {
915
+ await this.ports.zitadel.deleteTrustedDomain(candidate);
916
+ }
917
+ catch (error) {
918
+ if (!isNotFoundError(error))
919
+ throw error;
920
+ }
921
+ try {
922
+ await bindings.deleteBinding(candidate);
923
+ }
924
+ catch (error) {
925
+ if (!isNotFoundError(error))
926
+ throw error;
927
+ }
928
+ }
929
+ // Both last, so a crash above reruns the whole step rather than losing
930
+ // track of either. A client id recorded after this point differs from
931
+ // the binding and replans the step next run; a nonempty pending list
932
+ // does too (`planLoginHost`), independent of the host comparison that
933
+ // normally alone decides it.
641
934
  context.recordScopeId(LOGIN_BINDING_SCOPE_KEY, loginBindingState(host, clientIds));
935
+ context.recordScopeId(LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, pendingRemovalState(stillPending));
642
936
  return { resourceId: host };
643
937
  }
644
938
  /**
@@ -776,7 +1070,7 @@ export class FoundryStepExecutor {
776
1070
  * that is not the survivor this is looking for.
777
1071
  */
778
1072
  async findRecreatedApp(zitadelOrgId, projectId, name, appType) {
779
- const apps = await this.ports.zitadel.listApps(zitadelOrgId, projectId);
1073
+ const { apps } = await this.ports.zitadel.listApps(zitadelOrgId, projectId);
780
1074
  return apps.find((app) => app.name === name && app.appType === appType && app.state !== 'APP_STATE_REMOVED');
781
1075
  }
782
1076
  async ensureZitadelProject(org, zitadelOrgId, context) {
@@ -809,23 +1103,42 @@ export class FoundryStepExecutor {
809
1103
  return server;
810
1104
  }
811
1105
  async coolifyApp(action, org, context) {
1106
+ const recordedAppId = context.recordedIds()[`coolify:app:${action.key}`];
812
1107
  if (action.desired.enabled === false) {
813
- const appId = context.recordedIds()[`coolify:app:${action.key}`];
814
- if (!appId) {
1108
+ if (!recordedAppId) {
815
1109
  throw new Error(`coolify:app for ${org.slug}: no live app recorded, nothing to park`);
816
1110
  }
817
- await this.ports.coolify.raw('POST', `/applications/${appId}/stop`);
818
- return { resourceId: appId };
1111
+ await this.ports.coolify.raw('POST', `/applications/${recordedAppId}/stop`);
1112
+ return { resourceId: recordedAppId };
819
1113
  }
1114
+ const entry = appEntry(org.hosting);
1115
+ if (!entry)
1116
+ throw new Error(`coolify:app for ${org.slug}: no '${APP_ENTRY_KEY}' hosting entry`);
820
1117
  // A level-1 org with no bespoke app of its own gets the shared standard
821
- // app instead (gap P1, `.plans/2026-09-26-level-1-org-map.md`), in the
822
- // same repo shape `bespokeAppRepo` itself uses.
823
- const appRepo = org.bespokeAppRepo ?? DEFAULT_APP_REPO;
1118
+ // app instead (gap P1, `.plans/2026-09-26-level-1-org-map.md`): an entry
1119
+ // with no `repo`.
1120
+ const appRepo = entry.repo ?? DEFAULT_APP_REPO;
824
1121
  const projectUuid = await this.ensureCoolifyProject(org, context);
825
1122
  const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
826
- const existing = await this.ports.coolify.findApplicationByName(org.slug, {
827
- environmentIds: [environment.id],
828
- });
1123
+ // The recorded id first; the name when nothing is recorded, or when the
1124
+ // recorded app is gone.
1125
+ let existing = null;
1126
+ let live;
1127
+ if (recordedAppId) {
1128
+ try {
1129
+ live = await this.ports.coolify.getApplication(recordedAppId);
1130
+ existing = { uuid: recordedAppId };
1131
+ }
1132
+ catch (error) {
1133
+ if (!isNotFoundError(error))
1134
+ throw error;
1135
+ }
1136
+ }
1137
+ if (!existing) {
1138
+ existing = await this.ports.coolify.findApplicationByName(appNameFor(org, entry), {
1139
+ environmentIds: [environment.id],
1140
+ });
1141
+ }
829
1142
  let appId;
830
1143
  if (existing) {
831
1144
  appId = existing.uuid;
@@ -835,7 +1148,7 @@ export class FoundryStepExecutor {
835
1148
  // stops the app without deleting it, so re-enabling found it here,
836
1149
  // still stopped, and it stayed stopped forever despite `apply`
837
1150
  // reporting success.
838
- const live = await this.ports.coolify.getApplication(existing.uuid);
1151
+ live ??= await this.ports.coolify.getApplication(existing.uuid);
839
1152
  if (!isRunningOrStarting(live.status)) {
840
1153
  await this.ports.coolify.raw('POST', `/applications/${existing.uuid}/start`);
841
1154
  }
@@ -850,13 +1163,13 @@ export class FoundryStepExecutor {
850
1163
  environment_uuid: environment.uuid,
851
1164
  github_app_uuid: githubApp.uuid,
852
1165
  git_repository: appRepo,
853
- git_branch: org.hosting.gitBranch,
854
- build_pack: org.hosting.buildPack,
855
- name: org.slug,
1166
+ git_branch: entry.branch,
1167
+ build_pack: entry.buildPack,
1168
+ name: appNameFor(org, entry),
856
1169
  domains: hostnameFor(org),
857
1170
  // Only the shared standard app builds from this repo; a bespoke
858
1171
  // app's Dockerfile is at its own repo's root.
859
- dockerfile_location: org.bespokeAppRepo ? undefined : DEFAULT_APP_DOCKERFILE,
1172
+ dockerfile_location: entry.repo ? undefined : DEFAULT_APP_DOCKERFILE,
860
1173
  });
861
1174
  appId = created.uuid;
862
1175
  }
@@ -866,8 +1179,8 @@ export class FoundryStepExecutor {
866
1179
  // covers the database being turned on after the app already exists (the
867
1180
  // app step still ran this apply, found the app by name, and lands here).
868
1181
  let dbPush = { pushed: false, changed: false };
869
- if (org.hosting.database) {
870
- const dbId = context.recordedIds()[`coolify:database:${action.key}`];
1182
+ if (entry.database) {
1183
+ const dbId = context.recordedIds()[`coolify:database:${org.id}`];
871
1184
  if (dbId) {
872
1185
  dbPush = await this.pushDatabaseUrl(appId, dbId, org.slug.replace(/-/g, '_'));
873
1186
  }
@@ -887,7 +1200,7 @@ export class FoundryStepExecutor {
887
1200
  // The app receives whatever it needs to sign in (gap P2). Unconditional,
888
1201
  // unlike the database push above: every app has a hostname, and an
889
1202
  // upsert of the same value on a later apply is a no-op, not a change.
890
- const appEnv = await this.pushAppEnv(appId, org, context);
1203
+ const appEnv = await this.pushAppEnv(appId, org, entry, context);
891
1204
  // Rule 4 (#239, #243): a container reads its env only at start, so an app
892
1205
  // that was already there is restarted, once, when its database URLs or its
893
1206
  // declared appEnv changed.
@@ -980,7 +1293,7 @@ export class FoundryStepExecutor {
980
1293
  // Handles the database being provisioned after the app already exists
981
1294
  // (`coolifyApp`'s own push handles the more common order, database
982
1295
  // before app on the same apply -- `diff.ts`'s `computePlanDetail`).
983
- const appId = context.recordedIds()[`coolify:app:${action.key}`];
1296
+ const appId = context.recordedIds()[`coolify:app:${appStepKey(org)}`];
984
1297
  if (appId) {
985
1298
  const push = await this.pushDatabaseUrl(appId, dbId, org.slug.replace(/-/g, '_'));
986
1299
  if (push.pushed) {
@@ -1061,7 +1374,7 @@ export class FoundryStepExecutor {
1061
1374
  * P2, `.plans/2026-09-26-level-1-org-map.md`) -- a second `bulkUpsertEnvs`
1062
1375
  * call rather than folding into `pushDatabaseUrl`'s, since this one runs
1063
1376
  * unconditionally (`coolifyApp`) where that one runs only once
1064
- * `hosting.database` is declared and provisioned.
1377
+ * the `app` entry's `database` is declared and provisioned.
1065
1378
  *
1066
1379
  * `APP_URL` and `LOG_DIR` (#75) are pushed unconditionally; every other
1067
1380
  * value is pushed only once its own source step has actually recorded it
@@ -1074,10 +1387,10 @@ export class FoundryStepExecutor {
1074
1387
  * is its own design question, left out of #204's own "How" rather than
1075
1388
  * guessed) is out of scope here (task brief). `DATABASE_URL_OWNER`/
1076
1389
  * `DATABASE_URL` are pushed by `pushDatabaseUrl` instead, not here --
1077
- * that call runs only once `hosting.database` is declared and
1390
+ * that call runs only once the `app` entry's `database` is declared and
1078
1391
  * provisioned, where this one is unconditional (#239).
1079
1392
  */
1080
- async pushAppEnv(appId, org, context) {
1393
+ async pushAppEnv(appId, org, entry, context) {
1081
1394
  const recorded = context.recordedIds();
1082
1395
  const envs = [
1083
1396
  { key: 'APP_URL', value: `https://${hostnameFor(org)}` },
@@ -1112,7 +1425,7 @@ export class FoundryStepExecutor {
1112
1425
  // minted here; a source with no value yet (its own step has not run,
1113
1426
  // or has not run for this apply) is simply omitted, the same way every
1114
1427
  // other optional entry above is.
1115
- const appEnv = resolveAppEnv(org, recorded).values;
1428
+ const appEnv = resolveAppEnv(org, entry, recorded).values;
1116
1429
  for (const [key, value] of Object.entries(appEnv))
1117
1430
  envs.push({ key, value });
1118
1431
  await this.ports.coolify.bulkUpsertEnvs(appId, envs);
@@ -1742,6 +2055,14 @@ export class FoundryStepExecutor {
1742
2055
  });
1743
2056
  }
1744
2057
  context.recordScopeId(CALLS_SYNAPSE_SCOPE_KEY, synapse.appId);
2058
+ // #251: only on create -- nothing here patches `docker_registry_image_tag`
2059
+ // on an app `findOrCreateCallsApp` found already running, so that is the
2060
+ // only moment this tag is actually live. Recording it every run, the way
2061
+ // the app id above is, would make `versionNotAppliedLine` compare a
2062
+ // bumped `VERSIONS.synapse` against a value never actually applied.
2063
+ if (!synapse.existed) {
2064
+ context.recordScopeId(CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, SYNAPSE_TAG);
2065
+ }
1745
2066
  const mas = await this.findOrCreateCallsApp(names.mas, environment.id, () => this.ports.coolify.createDockerfileApplication({
1746
2067
  project_uuid: projectUuid,
1747
2068
  server_uuid: server.uuid,
@@ -1759,6 +2080,10 @@ export class FoundryStepExecutor {
1759
2080
  });
1760
2081
  }
1761
2082
  context.recordScopeId(CALLS_MAS_SCOPE_KEY, mas.appId);
2083
+ // #251: only on create -- see the matching comment on synapse above.
2084
+ if (!mas.existed) {
2085
+ context.recordScopeId(CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, MAS_TAG);
2086
+ }
1762
2087
  // Synapse's own signing key, media store and pid file (#75's own
1763
2088
  // durable-volume convention, reused here for a different reason: this
1764
2089
  // volume is Synapse's own state, not a log).
@@ -2208,8 +2533,20 @@ export class FoundryStepExecutor {
2208
2533
  return { resourceId: tunnelId };
2209
2534
  }
2210
2535
  const tunnelName = `${org.slug}-tunnel`;
2536
+ // #236 round 5 finding 2 / round 6 finding 2: read, and (if it is about
2537
+ // to change) added to the accumulated replaced-id set, before
2538
+ // `createTunnel` can replace it -- unconditionally, not only inside the
2539
+ // `if (org.domain)` block below, so a recreate on an apply with no
2540
+ // domain declared still leaves `destroy` a way to recognise the old
2541
+ // id's CNAMEs as this tunnel's own rather than a foreign record.
2542
+ const recordedTunnelId = context.recordedIds()[`cloudflare:tunnel:${action.key}`];
2211
2543
  const existing = await this.ports.cloudflare.findTunnelByName(tunnelName);
2212
2544
  const tunnel = existing ?? (await this.ports.cloudflare.createTunnel(tunnelName));
2545
+ const previousIds = parseTunnelPreviousIds(context.recordedIds());
2546
+ if (recordedTunnelId && recordedTunnelId !== tunnel.id) {
2547
+ previousIds.add(recordedTunnelId);
2548
+ context.recordScopeId(CLOUDFLARE_TUNNEL_PREVIOUS_IDS_SCOPE_KEY, tunnelPreviousIdsState(previousIds));
2549
+ }
2213
2550
  // Routes each of the org's hostnames (`tunnelIngressHostnames`) to
2214
2551
  // Coolify's own Traefik reverse proxy, not a per-app container address --
2215
2552
  // `COOLIFY_PROXY_ORIGIN`, the same constant name and value
@@ -2257,8 +2594,296 @@ export class FoundryStepExecutor {
2257
2594
  await context.persistScopeIds();
2258
2595
  },
2259
2596
  });
2597
+ // #236: an org with no domain (a level-1 org whose app still serves off
2598
+ // the shared `*.oikos.host` wildcard tunnel, `scripts/shared-ingress.mjs`)
2599
+ // has no zone of its own and nothing here to publish -- `org.domain` is
2600
+ // set whenever `plan/diff.ts`'s `planTunnel` actually plans this step,
2601
+ // but this executor is also exercised directly against an action it
2602
+ // would never plan, so the check stays explicit rather than assumed.
2603
+ //
2604
+ // Round 3 finding: publish the new domain *before* cleaning up the old
2605
+ // one. `previousDomain`/`previousHostnames`/`previousTunnelId` are
2606
+ // captured here, before either scope id is recorded below, because
2607
+ // `context.recordedIds()` is a live view of the same map this method
2608
+ // writes to (`StepExecutionContext.recordedIds`'s own doc comment) --
2609
+ // reading any of them any later would already see the new values. The
2610
+ // old order (delete, then publish) could take a domain change's
2611
+ // hostnames dark: if the new zone was missing or foreign-conflicted,
2612
+ // `publishTunnelDnsRecords` threw *after* the old CNAMEs were already
2613
+ // gone and nothing had replaced them. Publish throwing now leaves the
2614
+ // old domain's records and its recorded scope ids untouched, so a
2615
+ // conflict there is a no-op here rather than an outage.
2616
+ //
2617
+ // Round 4 finding 3: the new domain/hostnames are recorded here, by this
2618
+ // method, only *after* `deleteStaleTunnelDnsRecords` has succeeded --
2619
+ // never inside `publishTunnelDnsRecords` itself. The old order recorded
2620
+ // the new domain as soon as publish returned; `runSteps`'s own catch
2621
+ // block persists whatever was recorded so far even when the step then
2622
+ // throws, so a cleanup failure right after a successful publish left the
2623
+ // new domain durably recorded. A retry then read `previousDomain` as
2624
+ // already equal to `org.domain` and skipped cleanup forever, orphaning
2625
+ // the old domain's records. Recording after cleanup means a cleanup
2626
+ // failure leaves the *old* domain recorded, so a retry sees the same
2627
+ // domain change again and tries cleanup again.
2628
+ if (org.domain) {
2629
+ const recordedBefore = context.recordedIds();
2630
+ const previousDomain = recordedBefore[CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY];
2631
+ const previousHostnamesRaw = recordedBefore[CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY];
2632
+ const previousHostnames = previousHostnamesRaw ? previousHostnamesRaw.split(',') : [];
2633
+ const domainChanged = previousDomain !== undefined && previousDomain !== org.domain;
2634
+ const publishedHostnames = await this.publishTunnelDnsRecords(org, org.domain, tunnel.id,
2635
+ // Round 6 finding 2: every id this step has ever replaced, not only
2636
+ // the one `recordedTunnelId` captured above -- see the scope key's
2637
+ // own doc comment.
2638
+ previousIds,
2639
+ // Round 4 finding 2: a hostname this domain itself dropped (no domain
2640
+ // change) is cleaned up inline by `publishTunnelDnsRecords`, which
2641
+ // needs the domain's own previous hostname list for that. A domain
2642
+ // change instead cleans the *whole* old domain below, so it gets an
2643
+ // empty list here rather than a second, redundant pass over it.
2644
+ domainChanged ? [] : previousHostnames, context);
2645
+ if (domainChanged) {
2646
+ await this.deleteStaleTunnelDnsRecords(tunnel.id, previousIds, previousDomain, previousHostnames, context);
2647
+ }
2648
+ context.recordScopeId(CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY, org.domain);
2649
+ context.recordScopeId(CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY, publishedHostnames.join(','));
2650
+ // #236 round 7: every previous id's CNAMEs *under `org.domain`* have
2651
+ // now been repointed to `tunnel.id` or cleaned up by publish/cleanup
2652
+ // above (current-domain hostnames by the write loop inside
2653
+ // `publishTunnelDnsRecords`, the old domain's by
2654
+ // `deleteStaleTunnelDnsRecords` above) -- but a tracked entry left
2655
+ // under some *other* domain by an earlier apply whose own cleanup
2656
+ // never ran, or itself threw, can still point at one of `previousIds`.
2657
+ // Round 6 finding 2's unconditional clear missed exactly that: apply 1
2658
+ // moves domain A to B, creates `h1.B` on tunnel T1, and A's cleanup
2659
+ // throws; the tunnel is then recreated as T2 out of band and the org
2660
+ // declares domain C; apply 2 publishes C and cleans up only A, leaving
2661
+ // `h1.B` tracked and still pointed at T1 -- clearing the id set here
2662
+ // would make `destroy` and the next `deleteStaleTunnelDnsRecords`
2663
+ // match only T2 and leave `h1.B` live forever. So the set is cleared
2664
+ // only once the tracked set names no domain but the one just
2665
+ // published: the one case where nothing tracked can still reference
2666
+ // an old id. A no-op the first time this step ever runs.
2667
+ const trackedDomains = trackedTunnelDnsByDomain(parseTrackedTunnelDns(context.recordedIds())).keys();
2668
+ if ([...trackedDomains].every((domain) => domain === org.domain)) {
2669
+ context.recordScopeId(CLOUDFLARE_TUNNEL_PREVIOUS_IDS_SCOPE_KEY, '');
2670
+ }
2671
+ }
2260
2672
  return { resourceId: tunnel.id };
2261
2673
  }
2674
+ /**
2675
+ * #236: when this tunnel's DNS was last published under a domain that
2676
+ * differs from the one `org` now declares, deletes every CNAME in the
2677
+ * *old* domain's zone that points at this tunnel, so a domain switch
2678
+ * never leaves the old domain still answering for it. Takes the previous
2679
+ * domain and hostnames as plain arguments rather than reading them off
2680
+ * `context` itself (round 3): `cloudflareTunnel` reads the old values
2681
+ * before calling either this or `publishTunnelDnsRecords`, and (round 4
2682
+ * finding 3) does not record the new domain/hostnames until *after* this
2683
+ * method has returned, so a crash here still sees the old domain on retry.
2684
+ * A no-op when the old zone itself is already gone.
2685
+ *
2686
+ * `previousHostnames` (round 3) is {@link CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY}'s
2687
+ * recorded list, looked up one by one instead of a zone-wide
2688
+ * `listDnsRecords` -- the real adapter's version of that call asks for one
2689
+ * page and never walks the rest. A domain change recorded before that key
2690
+ * existed falls back to the old zone-wide pass, the one case where no
2691
+ * hostname list was ever written to look up instead.
2692
+ *
2693
+ * `previousIds` (round 4 finding 1, round 6 finding 2): the old domain's
2694
+ * CNAMEs were published under whatever tunnel id was live the *last*
2695
+ * time this step recorded that domain, which may be any id this step has
2696
+ * ever replaced, not necessarily `tunnelId` -- a tunnel recreated under
2697
+ * the same name (`findTunnelByName` returning a new id), possibly more
2698
+ * than once with no successful publish in between, in the same apply
2699
+ * that also changes the domain leaves the old domain's records pointed
2700
+ * at an old id. Matching only `tunnelId`'s target left them live
2701
+ * forever; matching only the single most recent previous id (round 4)
2702
+ * still missed an older one two unresolved recreates back. Every target
2703
+ * is checked so this stays a no-op once a retry has already moved the
2704
+ * old domain onto the current id.
2705
+ *
2706
+ * Round 6 finding 1: once `previousHostnames` is non-empty, every name in
2707
+ * it has now been looked up -- deleted (its CNAME matched one of
2708
+ * `dnsTargets`), left alone (found but foreign), or never found at all
2709
+ * (already gone) -- so none of them needs tracking under `previousDomain`
2710
+ * in {@link CLOUDFLARE_TUNNEL_DNS_TRACKED_SCOPE_KEY} any longer; dropped
2711
+ * from that set accordingly. The zone-wide fallback path (no recorded
2712
+ * hostnames) never added entries of its own to begin with, so there is
2713
+ * nothing of this domain's left in that set either way.
2714
+ */
2715
+ async deleteStaleTunnelDnsRecords(tunnelId, previousIds, previousDomain, previousHostnames, context) {
2716
+ const zone = await this.ports.cloudflare.findZoneByApex(previousDomain);
2717
+ if (!zone)
2718
+ return;
2719
+ const dnsTargets = new Set([`${tunnelId}.cfargotunnel.com`]);
2720
+ for (const id of previousIds) {
2721
+ if (id !== tunnelId)
2722
+ dnsTargets.add(`${id}.cfargotunnel.com`);
2723
+ }
2724
+ const cf = this.ports.cloudflare;
2725
+ const records = previousHostnames.length > 0
2726
+ ? (await Promise.all(previousHostnames.map((hostname) => cf.findDnsRecord(zone.id, hostname, 'CNAME')))).flat()
2727
+ : await cf.listDnsRecords(zone.id, { type: 'CNAME' });
2728
+ for (const record of records) {
2729
+ if (dnsTargets.has(record.content)) {
2730
+ await cf.deleteDnsRecord(zone.id, record.id);
2731
+ }
2732
+ }
2733
+ if (previousHostnames.length > 0) {
2734
+ const tracked = parseTrackedTunnelDns(context.recordedIds());
2735
+ for (const hostname of previousHostnames) {
2736
+ tracked.delete(trackedTunnelDnsEntry(previousDomain, hostname));
2737
+ }
2738
+ context.recordScopeId(CLOUDFLARE_TUNNEL_DNS_TRACKED_SCOPE_KEY, trackedTunnelDnsState(tracked));
2739
+ }
2740
+ }
2741
+ /**
2742
+ * #236: upserts a proxied CNAME to this tunnel for each of
2743
+ * `tunnelIngressHostnames(org)` that actually belongs to `domain`'s own
2744
+ * zone -- a hosting app still served off the shared `*.oikos.host`
2745
+ * wildcard tunnel (`scripts/shared-ingress.mjs`) can appear in that list
2746
+ * alongside `agora.<domain>` (#164's unconditional Agora hostname) when
2747
+ * `hosting.usesDomain` is off, and `<slug>.oikos.host` is not this org's
2748
+ * zone to touch.
2749
+ *
2750
+ * Checks every hostname for a conflicting record *before* writing any
2751
+ * (round 2 finding: a conflict found only partway through a hostname-by-
2752
+ * hostname pass left earlier hostnames written but the domain never
2753
+ * recorded -- an orphaned partial write). An existing A or AAAA record,
2754
+ * or a CNAME already pointed anywhere but this tunnel or (round 3) its own
2755
+ * immediately prior tunnel id, blocks the whole step with a named
2756
+ * conflict rather than being adopted or replaced, the same do-not-adopt
2757
+ * policy {@link cloudflareZone} itself follows for the zone. A CNAME
2758
+ * already pointed at this tunnel is left alone, so a second apply changes
2759
+ * nothing. The caller ({@link cloudflareTunnel}) records the domain and the
2760
+ * returned hostname list as scope ids only once this method -- and, on a
2761
+ * domain change, the old domain's own cleanup -- has returned successfully
2762
+ * (round 4 finding 3), so a crash mid-write is recovered by re-running
2763
+ * this same idempotent step, never silently skipped.
2764
+ *
2765
+ * Round 3, round 6 finding 2: `previousIds` is every tunnel id this same
2766
+ * step has ever replaced (`cloudflareTunnel`'s own accumulated set,
2767
+ * captured before `tunnel` above is computed for this run) -- a tunnel
2768
+ * Cloudflare shows as recreated under the same name (`findTunnelByName`
2769
+ * returning a new id for `<slug>-tunnel`), possibly more than once with
2770
+ * no successful publish in between, leaves every CNAME still pointed at
2771
+ * one of those old ids. That is this org's own stale record, not a
2772
+ * foreign one, so it is repointed rather than refused, whichever of
2773
+ * `previousIds` it still names.
2774
+ *
2775
+ * Round 4 finding 2: `previousHostnames` is this domain's own previously
2776
+ * recorded hostname list (empty when the domain just changed --
2777
+ * {@link cloudflareTunnel} hands the *whole* old domain to
2778
+ * {@link deleteStaleTunnelDnsRecords} for that case instead). A name in it
2779
+ * that the org's current config no longer produces is dropped from
2780
+ * `hostnames` below but was never cleaned up before this round: its CNAME
2781
+ * stayed live and its name stayed in the recorded list forever, because
2782
+ * only a domain *change* ever ran cleanup. Each dropped name's CNAME is
2783
+ * deleted here when it still points at this tunnel or any of
2784
+ * `previousIds`; a name already gone, or now pointed at something
2785
+ * foreign, is simply left out of the returned list -- nothing here is
2786
+ * this method's to reclaim or report as a conflict.
2787
+ *
2788
+ * Round 5 finding 1, round 6 finding 1: `context` is used to add every
2789
+ * hostname this call confirms correct (settled, created or repointed) to
2790
+ * {@link CLOUDFLARE_TUNNEL_DNS_TRACKED_SCOPE_KEY} as soon as it is
2791
+ * confirmed, and to drop a dropped hostname from it once that hostname's
2792
+ * own CNAME is resolved one way or another -- additively, alongside
2793
+ * whatever other domains that shared set already names, never
2794
+ * overwriting them. `destroy` and {@link deleteStaleTunnelDnsRecords}
2795
+ * both read this set.
2796
+ */
2797
+ async publishTunnelDnsRecords(org, domain, tunnelId, previousIds, previousHostnames, context) {
2798
+ const cf = this.ports.cloudflare;
2799
+ const zone = await cf.findZoneByApex(domain);
2800
+ if (!zone) {
2801
+ 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`);
2802
+ }
2803
+ const dnsTarget = `${tunnelId}.cfargotunnel.com`;
2804
+ const previousTargets = new Set([...previousIds].filter((id) => id !== tunnelId).map((id) => `${id}.cfargotunnel.com`));
2805
+ const hostnames = tunnelIngressHostnames(org).filter((hostname) => hostname === domain || hostname.endsWith(`.${domain}`));
2806
+ const droppedHostnames = previousHostnames.filter((hostname) => !hostnames.includes(hostname));
2807
+ const conflicts = [];
2808
+ const toCreate = [];
2809
+ const toRepoint = [];
2810
+ for (const hostname of hostnames) {
2811
+ const [a] = await cf.findDnsRecord(zone.id, hostname, 'A');
2812
+ const [aaaa] = await cf.findDnsRecord(zone.id, hostname, 'AAAA');
2813
+ const foreign = a ?? aaaa;
2814
+ if (foreign) {
2815
+ conflicts.push(`${hostname} already has a ${foreign.type} record`);
2816
+ continue;
2817
+ }
2818
+ const [cname] = await cf.findDnsRecord(zone.id, hostname, 'CNAME');
2819
+ if (cname && cname.content !== dnsTarget) {
2820
+ if (previousTargets.has(cname.content)) {
2821
+ toRepoint.push({ hostname, recordId: cname.id });
2822
+ continue;
2823
+ }
2824
+ conflicts.push(`${hostname} is already a CNAME to ${cname.content}`);
2825
+ continue;
2826
+ }
2827
+ if (!cname)
2828
+ toCreate.push(hostname);
2829
+ }
2830
+ if (conflicts.length > 0) {
2831
+ throw new Error(`cloudflare:tunnel for ${org.slug}: will not point its tunnel at every hostname -- ${conflicts.join('; ')}; remove the conflicting record first`);
2832
+ }
2833
+ // Round 5 finding 1, round 6 finding 1: `settled` is every hostname
2834
+ // under `domain` confirmed to have the right CNAME so far -- starting
2835
+ // with the ones that needed no write at all, then one more per
2836
+ // successful create or repoint below -- and each is added to the
2837
+ // shared tracked set as soon as it is confirmed, well before this
2838
+ // method returns. A failure on any later hostname still leaves
2839
+ // everything settled up to that point recorded, rather than only in
2840
+ // this function's own local state.
2841
+ const settled = hostnames.filter((hostname) => !toCreate.includes(hostname) && !toRepoint.some((r) => r.hostname === hostname));
2842
+ const tracked = parseTrackedTunnelDns(context.recordedIds());
2843
+ const recordTracked = (hostname) => {
2844
+ tracked.add(trackedTunnelDnsEntry(domain, hostname));
2845
+ context.recordScopeId(CLOUDFLARE_TUNNEL_DNS_TRACKED_SCOPE_KEY, trackedTunnelDnsState(tracked));
2846
+ };
2847
+ for (const hostname of settled)
2848
+ recordTracked(hostname);
2849
+ for (const hostname of toCreate) {
2850
+ await cf.createDnsRecord(zone.id, {
2851
+ type: 'CNAME',
2852
+ name: hostname,
2853
+ content: dnsTarget,
2854
+ proxied: true,
2855
+ });
2856
+ recordTracked(hostname);
2857
+ }
2858
+ for (const { hostname, recordId } of toRepoint) {
2859
+ await cf.updateDnsRecord(zone.id, recordId, {
2860
+ type: 'CNAME',
2861
+ name: hostname,
2862
+ content: dnsTarget,
2863
+ proxied: true,
2864
+ });
2865
+ recordTracked(hostname);
2866
+ }
2867
+ // Round 4 finding 2: clean up a hostname this domain itself dropped.
2868
+ // Not a conflict check -- there is nothing to create or repoint here,
2869
+ // only a possible stale CNAME of this tunnel's own to remove. Never in
2870
+ // `hostnames`/`settled` to begin with, so nothing to track-add there.
2871
+ //
2872
+ // Round 6 finding 1: whatever happens -- deleted (its CNAME matched),
2873
+ // left alone (found but foreign), or never found at all (already gone)
2874
+ // -- this hostname's own entry (if it had one) no longer needs
2875
+ // tracking under `domain`, so it is dropped from the shared set either
2876
+ // way.
2877
+ for (const hostname of droppedHostnames) {
2878
+ const [cname] = await cf.findDnsRecord(zone.id, hostname, 'CNAME');
2879
+ if (cname && (cname.content === dnsTarget || previousTargets.has(cname.content))) {
2880
+ await cf.deleteDnsRecord(zone.id, cname.id);
2881
+ }
2882
+ tracked.delete(trackedTunnelDnsEntry(domain, hostname));
2883
+ context.recordScopeId(CLOUDFLARE_TUNNEL_DNS_TRACKED_SCOPE_KEY, trackedTunnelDnsState(tracked));
2884
+ }
2885
+ return hostnames;
2886
+ }
2262
2887
  /**
2263
2888
  * `plan/diff.ts`'s `planZone`: finds or creates the Cloudflare zone for the
2264
2889
  * org's own domain. Refuses to adopt a zone the store already records
@@ -2654,6 +3279,42 @@ export class FoundryStepExecutor {
2654
3279
  const { org, recordedIds } = context;
2655
3280
  try {
2656
3281
  switch (`${target.system}:${target.kind}`) {
3282
+ case 'zitadel:login-host': {
3283
+ // #274: `target.id` is the host itself (`zitadelLoginHost`'s own
3284
+ // `resourceId: host`), the same shape `plan-apply.ts`'s
3285
+ // `destroyServiceIdKey` and `ORDERED_PREFIXES` rely on. Each
3286
+ // removal catches its own not-found separately -- the domain
3287
+ // already gone must not skip the binding delete that follows it,
3288
+ // the same reason `cloudflare:tunnel` below handles its connector
3289
+ // app's 404 locally rather than leaning on this method's own
3290
+ // outer catch.
3291
+ const bindings = this.ports.loginHostBindings;
3292
+ // #274 fix round 2: any host `zitadelLoginHost` had already moved
3293
+ // away from but could not yet remove (some live app's login base
3294
+ // URI still pointed at it) is named only in
3295
+ // `LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY` -- nothing else ever runs
3296
+ // for this org again to finish removing it once destroy deletes
3297
+ // the ZITADEL project and org below, so it has to go here too.
3298
+ for (const host of [target.id, ...pendingRemovalHosts(recordedIds)]) {
3299
+ try {
3300
+ await this.ports.zitadel.deleteTrustedDomain(host);
3301
+ }
3302
+ catch (error) {
3303
+ if (!isNotFoundError(error))
3304
+ throw error;
3305
+ }
3306
+ if (bindings) {
3307
+ try {
3308
+ await bindings.deleteBinding(host);
3309
+ }
3310
+ catch (error) {
3311
+ if (!isNotFoundError(error))
3312
+ throw error;
3313
+ }
3314
+ }
3315
+ }
3316
+ return;
3317
+ }
2657
3318
  case 'cloudflare:tunnel': {
2658
3319
  // #235: the connector app first, its own 404 handled locally --
2659
3320
  // a retry where this already succeeded must still reach
@@ -2669,6 +3330,75 @@ export class FoundryStepExecutor {
2669
3330
  throw error;
2670
3331
  }
2671
3332
  }
3333
+ // #236: every CNAME `publishTunnelDnsRecords` created for this
3334
+ // org's tunnel, in the zone it was *last* published under -- the
3335
+ // recorded domain, not whatever `org.domain` says now, since a
3336
+ // domain change (or the org being deleted entirely) can leave
3337
+ // them disagreeing. Round 3: looks each recorded hostname up by
3338
+ // name (`CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY`) rather than
3339
+ // listing every CNAME in the zone -- the real adapter's
3340
+ // `listDnsRecords` asks for one page of 100 and never walks the
3341
+ // rest, so a zone with more records than that silently lost
3342
+ // everything past the first page. A hostname a later config
3343
+ // change dropped is still in that recorded list, so it still gets
3344
+ // cleaned up. A record from before that key existed falls back to
3345
+ // the old zone-wide list, the one case with nothing recorded to
3346
+ // look up instead. 404 handled locally, same reason as the
3347
+ // connector above.
3348
+ //
3349
+ // Round 5 finding 2, round 6 finding 2: `dnsTargets` accepts this
3350
+ // tunnel's current id *or* any id
3351
+ // {@link CLOUDFLARE_TUNNEL_PREVIOUS_IDS_SCOPE_KEY} still names --
3352
+ // every id this step has ever replaced, not only the latest -- the
3353
+ // same match `publishTunnelDnsRecords`'s own repoint logic already
3354
+ // uses, so a CNAME a domain-less apply's recreate left pointed at
3355
+ // an old id (never repointed, since that apply never ran the
3356
+ // DNS-publish block at all), possibly more than one recreate back,
3357
+ // still gets removed here.
3358
+ const previousTunnelIds = parseTunnelPreviousIds(recordedIds);
3359
+ const dnsTargets = new Set([`${target.id}.cfargotunnel.com`]);
3360
+ for (const id of previousTunnelIds) {
3361
+ if (id !== target.id)
3362
+ dnsTargets.add(`${id}.cfargotunnel.com`);
3363
+ }
3364
+ // Round 6 finding 1: every domain the shared tracked set still
3365
+ // names, folded together with the authoritative domain/hostnames
3366
+ // pair (an org that predates that set has only the latter). A
3367
+ // domain the tracked set names but the authoritative pair does
3368
+ // not is exactly the case this round exists for -- a hostname
3369
+ // published under a domain the org has since moved away from
3370
+ // twice over, never caught by any single "previous domain" field.
3371
+ const hostnamesByDomain = trackedTunnelDnsByDomain(parseTrackedTunnelDns(recordedIds));
3372
+ const authoritativeDomain = recordedIds[CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY];
3373
+ if (authoritativeDomain) {
3374
+ const authoritativeHostnamesRaw = recordedIds[CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY];
3375
+ const merged = new Set(hostnamesByDomain.get(authoritativeDomain) ?? []);
3376
+ if (authoritativeHostnamesRaw) {
3377
+ for (const hostname of authoritativeHostnamesRaw.split(','))
3378
+ merged.add(hostname);
3379
+ }
3380
+ hostnamesByDomain.set(authoritativeDomain, [...merged]);
3381
+ }
3382
+ for (const [dnsDomain, dnsHostnames] of hostnamesByDomain) {
3383
+ try {
3384
+ const zone = await this.ports.cloudflare.findZoneByApex(dnsDomain);
3385
+ if (zone) {
3386
+ const cf = this.ports.cloudflare;
3387
+ const records = dnsHostnames.length > 0
3388
+ ? (await Promise.all(dnsHostnames.map((hostname) => cf.findDnsRecord(zone.id, hostname, 'CNAME')))).flat()
3389
+ : await cf.listDnsRecords(zone.id, { type: 'CNAME' });
3390
+ for (const record of records) {
3391
+ if (dnsTargets.has(record.content)) {
3392
+ await cf.deleteDnsRecord(zone.id, record.id);
3393
+ }
3394
+ }
3395
+ }
3396
+ }
3397
+ catch (error) {
3398
+ if (!isNotFoundError(error))
3399
+ throw error;
3400
+ }
3401
+ }
2672
3402
  await this.ports.cloudflare.deleteTunnel(target.id);
2673
3403
  return;
2674
3404
  }