@wtfalch/foundry 0.7.1 → 0.8.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 (43) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +12 -8
  2. package/dist/bootstrap/bootstrap.js +4 -12
  3. package/dist/bootstrap/versions.d.ts +8 -0
  4. package/dist/bootstrap/versions.js +31 -0
  5. package/dist/calls-sfu-deploy-support.d.ts +1 -1
  6. package/dist/calls-sfu-deploy-support.js +1 -1
  7. package/dist/calls-sfu-ssh-adapter.d.ts +1 -1
  8. package/dist/calls-sfu-ssh-adapter.js +1 -1
  9. package/dist/calls-sfu.d.ts +5 -4
  10. package/dist/calls-sfu.js +5 -4
  11. package/dist/index.d.ts +1 -2
  12. package/dist/index.js +4 -1
  13. package/dist/migrations/0024_audit_events.sql +1 -1
  14. package/dist/migrations/0025_audit_display.sql +1 -1
  15. package/dist/migrations/0026_audit_erase_email_guard.sql +1 -1
  16. package/dist/migrations/0027_audit_chain.sql +1 -1
  17. package/dist/migrations/0028_audit_rls.sql +2 -2
  18. package/dist/migrations-dir.d.ts +9 -0
  19. package/dist/migrations-dir.js +10 -0
  20. package/dist/plan/calls-matrix.d.ts +2 -0
  21. package/dist/plan/calls-matrix.js +2 -0
  22. package/dist/plan/diff.d.ts +3 -1
  23. package/dist/plan/diff.js +76 -14
  24. package/dist/plan/executor.d.ts +305 -16
  25. package/dist/plan/executor.js +606 -9
  26. package/dist/plan/login-host.d.ts +24 -0
  27. package/dist/plan/login-host.js +21 -0
  28. package/dist/plan/plan-apply.d.ts +2 -0
  29. package/dist/plan/plan-apply.js +20 -1
  30. package/dist/plan/platform-diff.d.ts +79 -1
  31. package/dist/plan/platform-diff.js +197 -1
  32. package/dist/plan/platform-providers.d.ts +36 -1
  33. package/dist/plan/platform-providers.js +153 -21
  34. package/dist/plan.d.ts +43 -0
  35. package/dist/plan.js +16 -1
  36. package/dist/store/audit.d.ts +13 -17
  37. package/dist/store/audit.js +14 -18
  38. package/dist/store/postgres.d.ts +14 -0
  39. package/dist/store/postgres.js +13 -8
  40. package/package.json +6 -3
  41. package/versions.json +1 -1
  42. package/dist/migrate.d.ts +0 -38
  43. package/dist/migrate.js +0 -47
@@ -2,11 +2,32 @@ 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
4
  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';
