@wtfalch/foundry 0.34.0 → 0.35.2

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 (88) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +1 -1
  2. package/dist/bootstrap/bootstrap.js +49 -11
  3. package/dist/bootstrap/declare.d.ts +12 -5
  4. package/dist/bootstrap/declare.js +33 -18
  5. package/dist/bootstrap/versions.d.ts +20 -0
  6. package/dist/bootstrap/versions.js +46 -0
  7. package/dist/calls-sfu.js +3 -3
  8. package/dist/index.d.ts +9 -4
  9. package/dist/index.js +5 -2
  10. package/dist/migrations/0053_heartbeats_provider.sql +9 -0
  11. package/dist/migrations/0053_org_version_pin.sql +6 -0
  12. package/dist/org.d.ts +60 -14
  13. package/dist/org.js +118 -28
  14. package/dist/plan/backup-gate.d.ts +93 -0
  15. package/dist/plan/backup-gate.js +261 -0
  16. package/dist/plan/broker-entries.d.ts +9 -1
  17. package/dist/plan/broker-entries.js +20 -3
  18. package/dist/plan/build-record.d.ts +56 -0
  19. package/dist/plan/build-record.js +109 -0
  20. package/dist/plan/calls-matrix.js +3 -3
  21. package/dist/plan/cloudflared-connector.js +2 -2
  22. package/dist/plan/company-backups.d.ts +31 -0
  23. package/dist/plan/company-backups.js +101 -0
  24. package/dist/plan/deploy-health.d.ts +12 -2
  25. package/dist/plan/deploy-health.js +3 -1
  26. package/dist/plan/destinations.d.ts +73 -0
  27. package/dist/plan/destinations.js +217 -0
  28. package/dist/plan/diff.d.ts +17 -8
  29. package/dist/plan/diff.js +131 -131
  30. package/dist/plan/edge-access.d.ts +15 -0
  31. package/dist/plan/edge-access.js +21 -2
  32. package/dist/plan/executor.d.ts +166 -7
  33. package/dist/plan/executor.js +547 -106
  34. package/dist/plan/health-targets.d.ts +1 -3
  35. package/dist/plan/health-targets.js +36 -36
  36. package/dist/plan/held-key-refs.d.ts +5 -0
  37. package/dist/plan/held-key-refs.js +5 -0
  38. package/dist/plan/hostnames.d.ts +10 -0
  39. package/dist/plan/hostnames.js +14 -0
  40. package/dist/plan/index.d.ts +5 -0
  41. package/dist/plan/index.js +3 -0
  42. package/dist/plan/live.d.ts +6 -0
  43. package/dist/plan/live.js +3 -2
  44. package/dist/plan/locked-hostnames.d.ts +2 -1
  45. package/dist/plan/locked-hostnames.js +16 -3
  46. package/dist/plan/login-host.d.ts +9 -20
  47. package/dist/plan/login-host.js +10 -25
  48. package/dist/plan/mail-relay.d.ts +48 -29
  49. package/dist/plan/mail-relay.js +145 -68
  50. package/dist/plan/network-move.d.ts +20 -0
  51. package/dist/plan/network-move.js +41 -0
  52. package/dist/plan/oikos-ingress.d.ts +111 -0
  53. package/dist/plan/oikos-ingress.js +231 -0
  54. package/dist/plan/org-names.d.ts +8 -2
  55. package/dist/plan/org-names.js +57 -1
  56. package/dist/plan/own-diff.d.ts +9 -8
  57. package/dist/plan/own-diff.js +42 -34
  58. package/dist/plan/own-hostnames.d.ts +27 -0
  59. package/dist/plan/own-hostnames.js +24 -0
  60. package/dist/plan/own-limits.d.ts +198 -7
  61. package/dist/plan/own-limits.js +163 -32
  62. package/dist/plan/own-services.js +3 -1
  63. package/dist/plan/own-sfu.js +3 -2
  64. package/dist/plan/plan-apply.d.ts +47 -7
  65. package/dist/plan/plan-apply.js +184 -38
  66. package/dist/plan/platform-diff.d.ts +133 -17
  67. package/dist/plan/platform-diff.js +231 -68
  68. package/dist/plan/platform-org.d.ts +79 -10
  69. package/dist/plan/platform-org.js +93 -13
  70. package/dist/plan/platform-providers.d.ts +118 -73
  71. package/dist/plan/platform-providers.js +799 -241
  72. package/dist/plan/proxy-labels.d.ts +57 -11
  73. package/dist/plan/proxy-labels.js +94 -14
  74. package/dist/plan/run-options.d.ts +19 -0
  75. package/dist/plan/run-options.js +46 -0
  76. package/dist/plan/zitadel-login-policy.d.ts +1 -1
  77. package/dist/plan/zitadel-login-policy.js +76 -8
  78. package/dist/platform.d.ts +32 -0
  79. package/dist/platform.js +16 -0
  80. package/dist/provision.d.ts +2 -0
  81. package/dist/provision.js +3 -1
  82. package/dist/slug.d.ts +3 -1
  83. package/dist/slug.js +25 -3
  84. package/dist/store/postgres.js +43 -1
  85. package/dist/store/schema.d.ts +36 -0
  86. package/dist/store/schema.js +4 -0
  87. package/package.json +3 -3
  88. package/versions.digests.json +11 -0
@@ -1,16 +1,22 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
1
2
  import { randomBytes, randomUUID } from 'node:crypto';
2
3
  import { URL } from 'node:url';
4
+ import { pinnedTagFor } from '../bootstrap/versions.js';
3
5
  import { redactSecrets } from '../calls-sfu-deploy-support.js';
4
6
  import { resolveCallsSfuUrls } from '../calls-sfu.js';
5
7
  import { APP_ENTRY_KEY, appEntry, filesCollectionEnvName, ownerMailboxRecorded, ownerMailboxes, ownsServices, sharedSecretRef, } from '../org.js';
8
+ import { adoptedKeysUnreadable, parseAdoptSnapshot } from './adopt.js';
6
9
  import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, AGORA_CLIENT_ID_SCOPE_KEY, AGORA_DEPLOYED_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_DEPLOYED_SCOPE_KEY, CALLS_SERVICE_NAME_SCOPE_KEY, CALLS_SERVICE_PORT, CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY, CHAT_CALLS_APPLIED_SCOPE_KEY, CHAT_CALLS_TOKEN_SCOPE_KEY, CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, CHAT_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CHAT_SERVICE_URL_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, ZITADEL_MESSAGING_PROJECT_SCOPE_KEY, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostnameFor, callsIntrospectionAppName, callsServiceHasName, callsServiceInternalName, callsTokenEnvMarker, callsTokenEnvMovedMessage, chatCallsApplied, chatHasUpstreams, chatIntrospectionAppName, desiredAgoraEnv, entryClientIdScopeKey, } from './agora.js';
