@wtfalch/foundry 0.19.0 → 0.20.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.
@@ -5,7 +5,7 @@ export { computePlanDetail, toPlan } from './diff.js';
5
5
  export type { StepSystem } from './step-system.js';
6
6
  export { stepSystemOf } from './step-system.js';
7
7
  export type { PlatformPlanDetail } from './platform-diff.js';
8
- export { planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, } from './platform-diff.js';
8
+ export { planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, } from './platform-diff.js';
9
9
  export type { HeldKeyResolver, OrgPlatformReadiness } from './platform-org.js';
10
10
  export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './platform-org.js';
11
11
  export type { PlatformOrgClientFactories } from './platform-org-ports.js';
@@ -15,7 +15,7 @@ export { AdoptRefusedError } from './adopt.js';
15
15
  export type { AuditActor, AuditLog, AuthzGate, StepExecutor, StepDestroyer, DestroyTarget, StepExecutionContext, PlatformStepExecutor, PlatformStepExecutionContext, FoundryPlanApplyOptions, } from './plan-apply.js';
16
16
  export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan-apply.js';
17
17
  export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, filesPublicHostsValue, filesShareDomains, } from './platform-providers.js';
18
- export type { PlatformAuthDeps, PlatformAuthRuntime, PlatformFilesDeps, PlatformFilesRuntime, PlatformEdgeDeps, PlatformEdgeRuntime, } from './platform-providers.js';
18
+ export type { PlatformAuthDeps, PlatformAuthRuntime, PlatformFilesDeps, PlatformFilesRuntime, PlatformEdgeDeps, PlatformDnsFactory, PlatformAccessFactory, PlatformAccessPort, PlatformTunnelsFactory, PlatformEdgeRuntime, } from './platform-providers.js';
19
19
  export type { PlatformMailDeps, PlatformMailAdminPort, PlatformMailRuntime, } from './platform-providers.js';
20
20
  export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, PLATFORM_AUTH_INSTANCE_BEARER_REF, } from './platform-providers.js';
21
21
  export { AI_MANAGEMENT_ISSUER, AI_MANAGEMENT_KEY_ID, AI_SIGNING_KEY_REF, AiManagementKeyExistsError, createAiManagementKeyMinter, PLATFORM_AI_MANAGEMENT_KEY_REF, PLATFORM_AI_MANAGEMENT_PERMISSIONS, } from './platform-providers.js';
@@ -27,7 +27,7 @@ export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep } from './pla
27
27
  export type { MailCopyJob, MailCopyPort, MailCopyStepResult } from './mail-copy.js';