5
+ import { MAS_IMAGE, MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
6
6
  import { ensureCloudflaredConnector } from './cloudflared-connector.js';
7
- import { LOGIN_BINDING_SCOPE_KEY, loginBindingState, loginHostClientIds, } from './login-host.js';
7
+ import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
8
8
  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';
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';
10
+ /**
11
+ * #274 fix round 4: bridges `@wtfalch/providers`' real `ZitadelClient.
12
+ * listApps` -- a plain array, with no `loginBaseUri` or completeness signal
13
+ * -- into {@link ZitadelPort.listApps}'s own shape, so a host wiring that
14
+ * real client in satisfies this port without reaching into `executor.ts`
15
+ * itself to build the wrapper by hand. `complete: false` on every call:
16
+ * the real client's own `_search` request has no pagination on it yet
17
+ * (https://github.com/wtfalch/providers/issues/29), so this adapter cannot
18
+ * promise it read more than one page. `loginBaseUri` comes back `undefined`
19
+ * for every app for the identical reason -- the real client does not read
20
+ * it back at all yet, not "read it back as absent" -- which is exactly the
21
+ * state `zitadelLoginHost`'s fail-closed rule exists for: every pending
22
+ * host stays pending until the tracked change above lands and this adapter
23
+ * (or its replacement) can start reporting `string | null` for real.
24
+ */
25
+ export function adaptZitadelListApps(client) {
26
+ return async (orgId, projectId) => ({
27
+ apps: await client.listApps(orgId, projectId),
28
+ complete: false,
29
+ });
30
+ }
10
31
  // ---------------------------------------------------------------------------
11
32
  // Errors
12
33
  // ---------------------------------------------------------------------------
@@ -37,6 +58,17 @@ export class NotBuiltError extends Error {
37
58
  function hostnameFor(org) {
38
59
  return org.hosting.usesDomain && org.domain ? org.domain : `${org.slug}.oikos.host`;
39
60
  }
61
+ /**
62
+ * #274 fix round 3 point 4: `zitadelLoginHost`'s own comparison between a
63
+ * live app's ZITADEL-reported login base URI and the one this step itself
64
+ * constructs (`https://<host>/`) -- lowercased and with its trailing `/`
65
+ * (there is always exactly one, a URI's path component, never more) trimmed,
66
+ * so a host is never read as "not in use" purely because ZITADEL's own
67
+ * casing or slashing differs from this step's.
68
+ */
69
+ function normalizedLoginBaseUri(uri) {
70
+ return uri.toLowerCase().replace(/\/$/, '');
71
+ }
40
72
  /**
41
73
  * `cms.<domain>` -- the cms studio's own hostname (`cmsSite` below), apart
42
74
  * from the public site itself at `org.domain` (used directly there). Called
@@ -278,6 +310,64 @@ const MAIL_DOMAIN_SCOPE_KEY = 'mail_domain';
278
310
  * (`platform-providers.ts`), never a shared tunnel with appended ingress.
279
311
  */
280
312
  const CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY = 'cloudflare_tunnel_connector_app';
313
+ /**
314
+ * #236: the domain `publishTunnelDnsRecords` last published this org's
315
+ * tunnel's hostnames under -- compared against the org's current `domain`
316
+ * so `plan/diff.ts`'s `planTunnel` can tell a stale publish (never done at
317
+ * all, or the org's domain has since changed) from a steady state with
318
+ * nothing left to do, and so `destroy` and `deleteStaleTunnelDnsRecords`
319
+ * know which zone to clean up even once the org's declared domain has moved
320
+ * on. `diff.ts` keeps its own copy of this exact string, the same
321
+ * duplication {@link CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY} above
322
+ * already follows.
323
+ */
324
+ const CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY = 'cloudflare_tunnel_dns_domain';
325
+ /**
326
+ * #236 round 3: every hostname {@link FoundryStepExecutor.publishTunnelDnsRecords}
327
+ * last wrote a CNAME for, joined by `,` (hostnames carry no `,`, the same
328
+ * join {@link MAIL_ROLE_MAILBOXES_SCOPE_KEY} already relies on) -- so
329
+ * cleanup (`destroy`'s `cloudflare:tunnel` case and
330
+ * {@link FoundryStepExecutor.deleteStaleTunnelDnsRecords}) can look each one
331
+ * up by name instead of listing the zone. The real adapter's
332
+ * `listDnsRecords` (`@wtfalch/providers`' `cloudflare.ts`) asks for one page
333
+ * of 100 and never walks `result_info.total_pages`, so a zone with more
334
+ * records than that silently lost everything past the first page. A record
335
+ * from before this key existed has no entry here yet: cleanup falls back to
336
+ * the old `listDnsRecords` zone-wide pass for that one run, and the next
337
+ * successful publish leaves this key set so later cleanups never need to.
338
+ */
339
+ const CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY = 'cloudflare_tunnel_dns_hostnames';
340
+ /**
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.
356
+ */
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';
359
+ /**
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.
369
+ */
370
+ const CLOUDFLARE_TUNNEL_PREVIOUS_ID_SCOPE_KEY = 'cloudflare_tunnel_previous_id';
281
371
  /**
282
372
  * #162: the owner's own mailbox, created once inside `coolify:mail` after
283
373
  * `applyOrgMail` succeeds, alongside {@link MAIL_DOMAIN_SCOPE_KEY} -- another
@@ -599,9 +689,70 @@ export class FoundryStepExecutor {
599
689
  * domains, writes zitadel-login's binding, then repoints the apps whose
600
690
  * client id that binding holds. Apps created later are not in it yet:
601
691
  * the next run replans (`diff.ts`'s `planLoginHost`) and moves them.
692
+ *
693
+ * #274: when the org's host has moved (`recorded[key]`, the *previous*
694
+ * run's resourceId, differs from this run's `host`), also removes every
695
+ * host that is no longer the org's current one from both places --
696
+ * `ensureTrustedDomain` and `upsertBinding` above only ever add, so
697
+ * without this an old host stays trusted and bound forever, and a later
698
+ * org that reuses the old slug inherits it unasked.
699
+ *
700
+ * #274 fix round 2: this always returns `host` -- the *current* desired
701
+ * host -- as its `resourceId`, never an old one, however many of its
702
+ * removals are still outstanding. Fix round 1 returned `previousHost`
703
+ * while a removal was skipped, banking on `planLoginHost` replanning the
704
+ * whole step off that mismatch alone; that loses track of the *new* host
705
+ * the moment a second host change moves `previousHost` on again (A -> B
706
+ * skipped, then B -> C: A's own removal has nothing left recording it was
707
+ * ever owed). `LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY`
708
+ * (`pendingRemovalHosts`/`pendingRemovalState`, `login-host.ts`) is the
709
+ * durable record instead -- every host still awaiting removal, independent
710
+ * of which one is current -- and `planLoginHost` replans the step whenever
711
+ * it is non-empty, not only when the current host itself differs.
712
+ *
713
+ * Whether a host is still "in use" is decided from each live app's actual
714
+ * ZITADEL Login V2 base URI (`ZitadelPort.listApps`' `loginBaseUri`), not
715
+ * from `boundAppIds` membership: fix round 1 skipped removal only while an
716
+ * app outside `boundAppIds` existed, but `boundAppIds` is deliberately
717
+ * narrower than "every live app" (it excludes `native`/`api` apps and any
718
+ * `web` app without a recorded client id yet) and a host can safely be
719
+ * removed even while such an app exists, so long as nothing is actually
720
+ * pointed at it. Removing by the wrong signal either breaks sign-in (an
721
+ * app repointed is not the same fact as an app with no client id yet) or
722
+ * strands a host forever (an org with a `native`/`api` app would never
723
+ * satisfy "every live app bound").
724
+ *
725
+ * #274 fix round 3, four more defects fix round 2 left:
726
+ *
727
+ * 1. The *current* host is excluded from the candidates for removal.
728
+ * Without this, A -> B (A pending, not yet safe) -> A deletes the host
729
+ * this very call just (re-)trusted and bound: a host already in
730
+ * `LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY` from an earlier run can be
731
+ * exactly the one the org's host is moving back to.
732
+ * 2. Fail closed on missing information, rather than ever reading
733
+ * "nothing uses it" off of one: an app whose `loginBaseUri` cannot be
734
+ * read at all (`undefined` -- which, until a real adapter is wired per
735
+ * `ZitadelPort.listApps`'s own doc comment, is every app, every call)
736
+ * means every pending host stays pending this run, not just the ones a
737
+ * narrower check happened to miss.
738
+ * 3. Same fail-closed rule for `listApps`' own `complete` flag: an
739
+ * incomplete read (today, always, for the identical reason) means the
740
+ * set of apps this call saw could be missing one still pointed at a
741
+ * pending host, so nothing is removed off of it either.
742
+ * 4. A base URI compares after lowercasing and trimming its trailing
743
+ * `/` (`normalizedLoginBaseUri`) -- ZITADEL's own value and the one
744
+ * this step constructs are not guaranteed to agree on case or a
745
+ * trailing slash, and a comparison that is case- or slash-sensitive
746
+ * both ways can report "in use" as "not" by accident.
747
+ * 5. Each live app's base URI is read as of *after* this call's own
748
+ * repoint loop above, not the stale value `listApps` returned before
749
+ * it: a bound app this very call just repointed away from a pending
750
+ * host must not hold that host pending for one more run only because
751
+ * the snapshot taken before the repoint still named it.
602
752
  */
603
753
  async zitadelLoginHost(action, org, context) {
604
754
  const recorded = context.recordedIds();
755
+ const previousHost = recorded[`zitadel:login-host:${action.key}`] ?? null;
605
756
  const zitadelOrgId = recorded.zitadel_org;
606
757
  if (!zitadelOrgId) {
607
758
  throw new Error(`zitadel:login-host for ${org.slug}: no ZITADEL org recorded yet -- the issuer must be provisioned first`);
@@ -628,17 +779,93 @@ export class FoundryStepExecutor {
628
779
  ...(recorded[AGORA_CLIENT_ID_SCOPE_KEY] ? ['agora'] : []),
629
780
  ].map((name) => recorded[`zitadel:app:${name}`]));
630
781
  const projectId = recorded.zitadel_project;
782
+ // No project yet means no apps yet: vacuously safe to decide from (fix
783
+ // round 3 point 2/3 apply only once there is something to fail closed
784
+ // on), and nothing to repoint either.
785
+ let liveApps = [];
786
+ let listingComplete = true;
631
787
  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))
788
+ const listed = await this.ports.zitadel.listApps(zitadelOrgId, projectId);
789
+ listingComplete = listed.complete;
790
+ liveApps = listed.apps.filter((app) => app.state !== 'APP_STATE_REMOVED');
791
+ for (const app of liveApps) {
792
+ if (!boundAppIds.has(app.id))
635
793
  continue;
636
794
  await this.ports.zitadel.updateOidcAppLoginBaseUri(zitadelOrgId, projectId, app.id, `https://${host}/`);
637
795
  }
638
796
  }
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.
797
+ // Fix round 3 point 5: a bound app's current base URI is `host` for
798
+ // certain, just set above, whatever `listApps` read before that --
799
+ // never its stale pre-repoint snapshot. Every other live app's is
800
+ // exactly what `listApps` reported, unmodified by this call. `null`
801
+ // (fix round 4 point 1: read, and confirmed this app has never had one)
802
+ // is deliberately not added -- it names no host, so there is nothing to
803
+ // compare against, same as an app this loop never visits at all.
804
+ const currentLoginBaseUris = new Set();
805
+ for (const app of liveApps) {
806
+ const uri = boundAppIds.has(app.id) ? `https://${host}/` : app.loginBaseUri;
807
+ if (typeof uri === 'string')
808
+ currentLoginBaseUris.add(normalizedLoginBaseUri(uri));
809
+ }
810
+ // Fix round 3 points 2/3, fix round 4 point 1: an unbound app's base
811
+ // URI this call could not read *at all* (`undefined`, never `null` --
812
+ // {@link ZitadelPort.listApps}'s own doc comment has the three-state
813
+ // distinction), or a listing this call cannot be sure is complete,
814
+ // means there is no way to tell whether a pending host is still in use
815
+ // -- fail closed for every pending host this run rather than trust a
816
+ // partial picture. A bound app always has a known-good URI as of just
817
+ // above, so it is never the reason to fail closed; nor is an unbound
818
+ // app `listApps` confirmed has no base URI at all (`null`) -- fail
819
+ // round 3 collapsed that into "unreadable" too, which would have kept
820
+ // a host pending forever against an org with such an app, even once
821
+ // the real client can tell the two apart.
822
+ const canDecideSafely = listingComplete &&
823
+ liveApps.every((app) => boundAppIds.has(app.id) || app.loginBaseUri !== undefined);
824
+ // Every host up for removal this run: whatever was already pending,
825
+ // plus the host this run is moving away from (if any) -- deduplicated,
826
+ // since a host can arrive here twice across two runs that both saw it
827
+ // pending -- and, fix round 3 point 1, never the host this very call
828
+ // just (re-)trusted and bound, however it got into either set.
829
+ const candidates = new Set(pendingRemovalHosts(recorded));
830
+ candidates.delete(host);
831
+ if (previousHost && previousHost !== host)
832
+ candidates.add(previousHost);
833
+ // #274: a removal, once the new host is fully live (trusted, bound, and
834
+ // every repointable app repointed) -- never the other way round, so an
835
+ // org is never left with no trusted host at all if this throws partway
836
+ // through. Each removal catches its own not-found separately (`destroy`'s
837
+ // own `cloudflare:tunnel` case below takes the identical precaution, for
838
+ // its connector app and its tunnel): a domain already gone must not skip
839
+ // the binding delete that follows it.
840
+ const stillPending = [];
841
+ for (const candidate of candidates) {
842
+ if (!canDecideSafely ||
843
+ currentLoginBaseUris.has(normalizedLoginBaseUri(`https://${candidate}/`))) {
844
+ stillPending.push(candidate);
845
+ continue;
846
+ }
847
+ try {
848
+ await this.ports.zitadel.deleteTrustedDomain(candidate);
849
+ }
850
+ catch (error) {
851
+ if (!isNotFoundError(error))
852
+ throw error;
853
+ }
854
+ try {
855
+ await bindings.deleteBinding(candidate);
856
+ }
857
+ catch (error) {
858
+ if (!isNotFoundError(error))
859
+ throw error;
860
+ }
861
+ }
862
+ // Both last, so a crash above reruns the whole step rather than losing
863
+ // track of either. A client id recorded after this point differs from
864
+ // the binding and replans the step next run; a nonempty pending list
865
+ // does too (`planLoginHost`), independent of the host comparison that
866
+ // normally alone decides it.
641
867
  context.recordScopeId(LOGIN_BINDING_SCOPE_KEY, loginBindingState(host, clientIds));