7
10
  import { BOULE_BRIDGE_SECRET_ENV, BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_DATABASE_SCOPE_KEY, BOULE_DEPLOYED_SCOPE_KEY, BOULE_PEER_PASSWORD_ENV, BOULE_SECRETS_RESTARTED_SCOPE_KEY, bouleEnvDrift, bouleHostnameFor, bouleMailWanted, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
8
11
  import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
12
+ import { recordBuiltCommit } from './build-record.js';
9
13
  import { MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, masDockerfile, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
10
14
  import { CHAT_MAS_PORT, CHAT_REPO, CHAT_SERVICE_DOCKERFILE, CHAT_SERVICE_PORT, CHAT_SYNAPSE_DOCKERFILE, CHAT_SYNAPSE_PORT, assertChatServerName, chatInternalNames, chatMasConfigYaml, chatRekeyedInternalNames, chatServerName, chatServiceEnvs, chatSynapseHomeserverYaml, chatUpstreams, } from './chat-matrix.js';
11
15
  import { ensureCloudflaredConnector } from './cloudflared-connector.js';
16
+ import { BACKUP_SCOPE_PREFIX, backupScopeKey } from './company-backups.js';
12
17
  import { CORE_FILES_COLLECTION_NAME, FILES_CORE_COLLECTION_SCOPE_KEY, ZITADEL_FILES_PROJECT_SCOPE_KEY, } from './core-files.js';
13
18
  import { waitForNewDeployment, } from './deploy-health.js';
19
+ import { PLATFORM_NETWORK, companyNetworkName, ensureDestination, findDestination, onPlatformNetwork, placed, placedByScope, } from './destinations.js';
14
20
  import { DEFAULT_AI_LIMIT_MICROS } from './diff.js';
15
21
  import { createEdgeAccess, isNotFound as isEdgeNotFound, } from './edge-access.js';
16
22
  import { EDGE_DNS_WRITE_CONCURRENCY, createEdgeDns, limiter, } from './edge-dns.js';
@@ -22,18 +28,21 @@ import { BOULE_EDGE_ENTRY_KEY, EDGE_ENTRY_KEY_DAYS, EDGE_RESERVED_LABELS_APPLIED
22
28
  import { FILES_AGORA_KEY_SCOPE_KEY, FILES_AGORA_UNDELIVERED_SCOPE_KEY, FILES_KEY_DAYS, FILES_KEY_RENEW_WINDOW_DAYS, FILES_ORG_OWNER_SCOPE_KEY, FILES_ROOT_EXPIRES_SCOPE_KEY, FILES_ROOT_PREVIOUS_SCOPE_KEY, FILES_ROOT_UNDELIVERED_SCOPE_KEY, FILES_ROOT_UNREVOKED_SCOPE_KEY, entryFilesAppliedScopeKey, entryFilesExpiresScopeKey, entryFilesMinterScopeKey, entryFilesPendingExpiresScopeKey, entryFilesRetiredScopeKey, entryFilesUndeliveredScopeKey, entryFilesUnrevokedScopeKey, entryFilesWrittenScopeKey, filesOwnerUserId, parseIdList as parseFilesIdList, } from './entry-files-key.js';
23
29
  import { GATE_CLAIM_NAME, gateHostnamesScopeKey, gateIdRecordKey, isBehindGate } from './gates.js';
24
30
  import { IMAGE_HEALTHCHECK_SETTINGS, commandHealthCheckSettings, healthCheckSettings, wgetProbe, } from './health-check-settings.js';
25
- import { appHostname, coolifyTunnelDomains, entryHostnames, tunnelDnsHostnames, tunnelDomainList, tunnelIngressHostnames, } from './hostnames.js';
31
+ import { PROXY_LABEL_TOKEN_REF } from './held-key-refs.js';
32
+ import { appHostname, cmsStudioHostnameFor, coolifyTunnelDomains, entryHostnames, siteHostnameFor, tunnelDnsHostnames, tunnelIngressHostnames, } from './hostnames.js';
26
33
  import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, LOGIN_HOST_SHARED_SCOPE_KEY, customersEntryKeys, loginBindingState, loginHostClientIds, ownAuthApplied, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
27
34
  import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
28
- import { OWN_MAIL_SMTP_INTERNAL_SCOPE_KEY } from './mail-relay.js';
35
+ import { OWN_MAIL_SMTP_INTERNAL_SCOPE_KEY, OWN_MAIL_SMTP_PORT_SCOPE_KEY, isOwnMailSmtpPort, normalizeMailTarget, } from './mail-relay.js';
36
+ import { NETWORK_APPLIED_SCOPE_KEY } from './network-move.js';
29
37
  import { OWN_AUTH_APPLIED_GIT_REF_SCOPE_KEY, OWN_AUTH_APPLIED_HOSTNAME_SCOPE_KEY, } from './own-diff.js';
30
- import { limited } from './own-limits.js';
38
+ import { NO_DOMAIN_ZONE, ownHostnames } from './own-hostnames.js';
39
+ import { LIMITS, limited } from './own-limits.js';
31
40
  import { OWN_FILES_R2_SCOPE_KEY, OWN_FILES_R2_UNDELIVERED_SCOPE_KEY, OWN_SERVICES_APPLIED_SCOPE_KEY, OwnServicesBlockedError, orgNetwork, orgTarget, scopeFromOrg, } from './own-services.js';
32
41
  import { ownSfuUrls, provisionSfuApp } from './own-sfu.js';
33
42
  import { PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, } from './platform-diff.js';
34
43
  import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, ORG_MAIL_APPLIED_MODE_SCOPE_KEY, ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY, appDomainsAppliedScopeKey, appEnvAppliedScopeKey, appFolderDiffers, appNameFor, appSettingsAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, zitadelAppIdTokenUserinfoAppliedScopeKey, } from './platform-diff.js';
35
- import { AUTH_LOGIN_PORT, PLATFORM_AUTH_BROKER_MAIL_KEY_REF, PLATFORM_AUTH_LOGIN_INTERNAL_NAME, PLATFORM_AUTH_LOGIN_SCOPE_KEY, defaultStalwartForInstance, filesDepsInvalid, filesShareDomains, provisionAuthStack, provisionFilesHost, provisionJobsHost, provisionMailServer, resolveOwnFilesKeys, } from './platform-providers.js';
36
- import { appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec, entryProxyLabels, sameLabelSets, } from './proxy-labels.js';
44
+ import { AUTH_LOGIN_PORT, PLATFORM_AUTH_BROKER_MAIL_KEY_REF, PLATFORM_AUTH_LOGIN_INTERNAL_NAME, PLATFORM_AUTH_LOGIN_SCOPE_KEY, PLATFORM_MAIL_SERVER_SCOPE_KEY, defaultStalwartForInstance, filesDepsInvalid, filesShareDomains, provisionAuthStack, provisionDatabaseBackup, provisionFilesHost, provisionJobsHost, provisionMailServer, resolveOwnFilesKeys, } from './platform-providers.js';
45
+ import { CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY, ENTRY_CONTAINER_LABELS, appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec, entryOikosHostnames, entryProxyLabels, entryRoutes, entryRoutesMarker, ownsOikosName, routesWithoutToken, sameLabelSets, } from './proxy-labels.js';
37
46
  import { ZITADEL_ORG_DOMAIN_SCOPE_KEY, ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY, closeSignInOrgRegistration, ensureSignInOrg, inviteOwnerWithLink, registerSignInDomainOrBlock, signInOrgDomain, } from './sign-in-org.js';
38
47
  import { entryWorkerAppliedScopeKey, entryWorkerExclusionsScopeKey, entryWorkerRetiredScopeKey, entryWorkerRouteIdScopeKey, entryWorkerRoutePatternScopeKey, entryWorkerRoutePendingScopeKey, entryWorkerRouteZoneScopeKey, entryWorkerUploadedScopeKey, } from './worker.js';
39
48
  /**
@@ -119,14 +128,13 @@ function normalizedLoginBaseUri(uri) {
119
128
  * `cms.<domain>` -- the cms studio's own hostname (`cmsSite` below), apart
120
129
  * from the public site itself at `org.domain` (used directly there). Called
121
130
  * from `cmsSite` and from `zitadelApp` for the fixed `cms` client (#139),
122
- * both of which only run once `org.site.enabled` is true, so `org.domain`
123
- * is non-null by the same `validateSite` guarantee
124
- * {@link tunnelIngressHostnames}'s own doc comment cites.
131
+ * both of which only run once `org.site.enabled` is true. A company without
132
+ * a domain has its studio at `<slug>-cms.oikos.host` (`cmsStudioHostnameFor`).
125
133
  */
126
134
  // #541: exported so `health-targets.ts` reuses this hostname rule rather
127
135
  // than restating it; nothing else about this function changes.
128
136
  export function cmsHostnameFor(org) {
129
- return `cms.${org.domain}`;
137
+ return cmsStudioHostnameFor(org);
130
138
  }
131
139
  /**
132
140
  * #145's "Decided" (2026-09-27): the calls service's own public hostname.
@@ -164,8 +172,11 @@ export function healthUrls(org) {
164
172
  ];
165
173
  });
166
174
  }
167
- if (org.site.enabled && org.domain) {
168
- return [`https://${org.domain}/api/health`, `https://${cmsHostnameFor(org)}/api/health`];
175
+ if (org.site.enabled) {
176
+ return [
177
+ `https://${siteHostnameFor(org)}/api/health`,
178
+ `https://${cmsHostnameFor(org)}/api/health`,
179
+ ];
169
180
  }
170
181
  return [];
171
182
  }
@@ -192,7 +203,7 @@ function mailOrgFor(org, usesDomain, mode) {
192
203
  * https://github.com/wtfalch/foundry/issues/71) names this same constant and
193
204
  * value.
194
205
  */
195
- const COOLIFY_PROXY_ORIGIN = 'http://coolify-proxy:80';
206
+ export const COOLIFY_PROXY_ORIGIN = 'http://coolify-proxy:80';
196
207
  /** The one Access policy foundry puts on a platform Access app (#84). */
197
208
  export const ACCESS_POLICY_NAME = 'foundry: allowed emails';
198
209
  /**
@@ -306,7 +317,13 @@ const BOULE_PORT = 3000;
306
317
  * it from this file, the same way `hostnameFor`'s hostname rule is
307
318
  * restated rather than shared across the two files.
308
319
  */
309
- const LOG_DIR = '/app/logs';
320
+ /** Four dotted decimal numbers from 0 to 255, nothing else (the value goes into `ports_mappings`). */
321
+ function isIpv4Address(value) {
322
+ const parts = value.split('.');
323
+ return (parts.length === 4 &&
324
+ parts.every((part) => /^(0|[1-9][0-9]{0,2})$/.test(part) && Number(part) <= 255));
325
+ }
326
+ export const LOG_DIR = '/app/logs';
310
327
  /**
311
328
  * `cmsSite` provisions two applications from one `cms:site` step, but
312
329
  * `execute`'s own return value auto-records only one `resourceId`, under
@@ -324,6 +341,8 @@ const CMS_APP_SCOPE_KEY = 'coolify_cms_app';
324
341
  const SITE_APP_SCOPE_KEY = 'coolify_site_app';
325
342
  /** #445: the one Coolify server every resource of this org lands on, chosen once and recorded. */
326
343
  const COOLIFY_SERVER_SCOPE_KEY = 'coolify_server';
344
+ /** The company's Coolify destination (its Docker network `co-<slug>`), recorded when its first resource is created. */
345
+ export const COOLIFY_DESTINATION_SCOPE_KEY = 'coolify_destination';
327
346
  /**
328
347
  * #117: the cms app's own Postgres database (`<org.slug>-cms-db`), found or
329
348
  * created inside `cmsSite` the same way `coolifyDatabase` does for the org's
@@ -606,8 +625,8 @@ const CHAT_SERVICE_DB_SCOPE_KEY = 'coolify_chat_service_database';
606
625
  */
607
626
  const CHAT_REKEY_SYNAPSE_SCOPE_KEY = 'coolify_chat_rekey_synapse';
608
627
  const CHAT_REKEY_MAS_SCOPE_KEY = 'coolify_chat_rekey_mas';
609
- const CHAT_REKEY_SYNAPSE_DB_SCOPE_KEY = 'coolify_chat_rekey_synapse_database';
610
- const CHAT_REKEY_MAS_DB_SCOPE_KEY = 'coolify_chat_rekey_mas_database';
628
+ export const CHAT_REKEY_SYNAPSE_DB_SCOPE_KEY = 'coolify_chat_rekey_synapse_database';
629
+ export const CHAT_REKEY_MAS_DB_SCOPE_KEY = 'coolify_chat_rekey_mas_database';
611
630
  /**
612
631
  * Set once {@link ChatRekeyPort.copyHomeserver} returns, so a retried or
613
632
  * repeated apply never copies the same history twice -- the copy itself is
@@ -800,8 +819,47 @@ function blockedPort(service, ref) {
800
819
  },
801
820
  });
802
821
  }
822
+ /** The network a company's resources are made on: the platform's for its owner, `co-<slug>` for the rest. */
823
+ function appliedNetworkOf(org, context) {
824
+ return onPlatformNetwork(context.ownerCompany) ? PLATFORM_NETWORK : companyNetworkName(org.slug);
825
+ }
826
+ /** The limit profile of the step now running; `limited` reads it on every Coolify call. */
827
+ const limitScope = new AsyncLocalStorage();
828
+ /** The company and run of the step now running; `placedByScope` reads it on every create. */
829
+ const companyScope = new AsyncLocalStorage();
830
+ /**
831
+ * The limit profile of a step on `this.ports.coolify`. Own-services steps
832
+ * (`coolify:own-*`) wrap their own ports with their own row of `LIMITS`.
833
+ */
834
+ function limitProfileOf(action) {
835
+ if (action.kind.startsWith('own-'))
836
+ return undefined;
837
+ switch (`${action.system}:${action.kind}`) {
838
+ case 'coolify:app':
839
+ case 'coolify:database':
840
+ return LIMITS.companyApp;
841
+ case 'coolify:broker-entry':
842
+ return LIMITS.brokerEntry;
843
+ case 'cms:site':
844
+ return LIMITS.cms;
845
+ case 'coolify:chat':
846
+ return LIMITS.chat;
847
+ case 'coolify:calls':
848
+ case 'coolify:calls-service':
849
+ case 'coolify:calls-chat':
850
+ return LIMITS.calls;
851
+ case 'coolify:agora':
852
+ return LIMITS.agora;
853
+ case 'coolify:boule':
854
+ case 'coolify:boule-broker-entry':
855
+ return LIMITS.boule;
856
+ case 'cloudflare:tunnel':
857
+ return LIMITS.connector;
858
+ default:
859
+ return action.system === 'coolify' ? LIMITS.companyOther : undefined;
860
+ }
861
+ }
803
862
  export class FoundryStepExecutor {
804
- ports;
805
863
  edgeDns;
806
864
  edgeTunnels;
807
865
  edgeAccess;
@@ -810,8 +868,37 @@ export class FoundryStepExecutor {
810
868
  edgeWriteSlot;
811
869
  /** Secrets this executor minted or was handed; see `StepExecutor.secretsHandled`. */
812
870
  handledSecrets = new Set();
871
+ ports;
872
+ get rawCoolify() {
873
+ return this.ports.coolify;
874
+ }
813
875
  constructor(ports) {
814
- this.ports = ports;
876
+ const destinationOf = (coolify, step, serverUuid) => this.companyDestination(coolify, step.org, step.context, serverUuid);
877
+ // A live view of `ports` whose Coolify port is the limited one; a port set later is seen.
878
+ const limitedPort = new WeakMap();
879
+ this.ports = new Proxy(ports, {
880
+ get(target, prop) {
881
+ const value = Reflect.get(target, prop, target);
882
+ if (prop !== 'coolify' || !value)
883
+ return value;
884
+ const coolify = value;
885
+ let made = limitedPort.get(coolify);
886
+ if (!made) {
887
+ const placedPort = placedByScope(coolify, () => {
888
+ const step = companyScope.getStore();
889
+ if (!step)
890
+ return undefined;
891
+ return {
892
+ key: step.org.slug,
893
+ resolve: (serverUuid) => destinationOf(coolify, step, serverUuid),
894
+ };
895
+ });
896
+ made = limited(placedPort, () => limitScope.getStore());
897
+ limitedPort.set(coolify, made);
898
+ }
899
+ return made;
900
+ },
901
+ });
815
902
  }
816
903
  secretsHandled() {
817
904
  return [...this.handledSecrets];
@@ -895,6 +982,18 @@ export class FoundryStepExecutor {
895
982
  throw e;
896
983
  }
897
984
  }
985
+ /** Whether the host's scoped ports for this own org include a broker port: the company's own broker, which its broker entries are written to. */
986
+ async ownBrokerPort(org, recordedIds) {
987
+ try {
988
+ const scoped = await this.ports.ownServices?.portsFor(org, recordedIds);
989
+ return Boolean(scoped?.broker);
990
+ }
991
+ catch (e) {
992
+ if (e instanceof OwnServicesBlockedError)
993
+ return false;
994
+ throw e;
995
+ }
996
+ }
898
997
  /** #555: the edge-routed ports this executor was built with itself (they win over edge). */
899
998
  ownEdgePorts() {
900
999
  return {
@@ -913,6 +1012,42 @@ export class FoundryStepExecutor {
913
1012
  return this.run(action, context);
914
1013
  }
915
1014
  async run(action, context) {
1015
+ let profile = limitProfileOf(action);
1016
+ // An entry that declares `capabilities: 'standard'` is the fallback from tier B to tier A.
1017
+ if (profile === LIMITS.companyApp && action.kind === 'app') {
1018
+ const entry = context.org.hosting.entries.find((e) => appStepKey(context.org, e.key) === action.key);
1019
+ if (entry?.capabilities === 'standard')
1020
+ profile = LIMITS.companyAppStandard;
1021
+ }
1022
+ const dispatch = () => this.dispatch(action, context);
1023
+ return companyScope.run({ org: context.org, context }, () => profile ? limitScope.run(profile, dispatch) : dispatch());
1024
+ }
1025
+ /**
1026
+ * The company's destination on `serverUuid`: its Docker network `co-<slug>`, made when absent and
1027
+ * recorded as {@link COOLIFY_DESTINATION_SCOPE_KEY}. Called before the first create of a step;
1028
+ * a server that already holds too many networks fails here (`DestinationCapacityError`), before
1029
+ * the destination or the resource exists. A crash after the destination is made and before it is
1030
+ * recorded leaves a destination the next run finds by its network name, so a re-run converges.
1031
+ * Each Coolify call has a 60 second timeout (`destinations.ts`).
1032
+ */
1033
+ async companyDestination(coolify, org, context, serverUuid) {
1034
+ // The platform's own company stays on the platform network: Archon reads its database by name.
1035
+ if (onPlatformNetwork(context.ownerCompany)) {
1036
+ return findDestination(coolify, serverUuid, PLATFORM_NETWORK);
1037
+ }
1038
+ const uuid = await ensureDestination(coolify, serverUuid, companyNetworkName(org.slug));
1039
+ if (context.recordedIds()[COOLIFY_DESTINATION_SCOPE_KEY] !== uuid) {
1040
+ context.recordScopeId(COOLIFY_DESTINATION_SCOPE_KEY, uuid);
1041
+ }
1042
+ return uuid;
1043
+ }
1044
+ /** `coolify` for an own-services step: its creates go to the company's destination. */
1045
+ placedForCompany(coolify, org, context) {
1046
+ const own = coolify;
1047
+ const listing = typeof own.raw === 'function' ? own : this.rawCoolify;
1048
+ return placed(coolify, (serverUuid) => this.companyDestination(listing, org, context, serverUuid));
1049
+ }
1050
+ async dispatch(action, context) {
916
1051
  const { org } = context;
917
1052
  switch (`${action.system}:${action.kind}`) {
918
1053
  case 'zitadel:issuer':
@@ -933,10 +1068,14 @@ export class FoundryStepExecutor {
933
1068
  return this.zitadelCustomers(org, context);
934
1069
  case 'coolify:app':
935
1070
  return this.coolifyApp(action, org, context);
1071
+ case 'coolify:network':
1072
+ return this.coolifyNetwork(action, org, context);
936
1073
  case 'coolify:broker-entry':
937
1074
  return this.coolifyBrokerEntry(action, org, context);
938
1075
  case 'coolify:database':
939
1076
  return this.coolifyDatabase(action, org, context);
1077
+ case 'coolify:backup':
1078
+ return this.coolifyBackup(action, org, context);
940
1079
  case 'coolify:calls':
941
1080
  return this.coolifyCalls(action, org, context);
942
1081
  case 'coolify:calls-service':
@@ -1922,10 +2061,9 @@ export class FoundryStepExecutor {
1922
2061
  return { resourceId: created.appId };
1923
2062
  }
1924
2063
  // #139: the fixed `cms` client (`diff.ts`'s `planCmsClient`) signs
1925
- // people into the cms studio at `cms.<domain>`, not the org's own app
1926
- // hostname -- `org.site.enabled` guarantees `org.domain` is set
1927
- // (`org.ts`'s `validateSite`), the same guarantee `cmsHostnameFor`'s own
1928
- // doc comment relies on. #164: the fixed `agora` client (`diff.ts`'s
2064
+ // people into the cms studio at `cmsHostnameFor(org)` -- `cms.<domain>`,
2065
+ // or `<slug>-cms.oikos.host` for a company without a domain -- not the
2066
+ // org's own app hostname. #164: the fixed `agora` client (`diff.ts`'s
1929
2067
  // `planAgoraClient`) is the same shape, always -- there is no
1930
2068
  // `org.site.enabled`-style guard to check, since Agora is always on.
1931
2069
  // The Agora client stays `appType: 'web'`: providers' `createOidcApp`
@@ -2024,12 +2162,110 @@ export class FoundryStepExecutor {
2024
2162
  throw new Error(`coolify project ${org.slug} (${found.uuid}) was not created by foundry for this org and may hold other resources: rename or delete it, then apply again`);
2025
2163
  }
2026
2164
  }
2027
- const projectUuid = found
2028
- ? found.uuid
2029
- : (await this.ports.coolify.createProject(org.slug, coolifyProjectMark(org))).uuid;
2165
+ if (!found) {
2166
+ // A project foundry makes now is empty and on the company's own network. Record both, and persist
2167
+ // them, before any resource is made in it: a crash later must never leave a project with
2168
+ // resources and no marker, which the next plan would move (delete) over what was just built.
2169
+ const created = await this.ports.coolify.createProject(org.slug, coolifyProjectMark(org));
2170
+ context.recordScopeId(NETWORK_APPLIED_SCOPE_KEY, appliedNetworkOf(org, context));
2171
+ context.recordScopeId('coolify_project', created.uuid);
2172
+ await context.persistScopeIds();
2173
+ return created.uuid;
2174
+ }
2175
+ const projectUuid = found.uuid;
2176
+ // A project that holds nothing but its environment starts on the company's own network; one that
2177
+ // holds resources was built on the shared network and is moved by `coolify:network`
2178
+ // (`plan/network-move.ts`).
2179
+ const holds = await this.ports.coolify.listResources?.(found.uuid);
2180
+ if (holds?.every((r) => r.kind === 'environment')) {
2181
+ context.recordScopeId(NETWORK_APPLIED_SCOPE_KEY, appliedNetworkOf(org, context));
2182
+ }
2030
2183
  context.recordScopeId('coolify_project', projectUuid);
2031
2184
  return projectUuid;
2032
2185
  }
2186
+ /**
2187
+ * `plan/network-move.ts`'s `planNetworkMove`: a company built on the shared network is moved to its
2188
+ * own. Coolify cannot move a resource in production, so every application and database in the
2189
+ * company's project that foundry did not adopt is deleted, applications first, and the ids recorded
2190
+ * for them are forgotten; the next apply builds them again on `co-<slug>`. Apply has already asked
2191
+ * for the typed company name and checked a fresh backup of every database. A failure part way
2192
+ * leaves what is left recorded and the marker unset, so the same step is planned again and goes on
2193
+ * from there (a 404 counts as deleted). The marker is recorded last. No new timeout: each delete
2194
+ * has the Coolify port's own.
2195
+ */
2196
+ async coolifyNetwork(action, org, context) {
2197
+ const at = `coolify:network for ${org.slug}`;
2198
+ const projectUuid = context.recordedIds().coolify_project;
2199
+ if (!projectUuid)
2200
+ throw new Error(`${at}: no Coolify project is recorded, nothing to move`);
2201
+ const network = companyNetworkName(org.slug);
2202
+ if (action.desired.network !== network) {
2203
+ throw new Error(`${at}: the plan names another network; plan this org again`);
2204
+ }
2205
+ const list = this.ports.coolify.listResources;
2206
+ if (!list) {
2207
+ throw new Error(`${at}: this Coolify port cannot list a project's resources, so the move cannot tell what to delete`);
2208
+ }
2209
+ // Fail closed: an adopted record that cannot be read could name a resource foundry must keep.
2210
+ const unreadable = adoptedKeysUnreadable(context.recordedIds());
2211
+ if (unreadable.length > 0) {
2212
+ throw new Error(`${at}: an adopted record cannot be read, so the move cannot tell what to keep`);
2213
+ }
2214
+ const kept = new Set();
2215
+ for (const [key, raw] of Object.entries(context.recordedIds())) {
2216
+ if (!key.startsWith('adopted.'))
2217
+ continue;
2218
+ const snapshot = parseAdoptSnapshot(raw);
2219
+ if (snapshot)
2220
+ kept.add(snapshot.application.uuid).add(snapshot.database.uuid);
2221
+ }
2222
+ const resources = await list.call(this.ports.coolify, projectUuid);
2223
+ // Delete only what foundry recorded. Anything else in the project (a resource a person made there)
2224
+ // stops the move before the first delete; it is moved by hand.
2225
+ const recorded = new Set(Object.entries(context.recordedIds())
2226
+ .filter(([key]) => !key.startsWith('adopted.'))
2227
+ .map(([, id]) => id));
2228
+ const candidates = resources.filter((r) => (r.kind === 'application' || r.kind === 'database') &&
2229
+ typeof r.key === 'string' &&
2230
+ !kept.has(r.key));
2231
+ const stranger = candidates.find((r) => !recorded.has(r.key));
2232
+ if (stranger) {
2233
+ const name = typeof stranger.config?.name === 'string' ? stranger.config.name : stranger.key;
2234
+ throw new Error(`${at}: the project holds the ${stranger.kind} "${name}", which foundry did not create; move this company by hand (docs/runbooks/network-move.md)`);
2235
+ }
2236
+ const doomed = candidates.sort((a, b) => a.kind === b.kind ? 0 : a.kind === 'application' ? -1 : 1);
2237
+ for (const resource of doomed) {
2238
+ try {
2239
+ if (resource.kind === 'application') {
2240
+ await this.ports.coolify.deleteApplication(resource.key);
2241
+ }
2242
+ else {
2243
+ await this.ports.coolify.deleteDatabase(resource.key);
2244
+ }
2245
+ }
2246
+ catch (error) {
2247
+ if (!isNotFoundError(error))
2248
+ throw error;
2249
+ }
2250
+ for (const [key, id] of Object.entries(context.recordedIds())) {
2251
+ if (id === resource.key)
2252
+ context.forgetId?.(key);
2253
+ }
2254
+ await context.persistScopeIds();
2255
+ }
2256
+ // Every database is gone, so every recorded backup schedule is too. A kept marker would stop
2257
+ // `planCompanyBackups` from scheduling the rebuilt databases, and the upgrade gate would look for
2258
+ // executions of a schedule that no longer exists.
2259
+ for (const key of Object.keys(context.recordedIds())) {
2260
+ if (key.startsWith(BACKUP_SCOPE_PREFIX))
2261
+ context.forgetId?.(key);
2262
+ }
2263
+ // The company's connector is gone, so the tunnel's ingress is written again after the move.
2264
+ context.forgetId?.(CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY);
2265
+ context.recordScopeId(NETWORK_APPLIED_SCOPE_KEY, network);
2266
+ await context.persistScopeIds();
2267
+ return {};
2268
+ }
2033
2269
  /**
2034
2270
  * #445: one company lands on one Coolify server. The first call for an
2035
2271
  * org picks a usable, reachable server and records it
@@ -2093,6 +2329,11 @@ export class FoundryStepExecutor {
2093
2329
  throw new Error(`coolify:app for ${org.slug}: entry '${entry.key}' has no repo`);
2094
2330
  }
2095
2331
  const appRepo = entry.repo;
2332
+ // A compose file can set `privileged`, host networking or a Docker socket mount, and its labels
2333
+ // are its own: refused for every company until compose files are checked (`diff.ts` blocks it).
2334
+ if (entry.buildPack === 'dockercompose') {
2335
+ throw new Error(`coolify:app for ${org.slug}: entry '${entry.key}' is compose-built, which is refused`);
2336
+ }
2096
2337
  const projectUuid = await this.ensureCoolifyProject(org, context);
2097
2338
  const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
2098
2339
  // The recorded id first; the name when nothing is recorded, or when the
@@ -2114,10 +2355,12 @@ export class FoundryStepExecutor {
2114
2355
  environmentIds: [environment.id],
2115
2356
  });
2116
2357
  }
2117
- // An entry with no names is internal: the other entries reach it as
2118
- // `<slug>-<key>`, and it gets no domain, route or DNS record.
2358
+ // No entry gets Coolify `domains`: Coolify would add a Traefik router for them. The company's
2359
+ // tunnel routes an entry's names straight to its container (also `<slug>.oikos.host`, through a
2360
+ // record in the platform's oikos.host zone), which every entry reaches as `<slug>-<key>` (its
2361
+ // internal name) on the company network.
2119
2362
  const names = entryHostnames(org, entry);
2120
- const wantDomains = tunnelDomainList(names);
2363
+ const wantDomains = [];
2121
2364
  const isStatic = entry.buildPack === 'static';
2122
2365
  const settingsKey = appSettingsAppliedScopeKey(entry.key);
2123
2366
  const domainsKey = appDomainsAppliedScopeKey(entry.key);
@@ -2157,8 +2400,7 @@ export class FoundryStepExecutor {
2157
2400
  git_branch: entry.branch,
2158
2401
  build_pack: entry.buildPack,
2159
2402
  name: appNameFor(org, entry),
2160
- domains: names.length > 0 ? coolifyTunnelDomains(names) : undefined,
2161
- autogenerate_domain: names.length > 0 ? undefined : false,
2403
+ autogenerate_domain: false,
2162
2404
  // A static site is served by Coolify's own web server on port 80.
2163
2405
  ports_exposes: isStatic ? '80' : entry.port === null ? undefined : String(entry.port),
2164
2406
  is_auto_deploy_enabled: entry.autoDeploy,
@@ -2203,9 +2445,9 @@ export class FoundryStepExecutor {
2203
2445
  // redirect loops behind the tunnel. The internal name is set after
2204
2446
  // create, as the calls stack's apps do.
2205
2447
  if (JSON.stringify(wantDomains) !== JSON.stringify(haveDomains)) {
2206
- patch.domains = coolifyTunnelDomains(names);
2448
+ patch.domains = '';
2207
2449
  }
2208
- if (names.length === 0 && (!existing || patch.domains !== undefined)) {
2450
+ if (!existing || patch.domains !== undefined) {
2209
2451
  patch.custom_internal_name = appNameFor(org, entry);
2210
2452
  }
2211
2453
  // A `*` entry's proxy labels: Coolify takes `custom_labels` base64 encoded
@@ -2217,16 +2459,16 @@ export class FoundryStepExecutor {
2217
2459
  // below, after the redeploy, so a failed run repeats this.
2218
2460
  const labelsKey = appLabelsAppliedScopeKey(entry.key);
2219
2461
  const haveLabelsHash = context.recordedIds()[labelsKey];
2220
- const labelSpec = entryLabelSpec(org, entry, haveLabelsHash !== undefined);
2221
- const wantLabelsHash = labelSpec ? entryLabelHash(labelSpec) : undefined;
2222
- const wantLabels = labelSpec ? entryProxyLabels(appId, labelSpec) : undefined;
2462
+ // Every entry container carries `traefik.enable=false` (a run label overrides the image's own), so
2463
+ // Traefik never routes it, whatever its image declares.
2464
+ const wantLabelsHash = entryLabelHash(entryLabelSpec(org, entry));
2465
+ const wantLabels = ENTRY_CONTAINER_LABELS;
2223
2466
  const liveLabels = existing ? live?.custom_labels : undefined;
2224
- const labelsDiffer = wantLabels !== undefined &&
2225
- (!existing ||
2226
- (liveLabels !== undefined
2227
- ? !sameLabelSets(Buffer.from(liveLabels ?? '', 'base64').toString('utf8'), wantLabels)
2228
- : wantLabelsHash !== haveLabelsHash));
2229
- if (wantLabels !== undefined && labelsDiffer) {
2467
+ const labelsDiffer = !existing ||
2468
+ (liveLabels !== undefined
2469
+ ? !sameLabelSets(Buffer.from(liveLabels ?? '', 'base64').toString('utf8'), wantLabels)
2470
+ : wantLabelsHash !== haveLabelsHash);
2471
+ if (labelsDiffer) {
2230
2472
  patch.custom_labels = Buffer.from(wantLabels).toString('base64');
2231
2473
  }
2232
2474
  if (Object.keys(patch).length > 0)
@@ -2268,7 +2510,6 @@ export class FoundryStepExecutor {
2268
2510
  // What the plan decided is available (diff.ts `planApps`), so the marker
2269
2511
  // recorded below is the one the plan compares against. Absent: none.
2270
2512
  action.desired.secretSources ?? {
2271
- platformMailKey: false,
2272
2513
  sharedSecret: false,
2273
2514
  });
2274
2515
  // Rule 4 (#239, #243): a container reads its env only at start, so an app
@@ -2288,9 +2529,15 @@ export class FoundryStepExecutor {
2288
2529
  const [hostname] = names;
2289
2530
  const healthPath = entry.healthPath ?? (isStatic ? '/' : '/api/health');
2290
2531
  const runtime = this.ports.runtime ?? {};
2291
- await waitForNewDeployment(this.ports.coolify, appId, hostname
2532
+ const built = await waitForNewDeployment(this.ports.coolify, appId,
2533
+ // The public name answers from the container only once the tunnel step has written the
2534
+ // direct route; before that (a first apply, or the one that moves it) the deployment is the check.
2535
+ hostname &&
2536
+ (context.recordedIds()[CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY] ?? '[]') ===
2537
+ entryRoutesMarker(org)
2292
2538
  ? publicHealthCheck(org, context.recordedIds(), hostname, healthPath)
2293
2539
  : { kind: 'deployment-only' }, runtime, runtime.sleep ?? defaultSleep, `coolify:app:${entry.key}`, `${org.slug}'s ${entry.key === APP_ENTRY_KEY ? 'app' : `${entry.key} app`} stays at its previous deploy`);
2540
+ await recordBuiltCommit(this.ports.coolify, { appId: appId, deploymentUuid: built.deploymentUuid, name: entry.key }, (key, value) => context.recordScopeId(key, value));
2294
2541
  }
2295
2542
  else if ((appEnv.changed || dbNeedsRestart) && existing) {
2296
2543
  await this.ports.coolify.restart(appId);
@@ -2301,8 +2548,7 @@ export class FoundryStepExecutor {
2301
2548
  // one and `planApp` reports it instead of planning it.
2302
2549
  context.recordScopeId(settingsKey, JSON.stringify({ ...want, port: want.port ?? have.port }));
2303
2550
  context.recordScopeId(domainsKey, JSON.stringify(wantDomains));
2304
- if (wantLabelsHash)
2305
- context.recordScopeId(labelsKey, wantLabelsHash);
2551
+ context.recordScopeId(labelsKey, wantLabelsHash);
2306
2552
  context.recordScopeId(appEnvAppliedScopeKey(entry.key), appEnv.applied);
2307
2553
  if (entry.uses?.auth) {
2308
2554
  context.recordScopeId(entryAuthEnvAppliedScopeKey(entry.key), appEnv.authApplied);
@@ -2366,6 +2612,31 @@ export class FoundryStepExecutor {
2366
2612
  await this.ports.coolify.restart(appId);
2367
2613
  }
2368
2614
  }
2615
+ /**
2616
+ * F6: a nightly dump of one of the company's databases to the platform's
2617
+ * backup bucket. The schedule is found before it is created
2618
+ * (`provisionDatabaseBackup`), so a run that died after the create and
2619
+ * before `backup_<db>` was recorded reuses the schedule on the next apply
2620
+ * instead of adding a second. The error carries the database's name, never
2621
+ * the connection URL or a response body.
2622
+ */
2623
+ async coolifyBackup(action, org, context) {
2624
+ const { databaseId, database, storageUuid } = action.desired;
2625
+ if (typeof databaseId !== 'string' ||
2626
+ typeof database !== 'string' ||
2627
+ typeof storageUuid !== 'string' ||
2628
+ !databaseId ||
2629
+ !storageUuid) {
2630
+ throw new Error(`coolify:backup for ${org.slug}: the plan named no database or no storage`);
2631
+ }
2632
+ const { uuid } = await provisionDatabaseBackup(this.ports.coolify, {
2633
+ databaseUuid: databaseId,
2634
+ label: `${org.slug} ${database}`,
2635
+ storageUuid,
2636
+ });
2637
+ context.recordScopeId(backupScopeKey(database), uuid);
2638
+ return undefined;
2639
+ }
2369
2640
  async coolifyDatabase(action, org, context) {
2370
2641
  if (action.desired.enabled === false) {
2371
2642
  const dbId = context.recordedIds()[`coolify:database:${action.key}`];
@@ -2563,24 +2834,12 @@ export class FoundryStepExecutor {
2563
2834
  // minted here; a source with no value yet (its own step has not run,
2564
2835
  // or has not run for this apply) is simply omitted, the same way every
2565
2836
  // other optional entry above is.
2566
- // The secret sources (`sharedSecret`, `platformMailKey`) are filled here
2837
+ // The secret source (`sharedSecret`) is filled here
2567
2838
  // only, straight into `envs`: never recorded, returned or logged.
2568
2839
  const heldKeys = this.ports.heldKeys;
2569
- // Availability is the plan's (`secretSources`). If the plan said a key is
2570
- // held and this port cannot read it, the host wired the two differently:
2571
- // fail by ref, never push a marker for an env that was not written.
2572
- let mailKey = null;
2573
- if (secretSources.platformMailKey) {
2574
- try {
2575
- mailKey = (await heldKeys?.resolve(PLATFORM_AUTH_BROKER_MAIL_KEY_REF)) || null;
2576
- }
2577
- catch {
2578
- // reported below, by ref
2579
- }
2580
- if (mailKey === null) {
2581
- throw new Error(`coolify:app for ${org.slug}: the plan found ${PLATFORM_AUTH_BROKER_MAIL_KEY_REF} held but the executor's heldKeys cannot read it`);
2582
- }
2583
- }
2840
+ // Availability is the plan's (`secretSources`). If the plan said secrets
2841
+ // can be held and this port cannot put, the host wired the two differently:
2842
+ // never push a marker for an env that was not written.
2584
2843
  if (secretSources.sharedSecret && typeof heldKeys?.put !== 'function') {
2585
2844
  throw new Error(`coolify:app for ${org.slug}: the plan found held keys that can put but the executor's heldKeys cannot`);
2586
2845
  }
@@ -2591,9 +2850,7 @@ export class FoundryStepExecutor {
2591
2850
  for (const { name, source } of resolved.secrets) {
2592
2851
  envs.push({
2593
2852
  key: name,
2594
- value: source === 'platformMailKey'
2595
- ? mailKey
2596
- : await this.ensureSharedSecret(org, source.name, recorded),
2853
+ value: await this.ensureSharedSecret(org, source.name, recorded),
2597
2854
  });
2598
2855
  }
2599
2856
  await this.ports.coolify.bulkUpsertEnvs(appId, envs);
@@ -2612,6 +2869,40 @@ export class FoundryStepExecutor {
2612
2869
  authApplied,
2613
2870
  };
2614
2871
  }
2872
+ /**
2873
+ * The install's edge label token: read from the held keys, else minted and held first, so a crash
2874
+ * after the `put` hands the same value out on the next apply. Read once more after the `put`, so two
2875
+ * runs that mint at the same moment end on the value that was held last. An error names the ref,
2876
+ * never a value.
2877
+ */
2878
+ async edgeLabelToken() {
2879
+ const heldKeys = this.ports.heldKeys;
2880
+ const held = await heldKeys?.resolve(PROXY_LABEL_TOKEN_REF);
2881
+ if (held)
2882
+ return held;
2883
+ if (typeof heldKeys?.put !== 'function') {
2884
+ throw new Error(`proxy labels: the edge label token (${PROXY_LABEL_TOKEN_REF}) is not held and the executor's heldKeys cannot put one`);
2885
+ }
2886
+ const minted = randomBytes(32).toString('base64url');
2887
+ this.handledSecrets.add(minted);
2888
+ await heldKeys.put(PROXY_LABEL_TOKEN_REF, minted);
2889
+ const final = (await heldKeys.resolve(PROXY_LABEL_TOKEN_REF)) ?? minted;
2890
+ this.handledSecrets.add(final);
2891
+ return final;
2892
+ }
2893
+ /**
2894
+ * The `custom_labels` patch of an app foundry routes through Traefik at `hostname`: the same http
2895
+ * router a `domains` entry gets, plus the edge token. Coolify uses these instead of the labels it
2896
+ * generates from `domains`. The router names use the app's own name, which is unique per company.
2897
+ */
2898
+ async routedLabels(name, hostname, port) {
2899
+ const token = await this.edgeLabelToken();
2900
+ const labels = entryProxyLabels(name, { names: [hostname], wildcardDomain: null, port }, token);
2901
+ if (routesWithoutToken(labels, token)) {
2902
+ throw new Error('proxy labels: a routed app would be sent without the edge token');
2903
+ }
2904
+ return { custom_labels: Buffer.from(labels).toString('base64') };
2905
+ }
2615
2906
  /**
2616
2907
  * The org's shared secret `name`: read from the held keys, else generated
2617
2908
  * and held first, so a crash after the `put` and before the env write
@@ -3247,14 +3538,8 @@ export class FoundryStepExecutor {
3247
3538
  await this.ports.coolify.raw('POST', `/applications/${siteAppId}/stop`);
3248
3539
  return { resourceId: cmsAppId ?? siteAppId };
3249
3540
  }
3250
- // `validateSite` ("site.enabled requires domain") guarantees this
3251
- // whenever this step runs -- checked anyway, the same way
3252
- // `cloudflareRateLimit` above guards its own `org.domain` read, since
3253
- // TypeScript's `string | null` gives no static guarantee of it.
3254
- if (!org.domain) {
3255
- throw new Error(`cms:site for ${org.slug}: site.enabled is set but domain is null`);
3256
- }
3257
- const domain = org.domain;
3541
+ // The site's address: the org's domain, or `<slug>.oikos.host` without one.
3542
+ const domain = siteHostnameFor(org);
3258
3543
  const projectUuid = await this.ensureCoolifyProject(org, context);
3259
3544
  const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
3260
3545
  const site = await this.findOrCreateCmsApp({
@@ -3288,9 +3573,11 @@ export class FoundryStepExecutor {
3288
3573
  // deploys, it does not add a deploy where none exists).
3289
3574
  await this.ports.coolify.updateApplication(site.appId, {
3290
3575
  ...healthCheckSettings('/api/health', CMS_APP_PORT),
3576
+ ...(await this.routedLabels(`${org.slug}-site`, domain, CMS_APP_PORT)),
3291
3577
  });
3292
3578
  await this.ports.coolify.updateApplication(cms.appId, {
3293
3579
  ...healthCheckSettings('/api/health', CMS_APP_PORT),
3580
+ ...(await this.routedLabels(`${org.slug}-cms`, cmsHostnameFor(org), CMS_APP_PORT)),
3294
3581
  });
3295
3582
  // Every app foundry deploys keeps its own logs (#75) -- reused as-is for
3296
3583
  // both of these two, the same unconditional call `coolifyApp` makes for
@@ -3577,16 +3864,17 @@ export class FoundryStepExecutor {
3577
3864
  // yet.
3578
3865
  throw new Error(`coolify:calls for ${org.slug}: a database has no internal_db_url yet -- retry this apply once Coolify reports one`);
3579
3866
  }
3867
+ const synapseTag = typeof action.desired.synapseTag === 'string' ? action.desired.synapseTag : SYNAPSE_TAG;
3580
3868
  const synapse = await this.findOrCreateCallsApp(names.synapse, environment.id, () => this.ports.coolify.createDockerImageApplication({
3581
3869
  project_uuid: projectUuid,
3582
3870
  server_uuid: server.uuid,
3583
3871
  environment_name: environment.name,
3584
3872
  environment_uuid: environment.uuid,
3585
3873
  docker_registry_image_name: SYNAPSE_IMAGE,
3586
- docker_registry_image_tag: SYNAPSE_TAG,
3874
+ docker_registry_image_tag: pinnedTagFor('synapse', synapseTag),
3587
3875
  ports_exposes: '8008',
3588
3876
  name: names.synapse,
3589
- description: `Synapse for ${org.slug}'s calls stack (${SYNAPSE_IMAGE}:${SYNAPSE_TAG})`,
3877
+ description: `Synapse for ${org.slug}'s calls stack (${SYNAPSE_IMAGE}:${synapseTag})`,
3590
3878
  instant_deploy: false,
3591
3879
  }));
3592
3880
  // The check goes on every run, so a Synapse made without one gets it. Synapse
@@ -3604,7 +3892,7 @@ export class FoundryStepExecutor {
3604
3892
  // the app id above is, would make `versionNotAppliedLine` compare a
3605
3893
  // bumped `VERSIONS.synapse` against a value never actually applied.
3606
3894
  if (!synapse.existed) {
3607
- context.recordScopeId(CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, SYNAPSE_TAG);
3895
+ context.recordScopeId(CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, synapseTag);
3608
3896
  }
3609
3897
  const mas = await this.findOrCreateCallsApp(names.mas, environment.id, () => this.ports.coolify.createDockerfileApplication({
3610
3898
  project_uuid: projectUuid,
@@ -4485,6 +4773,7 @@ export class FoundryStepExecutor {
4485
4773
  custom_internal_name: name,
4486
4774
  ...commandHealthCheckSettings(wgetProbe(CALLS_SERVICE_PORT, CALLS_HEALTH_CHECK_PATH)),
4487
4775
  ...(portDiffers ? { ports_exposes: CALLS_SERVICE_PORT } : {}),
4776
+ ...(await this.routedLabels(name, callsHostnameFor(org), Number(CALLS_SERVICE_PORT))),
4488
4777
  });
4489
4778
  // Every app foundry deploys keeps its own logs (#75), reused as-is here.
4490
4779
  await this.ensureLogVolume(app.appId, org, app.existed);
@@ -4865,6 +5154,7 @@ export class FoundryStepExecutor {
4865
5154
  await this.ports.coolify.updateApplication(app.appId, {
4866
5155
  ...(portDiffers ? { ports_exposes: AGORA_APP_PORTS_EXPOSES } : {}),
4867
5156
  ...healthCheckSettings('/', Number(AGORA_APP_PORTS_EXPOSES)),
5157
+ ...(await this.routedLabels(name, agoraHostnameFor(org), Number(AGORA_APP_PORTS_EXPOSES))),
4868
5158
  });
4869
5159
  // #442: read before overwriting -- `bulkUpsertEnvs` below always leaves
4870
5160
  // Coolify's own env store matching `env`, which is why that store alone
@@ -4891,7 +5181,8 @@ export class FoundryStepExecutor {
4891
5181
  // in the branch below, and this deploy already carries this run's env,
4892
5182
  // pushed above, so the two never both run).
4893
5183
  const runtime = this.ports.runtime ?? {};
4894
- await waitForNewDeployment(this.ports.coolify, app.appId, publicHealthCheck(org, context.recordedIds(), agoraHostnameFor(org), '/'), runtime, runtime.sleep ?? defaultSleep, 'coolify:agora', `${org.slug}'s Agora stays at its previous deploy`);
5184
+ const built = await waitForNewDeployment(this.ports.coolify, app.appId, publicHealthCheck(org, context.recordedIds(), agoraHostnameFor(org), '/'), runtime, runtime.sleep ?? defaultSleep, 'coolify:agora', `${org.slug}'s Agora stays at its previous deploy`);
5185
+ await recordBuiltCommit(this.ports.coolify, { appId: app.appId, deploymentUuid: built.deploymentUuid, name: 'agora' }, (key, value) => context.recordScopeId(key, value));
4895
5186
  context.recordScopeId(AGORA_DEPLOYED_SCOPE_KEY, '1');
4896
5187
  }
4897
5188
  else if (app.existed && (envChanged || !alreadyActivated)) {
@@ -4972,6 +5263,7 @@ export class FoundryStepExecutor {
4972
5263
  // `EXPOSE 3000` ({@link BOULE_PORT}'s own doc comment).
4973
5264
  await this.ports.coolify.updateApplication(app.appId, {
4974
5265
  ...healthCheckSettings('/api/health', BOULE_PORT),
5266
+ ...(await this.routedLabels(name, bouleHostnameFor(org), BOULE_PORT)),
4975
5267
  });
4976
5268
  const liveEnv = new Map((await this.ports.coolify.listEnvs(app.appId))
4977
5269
  .filter((e) => !e.is_preview)
@@ -5029,7 +5321,8 @@ export class FoundryStepExecutor {
5029
5321
  if (!context.recordedIds()[BOULE_DEPLOYED_SCOPE_KEY]) {
5030
5322
  // #543: every deploy waits for health -- Boule's own public hostname.
5031
5323
  const runtime = this.ports.runtime ?? {};
5032
- await waitForNewDeployment(this.ports.coolify, app.appId, publicHealthCheck(org, context.recordedIds(), bouleHostnameFor(org), '/api/health'), runtime, runtime.sleep ?? defaultSleep, 'coolify:boule', `${org.slug}'s Boule stays at its previous deploy`);
5324
+ const built = await waitForNewDeployment(this.ports.coolify, app.appId, publicHealthCheck(org, context.recordedIds(), bouleHostnameFor(org), '/api/health'), runtime, runtime.sleep ?? defaultSleep, 'coolify:boule', `${org.slug}'s Boule stays at its previous deploy`);
5325
+ await recordBuiltCommit(this.ports.coolify, { appId: app.appId, deploymentUuid: built.deploymentUuid, name: 'boule' }, (key, value) => context.recordScopeId(key, value));
5033
5326
  context.recordScopeId(BOULE_DEPLOYED_SCOPE_KEY, '1');
5034
5327
  }
5035
5328
  else if (app.existed &&
@@ -5220,7 +5513,7 @@ export class FoundryStepExecutor {
5220
5513
  if (!own)
5221
5514
  throw new Error(OWN_SERVICES_PORT_ABSENT);
5222
5515
  return provisionJobsHost(scopeFromOrg(context, {}), {
5223
- coolify: limited(own.provision.jobs),
5516
+ coolify: limited(this.placedForCompany(own.provision.jobs, org, context), LIMITS.ownJobs),
5224
5517
  deliverSecret: this.ports.deliverSecret,
5225
5518
  readSecret: async (ref) => (await this.ports.heldKeys?.resolve(ref)) ?? null,
5226
5519
  runtime: this.ports.runtime ?? {},
@@ -5245,14 +5538,14 @@ export class FoundryStepExecutor {
5245
5538
  if (!own)
5246
5539
  throw new Error(OWN_SERVICES_PORT_ABSENT);
5247
5540
  const at = `coolify:own-files for ${org.slug}`;
5248
- const domain = org.domain;
5249
- if (!domain)
5250
- throw new Error(`${at}: the org declares no domain`);
5251
5541
  const zitadelOrgId = context.recordedIds()[ZITADEL_ORG_SCOPE_KEY];
5252
5542
  if (!zitadelOrgId)
5253
5543
  throw new Error(`${at}: the org's ZITADEL organisation is not recorded yet`);
5254
5544
  const { r2Buckets, r2Keys: _r2Keys, keysMint, readSecret, ...ownFiles } = own.provision.files;
5255
- const files = { ...ownFiles, coolify: limited(ownFiles.coolify) };
5545
+ const files = {
5546
+ ...ownFiles,
5547
+ coolify: limited(this.placedForCompany(ownFiles.coolify, org, context), LIMITS.ownFiles),
5548
+ };
5256
5549
  // Never the platform's credential in its place.
5257
5550
  if (!keysMint)
5258
5551
  throw new OwnServicesBlockedError('files', 'keysMint');
@@ -5267,7 +5560,7 @@ export class FoundryStepExecutor {
5267
5560
  // The cause is dropped: a host port's message is not ours to repeat.
5268
5561
  throw new Error(`${at}: the bucket ${target.names.filesBucket} could not be ensured`);
5269
5562
  }
5270
- const issuer = `https://auth.${domain}`;
5563
+ const issuer = ownHostnames(org).issuerUrl;
5271
5564
  const deps = {
5272
5565
  ...files,
5273
5566
  // The platform's keys settings stay out of a company's containers; the minted keyring replaces them.
@@ -5422,9 +5715,7 @@ export class FoundryStepExecutor {
5422
5715
  throw new Error(OWN_SERVICES_PORT_ABSENT);
5423
5716
  const hostname = action.desired.hostname;
5424
5717
  const gitRef = action.desired.gitRef;
5425
- const domain = org.domain;
5426
- if (!domain)
5427
- throw new Error(`coolify:own-auth for ${org.slug}: the org declares no domain`);
5718
+ const hn = ownHostnames(org);
5428
5719
  const scope = scopeFromOrg(context, {});
5429
5720
  // The target (host, port, tls, user) is not secret; the platform's SMTP
5430
5721
  // password is, so the company's own token is the password instead.
@@ -5434,7 +5725,7 @@ export class FoundryStepExecutor {
5434
5725
  ...(smtp
5435
5726
  ? { smtp: { host: smtp.host, port: smtp.port, tls: smtp.tls, user: smtp.user } }
5436
5727
  : {}),
5437
- coolify: limited(own.provision.auth.coolify),
5728
+ coolify: limited(this.placedForCompany(own.provision.auth.coolify, org, context), LIMITS.ownAuth),
5438
5729
  runtime: {
5439
5730
  ...own.provision.auth.runtime,
5440
5731
  onSecret: (value) => {
@@ -5453,15 +5744,15 @@ export class FoundryStepExecutor {
5453
5744
  for (const id of gitApps)
5454
5745
  await deps.coolify.updateApplication(id, { git_branch: gitRef });
5455
5746
  }
5456
- await this.ensureOwnAuthMailToken(org, domain, deps);
5747
+ await this.ensureOwnAuthMailToken(org, hn.mailDomain, deps);
5457
5748
  const result = await provisionAuthStack(scope, deps, orgTarget(org), {
5458
5749
  hostname,
5459
- apex: domain,
5750
+ apex: hn.zone,
5460
5751
  gitRef,
5461
5752
  project: { ensure: () => this.ensureCoolifyProject(org, context) },
5462
5753
  network: orgNetwork({ dns: this.dnsPort(), tunnels: this.tunnelPort(), zones: this.zonePort() }, scope, `${org.slug}:auth`),
5463
- smtpSender: `noreply@${domain}`,
5464
- defaultRedirectUri: `https://agora.${domain}`,
5754
+ smtpSender: `noreply@${hn.mailDomain}`,
5755
+ defaultRedirectUri: `https://${agoraHostnameFor(org)}`,
5465
5756
  ownerCompany: null,
5466
5757
  });
5467
5758
  if (refChanged) {
@@ -5482,8 +5773,9 @@ export class FoundryStepExecutor {
5482
5773
  }
5483
5774
  /**
5484
5775
  * `plan/own-diff.ts`'s `planOwnMail`: the org's own Stalwart in the org's own
5485
- * project, with no host port (the shared relay reaches it by internal name)
5486
- * and a directory that trusts the org's own sign-in. The admin key goes to
5776
+ * project, publishing its port 25 on one host port of the server (the shared
5777
+ * relay is on another network and dials the server's address) and a directory
5778
+ * that trusts the org's own sign-in. The admin key goes to
5487
5779
  * `orgTarget(org).refs.mailAdminKey` and nowhere else. The address the relay
5488
5780
  * routes to is recorded last, and the step's marker after it.
5489
5781
  */
@@ -5491,14 +5783,22 @@ export class FoundryStepExecutor {
5491
5783
  const own = this.ports.ownServices;
5492
5784
  if (!own)
5493
5785
  throw new Error(OWN_SERVICES_PORT_ABSENT);
5494
- if (!org.domain)
5495
- throw new Error(`own step coolify:own-mail for ${org.slug}: the org has no domain`);
5786
+ const hn = ownHostnames(org);
5496
5787
  const target = orgTarget(org);
5497
5788
  const scope = scopeFromOrg(context, {});
5498
- const issuerUrl = `https://auth.${org.domain}`;
5789
+ const issuerUrl = hn.issuerUrl;
5790
+ const smtpPort = action.desired.smtpPort;
5791
+ if (!isOwnMailSmtpPort(smtpPort)) {
5792
+ throw new Error(`coolify:own-mail for ${org.slug}: the plan gives no valid mail port`);
5793
+ }
5794
+ const bind = this.ports.mailBindAddress;
5795
+ if (bind !== undefined && !isIpv4Address(bind)) {
5796
+ throw new Error(`coolify:own-mail for ${org.slug}: the mail bind address is not an IPv4 address`);
5797
+ }
5798
+ const coolify = this.placedForCompany(own.provision.mail.coolify, org, context);
5499
5799
  const result = await provisionMailServer(scope, {
5500
5800
  ...own.provision.mail,
5501
- coolify: limited(own.provision.mail.coolify),
5801
+ coolify: limited(coolify, LIMITS.ownMail),
5502
5802
  deliverSecret: (ref, secret) => {
5503
5803
  this.handledSecrets.add(secret);
5504
5804
  return this.ports.deliverSecret(ref, secret);
@@ -5511,16 +5811,27 @@ export class FoundryStepExecutor {
5511
5811
  zitadelForInstance: undefined,
5512
5812
  }, target, {
5513
5813
  hostname: action.desired.hostname,
5514
- apex: org.domain,
5814
+ apex: hn.mailDomain,
5815
+ zone: hn.zone,
5515
5816
  mailHost: action.desired.mailHost,
5516
5817
  imageTag: action.desired.gitRef,
5517
- hostPorts: '',
5818
+ hostPorts: bind ? `${bind}:${smtpPort}:25` : `${smtpPort}:25`,
5518
5819
  issuerUrl,
5519
5820
  mxRecord: null,
5520
5821
  project: { ensure: () => this.ensureCoolifyProject(org, context) },
5521
5822
  network: orgNetwork({ dns: this.dnsPort(), tunnels: this.tunnelPort(), zones: this.zonePort() }, scope, `${org.slug}:mail`),
5522
5823
  });
5523
- scope.recordScopeId(OWN_MAIL_SMTP_INTERNAL_SCOPE_KEY, `${target.internal.mail}:25`);
5824
+ // The relay is on the platform's network, so it reaches the company's Stalwart through the server's
5825
+ // address and the port published above. The port is recorded first, then the address the relay is given.
5826
+ const mailServer = (await coolify.listServers()).find((s) => s.uuid === context.recordedIds()[PLATFORM_MAIL_SERVER_SCOPE_KEY]);
5827
+ const wanted = this.ports.mailRelayTarget?.({ serverAddress: mailServer?.ip, port: smtpPort }) ??
5828
+ (bind ? `${bind}:${smtpPort}` : mailServer?.ip ? `${mailServer.ip}:${smtpPort}` : null);
5829
+ const relayTarget = wanted ? normalizeMailTarget(wanted) : null;
5830
+ if (!relayTarget) {
5831
+ throw new Error(`coolify:own-mail for ${org.slug}: the mail server has no address the relay can dial`);
5832
+ }
5833
+ scope.recordScopeId(OWN_MAIL_SMTP_PORT_SCOPE_KEY, String(smtpPort));
5834
+ scope.recordScopeId(OWN_MAIL_SMTP_INTERNAL_SCOPE_KEY, relayTarget);
5524
5835
  await scope.persist();
5525
5836
  return result;
5526
5837
  }
@@ -5536,7 +5847,7 @@ export class FoundryStepExecutor {
5536
5847
  const scope = scopeFromOrg(context, {});
5537
5848
  const { sfu } = own.provision;
5538
5849
  return provisionSfuApp(scope, {
5539
- coolify: limited(sfu.coolify),
5850
+ coolify: limited(this.placedForCompany(sfu.coolify, org, context), LIMITS.ownSfu),
5540
5851
  network: orgNetwork({ dns: this.dnsPort(), tunnels: this.tunnelPort(), zones: this.zonePort() }, scope, `${org.slug}:sfu`),
5541
5852
  deliverSecret: sfu.deliverSecret,
5542
5853
  readSecret: async (ref) => (await this.ports.heldKeys?.resolve(ref)) ?? null,
@@ -5548,7 +5859,7 @@ export class FoundryStepExecutor {
5548
5859
  }, orgTarget(org), {
5549
5860
  slot: action.desired.slot,
5550
5861
  hostname: action.desired.hostname,
5551
- apex: org.domain,
5862
+ apex: ownHostnames(org).zone,
5552
5863
  version: action.desired.version,
5553
5864
  project: { ensure: () => this.ensureCoolifyProject(org, context) },
5554
5865
  });
@@ -5669,15 +5980,19 @@ export class FoundryStepExecutor {
5669
5980
  // login app (the same container the platform auth tunnel routes to), not
5670
5981
  // through Coolify's proxy, which has no route for it.
5671
5982
  const loginHost = action.desired.loginHost ?? null;
5983
+ // A hosting entry's names (and its wildcard) go straight to its container on the company network:
5984
+ // the container carries `traefik.enable=false`, so the proxy has no route for it.
5985
+ const entryOrigins = new Map(entryRoutes(org).map((r) => [r.hostname, r.service]));
5672
5986
  const ingress = tunnelIngressHostnames(org, loginHost).map((hostname) => ({
5673
5987
  hostname,
5674
5988
  service: hostname === loginHost
5675
5989
  ? `http://${PLATFORM_AUTH_LOGIN_INTERNAL_NAME}:${AUTH_LOGIN_PORT}`
5676
- : COOLIFY_PROXY_ORIGIN,
5990
+ : (entryOrigins.get(hostname) ?? COOLIFY_PROXY_ORIGIN),
5677
5991
  }));
5678
5992
  await this.tunnelPort().putTunnelConfig(tunnel.id, {
5679
5993
  ingress: [...ingress, { service: 'http_status:404' }],
5680
5994
  });
5995
+ context.recordScopeId(CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, entryRoutesMarker(org));
5681
5996
  // #235: a connector for this org's own tunnel, the same call every
5682
5997
  // platform tunnel step already makes (Decided, William, 2026-10-01:
5683
5998
  // one connector per org tunnel, never a shared tunnel with appended
@@ -5776,8 +6091,77 @@ export class FoundryStepExecutor {
5776
6091
  context.recordScopeId(CLOUDFLARE_TUNNEL_PREVIOUS_IDS_SCOPE_KEY, '');
5777
6092
  }
5778
6093
  }
6094
+ await this.publishOikosEntryRecords(org, tunnel.id, previousIds, context);
5779
6095
  return { resourceId: tunnel.id };
5780
6096
  }
6097
+ /**
6098
+ * The entry names a company has in the platform's `oikos.host` zone (a company with no domain of its own):
6099
+ * one proxied CNAME each, to this company's tunnel, which routes them straight to the entry container. A
6100
+ * specific record beats the shared `*.oikos.host` wildcard, so the shared ingress stops carrying the name
6101
+ * without being edited. The company writes only its own `<slug>.oikos.host` ({@link ownsOikosName}); an
6102
+ * A or AAAA record, or a CNAME that points anywhere but this tunnel, an earlier id of it or the shared
6103
+ * wildcard's target, blocks the step. A name the company no longer declares has its record deleted when
6104
+ * it still points at this tunnel. The names are recorded only after every record is in place, so a failure
6105
+ * halfway is planned again.
6106
+ */
6107
+ async publishOikosEntryRecords(org, tunnelId, previousIds, context) {
6108
+ const wanted = entryOikosHostnames(org);
6109
+ const recorded = context.recordedIds()[CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY];
6110
+ const previous = recorded ? recorded.split(',') : [];
6111
+ if (wanted.length === 0 && previous.length === 0)
6112
+ return;
6113
+ for (const name of wanted) {
6114
+ if (!ownsOikosName(org, name)) {
6115
+ throw new Error(`cloudflare:tunnel for ${org.slug}: ${name} is not a name this company may write in the ${NO_DOMAIN_ZONE} zone`);
6116
+ }
6117
+ }
6118
+ const zone = await this.zonePort().findZoneByApex(NO_DOMAIN_ZONE);
6119
+ if (!zone) {
6120
+ throw new Error(`cloudflare:tunnel for ${org.slug}: no Cloudflare zone found for ${NO_DOMAIN_ZONE} to point its entry names at`);
6121
+ }
6122
+ const dns = this.dnsPort();
6123
+ const dnsTarget = `${tunnelId}.cfargotunnel.com`;
6124
+ const ownTargets = new Set([
6125
+ dnsTarget,
6126
+ ...[...previousIds].map((id) => `${id}.cfargotunnel.com`),
6127
+ ]);
6128
+ const [shared] = await dns.findDnsRecord(zone.name, `*.${NO_DOMAIN_ZONE}`, 'CNAME');
6129
+ const writes = [];
6130
+ const conflicts = [];
6131
+ for (const name of wanted) {
6132
+ const [a] = await dns.findDnsRecord(zone.name, name, 'A');
6133
+ const [aaaa] = await dns.findDnsRecord(zone.name, name, 'AAAA');
6134
+ if (a ?? aaaa) {
6135
+ conflicts.push(`${name} already has an ${(a ?? aaaa)?.type} record`);
6136
+ continue;
6137
+ }
6138
+ const [cname] = await dns.findDnsRecord(zone.name, name, 'CNAME');
6139
+ const record = { type: 'CNAME', name, content: dnsTarget, proxied: true };
6140
+ if (!cname)
6141
+ writes.push(() => dns.createDnsRecord(zone.name, record));
6142
+ else if (cname.content === dnsTarget)
6143
+ continue;
6144
+ else if (ownTargets.has(cname.content) || cname.content === shared?.content) {
6145
+ writes.push(() => dns.updateDnsRecord(zone.name, cname.id, record));
6146
+ }
6147
+ else
6148
+ conflicts.push(`${name} is already a CNAME to ${cname.content}`);
6149
+ }
6150
+ if (conflicts.length > 0) {
6151
+ throw new Error(`cloudflare:tunnel for ${org.slug}: will not point its tunnel at every entry name -- ${conflicts.join('; ')}; remove the conflicting record first`);
6152
+ }
6153
+ for (const write of writes)
6154
+ await write();
6155
+ for (const name of previous.filter((n) => !wanted.includes(n))) {
6156
+ if (!ownsOikosName(org, name))
6157
+ continue;
6158
+ for (const record of await dns.findDnsRecord(zone.name, name, 'CNAME')) {
6159
+ if (ownTargets.has(record.content))
6160
+ await dns.deleteDnsRecord(zone.name, record.id);
6161
+ }
6162
+ }
6163
+ context.recordScopeId(CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY, wanted.join(','));
6164
+ }
5781
6165
  /**
5782
6166
  * #236: when this tunnel's DNS was last published under a domain that
5783
6167
  * differs from the one `org` now declares, deletes every CNAME in the
@@ -6094,7 +6478,7 @@ export class FoundryStepExecutor {
6094
6478
  }
6095
6479
  const mailOrg = mailOrgFor(org, usesDomain, mode);
6096
6480
  const result = await this.ports.mail.applyOrgMail(mailOrg, options);
6097
- await this.publishMailDnsRecords(org, result);
6481
+ await this.publishMailDnsRecords(org, result, context);
6098
6482
  await this.deleteStaleLettermintDomain(context, result.domain, platformApex);
6099
6483
  context.recordScopeId(MAIL_DOMAIN_SCOPE_KEY, result.domain);
6100
6484
  await this.ensureOwnerMailbox(org, mailOrg, options, context);
@@ -6225,15 +6609,49 @@ export class FoundryStepExecutor {
6225
6609
  * call, since Stalwart mints the key on a background task; the next apply
6226
6610
  * publishes it.
6227
6611
  */
6228
- async publishMailDnsRecords(org, result) {
6612
+ async publishMailDnsRecords(org, result, context) {
6229
6613
  const zone = await this.zonePort().findZoneByApex(result.domain);
6230
6614
  if (!zone) {
6231
6615
  throw new Error(`coolify:mail for ${org.slug}: no Cloudflare zone found for ${result.domain}`);
6232
6616
  }
6617
+ if (ownsServices(org) && result.domain.toLowerCase() === `${org.slug}.${NO_DOMAIN_ZONE}`) {
6618
+ await this.ensureOwnWebAddressRecord(org, zone.name, context);
6619
+ }
6233
6620
  for (const record of result.records) {
6234
6621
  await this.upsertMailDnsRecord(zone.name, record);
6235
6622
  }
6236
6623
  }
6624
+ /**
6625
+ * A company without a domain keeps its web address at `<slug>.oikos.host`,
6626
+ * which only the shared `*.oikos.host` wildcard answers. A wildcard applies
6627
+ * only to a name that holds no record of its own, so the MX and TXT records
6628
+ * of the company's mail domain on that same name would take the web address
6629
+ * away. This writes the name's own proxied CNAME, to wherever the wildcard
6630
+ * points, before any mail record. Found by name and type, so a rerun after a
6631
+ * half-finished apply never doubles it. Cloudflare lists only A, AAAA and
6632
+ * CNAME as clashing with a CNAME, so MX and TXT sit beside it.
6633
+ * https://developers.cloudflare.com/dns/manage-dns-records/reference/wildcard-dns-records/
6634
+ * https://developers.cloudflare.com/dns/manage-dns-records/troubleshooting/records-with-same-name/
6635
+ */
6636
+ async ensureOwnWebAddressRecord(org, zone, context) {
6637
+ const dns = this.dnsPort();
6638
+ const name = `${org.slug}.${NO_DOMAIN_ZONE}`;
6639
+ // The company's own tunnel already carries this name (an entry is served at it): that record is ours.
6640
+ const own = context.recordedIds()[CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY];
6641
+ if (own?.split(',').includes(name))
6642
+ return;
6643
+ const [wildcard] = await dns.findDnsRecord(zone, `*.${NO_DOMAIN_ZONE}`, 'CNAME');
6644
+ if (!wildcard) {
6645
+ throw new Error(`coolify:mail for ${org.slug}: the zone ${zone} has no *.${NO_DOMAIN_ZONE} record to copy, so ${name} would stop answering once its mail records exist`);
6646
+ }
6647
+ const record = { type: 'CNAME', name, content: wildcard.content, proxied: true };
6648
+ const [existing] = await dns.findDnsRecord(zone, name, 'CNAME');
6649
+ if (!existing)
6650
+ await dns.createDnsRecord(zone, record);
6651
+ else if (existing.content !== wildcard.content) {
6652
+ await dns.updateDnsRecord(zone, existing.id, record);
6653
+ }
6654
+ }
6237
6655
  /**
6238
6656
  * Creates or updates one mail record (Stalwart's or Lettermint's).
6239
6657
  *
@@ -6307,7 +6725,7 @@ export class FoundryStepExecutor {
6307
6725
  // An own org's sign-in sends its mail through `<slug>-auth` and the domain it
6308
6726
  // applied at; parking the org's mail leaves both, or the sign-in can no
6309
6727
  // longer send (its held token would still resolve, so nothing re-mints it).
6310
- const ownSignIn = ownsServices(org) && Boolean(org.domain) && ownAuthApplied(org, context.recordedIds());
6728
+ const ownSignIn = ownsServices(org) && ownAuthApplied(org, context.recordedIds());
6311
6729
  const names = new Set([`${org.slug}-app`, `${org.slug}-relay`]);
6312
6730
  if (!ownSignIn)
6313
6731
  names.add(`${org.slug}-auth`);
@@ -6319,7 +6737,7 @@ export class FoundryStepExecutor {
6319
6737
  }
6320
6738
  if (isPlatformApexOrg)
6321
6739
  return;
6322
- if (ownSignIn && mailDomain.toLowerCase() === org.domain?.toLowerCase())
6740
+ if (ownSignIn && mailDomain.toLowerCase() === ownHostnames(org).mailDomain.toLowerCase())
6323
6741
  return;
6324
6742
  const domain = await this.findExactLettermintDomain(mailDomain);
6325
6743
  if (domain)
@@ -7255,6 +7673,29 @@ export class FoundryStepExecutor {
7255
7673
  throw error;
7256
7674
  }
7257
7675
  }
7676
+ // The entry names it has in the platform's oikos.host zone (its own `<slug>.oikos.host` only).
7677
+ const oikosNames = (recordedIds[CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY] ?? '')
7678
+ .split(',')
7679
+ .filter((name) => name !== '' && ownsOikosName(org, name));
7680
+ if (oikosNames.length > 0) {
7681
+ try {
7682
+ const zone = await this.zonePort().findZoneByApex(NO_DOMAIN_ZONE);
7683
+ if (zone) {
7684
+ const dns = this.dnsPort();
7685
+ for (const name of oikosNames) {
7686
+ for (const record of await dns.findDnsRecord(zone.name, name, 'CNAME')) {
7687
+ if (dnsTargets.has(record.content)) {
7688
+ await dns.deleteDnsRecord(zone.name, record.id);
7689
+ }
7690
+ }
7691
+ }
7692
+ }
7693
+ }
7694
+ catch (error) {
7695
+ if (!isNotFoundError(error))
7696
+ throw error;
7697
+ }
7698
+ }
7258
7699
  await this.tunnelPort().deleteTunnel(target.id);
7259
7700
  return;
7260
7701
  }