28
28
  export { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, MissingMailCopyAccountsError, MissingMailCopySourceTokenError, MissingMailCopyStalwartTokenError, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
29
29
  export type { ScopeResolver, RecordedIds } from './providers-readers.js';
30
- export { ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
30
+ export { ProvidersAiReader, ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
31
31
  export type { AgoraHostPort, AgoraHostValues } from './agora.js';
32
32
  export type { BrokerPort } from './broker-entries.js';
33
33
  export { PlatformBrokerPort } from './broker-entries.js';
@@ -39,6 +39,18 @@ export type { MailAllowlistApp } from './mail-identity-allowlist.js';
39
39
  export { checkMailAppsAllowlist } from './mail-identity-allowlist.js';
40
40
  export type { CoolifyPort, CloudflarePort, ZitadelPort, CallsSfuPort, JobsSchedulerPort, FilesOrganisationPort, AiOrganisationPort, FoundryStepExecutorPorts, PlatformOrgPortSource, } from './executor.js';
41
41
  export { FoundryStepExecutor, NotBuiltError, healthUrls } from './executor.js';
42
+ export { createEdgeDns, EDGE_DNS_WRITE_CONCURRENCY } from './edge-dns.js';
43
+ export type { DnsPort, DnsRecordInput, DnsRecordSummary, EdgePlatformDns } from './edge-dns.js';
44
+ export { createEdgeAccess } from './edge-access.js';
45
+ export type { AccessAppSummary, AccessDecision, AccessPolicyRule, AccessPort, EdgePlatformAccess, } from './edge-access.js';
46
+ export { createEdgeWorkers } from './edge-workers.js';
47
+ export type { EdgePlatformWorkers, EdgeWorkersOptions } from './edge-workers.js';
48
+ export { createEdgeRateLimits } from './edge-ratelimits.js';
49
+ export type { EdgePlatformRateLimits, EdgeRateLimitsOptions, RateLimitPort, RateLimitRuleInput, } from './edge-ratelimits.js';
50
+ export { createEdgeZones } from './edge-zones.js';
51
+ export type { EdgePlatformZones, EdgeZonesOptions, ZonePort } from './edge-zones.js';
52
+ export { createEdgeTunnels } from './edge-tunnels.js';
53
+ export type { EdgePlatformTunnels, TunnelIngress, TunnelPort, TunnelSummary, } from './edge-tunnels.js';
42
54
  export type { ZitadelListAppsRawApp } from './executor.js';
43
55
  export { adaptZitadelListApps } from './executor.js';
44
56
  export { HttpJobsSchedulerClient } from './jobs-scheduler-http.js';
@@ -1,7 +1,7 @@
1
1
  export { readLiveResources, readLivePlatformResources } from './live.js';
2
2
  export { computePlanDetail, toPlan } from './diff.js';
3
3
  export { stepSystemOf } from './step-system.js';
4
- export { planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, } from './platform-diff.js';
4
+ export { planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, } from './platform-diff.js';
5
5
  export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './platform-org.js';
6
6
  export { PlatformOrgBlockedError, PlatformOrgPorts } from './platform-org-ports.js';
7
7
  export { AdoptRefusedError } from './adopt.js';
@@ -14,11 +14,17 @@ export { createZitadelAdminMinter } from './zitadel-machine-user.js';
14
14
  export { createZitadelProjectRoles } from './zitadel-project-roles.js';
15
15
  export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep } from './platform-sfu.js';
16
16
  export { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, MissingMailCopyAccountsError, MissingMailCopySourceTokenError, MissingMailCopyStalwartTokenError, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
17
- export { ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
17
+ export { ProvidersAiReader, ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
18
18
  export { PlatformBrokerPort } from './broker-entries.js';
19
19
  export { loginHostnameFor } from './login-host.js';
20
20
  export { checkMailIdentityAudience } from './mail-identity.js';
21
21
  export { checkMailAppsAllowlist } from './mail-identity-allowlist.js';
22
22
  export { FoundryStepExecutor, NotBuiltError, healthUrls } from './executor.js';
23
+ export { createEdgeDns, EDGE_DNS_WRITE_CONCURRENCY } from './edge-dns.js';
24
+ export { createEdgeAccess } from './edge-access.js';
25
+ export { createEdgeWorkers } from './edge-workers.js';
26
+ export { createEdgeRateLimits } from './edge-ratelimits.js';
27
+ export { createEdgeZones } from './edge-zones.js';
28
+ export { createEdgeTunnels } from './edge-tunnels.js';
23
29
  export { adaptZitadelListApps } from './executor.js';
24
30
  export { HttpJobsSchedulerClient } from './jobs-scheduler-http.js';
@@ -8,7 +8,7 @@ import { AdoptRefusedError, adoptCandidates, adoptedKeysUnreadable, adoptedScope
8
8
  import { NO_AGORA_HOST_VALUES, readAgoraHost, } from './agora.js';
9
9
  import { AI_KEY_UNDELIVERED_SCOPE_KEY, JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY, computePlanDetail, toPlan, } from './diff.js';
10
10
  import { entryAiUndeliveredScopeKey } from './entry-ai-key.js';
11
- import { entryEdgeRetiredScopeKey, entryEdgeUndeliveredScopeKey } from './entry-edge-key.js';
11
+ import { BOULE_EDGE_ENTRY_KEY, entryEdgeRetiredScopeKey, entryEdgeUndeliveredScopeKey, } from './entry-edge-key.js';
12
12
  import { FILES_ROOT_UNDELIVERED_SCOPE_KEY, entryFilesUndeliveredScopeKey, } from './entry-files-key.js';
13
13
  import { readLivePlatformResources, readLiveResources, } from './live.js';
14
14
  import { lockedHostnames } from './locked-hostnames.js';
@@ -1376,6 +1376,10 @@ function failedFields(org, detail, completed, serviceIds) {
1376
1376
  failed.add('hosting.entries.uses.edge');
1377
1377
  }
1378
1378
  }
1379
+ // F3: Boule's own key, wanted whenever the org has a domain.
1380
+ if (org.domain && serviceIds[entryEdgeUndeliveredScopeKey(BOULE_EDGE_ENTRY_KEY)]) {
1381
+ failed.add('hosting.entries.uses.edge');
1382
+ }
1379
1383
  // The same for a jobs registration whose credentials never reached the host.
1380
1384
  if (serviceIds[JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY] &&
1381
1385
  !serviceIds[`jobs:registration:${org.id}`]) {
@@ -665,6 +665,15 @@ export declare const PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY = "edge_platform_k
665
665
  */
666
666
  export declare const PLATFORM_EDGE_APPLIED_HOSTNAME_SCOPE_KEY = "edge_applied_hostname";
667
667
  export declare const PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY = "edge_applied_git_ref";
668
+ /**
669
+ * F4.dns: where edge answers on the Coolify network
670
+ * (`http://<custom_internal_name>:3000`), recorded by the edge step once the
671
+ * host reports healthy there. Edge is the only caller of Cloudflare, so every
672
+ * platform step that writes a DNS record (auth, mail, archon, and edge's own
673
+ * CNAME) reaches edge at this address, before any public hostname exists
674
+ * (the {@link PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY} pattern). Not a secret.
675
+ */
676
+ export declare const PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY = "edge_platform_internal_url";
668
677
  /**
669
678
  * Options for {@link planPlatform}, replacing four positional arguments that
670
679
  * shifted on every merge that added a platform service (#186, #187, #188) --
@@ -741,6 +741,15 @@ export const PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY = 'edge_platform_key_set';
741
741
  */
742
742
  export const PLATFORM_EDGE_APPLIED_HOSTNAME_SCOPE_KEY = 'edge_applied_hostname';
743
743
  export const PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY = 'edge_applied_git_ref';
744
+ /**
745
+ * F4.dns: where edge answers on the Coolify network
746
+ * (`http://<custom_internal_name>:3000`), recorded by the edge step once the
747
+ * host reports healthy there. Edge is the only caller of Cloudflare, so every
748
+ * platform step that writes a DNS record (auth, mail, archon, and edge's own
749
+ * CNAME) reaches edge at this address, before any public hostname exists
750
+ * (the {@link PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY} pattern). Not a secret.
751
+ */
752
+ export const PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY = 'edge_platform_internal_url';
744
753
  /**
745
754
  * #167: the platform's own diff engine, mirroring `diff.ts`'s
746
755
  * `computePlanDetail` for the one shared {@link DeclaredPlatform} record
@@ -782,7 +791,24 @@ export function planPlatform(platform, live, options = {}) {
782
791
  const sfu = platform.sfu.enabled && sfuBlocked ? null : planSfu(platform);
783
792
  if (sfu)
784
793
  plannedSteps.push(sfu);
785
- const auth = planAuth(platform, recordedIds, blocked.auth, blocked.authExecutorMissing ?? false, drifted.auth ?? false, blocked.authOtherInputsMissing ?? []);
794
+ // #478, F4.dns: first of the services, ahead of auth. Edge is the only
795
+ // caller of Cloudflare, so auth, mail and archon write their DNS records
796
+ // through it; it needs none of them (a Coolify database and app, its own
797
+ // tunnel and DNS record, a held Cloudflare token) and records the address
798
+ // they reach it at.
799
+ const edge = planEdge(platform, recordedIds, blocked.edge);
800
+ if (edge.step)
801
+ plannedSteps.push(edge.step);
802
+ // Auth, mail and archon would build their tunnels and then fail at the DNS
803
+ // write, so while edge is neither applied nor planned in this plan they are
804
+ // blocked, not planned (rule 2), naming edge as the cause.
805
+ const edgeApplied = Boolean(recordedIds[PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY]);
806
+ const dnsBlocked = edgeApplied || edge.step
807
+ ? undefined
808
+ : (service) => `the platform's ${service} is blocked, not planned: every DNS record is written through edge, and edge is neither applied nor planned (${platform.edge.enabled
809
+ ? (edge.blocked ?? 'its step is not planned')
810
+ : 'the platform does not enable edge'})`;
811
+ const auth = planAuth(platform, recordedIds, blocked.auth ?? dnsBlocked?.('sign-in'), blocked.authExecutorMissing ?? false, drifted.auth ?? false, blocked.authOtherInputsMissing ?? []);
786
812
  if (auth.step)
787
813
  plannedSteps.push(auth.step);
788
814
  // #174: the integrations host. Planned after auth; neither step reads the
@@ -798,21 +824,17 @@ export function planPlatform(platform, live, options = {}) {
798
824
  plannedSteps.push(files.step);
799
825
  // #169: the platform's own mail server. After auth, because its OIDC
800
826
  // directory is built against the platform's sign-in.
801
- const mail = planMail(platform, recordedIds, blocked.mail, authReady);
827
+ const mail = planMail(platform, recordedIds, blocked.mail ?? dnsBlocked?.('mail server'), authReady);
802
828
  if (mail.step)
803
829
  plannedSteps.push(mail.step);
804
830
  // #172: a throw halts every step after it, so this one goes late.
805
831
  const ai = planAi(platform, recordedIds, blocked.ai);
806
832
  if (ai.step)
807
833
  plannedSteps.push(ai.step);
808
- // #478: independent of auth and files -- the edge host needs neither.
809
- const edge = planEdge(platform, recordedIds, blocked.edge);
810
- if (edge.step)
811
- plannedSteps.push(edge.step);
812
834
  // #175: last, on purpose. Archon's sign-in client lives in the sign-in
813
835
  // `planAuth` builds, and its env reads what every other service's step
814
836
  // records or delivers, so in one apply it runs after all of them.
815
- const archon = planArchon(platform, recordedIds, blocked.archon, authReady, drifted.archon);
837
+ const archon = planArchon(platform, recordedIds, blocked.archon ?? dnsBlocked?.('Archon'), authReady, drifted.archon);
816
838
  if (archon.step)
817
839
  plannedSteps.push(archon.step);
818
840
  // #257: after every service above, so a database any of them just
@@ -1780,7 +1802,9 @@ function planEdge(platform, recordedIds, executorBlocked) {
1780
1802
  }
1781
1803
  const applied = Boolean(recordedIds[resourceKey('coolify', 'app', PLATFORM_EDGE_KEY)]) &&
1782
1804
  recordedIds[PLATFORM_EDGE_APPLIED_HOSTNAME_SCOPE_KEY] === platform.edge.hostname &&
1783
- recordedIds[PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY] === platform.edge.gitRef;
1805
+ recordedIds[PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY] === platform.edge.gitRef &&
1806
+ // F4.dns: an edge applied before its address was recorded runs once more to record it.
1807
+ Boolean(recordedIds[PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY]);
1784
1808
  if (applied)
1785
1809
  return { step: null, blocked: null };
1786
1810
  if (executorBlocked)
@@ -3,6 +3,9 @@ import type { DeclaredOrg } from '../org.js';
3
3
  import type { DeclaredPlatform } from '../platform.js';
4
4
  import type { DeclaredOrgStore, PlatformStore } from '../store.js';
5
5
  import type { StepAction } from './diff.js';
6
+ import type { AccessPort } from './edge-access.js';
7
+ import type { DnsPort } from './edge-dns.js';
8
+ import type { TunnelPort } from './edge-tunnels.js';
6
9
  import type { CloudflarePort, CoolifyPort, FoundryStepExecutorPorts, ZitadelPort } from './executor.js';
7
10
  import type { LiveResource, PlatformCloudflareReader, PlatformCoolifyReader } from './live.js';
8
11
  import type { PlatformStepExecutionContext, PlatformStepExecutor } from './plan-apply.js';
@@ -14,6 +17,34 @@ import { type CallsSfuPlatformDeps } from './platform-sfu.js';
14
17
  * positions, so every #167-era caller of {@link FoundryPlatformStepExecutor}
15
18
  * (zone only) keeps compiling with this third argument simply omitted.
16
19
  */
20
+ /**
21
+ * F4.dns: builds the {@link DnsPort} a platform step writes its records
22
+ * through, given the address edge answers at on the Coolify network
23
+ * ({@link PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY}). The host builds it with
24
+ * `createEdgeDns(createEdgeClient({ baseUrl, key }).platform.dns)`, `key`
25
+ * being the platform key held under {@link PLATFORM_EDGE_KEY_REF}.
26
+ */
27
+ export type PlatformDnsFactory = (edgeUrl: string) => DnsPort | Promise<DnsPort>;
28
+ /**
29
+ * F4.tunnels: builds the {@link TunnelPort} a platform step creates, configures
30
+ * and reads the connector token of its tunnel through, given the same address
31
+ * as {@link PlatformDnsFactory}. The host builds it with
32
+ * `createEdgeTunnels(createEdgeClient({ baseUrl, key }).platform.tunnels)`,
33
+ * `key` being the platform key held under {@link PLATFORM_EDGE_KEY_REF}.
34
+ */
35
+ export type PlatformTunnelsFactory = (edgeUrl: string) => PlatformTunnelPort | Promise<PlatformTunnelPort>;
36
+ /** The tunnel calls a platform step makes; it never deletes one. */
37
+ export type PlatformTunnelPort = Pick<TunnelPort, 'findTunnelByName' | 'createTunnel' | 'putTunnelConfig' | 'getTunnelToken'>;
38
+ /**
39
+ * F4.access: builds the {@link AccessPort} a platform step finds, creates and
40
+ * deletes its Access app through, given the same address as
41
+ * {@link PlatformDnsFactory}. The host builds it with
42
+ * `createEdgeAccess(createEdgeClient({ baseUrl, key }).platform.access)`,
43
+ * `key` being the platform key held under {@link PLATFORM_EDGE_KEY_REF}.
44
+ */
45
+ export type PlatformAccessFactory = (edgeUrl: string) => PlatformAccessPort | Promise<PlatformAccessPort>;
46
+ /** The Access calls a platform step makes. */
47
+ export type PlatformAccessPort = Pick<AccessPort, 'listAccessApps' | 'createAccessApp' | 'listAccessPolicies' | 'createAccessPolicy' | 'deleteAccessApp'>;
17
48
  /**
18
49
  * The SMTP call `provisionPlatformAuth` makes on the instance's ZITADEL.
19
50
  * `@wtfalch/providers` gains it in 0.7.2; stated here so foundry compiles
@@ -35,9 +66,11 @@ export interface PlatformZitadelSmtp {
35
66
  }
36
67
  export interface PlatformAuthDeps {
37
68
  coolify: Pick<CoolifyPort, 'findProjectByName' | 'createProject' | 'findOrCreateEnvironment' | 'listServers' | 'findApplicationByName' | 'getApplication' | 'requireGithubApp' | 'createGithubAppApplication' | 'createDockerImageApplication' | 'createDockerfileApplication' | 'createPostgresDatabase' | 'getDatabase' | 'bulkUpsertEnvs' | 'listEnvs' | 'updateApplication' | 'deploy' | 'raw'>;
38
- cloudflare: Pick<CloudflarePort, 'findZoneByApex' | 'findTunnelByName' | 'createTunnel' | 'putTunnelConfig' | 'findDnsRecord' | 'createDnsRecord' | 'updateDnsRecord'
39
- /** #197: fetches the tunnel's connector token for {@link ensureCloudflaredConnector}. */
40
- | 'getTunnelToken'>;
69
+ /** F4.dns: every DNS record call, through edge at its recorded address (see {@link PlatformDnsFactory}). */
70
+ dns: PlatformDnsFactory;
71
+ /** F4.tunnels: every tunnel call, through edge at its recorded address (see {@link PlatformTunnelsFactory}). */
72
+ tunnels: PlatformTunnelsFactory;
73
+ cloudflare: Pick<CloudflarePort, 'findZoneByApex'>;
41
74
  /** Must target the platform's own new instance (its issuer), or `zitadelForInstance` must be set. */
42
75
  zitadel: Pick<ZitadelPort, 'findOrCreateOrg' | 'mintAdminMachineUser' | 'mintLoginClientMachineUser' | 'listLoginClientPats' | 'inviteOrgOwner'> & PlatformZitadelSmtp;
43
76
  /**
@@ -377,7 +410,11 @@ export declare function defaultStalwartForInstance(input: {
377
410
  */
378
411
  export interface PlatformMailDeps {
379
412
  coolify: Pick<CoolifyPort, 'findProjectByName' | 'createProject' | 'findOrCreateEnvironment' | 'listServers' | 'findApplicationByName' | 'getApplication' | 'createDockerImageApplication' | 'createDockerfileApplication' | 'raw' | 'listStorages' | 'createPersistentStorage' | 'bulkUpsertEnvs' | 'listEnvs' | 'updateApplication' | 'deploy' | 'deployAndWait'>;
380
- cloudflare: Pick<CloudflarePort, 'findZoneByApex' | 'findTunnelByName' | 'createTunnel' | 'putTunnelConfig' | 'getTunnelToken' | 'findDnsRecord' | 'createDnsRecord' | 'updateDnsRecord'>;
413
+ /** F4.dns: every DNS record call, through edge at its recorded address (see {@link PlatformDnsFactory}). */
414
+ dns: PlatformDnsFactory;
415
+ /** F4.tunnels: every tunnel call, through edge at its recorded address (see {@link PlatformTunnelsFactory}). */
416
+ tunnels: PlatformTunnelsFactory;
417
+ cloudflare: Pick<CloudflarePort, 'findZoneByApex'>;
381
418
  /**
382
419
  * Must target the platform's own ZITADEL instance, or `zitadelForInstance`
383
420
  * must be set. `provisionPlatformMail` finds or creates a `mail` project
@@ -421,7 +458,13 @@ export interface PlatformMailRuntime {
421
458
  */
422
459
  export interface PlatformArchonDeps {
423
460
  coolify: Pick<CoolifyPort, 'findProjectByName' | 'createProject' | 'findOrCreateEnvironment' | 'listServers' | 'findApplicationByName' | 'getApplication' | 'requireGithubApp' | 'createGithubAppApplication' | 'createDockerfileApplication' | 'listDatabases' | 'createPostgresDatabase' | 'getDatabase' | 'raw' | 'bulkUpsertEnvs' | 'listEnvs' | 'deleteEnv' | 'updateApplication' | 'deploy' | 'deployAndWait'>;
424
- cloudflare: Pick<CloudflarePort, 'findZoneByApex' | 'findTunnelByName' | 'createTunnel' | 'putTunnelConfig' | 'getTunnelToken' | 'findDnsRecord' | 'createDnsRecord' | 'updateDnsRecord' | 'listAccessApps' | 'createAccessApp' | 'listAccessPolicies' | 'createAccessPolicy' | 'deleteAccessApp'>;
461
+ /** F4.dns: every DNS record call, through edge at its recorded address (see {@link PlatformDnsFactory}). */
462
+ dns: PlatformDnsFactory;
463
+ /** F4.tunnels: every tunnel call, through edge at its recorded address (see {@link PlatformTunnelsFactory}). */
464
+ tunnels: PlatformTunnelsFactory;
465
+ /** F4.access: every Access app call, through edge at its recorded address (see {@link PlatformAccessFactory}). */
466
+ access: PlatformAccessFactory;
467
+ cloudflare: Pick<CloudflarePort, 'findZoneByApex'>;
425
468
  /** Must target the platform's own ZITADEL instance, or `zitadelForInstance` must be set. */
426
469
  zitadel: Pick<ZitadelPort, 'findOrCreateOrg' | 'findOrCreateProject' | 'createOidcApp' | 'listApps' | 'deleteApp'>;
427
470
  /** {@link PlatformAuthDeps.zitadelForInstance}, for Archon's sign-in client. */
@@ -834,7 +877,11 @@ export interface PlatformEdgeRuntime {
834
877
  }
835
878
  export interface PlatformEdgeDeps {
836
879
  coolify: Pick<CoolifyPort, 'findProjectByName' | 'createProject' | 'findOrCreateEnvironment' | 'listServers' | 'findApplicationByName' | 'getApplication' | 'requireGithubApp' | 'createGithubAppApplication' | 'createDockerfileApplication' | 'listDatabases' | 'createPostgresDatabase' | 'getDatabase' | 'bulkUpsertEnvs' | 'listEnvs' | 'updateApplication' | 'deploy' | 'deployAndWait' | 'raw'>;
837
- cloudflare: Pick<CloudflarePort, 'findZoneByApex' | 'findTunnelByName' | 'createTunnel' | 'putTunnelConfig' | 'getTunnelToken' | 'findDnsRecord' | 'createDnsRecord' | 'updateDnsRecord'>;
880
+ /** F4.dns: every DNS record call, through edge at its recorded address (see {@link PlatformDnsFactory}). */
881
+ dns: PlatformDnsFactory;
882
+ /** F4.tunnels: every tunnel call, through edge at its recorded address (see {@link PlatformTunnelsFactory}). */
883
+ tunnels: PlatformTunnelsFactory;
884
+ cloudflare: Pick<CloudflarePort, 'findZoneByApex'>;
838
885
  deliverSecret: FoundryStepExecutorPorts['deliverSecret'];
839
886
  /**
840
887
  * Reads the edge service's own Cloudflare API token back, held under
@@ -7,7 +7,7 @@ import { ensureCloudflaredConnector } from './cloudflared-connector.js';
7
7
  import { waitForDeploymentFinished, waitForHealthyVersion, waitForNewDeployment, } from './deploy-health.js';
8
8
  import { ACCESS_POLICY_NAME, getDatabaseStarted, isNotFoundError, isRunningOrStarting, } from './executor.js';
9
9
  import { coolifyDomains, filesShareHostname } from './hostnames.js';
10
- import { AUTH_ZITADEL_RUN_OPTIONS, JOBS_SCHEDULER_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_OPERATOR_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_COOLIFY_READ_TOKEN_REF, PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, PLATFORM_AUTH_SMTP_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_EDGE_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_EDGE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY, PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_PREVIOUS_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_REQUESTED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_RUNNER_KEYRING_DEPLOYED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, archonBootstrapOperator, parseBrokerDeployRecord, parseLoginClientRecord, platformMailMxHost, } from './platform-diff.js';
10
+ import { AUTH_ZITADEL_RUN_OPTIONS, JOBS_SCHEDULER_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_OPERATOR_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_COOLIFY_READ_TOKEN_REF, PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, PLATFORM_AUTH_SMTP_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_EDGE_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY, PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_PREVIOUS_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_REQUESTED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_RUNNER_KEYRING_DEPLOYED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, archonBootstrapOperator, parseBrokerDeployRecord, parseLoginClientRecord, platformMailMxHost, } from './platform-diff.js';
11
11
  import { applyCallsSfuPlatformStep } from './platform-sfu.js';
12
12
  import { CreateInstanceError, createZitadelInstance, generateSystemApiKeypair, instanceWithoutBearerMessage, systemApiUsersEnv, } from './zitadel-system-api.js';
13
13
  /** The key `runPlatformSteps` records the apex zone's id under (`system:kind:key`). */
@@ -16,6 +16,35 @@ function apexZoneKey(apex) {
16
16
  }
17
17
  /** The key `runPlatformSteps` records the jobs scheduler app's id under (`system:kind:key`, #173). */
18
18
  const JOBS_APP_KEY = `coolify:jobs:${JOBS_SCHEDULER_KEY}`;
19
+ /**
20
+ * The DNS port for a platform step: edge's recorded address, never a public
21
+ * hostname, because auth, mail and archon write their records before any of
22
+ * those exist. Edge's step runs first and records the address
23
+ * (`planPlatform`), so a missing one means that step has not finished.
24
+ */
25
+ async function platformDns(deps, recorded, step) {
26
+ const edgeUrl = recorded[PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY];
27
+ if (!edgeUrl) {
28
+ throw new Error(`${step}: edge has not been applied yet (no recorded ${PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY}); platform:edge runs first and every DNS record is written through it`);
29
+ }
30
+ return deps.dns(edgeUrl);
31
+ }
32
+ /** The tunnel port for a platform step; same address and same reason as {@link platformDns}. */
33
+ async function platformTunnels(deps, recorded, step) {
34
+ const edgeUrl = recorded[PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY];
35
+ if (!edgeUrl) {
36
+ throw new Error(`${step}: edge has not been applied yet (no recorded ${PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY}); platform:edge runs first and every tunnel is managed through it`);
37
+ }
38
+ return deps.tunnels(edgeUrl);
39
+ }
40
+ /** The Access port for a platform step; same address and same reason as {@link platformDns}. */
41
+ async function platformAccess(deps, recorded, step) {
42
+ const edgeUrl = recorded[PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY];
43
+ if (!edgeUrl) {
44
+ throw new Error(`${step}: edge has not been applied yet (no recorded ${PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY}); platform:edge runs first and every Access app is managed through it`);
45
+ }
46
+ return deps.access(edgeUrl);
47
+ }
19
48
  /**
20
49
  * #173: the jobs scheduler's own Coolify project and database, flat scope
21
50
  * ids recorded via `PlatformStepExecutionContext.recordScopeId` -- not
@@ -271,7 +300,7 @@ export class FoundryPlatformStepExecutor {
271
300
  if (platform.archon.enabled &&
272
301
  this.archon &&
273
302
  recordedIds[`coolify:app:${PLATFORM_ARCHON_KEY}`]) {
274
- const accessDrifted = await archonAccessDrifted(this.archon.cloudflare, platform.archon.hostname, this.archon.accessEmails);
303
+ const accessDrifted = await archonAccessDrifted(this.archon, recordedIds, platform.archon.hostname, this.archon.accessEmails);
275
304
  const brokerDrifted = !accessDrifted && (await archonBrokerRegistrationDrifted(this.archon.coolify, recordedIds));
276
305
  const bootstrapOperatorDrifted = !accessDrifted &&
277
306
  !brokerDrifted &&
@@ -1193,10 +1222,11 @@ async function provisionPlatformAuth(context, deps, owners = []) {
1193
1222
  await record(PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, brokerDeployRecordValue(brokerAppId, authAppsRaw));
1194
1223
  }
1195
1224
  // ---- tunnel and DNS ------------------------------------------------------
1196
- const existingTunnel = await deps.cloudflare.findTunnelByName(PLATFORM_AUTH_TUNNEL_NAME);
1197
- const tunnel = existingTunnel ?? (await deps.cloudflare.createTunnel(PLATFORM_AUTH_TUNNEL_NAME));
1225
+ const tunnels = await platformTunnels(deps, context.recordedIds(), 'platform:auth');
1226
+ const existingTunnel = await tunnels.findTunnelByName(PLATFORM_AUTH_TUNNEL_NAME);
1227
+ const tunnel = existingTunnel ?? (await tunnels.createTunnel(PLATFORM_AUTH_TUNNEL_NAME));
1198
1228
  await record(PLATFORM_AUTH_TUNNEL_SCOPE_KEY, tunnel.id);
1199
- await deps.cloudflare.putTunnelConfig(tunnel.id, {
1229
+ await tunnels.putTunnelConfig(tunnel.id, {
1200
1230
  ingress: [
1201
1231
  {
1202
1232
  hostname,
@@ -1221,7 +1251,7 @@ async function provisionPlatformAuth(context, deps, owners = []) {
1221
1251
  // step's own tunnel only (see this function's doc comment).
1222
1252
  await ensureCloudflaredConnector({
1223
1253
  coolify: deps.coolify,
1224
- cloudflare: deps.cloudflare,
1254
+ tunnels,
1225
1255
  tunnelId: tunnel.id,
1226
1256
  appName: PLATFORM_AUTH_CLOUDFLARED_APP_NAME,
1227
1257
  projectUuid,
@@ -1237,9 +1267,10 @@ async function provisionPlatformAuth(context, deps, owners = []) {
1237
1267
  throw new Error(`platform:auth: no Cloudflare zone for ${platform.apex} to put ${hostname} in`);
1238
1268
  }
1239
1269
  const dnsTarget = `${tunnel.id}.cfargotunnel.com`;
1240
- const [dnsRecord] = await deps.cloudflare.findDnsRecord(zoneId, hostname, 'CNAME');
1270
+ const dns = await platformDns(deps, context.recordedIds(), 'platform:auth');
1271
+ const [dnsRecord] = await dns.findDnsRecord(platform.apex, hostname, 'CNAME');
1241
1272
  if (!dnsRecord) {
1242
- await deps.cloudflare.createDnsRecord(zoneId, {
1273
+ await dns.createDnsRecord(platform.apex, {
1243
1274
  type: 'CNAME',
1244
1275
  name: hostname,
1245
1276
  content: dnsTarget,
@@ -1247,7 +1278,7 @@ async function provisionPlatformAuth(context, deps, owners = []) {
1247
1278
  });
1248
1279
  }
1249
1280
  else if (dnsRecord.content !== dnsTarget) {
1250
- await deps.cloudflare.updateDnsRecord(zoneId, dnsRecord.id, {
1281
+ await dns.updateDnsRecord(platform.apex, dnsRecord.id, {
1251
1282
  type: 'CNAME',
1252
1283
  name: hostname,
1253
1284
  content: dnsTarget,
@@ -1884,10 +1915,11 @@ async function provisionPlatformMail(context, deps) {
1884
1915
  await record(PLATFORM_MAIL_DEPLOYED_GIT_REF_SCOPE_KEY, imageTag);
1885
1916
  }
1886
1917
  // ---- tunnel and DNS: web/JMAP/management ------------------------------------
1887
- const existingTunnel = await deps.cloudflare.findTunnelByName(PLATFORM_MAIL_TUNNEL_NAME);
1888
- const tunnel = existingTunnel ?? (await deps.cloudflare.createTunnel(PLATFORM_MAIL_TUNNEL_NAME));
1918
+ const tunnels = await platformTunnels(deps, context.recordedIds(), 'platform:mail');
1919
+ const existingTunnel = await tunnels.findTunnelByName(PLATFORM_MAIL_TUNNEL_NAME);
1920
+ const tunnel = existingTunnel ?? (await tunnels.createTunnel(PLATFORM_MAIL_TUNNEL_NAME));
1889
1921
  await record(PLATFORM_MAIL_TUNNEL_SCOPE_KEY, tunnel.id);
1890
- await deps.cloudflare.putTunnelConfig(tunnel.id, {
1922
+ await tunnels.putTunnelConfig(tunnel.id, {
1891
1923
  ingress: [
1892
1924
  {
1893
1925
  hostname,
@@ -1899,7 +1931,7 @@ async function provisionPlatformMail(context, deps) {
1899
1931
  // #197: without a connector, `hostname` answers with a Cloudflare error.
1900
1932
  await ensureCloudflaredConnector({
1901
1933
  coolify: deps.coolify,
1902
- cloudflare: deps.cloudflare,
1934
+ tunnels,
1903
1935
  tunnelId: tunnel.id,
1904
1936
  appName: PLATFORM_MAIL_CLOUDFLARED_APP_NAME,
1905
1937
  projectUuid,
@@ -1914,9 +1946,10 @@ async function provisionPlatformMail(context, deps) {
1914
1946
  throw new Error(`platform:mail: no Cloudflare zone for ${platform.apex} to put ${hostname} in`);
1915
1947
  }
1916
1948
  const dnsTarget = `${tunnel.id}.cfargotunnel.com`;
1917
- const [dnsRecord] = await deps.cloudflare.findDnsRecord(zoneId, hostname, 'CNAME');
1949
+ const dns = await platformDns(deps, context.recordedIds(), 'platform:mail');
1950
+ const [dnsRecord] = await dns.findDnsRecord(platform.apex, hostname, 'CNAME');
1918
1951
  if (!dnsRecord) {
1919
- await deps.cloudflare.createDnsRecord(zoneId, {
1952
+ await dns.createDnsRecord(platform.apex, {
1920
1953
  type: 'CNAME',
1921
1954
  name: hostname,
1922
1955
  content: dnsTarget,
@@ -1924,7 +1957,7 @@ async function provisionPlatformMail(context, deps) {
1924
1957
  });
1925
1958
  }
1926
1959
  else if (dnsRecord.content !== dnsTarget) {
1927
- await deps.cloudflare.updateDnsRecord(zoneId, dnsRecord.id, {
1960
+ await dns.updateDnsRecord(platform.apex, dnsRecord.id, {
1928
1961
  type: 'CNAME',
1929
1962
  name: hostname,
1930
1963
  content: dnsTarget,
@@ -1933,9 +1966,9 @@ async function provisionPlatformMail(context, deps) {
1933
1966
  }
1934
1967
  // ---- the MX hostname: a direct, unproxied A record --------------------------
1935
1968
  const serverIp = (platform.serverIpv4 ?? server.ip); // checked by mailServerIpBlocked above
1936
- const [mxRecord] = await deps.cloudflare.findDnsRecord(zoneId, mailHost, 'A');
1969
+ const [mxRecord] = await dns.findDnsRecord(platform.apex, mailHost, 'A');
1937
1970
  if (!mxRecord) {
1938
- await deps.cloudflare.createDnsRecord(zoneId, {
1971
+ await dns.createDnsRecord(platform.apex, {
1939
1972
  type: 'A',
1940
1973
  name: mailHost,
1941
1974
  content: serverIp,
@@ -1943,7 +1976,7 @@ async function provisionPlatformMail(context, deps) {
1943
1976
  });
1944
1977
  }
1945
1978
  else if (mxRecord.content !== serverIp) {
1946
- await deps.cloudflare.updateDnsRecord(zoneId, mxRecord.id, {
1979
+ await dns.updateDnsRecord(platform.apex, mxRecord.id, {
1947
1980
  type: 'A',
1948
1981
  name: mailHost,
1949
1982
  content: serverIp,
@@ -2878,17 +2911,19 @@ async function provisionPlatformArchon(context, deps) {
2878
2911
  await record(PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, brokerDeployRecordValue(brokerAppId, desiredAuthAppsRaw));
2879
2912
  }
2880
2913
  // ---- Cloudflare Access, before the hostname resolves ----------------------
2914
+ const tunnels = await platformTunnels(deps, context.recordedIds(), 'platform:archon');
2915
+ const access = await platformAccess(deps, context.recordedIds(), 'platform:archon');
2881
2916
  let gateClosed = Boolean(recorded[PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY]);
2882
- const accessAppId = await ensureArchonAccess(deps.cloudflare, hostname, deps.accessEmails, async () => {
2917
+ const accessAppId = await ensureArchonAccess(access, hostname, deps.accessEmails, async () => {
2883
2918
  // The Access app is about to be rebuilt: answer 404 for every hostname
2884
2919
  // meanwhile, so it is never served without the gate. The marker is
2885
2920
  // persisted first: a failure before the real ingress is written back
2886
2921
  // leaves it set, and the next plan repairs the step.
2887
2922
  gateClosed = true;
2888
2923
  await record(PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, '1');
2889
- const tunnel = await deps.cloudflare.findTunnelByName(PLATFORM_ARCHON_TUNNEL_NAME);
2924
+ const tunnel = await tunnels.findTunnelByName(PLATFORM_ARCHON_TUNNEL_NAME);
2890
2925
  if (tunnel)
2891
- await deps.cloudflare.putTunnelConfig(tunnel.id, {
2926
+ await tunnels.putTunnelConfig(tunnel.id, {
2892
2927
  ingress: [{ service: 'http_status:404' }],
2893
2928
  });
2894
2929
  });
@@ -2940,12 +2975,12 @@ async function provisionPlatformArchon(context, deps) {
2940
2975
  await waitForDeploymentFinished(deps.coolify, appId, runtime, 'platform:archon', `archon stays at ${appliedGitRef ?? 'no recorded ref'} on ${recorded[PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY] ?? 'no recorded hostname'}`);
2941
2976
  }
2942
2977
  // ---- tunnel and DNS ---------------------------------------------------------
2943
- const existingTunnel = await deps.cloudflare.findTunnelByName(PLATFORM_ARCHON_TUNNEL_NAME);
2944
- const tunnel = existingTunnel ?? (await deps.cloudflare.createTunnel(PLATFORM_ARCHON_TUNNEL_NAME));
2978
+ const existingTunnel = await tunnels.findTunnelByName(PLATFORM_ARCHON_TUNNEL_NAME);
2979
+ const tunnel = existingTunnel ?? (await tunnels.createTunnel(PLATFORM_ARCHON_TUNNEL_NAME));
2945
2980
  if (recorded[PLATFORM_ARCHON_TUNNEL_SCOPE_KEY] !== tunnel.id) {
2946
2981
  await record(PLATFORM_ARCHON_TUNNEL_SCOPE_KEY, tunnel.id);
2947
2982
  }
2948
- await deps.cloudflare.putTunnelConfig(tunnel.id, {
2983
+ await tunnels.putTunnelConfig(tunnel.id, {
2949
2984
  ingress: [
2950
2985
  {
2951
2986
  hostname,
@@ -2960,7 +2995,7 @@ async function provisionPlatformArchon(context, deps) {
2960
2995
  // #197: without a connector, `hostname` answers with a Cloudflare error.
2961
2996
  await ensureCloudflaredConnector({
2962
2997
  coolify: deps.coolify,
2963
- cloudflare: deps.cloudflare,
2998
+ tunnels,
2964
2999
  tunnelId: tunnel.id,
2965
3000
  appName: PLATFORM_ARCHON_CLOUDFLARED_APP_NAME,
2966
3001
  projectUuid,
@@ -2974,9 +3009,10 @@ async function provisionPlatformArchon(context, deps) {
2974
3009
  throw new Error(`platform:archon: no Cloudflare zone for ${platform.apex} to put ${hostname} in`);
2975
3010
  }
2976
3011
  const dnsTarget = `${tunnel.id}.cfargotunnel.com`;
2977
- const [dnsRecord] = await deps.cloudflare.findDnsRecord(zoneId, hostname, 'CNAME');
3012
+ const dns = await platformDns(deps, context.recordedIds(), 'platform:archon');
3013
+ const [dnsRecord] = await dns.findDnsRecord(platform.apex, hostname, 'CNAME');
2978
3014
  if (!dnsRecord) {
2979
- await deps.cloudflare.createDnsRecord(zoneId, {
3015
+ await dns.createDnsRecord(platform.apex, {
2980
3016
  type: 'CNAME',
2981
3017
  name: hostname,
2982
3018
  content: dnsTarget,
@@ -2984,7 +3020,7 @@ async function provisionPlatformArchon(context, deps) {
2984
3020
  });
2985
3021
  }
2986
3022
  else if (dnsRecord.content !== dnsTarget) {
2987
- await deps.cloudflare.updateDnsRecord(zoneId, dnsRecord.id, {
3023
+ await dns.updateDnsRecord(platform.apex, dnsRecord.id, {
2988
3024
  type: 'CNAME',
2989
3025
  name: hostname,
2990
3026
  content: dnsTarget,
@@ -3365,8 +3401,9 @@ function sameEmails(include, emails) {
3365
3401
  * exactly the declared emails. A failed read is not drift: the next plan reads
3366
3402
  * again.
3367
3403
  */
3368
- async function archonAccessDrifted(cf, domain, emails) {
3404
+ async function archonAccessDrifted(deps, recorded, domain, emails) {
3369
3405
  try {
3406
+ const cf = await platformAccess(deps, recorded, 'platform:archon');
3370
3407
  const app = (await cf.listAccessApps()).find((a) => a.domain === domain && a.name === PLATFORM_ARCHON_ACCESS_APP_NAME);
3371
3408
  if (!app)
3372
3409
  return true;
@@ -5359,11 +5396,22 @@ async function provisionPlatformEdge(context, deps) {
5359
5396
  { key: 'EDGE_PLATFORM_KEY_HASH', value: keyHash },
5360
5397
  ...(await missingEnvs(deps.coolify, appId, PLATFORM_EDGE_RUNNER_DEFAULTS)),
5361
5398
  ]);
5362
- // ---- tunnel and DNS -------------------------------------------------------
5363
- const existingTunnel = await deps.cloudflare.findTunnelByName(EDGE_TUNNEL_NAME);
5364
- const tunnel = existingTunnel ?? (await deps.cloudflare.createTunnel(EDGE_TUNNEL_NAME));
5399
+ // ---- deploy and wait, on edge's own address ---------------------------------
5400
+ // F4.dns: edge is the only caller of Cloudflare, so its own CNAME is
5401
+ // written through edge. Edge has to be up before that, and no public name
5402
+ // routes to it yet: health is checked on its Coolify-network address, which
5403
+ // is then recorded for every step that writes DNS (auth, mail, archon). #252:
5404
+ // the combined helper checks the deployment record, then the `/api/health`
5405
+ // probe.
5406
+ const internalUrl = `http://${EDGE_RUNNER_INTERNAL_NAME}:${EDGE_PORT}`;
5407
+ await waitForNewDeployment(deps.coolify, appId, { kind: 'ready', url: `${internalUrl}/api/health` }, runtime, sleep, 'platform:edge', `edge stays at ${recorded[PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY] ?? 'no recorded ref'}`);
5408
+ await record(PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, internalUrl);
5409
+ // ---- tunnel and DNS, now that edge answers ---------------------------------
5410
+ const tunnels = await platformTunnels(deps, { [PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY]: internalUrl }, 'platform:edge');
5411
+ const existingTunnel = await tunnels.findTunnelByName(EDGE_TUNNEL_NAME);
5412
+ const tunnel = existingTunnel ?? (await tunnels.createTunnel(EDGE_TUNNEL_NAME));
5365
5413
  await record(PLATFORM_EDGE_TUNNEL_SCOPE_KEY, tunnel.id);
5366
- await deps.cloudflare.putTunnelConfig(tunnel.id, {
5414
+ await tunnels.putTunnelConfig(tunnel.id, {
5367
5415
  ingress: [
5368
5416
  { hostname, service: `http://${EDGE_RUNNER_INTERNAL_NAME}:${EDGE_PORT}` },
5369
5417
  { service: 'http_status:404' },
@@ -5372,7 +5420,7 @@ async function provisionPlatformEdge(context, deps) {
5372
5420
  // #197: without a connector, `hostname` answers with a Cloudflare error.
5373
5421
  await ensureCloudflaredConnector({
5374
5422
  coolify: deps.coolify,
5375
- cloudflare: deps.cloudflare,
5423
+ tunnels,
5376
5424
  tunnelId: tunnel.id,
5377
5425
  appName: EDGE_CLOUDFLARED_APP_NAME,
5378
5426
  projectUuid,
@@ -5386,9 +5434,10 @@ async function provisionPlatformEdge(context, deps) {
5386
5434
  throw new Error(`platform:edge: no Cloudflare zone for ${platform.apex} to put ${hostname} in`);
5387
5435
  }
5388
5436
  const dnsTarget = `${tunnel.id}.cfargotunnel.com`;
5389
- const [dnsRecord] = await deps.cloudflare.findDnsRecord(zoneId, hostname, 'CNAME');
5437
+ const dns = await deps.dns(internalUrl);
5438
+ const [dnsRecord] = await dns.findDnsRecord(platform.apex, hostname, 'CNAME');
5390
5439
  if (!dnsRecord) {
5391
- await deps.cloudflare.createDnsRecord(zoneId, {
5440
+ await dns.createDnsRecord(platform.apex, {
5392
5441
  type: 'CNAME',
5393
5442
  name: hostname,
5394
5443
  content: dnsTarget,
@@ -5396,19 +5445,13 @@ async function provisionPlatformEdge(context, deps) {
5396
5445
  });
5397
5446
  }
5398
5447
  else if (dnsRecord.content !== dnsTarget) {
5399
- await deps.cloudflare.updateDnsRecord(zoneId, dnsRecord.id, {
5448
+ await dns.updateDnsRecord(platform.apex, dnsRecord.id, {
5400
5449
  type: 'CNAME',
5401
5450
  name: hostname,
5402
5451
  content: dnsTarget,
5403
5452
  proxied: true,
5404
5453
  });
5405
5454
  }
5406
- // ---- deploy and wait -------------------------------------------------------
5407
- // #252: the tunnel and DNS above already route `hostname`, so the combined
5408
- // helper (deployment-record check, then the `/api/health` probe) can run
5409
- // in one call -- unlike `platform:mail`, nothing here needs to happen
5410
- // between the two phases.
5411
- await waitForNewDeployment(deps.coolify, appId, { kind: 'ready', url: `https://${hostname}/api/health` }, runtime, sleep, 'platform:edge', `edge stays at ${recorded[PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY] ?? 'no recorded ref'}`);
5412
5455
  // Last: a run that failed anywhere above is planned again.
5413
5456
  context.recordScopeId(PLATFORM_EDGE_APPLIED_HOSTNAME_SCOPE_KEY, hostname);
5414
5457
  await record(PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY, gitRef);