868
+ context.recordScopeId(LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, pendingRemovalState(stillPending));
642
869
  return { resourceId: host };
643
870
  }
644
871
  /**
@@ -776,7 +1003,7 @@ export class FoundryStepExecutor {
776
1003
  * that is not the survivor this is looking for.
777
1004
  */
778
1005
  async findRecreatedApp(zitadelOrgId, projectId, name, appType) {
779
- const apps = await this.ports.zitadel.listApps(zitadelOrgId, projectId);
1006
+ const { apps } = await this.ports.zitadel.listApps(zitadelOrgId, projectId);
780
1007
  return apps.find((app) => app.name === name && app.appType === appType && app.state !== 'APP_STATE_REMOVED');
781
1008
  }
782
1009
  async ensureZitadelProject(org, zitadelOrgId, context) {
@@ -1742,6 +1969,14 @@ export class FoundryStepExecutor {
1742
1969
  });
1743
1970
  }
1744
1971
  context.recordScopeId(CALLS_SYNAPSE_SCOPE_KEY, synapse.appId);
1972
+ // #251: only on create -- nothing here patches `docker_registry_image_tag`
1973
+ // on an app `findOrCreateCallsApp` found already running, so that is the
1974
+ // only moment this tag is actually live. Recording it every run, the way
1975
+ // the app id above is, would make `versionNotAppliedLine` compare a
1976
+ // bumped `VERSIONS.synapse` against a value never actually applied.
1977
+ if (!synapse.existed) {
1978
+ context.recordScopeId(CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, SYNAPSE_TAG);
1979
+ }
1745
1980
  const mas = await this.findOrCreateCallsApp(names.mas, environment.id, () => this.ports.coolify.createDockerfileApplication({
1746
1981
  project_uuid: projectUuid,
1747
1982
  server_uuid: server.uuid,
@@ -1759,6 +1994,10 @@ export class FoundryStepExecutor {
1759
1994
  });
1760
1995
  }
1761
1996
  context.recordScopeId(CALLS_MAS_SCOPE_KEY, mas.appId);
1997
+ // #251: only on create -- see the matching comment on synapse above.
1998
+ if (!mas.existed) {
1999
+ context.recordScopeId(CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, MAS_TAG);
2000
+ }
1762
2001
  // Synapse's own signing key, media store and pid file (#75's own
1763
2002
  // durable-volume convention, reused here for a different reason: this
1764
2003
  // volume is Synapse's own state, not a log).
@@ -2208,8 +2447,17 @@ export class FoundryStepExecutor {
2208
2447
  return { resourceId: tunnelId };
2209
2448
  }
2210
2449
  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.
2455
+ const recordedTunnelId = context.recordedIds()[`cloudflare:tunnel:${action.key}`];
2211
2456
  const existing = await this.ports.cloudflare.findTunnelByName(tunnelName);
2212
2457
  const tunnel = existing ?? (await this.ports.cloudflare.createTunnel(tunnelName));
2458
+ if (recordedTunnelId && recordedTunnelId !== tunnel.id) {
2459
+ context.recordScopeId(CLOUDFLARE_TUNNEL_PREVIOUS_ID_SCOPE_KEY, recordedTunnelId);
2460
+ }
2213
2461
  // Routes each of the org's hostnames (`tunnelIngressHostnames`) to
2214
2462
  // Coolify's own Traefik reverse proxy, not a per-app container address --
2215
2463
  // `COOLIFY_PROXY_ORIGIN`, the same constant name and value
@@ -2257,8 +2505,252 @@ export class FoundryStepExecutor {
2257
2505
  await context.persistScopeIds();
2258
2506
  },
2259
2507
  });
2508
+ // #236: an org with no domain (a level-1 org whose app still serves off
2509
+ // the shared `*.oikos.host` wildcard tunnel, `scripts/shared-ingress.mjs`)
2510
+ // has no zone of its own and nothing here to publish -- `org.domain` is
2511
+ // set whenever `plan/diff.ts`'s `planTunnel` actually plans this step,
2512
+ // but this executor is also exercised directly against an action it
2513
+ // would never plan, so the check stays explicit rather than assumed.
2514
+ //
2515
+ // Round 3 finding: publish the new domain *before* cleaning up the old
2516
+ // one. `previousDomain`/`previousHostnames`/`previousTunnelId` are
2517
+ // captured here, before either scope id is recorded below, because
2518
+ // `context.recordedIds()` is a live view of the same map this method
2519
+ // writes to (`StepExecutionContext.recordedIds`'s own doc comment) --
2520
+ // reading any of them any later would already see the new values. The
2521
+ // old order (delete, then publish) could take a domain change's
2522
+ // hostnames dark: if the new zone was missing or foreign-conflicted,
2523
+ // `publishTunnelDnsRecords` threw *after* the old CNAMEs were already
2524
+ // gone and nothing had replaced them. Publish throwing now leaves the
2525
+ // old domain's records and its recorded scope ids untouched, so a
2526
+ // conflict there is a no-op here rather than an outage.
2527
+ //
2528
+ // Round 4 finding 3: the new domain/hostnames are recorded here, by this
2529
+ // method, only *after* `deleteStaleTunnelDnsRecords` has succeeded --
2530
+ // never inside `publishTunnelDnsRecords` itself. The old order recorded
2531
+ // the new domain as soon as publish returned; `runSteps`'s own catch
2532
+ // block persists whatever was recorded so far even when the step then
2533
+ // throws, so a cleanup failure right after a successful publish left the
2534
+ // new domain durably recorded. A retry then read `previousDomain` as
2535
+ // already equal to `org.domain` and skipped cleanup forever, orphaning
2536
+ // the old domain's records. Recording after cleanup means a cleanup
2537
+ // failure leaves the *old* domain recorded, so a retry sees the same
2538
+ // domain change again and tries cleanup again.
2539
+ if (org.domain) {
2540
+ const recordedBefore = context.recordedIds();
2541
+ const previousDomain = recordedBefore[CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY];
2542
+ const previousHostnamesRaw = recordedBefore[CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY];
2543
+ 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
+ const domainChanged = previousDomain !== undefined && previousDomain !== org.domain;
2549
+ const publishedHostnames = await this.publishTunnelDnsRecords(org, org.domain, tunnel.id, previousTunnelId,
2550
+ // Round 4 finding 2: a hostname this domain itself dropped (no domain
2551
+ // change) is cleaned up inline by `publishTunnelDnsRecords`, which
2552
+ // needs the domain's own previous hostname list for that. A domain
2553
+ // change instead cleans the *whole* old domain below, so it gets an
2554
+ // empty list here rather than a second, redundant pass over it.
2555
+ domainChanged ? [] : previousHostnames, context);
2556
+ if (domainChanged) {
2557
+ await this.deleteStaleTunnelDnsRecords(tunnel.id, previousTunnelId, previousDomain, previousHostnames);
2558
+ }
2559
+ context.recordScopeId(CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY, org.domain);
2560
+ 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, '');
2568
+ }
2260
2569
  return { resourceId: tunnel.id };
2261
2570
  }
2571
+ /**
2572
+ * #236: when this tunnel's DNS was last published under a domain that
2573
+ * differs from the one `org` now declares, deletes every CNAME in the
2574
+ * *old* domain's zone that points at this tunnel, so a domain switch
2575
+ * never leaves the old domain still answering for it. Takes the previous
2576
+ * domain and hostnames as plain arguments rather than reading them off
2577
+ * `context` itself (round 3): `cloudflareTunnel` reads the old values
2578
+ * before calling either this or `publishTunnelDnsRecords`, and (round 4
2579
+ * finding 3) does not record the new domain/hostnames until *after* this
2580
+ * method has returned, so a crash here still sees the old domain on retry.
2581
+ * A no-op when the old zone itself is already gone.
2582
+ *
2583
+ * `previousHostnames` (round 3) is {@link CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY}'s
2584
+ * recorded list, looked up one by one instead of a zone-wide
2585
+ * `listDnsRecords` -- the real adapter's version of that call asks for one
2586
+ * page and never walks the rest. A domain change recorded before that key
2587
+ * existed falls back to the old zone-wide pass, the one case where no
2588
+ * hostname list was ever written to look up instead.
2589
+ *
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.
2599
+ */
2600
+ async deleteStaleTunnelDnsRecords(tunnelId, previousTunnelId, previousDomain, previousHostnames) {
2601
+ const zone = await this.ports.cloudflare.findZoneByApex(previousDomain);
2602
+ if (!zone)
2603
+ return;
2604
+ const dnsTargets = new Set([`${tunnelId}.cfargotunnel.com`]);
2605
+ if (previousTunnelId && previousTunnelId !== tunnelId) {
2606
+ dnsTargets.add(`${previousTunnelId}.cfargotunnel.com`);
2607
+ }
2608
+ const cf = this.ports.cloudflare;
2609
+ const records = previousHostnames.length > 0
2610
+ ? (await Promise.all(previousHostnames.map((hostname) => cf.findDnsRecord(zone.id, hostname, 'CNAME')))).flat()
2611
+ : await cf.listDnsRecords(zone.id, { type: 'CNAME' });
2612
+ for (const record of records) {
2613
+ if (dnsTargets.has(record.content)) {
2614
+ await cf.deleteDnsRecord(zone.id, record.id);
2615
+ }
2616
+ }
2617
+ }
2618
+ /**
2619
+ * #236: upserts a proxied CNAME to this tunnel for each of
2620
+ * `tunnelIngressHostnames(org)` that actually belongs to `domain`'s own
2621
+ * zone -- a hosting app still served off the shared `*.oikos.host`
2622
+ * wildcard tunnel (`scripts/shared-ingress.mjs`) can appear in that list
2623
+ * alongside `agora.<domain>` (#164's unconditional Agora hostname) when
2624
+ * `hosting.usesDomain` is off, and `<slug>.oikos.host` is not this org's
2625
+ * zone to touch.
2626
+ *
2627
+ * Checks every hostname for a conflicting record *before* writing any
2628
+ * (round 2 finding: a conflict found only partway through a hostname-by-
2629
+ * hostname pass left earlier hostnames written but the domain never
2630
+ * recorded -- an orphaned partial write). An existing A or AAAA record,
2631
+ * or a CNAME already pointed anywhere but this tunnel or (round 3) its own
2632
+ * immediately prior tunnel id, blocks the whole step with a named
2633
+ * conflict rather than being adopted or replaced, the same do-not-adopt
2634
+ * policy {@link cloudflareZone} itself follows for the zone. A CNAME
2635
+ * already pointed at this tunnel is left alone, so a second apply changes
2636
+ * nothing. The caller ({@link cloudflareTunnel}) records the domain and the
2637
+ * returned hostname list as scope ids only once this method -- and, on a
2638
+ * domain change, the old domain's own cleanup -- has returned successfully
2639
+ * (round 4 finding 3), so a crash mid-write is recovered by re-running
2640
+ * this same idempotent step, never silently skipped.
2641
+ *
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.
2649
+ *
2650
+ * Round 4 finding 2: `previousHostnames` is this domain's own previously
2651
+ * recorded hostname list (empty when the domain just changed --
2652
+ * {@link cloudflareTunnel} hands the *whole* old domain to
2653
+ * {@link deleteStaleTunnelDnsRecords} for that case instead). A name in it
2654
+ * that the org's current config no longer produces is dropped from
2655
+ * `hostnames` below but was never cleaned up before this round: its CNAME
2656
+ * stayed live and its name stayed in the recorded list forever, because
2657
+ * 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.
2662
+ *
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.
2669
+ */
2670
+ async publishTunnelDnsRecords(org, domain, tunnelId, previousTunnelId, previousHostnames, context) {
2671
+ const cf = this.ports.cloudflare;
2672
+ const zone = await cf.findZoneByApex(domain);
2673
+ if (!zone) {
2674
+ 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
+ }
2676
+ 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}`));
2681
+ const droppedHostnames = previousHostnames.filter((hostname) => !hostnames.includes(hostname));
2682
+ const conflicts = [];
2683
+ const toCreate = [];
2684
+ const toRepoint = [];
2685
+ for (const hostname of hostnames) {
2686
+ const [a] = await cf.findDnsRecord(zone.id, hostname, 'A');
2687
+ const [aaaa] = await cf.findDnsRecord(zone.id, hostname, 'AAAA');
2688
+ const foreign = a ?? aaaa;
2689
+ if (foreign) {
2690
+ conflicts.push(`${hostname} already has a ${foreign.type} record`);
2691
+ continue;
2692
+ }
2693
+ const [cname] = await cf.findDnsRecord(zone.id, hostname, 'CNAME');
2694
+ if (cname && cname.content !== dnsTarget) {
2695
+ if (previousDnsTarget && cname.content === previousDnsTarget) {
2696
+ toRepoint.push({ hostname, recordId: cname.id });
2697
+ continue;
2698
+ }
2699
+ conflicts.push(`${hostname} is already a CNAME to ${cname.content}`);
2700
+ continue;
2701
+ }
2702
+ if (!cname)
2703
+ toCreate.push(hostname);
2704
+ }
2705
+ if (conflicts.length > 0) {
2706
+ throw new Error(`cloudflare:tunnel for ${org.slug}: will not point its tunnel at every hostname -- ${conflicts.join('; ')}; remove the conflicting record first`);
2707
+ }
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.
2715
+ 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(','));
2719
+ };
2720
+ if (settled.length > 0)
2721
+ recordPending();
2722
+ for (const hostname of toCreate) {
2723
+ await cf.createDnsRecord(zone.id, {
2724
+ type: 'CNAME',
2725
+ name: hostname,
2726
+ content: dnsTarget,
2727
+ proxied: true,
2728
+ });
2729
+ settled.push(hostname);
2730
+ recordPending();
2731
+ }
2732
+ for (const { hostname, recordId } of toRepoint) {
2733
+ await cf.updateDnsRecord(zone.id, recordId, {
2734
+ type: 'CNAME',
2735
+ name: hostname,
2736
+ content: dnsTarget,
2737
+ proxied: true,
2738
+ });
2739
+ settled.push(hostname);
2740
+ recordPending();
2741
+ }
2742
+ // Round 4 finding 2: clean up a hostname this domain itself dropped.
2743
+ // Not a conflict check -- there is nothing to create or repoint here,
2744
+ // 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.
2746
+ for (const hostname of droppedHostnames) {
2747
+ const [cname] = await cf.findDnsRecord(zone.id, hostname, 'CNAME');
2748
+ if (cname && (cname.content === dnsTarget || cname.content === previousDnsTarget)) {
2749
+ await cf.deleteDnsRecord(zone.id, cname.id);
2750
+ }
2751
+ }
2752
+ return hostnames;
2753
+ }
2262
2754
  /**
2263
2755
  * `plan/diff.ts`'s `planZone`: finds or creates the Cloudflare zone for the
2264
2756
  * org's own domain. Refuses to adopt a zone the store already records
@@ -2654,6 +3146,42 @@ export class FoundryStepExecutor {
2654
3146
  const { org, recordedIds } = context;
2655
3147
  try {
2656
3148
  switch (`${target.system}:${target.kind}`) {
3149
+ case 'zitadel:login-host': {
3150
+ // #274: `target.id` is the host itself (`zitadelLoginHost`'s own
3151
+ // `resourceId: host`), the same shape `plan-apply.ts`'s
3152
+ // `destroyServiceIdKey` and `ORDERED_PREFIXES` rely on. Each
3153
+ // removal catches its own not-found separately -- the domain
3154
+ // already gone must not skip the binding delete that follows it,
3155
+ // the same reason `cloudflare:tunnel` below handles its connector
3156
+ // app's 404 locally rather than leaning on this method's own
3157
+ // outer catch.
3158
+ const bindings = this.ports.loginHostBindings;
3159
+ // #274 fix round 2: any host `zitadelLoginHost` had already moved
3160
+ // away from but could not yet remove (some live app's login base
3161
+ // URI still pointed at it) is named only in
3162
+ // `LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY` -- nothing else ever runs
3163
+ // for this org again to finish removing it once destroy deletes
3164
+ // the ZITADEL project and org below, so it has to go here too.
3165
+ for (const host of [target.id, ...pendingRemovalHosts(recordedIds)]) {
3166
+ try {
3167
+ await this.ports.zitadel.deleteTrustedDomain(host);
3168
+ }
3169
+ catch (error) {
3170
+ if (!isNotFoundError(error))
3171
+ throw error;
3172
+ }
3173
+ if (bindings) {
3174
+ try {
3175
+ await bindings.deleteBinding(host);
3176
+ }
3177
+ catch (error) {
3178
+ if (!isNotFoundError(error))
3179
+ throw error;
3180
+ }
3181
+ }
3182
+ }
3183
+ return;
3184
+ }
2657
3185
  case 'cloudflare:tunnel': {
2658
3186
  // #235: the connector app first, its own 404 handled locally --
2659
3187
  // a retry where this already succeeded must still reach
@@ -2669,6 +3197,75 @@ export class FoundryStepExecutor {
2669
3197
  throw error;
2670
3198
  }
2671
3199
  }
3200
+ // #236: every CNAME `publishTunnelDnsRecords` created for this
3201
+ // org's tunnel, in the zone it was *last* published under -- the
3202
+ // recorded domain, not whatever `org.domain` says now, since a
3203
+ // domain change (or the org being deleted entirely) can leave
3204
+ // them disagreeing. Round 3: looks each recorded hostname up by
3205
+ // name (`CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY`) rather than
3206
+ // listing every CNAME in the zone -- the real adapter's
3207
+ // `listDnsRecords` asks for one page of 100 and never walks the
3208
+ // rest, so a zone with more records than that silently lost
3209
+ // everything past the first page. A hostname a later config
3210
+ // change dropped is still in that recorded list, so it still gets
3211
+ // cleaned up. A record from before that key existed falls back to
3212
+ // the old zone-wide list, the one case with nothing recorded to
3213
+ // look up instead. 404 handled locally, same reason as the
3214
+ // connector above.
3215
+ //
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];
3231
+ const dnsTargets = new Set([`${target.id}.cfargotunnel.com`]);
3232
+ if (previousTunnelId && previousTunnelId !== target.id) {
3233
+ dnsTargets.add(`${previousTunnelId}.cfargotunnel.com`);
3234
+ }
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;
3248
+ try {
3249
+ const zone = await this.ports.cloudflare.findZoneByApex(dnsDomain);
3250
+ if (zone) {
3251
+ 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()
3256
+ : await cf.listDnsRecords(zone.id, { type: 'CNAME' });
3257
+ for (const record of records) {
3258
+ if (dnsTargets.has(record.content)) {
3259
+ await cf.deleteDnsRecord(zone.id, record.id);
3260
+ }
3261
+ }
3262
+ }
3263
+ }
3264
+ catch (error) {
3265
+ if (!isNotFoundError(error))
3266
+ throw error;
3267
+ }
3268
+ }
2672
3269
  await this.ports.cloudflare.deleteTunnel(target.id);
2673
3270
  return;
2674
3271
  }