@wtfalch/foundry 0.33.1 → 0.35.1

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 (106) 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 +24 -0
  6. package/dist/bootstrap/versions.js +46 -0
  7. package/dist/calls-sfu.d.ts +10 -0
  8. package/dist/calls-sfu.js +44 -3
  9. package/dist/index.d.ts +21 -8
  10. package/dist/index.js +11 -4
  11. package/dist/migrations/0051_org_services.sql +13 -0
  12. package/dist/migrations/0052_platform_mail_relay.sql +8 -0
  13. package/dist/migrations/0053_heartbeats_provider.sql +9 -0
  14. package/dist/migrations/0053_org_version_pin.sql +6 -0
  15. package/dist/org.d.ts +76 -12
  16. package/dist/org.js +136 -20
  17. package/dist/plan/backup-gate.d.ts +93 -0
  18. package/dist/plan/backup-gate.js +261 -0
  19. package/dist/plan/broker-entries.d.ts +9 -1
  20. package/dist/plan/broker-entries.js +20 -3
  21. package/dist/plan/build-record.d.ts +56 -0
  22. package/dist/plan/build-record.js +109 -0
  23. package/dist/plan/calls-matrix.d.ts +9 -0
  24. package/dist/plan/calls-matrix.js +18 -1
  25. package/dist/plan/cloudflared-connector.d.ts +7 -1
  26. package/dist/plan/cloudflared-connector.js +15 -2
  27. package/dist/plan/company-backups.d.ts +31 -0
  28. package/dist/plan/company-backups.js +101 -0
  29. package/dist/plan/deploy-health.d.ts +12 -2
  30. package/dist/plan/deploy-health.js +3 -1
  31. package/dist/plan/destinations.d.ts +73 -0
  32. package/dist/plan/destinations.js +217 -0
  33. package/dist/plan/diff.d.ts +46 -8
  34. package/dist/plan/diff.js +367 -126
  35. package/dist/plan/edge-access.d.ts +15 -0
  36. package/dist/plan/edge-access.js +21 -2
  37. package/dist/plan/executor.d.ts +276 -5
  38. package/dist/plan/executor.js +1111 -109
  39. package/dist/plan/health-check-settings.d.ts +28 -0
  40. package/dist/plan/health-check-settings.js +36 -0
  41. package/dist/plan/health-gaps.d.ts +33 -0
  42. package/dist/plan/health-gaps.js +89 -0
  43. package/dist/plan/health-targets.d.ts +1 -3
  44. package/dist/plan/health-targets.js +36 -36
  45. package/dist/plan/held-key-refs.d.ts +15 -0
  46. package/dist/plan/held-key-refs.js +15 -0
  47. package/dist/plan/hostnames.d.ts +10 -0
  48. package/dist/plan/hostnames.js +14 -0
  49. package/dist/plan/index.d.ts +19 -6
  50. package/dist/plan/index.js +10 -3
  51. package/dist/plan/live.d.ts +11 -2
  52. package/dist/plan/live.js +16 -2
  53. package/dist/plan/locked-hostnames.d.ts +7 -3
  54. package/dist/plan/locked-hostnames.js +25 -4
  55. package/dist/plan/login-host.d.ts +61 -12
  56. package/dist/plan/login-host.js +99 -11
  57. package/dist/plan/mail-relay.d.ts +139 -0
  58. package/dist/plan/mail-relay.js +400 -0
  59. package/dist/plan/network-move.d.ts +20 -0
  60. package/dist/plan/network-move.js +41 -0
  61. package/dist/plan/oikos-ingress.d.ts +111 -0
  62. package/dist/plan/oikos-ingress.js +231 -0
  63. package/dist/plan/org-names.d.ts +30 -0
  64. package/dist/plan/org-names.js +108 -0
  65. package/dist/plan/own-diff.d.ts +170 -0
  66. package/dist/plan/own-diff.js +440 -0
  67. package/dist/plan/own-dns.d.ts +6 -0
  68. package/dist/plan/own-dns.js +15 -0
  69. package/dist/plan/own-hostnames.d.ts +27 -0
  70. package/dist/plan/own-hostnames.js +24 -0
  71. package/dist/plan/own-limits.d.ts +213 -0
  72. package/dist/plan/own-limits.js +232 -0
  73. package/dist/plan/own-services.d.ts +193 -0
  74. package/dist/plan/own-services.js +169 -0
  75. package/dist/plan/own-sfu.d.ts +69 -0
  76. package/dist/plan/own-sfu.js +256 -0
  77. package/dist/plan/plan-apply.d.ts +121 -10
  78. package/dist/plan/plan-apply.js +357 -18
  79. package/dist/plan/platform-diff.d.ts +155 -11
  80. package/dist/plan/platform-diff.js +271 -66
  81. package/dist/plan/platform-org.d.ts +107 -4
  82. package/dist/plan/platform-org.js +175 -3
  83. package/dist/plan/platform-providers.d.ts +474 -81
  84. package/dist/plan/platform-providers.js +1407 -560
  85. package/dist/plan/proxy-labels.d.ts +57 -11
  86. package/dist/plan/proxy-labels.js +94 -14
  87. package/dist/plan/run-options.d.ts +19 -0
  88. package/dist/plan/run-options.js +46 -0
  89. package/dist/plan/sign-in-org.d.ts +1 -1
  90. package/dist/plan/sign-in-org.js +20 -2
  91. package/dist/plan/zitadel-login-policy.d.ts +1 -1
  92. package/dist/plan/zitadel-login-policy.js +77 -9
  93. package/dist/plan/zitadel-system-api.d.ts +22 -1
  94. package/dist/plan/zitadel-system-api.js +49 -8
  95. package/dist/platform.d.ts +40 -0
  96. package/dist/platform.js +16 -0
  97. package/dist/provision.d.ts +2 -0
  98. package/dist/provision.js +3 -1
  99. package/dist/slug.d.ts +3 -1
  100. package/dist/slug.js +26 -3
  101. package/dist/store/postgres.js +93 -2
  102. package/dist/store/schema.d.ts +87 -0
  103. package/dist/store/schema.js +9 -0
  104. package/package.json +3 -3
  105. package/versions.digests.json +11 -0
  106. package/versions.json +3 -1
@@ -1,16 +1,22 @@
1
- import { randomBytes } from 'node:crypto';
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
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
- import { APP_ENTRY_KEY, appEntry, filesCollectionEnvName, ownerMailboxRecorded, ownerMailboxes, sharedSecretRef, } from '../org.js';
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';
9
- import { MAS_IMAGE, MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
12
+ import { recordBuiltCommit } from './build-record.js';
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';
@@ -21,13 +27,22 @@ import { AI_ENTRY_RENEW_WINDOW_DAYS, entryAiAppliedScopeKey, entryAiClampedScope
21
27
  import { BOULE_EDGE_ENTRY_KEY, EDGE_ENTRY_KEY_DAYS, EDGE_RESERVED_LABELS_APPLIED_SCOPE_KEY, ENTRY_EDGE_KEY_DELIVERY_ATTEMPTS, edgeKeyAppRecordKey, entryEdgeMintedAtScopeKey, entryEdgeRetiredScopeKey, entryEdgeUndeliveredScopeKey, } from './entry-edge-key.js';
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
- import { healthCheckSettings } from './health-check-settings.js';
25
- import { appHostname, coolifyTunnelDomains, entryHostnames, tunnelDnsHostnames, tunnelDomainList, tunnelIngressHostnames, } from './hostnames.js';
26
- import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, LOGIN_HOST_SHARED_SCOPE_KEY, customersEntryKeys, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
30
+ import { IMAGE_HEALTHCHECK_SETTINGS, commandHealthCheckSettings, healthCheckSettings, wgetProbe, } from './health-check-settings.js';
31
+ import { PROXY_LABEL_TOKEN_REF } from './held-key-refs.js';
32
+ import { appHostname, cmsStudioHostnameFor, coolifyTunnelDomains, entryHostnames, siteHostnameFor, tunnelDnsHostnames, tunnelIngressHostnames, } from './hostnames.js';
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';
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';
37
+ import { OWN_AUTH_APPLIED_GIT_REF_SCOPE_KEY, OWN_AUTH_APPLIED_HOSTNAME_SCOPE_KEY, } from './own-diff.js';
38
+ import { NO_DOMAIN_ZONE, ownHostnames } from './own-hostnames.js';
39
+ import { LIMITS, limited } from './own-limits.js';
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';
41
+ import { ownSfuUrls, provisionSfuApp } from './own-sfu.js';
42
+ import { PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, } from './platform-diff.js';
28
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';
29
- import { AUTH_LOGIN_PORT, PLATFORM_AUTH_BROKER_MAIL_KEY_REF, PLATFORM_AUTH_LOGIN_INTERNAL_NAME, } from './platform-providers.js';
30
- 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';
31
46
  import { ZITADEL_ORG_DOMAIN_SCOPE_KEY, ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY, closeSignInOrgRegistration, ensureSignInOrg, inviteOwnerWithLink, registerSignInDomainOrBlock, signInOrgDomain, } from './sign-in-org.js';
32
47
  import { entryWorkerAppliedScopeKey, entryWorkerExclusionsScopeKey, entryWorkerRetiredScopeKey, entryWorkerRouteIdScopeKey, entryWorkerRoutePatternScopeKey, entryWorkerRoutePendingScopeKey, entryWorkerRouteZoneScopeKey, entryWorkerUploadedScopeKey, } from './worker.js';
33
48
  /**
@@ -113,14 +128,13 @@ function normalizedLoginBaseUri(uri) {
113
128
  * `cms.<domain>` -- the cms studio's own hostname (`cmsSite` below), apart
114
129
  * from the public site itself at `org.domain` (used directly there). Called
115
130
  * from `cmsSite` and from `zitadelApp` for the fixed `cms` client (#139),
116
- * both of which only run once `org.site.enabled` is true, so `org.domain`
117
- * is non-null by the same `validateSite` guarantee
118
- * {@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`).
119
133
  */
120
134
  // #541: exported so `health-targets.ts` reuses this hostname rule rather
121
135
  // than restating it; nothing else about this function changes.
122
136
  export function cmsHostnameFor(org) {
123
- return `cms.${org.domain}`;
137
+ return cmsStudioHostnameFor(org);
124
138
  }
125
139
  /**
126
140
  * #145's "Decided" (2026-09-27): the calls service's own public hostname.
@@ -158,8 +172,11 @@ export function healthUrls(org) {
158
172
  ];
159
173
  });
160
174
  }
161
- if (org.site.enabled && org.domain) {
162
- 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
+ ];
163
180
  }
164
181
  return [];
165
182
  }
@@ -186,7 +203,7 @@ function mailOrgFor(org, usesDomain, mode) {
186
203
  * https://github.com/wtfalch/foundry/issues/71) names this same constant and
187
204
  * value.
188
205
  */
189
- const COOLIFY_PROXY_ORIGIN = 'http://coolify-proxy:80';
206
+ export const COOLIFY_PROXY_ORIGIN = 'http://coolify-proxy:80';
190
207
  /** The one Access policy foundry puts on a platform Access app (#84). */
191
208
  export const ACCESS_POLICY_NAME = 'foundry: allowed emails';
192
209
  /**
@@ -300,7 +317,13 @@ const BOULE_PORT = 3000;
300
317
  * it from this file, the same way `hostnameFor`'s hostname rule is
301
318
  * restated rather than shared across the two files.
302
319
  */
303
- 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';
304
327
  /**
305
328
  * `cmsSite` provisions two applications from one `cms:site` step, but
306
329
  * `execute`'s own return value auto-records only one `resourceId`, under
@@ -318,6 +341,8 @@ const CMS_APP_SCOPE_KEY = 'coolify_cms_app';
318
341
  const SITE_APP_SCOPE_KEY = 'coolify_site_app';
319
342
  /** #445: the one Coolify server every resource of this org lands on, chosen once and recorded. */
320
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';
321
346
  /**
322
347
  * #117: the cms app's own Postgres database (`<org.slug>-cms-db`), found or
323
348
  * created inside `cmsSite` the same way `coolifyDatabase` does for the org's
@@ -600,8 +625,8 @@ const CHAT_SERVICE_DB_SCOPE_KEY = 'coolify_chat_service_database';
600
625
  */
601
626
  const CHAT_REKEY_SYNAPSE_SCOPE_KEY = 'coolify_chat_rekey_synapse';
602
627
  const CHAT_REKEY_MAS_SCOPE_KEY = 'coolify_chat_rekey_mas';
603
- const CHAT_REKEY_SYNAPSE_DB_SCOPE_KEY = 'coolify_chat_rekey_synapse_database';
604
- 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';
605
630
  /**
606
631
  * Set once {@link ChatRekeyPort.copyHomeserver} returns, so a retried or
607
632
  * repeated apply never copies the same history twice -- the copy itself is
@@ -633,11 +658,6 @@ const CHAT_SECRET_ENV_KEYS = {
633
658
  masRsaKeyPem: 'CHAT_MATRIX_MAS_RSA_KEY_PEM',
634
659
  masEcKeyPem: 'CHAT_MATRIX_MAS_EC_KEY_PEM',
635
660
  };
636
- /** `FROM ${MAS_IMAGE}` plus the entry command its published image needs (chat's own `MAS_DOCKERFILE`, same reason: no arguments by default). */
637
- const CALLS_MAS_DOCKERFILE = [
638
- `FROM ${MAS_IMAGE}`,
639
- 'CMD ["server", "--config", "/config/config.yaml"]',
640
- ].join('\n');
641
661
  /**
642
662
  * `coolifyCallsService`'s own database (#145) -- no `system:kind:key` slot
643
663
  * of its own, the same reason {@link CALLS_SYNAPSE_SCOPE_KEY}'s doc comment
@@ -665,6 +685,12 @@ const CALLS_HEALTH_CHECK_PATH = '/healthz';
665
685
  * {@link CALLS_HEALTH_CHECK_PATH} above.
666
686
  */
667
687
  const CHAT_SERVICE_HEALTH_CHECK_PATH = '/_chat/health';
688
+ /**
689
+ * Synapse's start period for its Coolify health check. It runs its database
690
+ * migrations before it listens, which is slower than the default start period
691
+ * of {@link healthCheckSettings} allows, and the image's own check waits 5 s.
692
+ */
693
+ const SYNAPSE_HEALTH_CHECK_START_PERIOD_SECONDS = 120;
668
694
  /**
669
695
  * Whether an application's own Coolify `status` (`` `running:healthy`,
670
696
  * `exited:unhealthy` `` -- `@wtfalch/providers`' own doc comment) means it is
@@ -755,8 +781,85 @@ export const databaseStartWait = {
755
781
  // ---------------------------------------------------------------------------
756
782
  // Executor
757
783
  // ---------------------------------------------------------------------------
784
+ /** How long the host's bucket port may take to make or find a bucket. */
785
+ const BUCKET_TIMEOUT_MS = 60_000;
786
+ /** How long the keys worker may take to mint an own files host's credential. */
787
+ const KEYS_MINT_TIMEOUT_MS = 15_000;
788
+ /** How long one call to the R2 key port or the files route that stores the key may take. */
789
+ const R2_KEY_TIMEOUT_MS = 30_000;
790
+ /** Clears a recorded id; a context that cannot forget one records it empty, which reads as unset. */
791
+ function forgetScopeId(context, key) {
792
+ if (context.forgetId)
793
+ context.forgetId(key);
794
+ else
795
+ context.recordScopeId(key, '');
796
+ }
797
+ async function withTimeout(work, ms) {
798
+ let timer;
799
+ try {
800
+ return await Promise.race([
801
+ work,
802
+ new Promise((_, reject) => {
803
+ timer = setTimeout(() => reject(new Error('timeout')), ms);
804
+ }),
805
+ ]);
806
+ }
807
+ finally {
808
+ clearTimeout(timer);
809
+ }
810
+ }
811
+ /** How long one Lettermint call of an own company's sign-in mint may take. */
812
+ const LETTERMINT_TIMEOUT_MS = 60_000;
813
+ const OWN_SERVICES_PORT_ABSENT = 'this org runs its own services and the host has no ownServices port for it';
814
+ /** A port that refuses every call, naming the held key the org's service waits for. */
815
+ function blockedPort(service, ref) {
816
+ return new Proxy({}, {
817
+ get: () => () => {
818
+ throw new OwnServicesBlockedError(service, ref);
819
+ },
820
+ });
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
+ }
758
862
  export class FoundryStepExecutor {
759
- ports;
760
863
  edgeDns;
761
864
  edgeTunnels;
762
865
  edgeAccess;
@@ -765,12 +868,132 @@ export class FoundryStepExecutor {
765
868
  edgeWriteSlot;
766
869
  /** Secrets this executor minted or was handed; see `StepExecutor.secretsHandled`. */
767
870
  handledSecrets = new Set();
871
+ ports;
872
+ get rawCoolify() {
873
+ return this.ports.coolify;
874
+ }
768
875
  constructor(ports) {
769
- 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
+ });
770
902
  }
771
903
  secretsHandled() {
772
904
  return [...this.handledSecrets];
773
905
  }
906
+ /**
907
+ * An own org's step runs on a copy of this executor whose ZITADEL, mail,
908
+ * broker, files, jobs and calls ports are the org's own. A port the org has
909
+ * not got yet is absent or throws, never the shared one.
910
+ */
911
+ async executeScoped(action, context) {
912
+ const own = this.ports.ownServices;
913
+ if (!own)
914
+ throw new Error(OWN_SERVICES_PORT_ABSENT);
915
+ const scoped = await own.portsFor(context.org, context.recordedIds());
916
+ if (!scoped)
917
+ throw new Error(OWN_SERVICES_PORT_ABSENT);
918
+ const refs = orgTarget(context.org).refs;
919
+ const copy = new FoundryStepExecutor({
920
+ ...this.ports,
921
+ ownServices: undefined,
922
+ zitadel: scoped.zitadel ?? blockedPort('zitadel', refs.instanceBearer),
923
+ mail: scoped.mail ?? blockedPort('mail', refs.mailAdminKey),
924
+ callsSfu: scoped.callsSfu ?? blockedPort('calls', refs.livekitKeys),
925
+ broker: scoped.broker,
926
+ filesOrganisation: scoped.filesOrganisation ??
927
+ blockedPort('files', refs.filesManagement),
928
+ jobsScheduler: scoped.jobsScheduler ?? blockedPort('jobs', refs.jobsAdminToken),
929
+ callsSfuUrls: ownSfuUrls(context.recordedIds()),
930
+ });
931
+ try {
932
+ return await copy.run(action, context);
933
+ }
934
+ finally {
935
+ for (const secret of copy.secretsHandled())
936
+ this.handledSecrets.add(secret);
937
+ }
938
+ }
939
+ /** Whether `ensureOwnAuthMailToken` can mint: the same two inputs it blocks on. */
940
+ ownMailKeyMintable() {
941
+ return Boolean(this.ports.lettermint && this.ports.ownServices?.provision.auth.heldKeys);
942
+ }
943
+ /** What the executor's own files dependencies supply, for the planner's blocked lines. */
944
+ ownFilesDeps() {
945
+ const files = this.ports.ownServices?.provision.files;
946
+ if (!files)
947
+ return { keys: false, bucket: false };
948
+ return {
949
+ keys: Boolean(files.keysMint),
950
+ bucket: Boolean(files.r2Buckets),
951
+ };
952
+ }
953
+ /** Which ports the own files storage key step needs this executor has, for the planner's blocked line. */
954
+ async ownFilesR2Ports(org, recordedIds) {
955
+ const r2Keys = Boolean(this.ports.ownServices?.provision.files.r2Keys);
956
+ // Only an own org whose files host is applied is asked for its scoped ports.
957
+ if (!ownsServices(org) || !recordedIds[`coolify:own-files:${org.id}`]) {
958
+ return { r2Keys, storeR2Credentials: false };
959
+ }
960
+ try {
961
+ const scoped = await this.ports.ownServices?.portsFor(org, recordedIds);
962
+ return {
963
+ r2Keys,
964
+ storeR2Credentials: typeof scoped?.filesOrganisation?.storeR2Credentials === 'function',
965
+ };
966
+ }
967
+ catch (e) {
968
+ if (e instanceof OwnServicesBlockedError)
969
+ return { r2Keys, storeR2Credentials: false };
970
+ throw e;
971
+ }
972
+ }
973
+ /** Whether the host's scoped ports for this own org include a files-organisation port. A held key still missing counts as no port. */
974
+ async ownFilesOrganisationPort(org, recordedIds) {
975
+ try {
976
+ const scoped = await this.ports.ownServices?.portsFor(org, recordedIds);
977
+ return Boolean(scoped?.filesOrganisation);
978
+ }
979
+ catch (e) {
980
+ if (e instanceof OwnServicesBlockedError)
981
+ return false;
982
+ throw e;
983
+ }
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
+ }
774
997
  /** #555: the edge-routed ports this executor was built with itself (they win over edge). */
775
998
  ownEdgePorts() {
776
999
  return {
@@ -782,6 +1005,49 @@ export class FoundryStepExecutor {
782
1005
  };
783
1006
  }
784
1007
  async execute(action, context) {
1008
+ const { org } = context;
1009
+ if (ownsServices(org) && !action.kind.startsWith('own-')) {
1010
+ return this.executeScoped(action, context);
1011
+ }
1012
+ return this.run(action, context);
1013
+ }
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) {
785
1051
  const { org } = context;
786
1052
  switch (`${action.system}:${action.kind}`) {
787
1053
  case 'zitadel:issuer':
@@ -802,10 +1068,14 @@ export class FoundryStepExecutor {
802
1068
  return this.zitadelCustomers(org, context);
803
1069
  case 'coolify:app':
804
1070
  return this.coolifyApp(action, org, context);
1071
+ case 'coolify:network':
1072
+ return this.coolifyNetwork(action, org, context);
805
1073
  case 'coolify:broker-entry':
806
1074
  return this.coolifyBrokerEntry(action, org, context);
807
1075
  case 'coolify:database':
808
1076
  return this.coolifyDatabase(action, org, context);
1077
+ case 'coolify:backup':
1078
+ return this.coolifyBackup(action, org, context);
809
1079
  case 'coolify:calls':
810
1080
  return this.coolifyCalls(action, org, context);
811
1081
  case 'coolify:calls-service':
@@ -860,6 +1130,18 @@ export class FoundryStepExecutor {
860
1130
  return this.bouleBrokerEntry(org, context);
861
1131
  case 'jobs:registration':
862
1132
  return this.jobsRegistration(action, org, context);
1133
+ case 'coolify:own-auth':
1134
+ return this.ownAuth(action, org, context);
1135
+ case 'coolify:own-jobs':
1136
+ return this.ownJobs(action, org, context);
1137
+ case 'coolify:own-mail':
1138
+ return this.ownMail(action, org, context);
1139
+ case 'coolify:own-sfu':
1140
+ return this.ownSfu(action, org, context);
1141
+ case 'coolify:own-files':
1142
+ return this.ownFiles(action, org, context);
1143
+ case 'coolify:own-files-r2':
1144
+ return this.ownFilesR2(org, context);
863
1145
  default:
864
1146
  throw new Error(`FoundryStepExecutor has no dispatch for ${action.system}:${action.kind}`);
865
1147
  }
@@ -1779,10 +2061,9 @@ export class FoundryStepExecutor {
1779
2061
  return { resourceId: created.appId };
1780
2062
  }
1781
2063
  // #139: the fixed `cms` client (`diff.ts`'s `planCmsClient`) signs
1782
- // people into the cms studio at `cms.<domain>`, not the org's own app
1783
- // hostname -- `org.site.enabled` guarantees `org.domain` is set
1784
- // (`org.ts`'s `validateSite`), the same guarantee `cmsHostnameFor`'s own
1785
- // 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
1786
2067
  // `planAgoraClient`) is the same shape, always -- there is no
1787
2068
  // `org.site.enabled`-style guard to check, since Agora is always on.
1788
2069
  // The Agora client stays `appType: 'web'`: providers' `createOidcApp`
@@ -1881,12 +2162,110 @@ export class FoundryStepExecutor {
1881
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`);
1882
2163
  }
1883
2164
  }
1884
- const projectUuid = found
1885
- ? found.uuid
1886
- : (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
+ }
1887
2183
  context.recordScopeId('coolify_project', projectUuid);
1888
2184
  return projectUuid;
1889
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
+ }
1890
2269
  /**
1891
2270
  * #445: one company lands on one Coolify server. The first call for an
1892
2271
  * org picks a usable, reachable server and records it
@@ -1950,6 +2329,11 @@ export class FoundryStepExecutor {
1950
2329
  throw new Error(`coolify:app for ${org.slug}: entry '${entry.key}' has no repo`);
1951
2330
  }
1952
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
+ }
1953
2337
  const projectUuid = await this.ensureCoolifyProject(org, context);
1954
2338
  const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
1955
2339
  // The recorded id first; the name when nothing is recorded, or when the
@@ -1971,10 +2355,12 @@ export class FoundryStepExecutor {
1971
2355
  environmentIds: [environment.id],
1972
2356
  });
1973
2357
  }
1974
- // An entry with no names is internal: the other entries reach it as
1975
- // `<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.
1976
2362
  const names = entryHostnames(org, entry);
1977
- const wantDomains = tunnelDomainList(names);
2363
+ const wantDomains = [];
1978
2364
  const isStatic = entry.buildPack === 'static';
1979
2365
  const settingsKey = appSettingsAppliedScopeKey(entry.key);
1980
2366
  const domainsKey = appDomainsAppliedScopeKey(entry.key);
@@ -2014,8 +2400,7 @@ export class FoundryStepExecutor {
2014
2400
  git_branch: entry.branch,
2015
2401
  build_pack: entry.buildPack,
2016
2402
  name: appNameFor(org, entry),
2017
- domains: names.length > 0 ? coolifyTunnelDomains(names) : undefined,
2018
- autogenerate_domain: names.length > 0 ? undefined : false,
2403
+ autogenerate_domain: false,
2019
2404
  // A static site is served by Coolify's own web server on port 80.
2020
2405
  ports_exposes: isStatic ? '80' : entry.port === null ? undefined : String(entry.port),
2021
2406
  is_auto_deploy_enabled: entry.autoDeploy,
@@ -2060,9 +2445,9 @@ export class FoundryStepExecutor {
2060
2445
  // redirect loops behind the tunnel. The internal name is set after
2061
2446
  // create, as the calls stack's apps do.
2062
2447
  if (JSON.stringify(wantDomains) !== JSON.stringify(haveDomains)) {
2063
- patch.domains = coolifyTunnelDomains(names);
2448
+ patch.domains = '';
2064
2449
  }
2065
- if (names.length === 0 && (!existing || patch.domains !== undefined)) {
2450
+ if (!existing || patch.domains !== undefined) {
2066
2451
  patch.custom_internal_name = appNameFor(org, entry);
2067
2452
  }
2068
2453
  // A `*` entry's proxy labels: Coolify takes `custom_labels` base64 encoded
@@ -2074,16 +2459,16 @@ export class FoundryStepExecutor {
2074
2459
  // below, after the redeploy, so a failed run repeats this.
2075
2460
  const labelsKey = appLabelsAppliedScopeKey(entry.key);
2076
2461
  const haveLabelsHash = context.recordedIds()[labelsKey];
2077
- const labelSpec = entryLabelSpec(org, entry, haveLabelsHash !== undefined);
2078
- const wantLabelsHash = labelSpec ? entryLabelHash(labelSpec) : undefined;
2079
- 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;
2080
2466
  const liveLabels = existing ? live?.custom_labels : undefined;
2081
- const labelsDiffer = wantLabels !== undefined &&
2082
- (!existing ||
2083
- (liveLabels !== undefined
2084
- ? !sameLabelSets(Buffer.from(liveLabels ?? '', 'base64').toString('utf8'), wantLabels)
2085
- : wantLabelsHash !== haveLabelsHash));
2086
- 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) {
2087
2472
  patch.custom_labels = Buffer.from(wantLabels).toString('base64');
2088
2473
  }
2089
2474
  if (Object.keys(patch).length > 0)
@@ -2125,7 +2510,6 @@ export class FoundryStepExecutor {
2125
2510
  // What the plan decided is available (diff.ts `planApps`), so the marker
2126
2511
  // recorded below is the one the plan compares against. Absent: none.
2127
2512
  action.desired.secretSources ?? {
2128
- platformMailKey: false,
2129
2513
  sharedSecret: false,
2130
2514
  });
2131
2515
  // Rule 4 (#239, #243): a container reads its env only at start, so an app
@@ -2145,9 +2529,15 @@ export class FoundryStepExecutor {
2145
2529
  const [hostname] = names;
2146
2530
  const healthPath = entry.healthPath ?? (isStatic ? '/' : '/api/health');
2147
2531
  const runtime = this.ports.runtime ?? {};
2148
- 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)
2149
2538
  ? publicHealthCheck(org, context.recordedIds(), hostname, healthPath)
2150
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));
2151
2541
  }
2152
2542
  else if ((appEnv.changed || dbNeedsRestart) && existing) {
2153
2543
  await this.ports.coolify.restart(appId);
@@ -2158,8 +2548,7 @@ export class FoundryStepExecutor {
2158
2548
  // one and `planApp` reports it instead of planning it.
2159
2549
  context.recordScopeId(settingsKey, JSON.stringify({ ...want, port: want.port ?? have.port }));
2160
2550
  context.recordScopeId(domainsKey, JSON.stringify(wantDomains));
2161
- if (wantLabelsHash)
2162
- context.recordScopeId(labelsKey, wantLabelsHash);
2551
+ context.recordScopeId(labelsKey, wantLabelsHash);
2163
2552
  context.recordScopeId(appEnvAppliedScopeKey(entry.key), appEnv.applied);
2164
2553
  if (entry.uses?.auth) {
2165
2554
  context.recordScopeId(entryAuthEnvAppliedScopeKey(entry.key), appEnv.authApplied);
@@ -2223,6 +2612,31 @@ export class FoundryStepExecutor {
2223
2612
  await this.ports.coolify.restart(appId);
2224
2613
  }
2225
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
+ }
2226
2640
  async coolifyDatabase(action, org, context) {
2227
2641
  if (action.desired.enabled === false) {
2228
2642
  const dbId = context.recordedIds()[`coolify:database:${action.key}`];
@@ -2420,24 +2834,12 @@ export class FoundryStepExecutor {
2420
2834
  // minted here; a source with no value yet (its own step has not run,
2421
2835
  // or has not run for this apply) is simply omitted, the same way every
2422
2836
  // other optional entry above is.
2423
- // The secret sources (`sharedSecret`, `platformMailKey`) are filled here
2837
+ // The secret source (`sharedSecret`) is filled here
2424
2838
  // only, straight into `envs`: never recorded, returned or logged.
2425
2839
  const heldKeys = this.ports.heldKeys;
2426
- // Availability is the plan's (`secretSources`). If the plan said a key is
2427
- // held and this port cannot read it, the host wired the two differently:
2428
- // fail by ref, never push a marker for an env that was not written.
2429
- let mailKey = null;
2430
- if (secretSources.platformMailKey) {
2431
- try {
2432
- mailKey = (await heldKeys?.resolve(PLATFORM_AUTH_BROKER_MAIL_KEY_REF)) || null;
2433
- }
2434
- catch {
2435
- // reported below, by ref
2436
- }
2437
- if (mailKey === null) {
2438
- 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`);
2439
- }
2440
- }
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.
2441
2843
  if (secretSources.sharedSecret && typeof heldKeys?.put !== 'function') {
2442
2844
  throw new Error(`coolify:app for ${org.slug}: the plan found held keys that can put but the executor's heldKeys cannot`);
2443
2845
  }
@@ -2448,9 +2850,7 @@ export class FoundryStepExecutor {
2448
2850
  for (const { name, source } of resolved.secrets) {
2449
2851
  envs.push({
2450
2852
  key: name,
2451
- value: source === 'platformMailKey'
2452
- ? mailKey
2453
- : await this.ensureSharedSecret(org, source.name, recorded),
2853
+ value: await this.ensureSharedSecret(org, source.name, recorded),
2454
2854
  });
2455
2855
  }
2456
2856
  await this.ports.coolify.bulkUpsertEnvs(appId, envs);
@@ -2469,6 +2869,40 @@ export class FoundryStepExecutor {
2469
2869
  authApplied,
2470
2870
  };
2471
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
+ }
2472
2906
  /**
2473
2907
  * The org's shared secret `name`: read from the held keys, else generated
2474
2908
  * and held first, so a crash after the `put` and before the env write
@@ -2925,6 +3359,40 @@ export class FoundryStepExecutor {
2925
3359
  }
2926
3360
  }
2927
3361
  }
3362
+ /**
3363
+ * The company's own sign-in mail token: a Lettermint project `<slug>-auth`
3364
+ * whose token goes to `orgTarget(org).refs.brokerMailKey`, where the broker
3365
+ * and ZITADEL's SMTP settings read it. The platform-wide Lettermint key
3366
+ * stays with the executor and never reaches a company. A held ref is left
3367
+ * alone. Otherwise the mint rule of {@link ensureOrgAppMailToken}: create
3368
+ * the project, restrict the domain, deliver its token, and only then
3369
+ * delete every other `<slug>-auth` project (a token cannot be read back,
3370
+ * so a project without a held token is a leftover). The restriction comes
3371
+ * before the delivery so a failure there leaves no held ref: the next run
3372
+ * starts over instead of skipping it. A failure before the delivery leaves
3373
+ * that project for the next run to delete. Every Lettermint call is
3374
+ * bounded; the token is registered for redaction before anything can throw
3375
+ * with it.
3376
+ */
3377
+ async ensureOwnAuthMailToken(org, mailDomain, deps) {
3378
+ const ref = orgTarget(org).refs.brokerMailKey;
3379
+ if (await deps.heldKeys?.resolve(ref))
3380
+ return;
3381
+ const lettermint = this.ports.lettermint;
3382
+ if (!lettermint || !deps.heldKeys)
3383
+ throw new OwnServicesBlockedError('lettermint', ref);
3384
+ const authName = `${org.slug}-auth`;
3385
+ const projects = await withTimeout(lettermint.listProjects(), LETTERMINT_TIMEOUT_MS);
3386
+ const created = await withTimeout(lettermint.createProject(authName), LETTERMINT_TIMEOUT_MS);
3387
+ deps.runtime?.onSecret?.(created.token);
3388
+ await withTimeout(this.ensureLettermintDomainRestriction(org, mailDomain, [...projects, created.project], null, null), LETTERMINT_TIMEOUT_MS);
3389
+ await deps.deliverSecret(ref, created.token);
3390
+ for (const project of projects) {
3391
+ if (project.name === authName && project.id !== created.project.id) {
3392
+ await withTimeout(lettermint.deleteProject(project.id), LETTERMINT_TIMEOUT_MS);
3393
+ }
3394
+ }
3395
+ }
2928
3396
  /**
2929
3397
  * Deletes a just-minted project from inside a `catch`. A failed delete is
2930
3398
  * dropped so it never replaces the error being rethrown; the orphan is
@@ -2971,6 +3439,7 @@ export class FoundryStepExecutor {
2971
3439
  async ensureLettermintDomainRestriction(org, mailDomain, allProjects, platformApex, platformLettermintProjectId) {
2972
3440
  const appName = `${org.slug}-app`;
2973
3441
  const relayName = `${org.slug}-relay`;
3442
+ const authName = `${org.slug}-auth`;
2974
3443
  const isPlatformApexDomain = Boolean(platformApex && mailDomain.toLowerCase() === platformApex.toLowerCase());
2975
3444
  if (isPlatformApexDomain && platformLettermintProjectId) {
2976
3445
  if (!allProjects.some((p) => p.id === platformLettermintProjectId)) {
@@ -2982,7 +3451,7 @@ export class FoundryStepExecutor {
2982
3451
  ? await this.ports.lettermint.getDomain(existing.id)
2983
3452
  : await this.ports.lettermint.createDomain(mailDomain);
2984
3453
  const scopedProjectIds = allProjects
2985
- .filter((p) => p.name === appName || p.name === relayName)
3454
+ .filter((p) => p.name === appName || p.name === relayName || p.name === authName)
2986
3455
  .map((p) => p.id);
2987
3456
  if (isPlatformApexDomain &&
2988
3457
  platformLettermintProjectId &&
@@ -3069,14 +3538,8 @@ export class FoundryStepExecutor {
3069
3538
  await this.ports.coolify.raw('POST', `/applications/${siteAppId}/stop`);
3070
3539
  return { resourceId: cmsAppId ?? siteAppId };
3071
3540
  }
3072
- // `validateSite` ("site.enabled requires domain") guarantees this
3073
- // whenever this step runs -- checked anyway, the same way
3074
- // `cloudflareRateLimit` above guards its own `org.domain` read, since
3075
- // TypeScript's `string | null` gives no static guarantee of it.
3076
- if (!org.domain) {
3077
- throw new Error(`cms:site for ${org.slug}: site.enabled is set but domain is null`);
3078
- }
3079
- const domain = org.domain;
3541
+ // The site's address: the org's domain, or `<slug>.oikos.host` without one.
3542
+ const domain = siteHostnameFor(org);
3080
3543
  const projectUuid = await this.ensureCoolifyProject(org, context);
3081
3544
  const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
3082
3545
  const site = await this.findOrCreateCmsApp({
@@ -3110,9 +3573,11 @@ export class FoundryStepExecutor {
3110
3573
  // deploys, it does not add a deploy where none exists).
3111
3574
  await this.ports.coolify.updateApplication(site.appId, {
3112
3575
  ...healthCheckSettings('/api/health', CMS_APP_PORT),
3576
+ ...(await this.routedLabels(`${org.slug}-site`, domain, CMS_APP_PORT)),
3113
3577
  });
3114
3578
  await this.ports.coolify.updateApplication(cms.appId, {
3115
3579
  ...healthCheckSettings('/api/health', CMS_APP_PORT),
3580
+ ...(await this.routedLabels(`${org.slug}-cms`, cmsHostnameFor(org), CMS_APP_PORT)),
3116
3581
  });
3117
3582
  // Every app foundry deploys keeps its own logs (#75) -- reused as-is for
3118
3583
  // both of these two, the same unconditional call `coolifyApp` makes for
@@ -3399,24 +3864,27 @@ export class FoundryStepExecutor {
3399
3864
  // yet.
3400
3865
  throw new Error(`coolify:calls for ${org.slug}: a database has no internal_db_url yet -- retry this apply once Coolify reports one`);
3401
3866
  }
3867
+ const synapseTag = typeof action.desired.synapseTag === 'string' ? action.desired.synapseTag : SYNAPSE_TAG;
3402
3868
  const synapse = await this.findOrCreateCallsApp(names.synapse, environment.id, () => this.ports.coolify.createDockerImageApplication({
3403
3869
  project_uuid: projectUuid,
3404
3870
  server_uuid: server.uuid,
3405
3871
  environment_name: environment.name,
3406
3872
  environment_uuid: environment.uuid,
3407
3873
  docker_registry_image_name: SYNAPSE_IMAGE,
3408
- docker_registry_image_tag: SYNAPSE_TAG,
3874
+ docker_registry_image_tag: pinnedTagFor('synapse', synapseTag),
3409
3875
  ports_exposes: '8008',
3410
3876
  name: names.synapse,
3411
- description: `Synapse for ${org.slug}'s calls stack (${SYNAPSE_IMAGE}:${SYNAPSE_TAG})`,
3877
+ description: `Synapse for ${org.slug}'s calls stack (${SYNAPSE_IMAGE}:${synapseTag})`,
3412
3878
  instant_deploy: false,
3413
3879
  }));
3414
- if (!synapse.existed) {
3415
- await this.ports.coolify.updateApplication(synapse.appId, {
3416
- health_check_enabled: false,
3417
- custom_internal_name: names.synapse,
3418
- });
3419
- }
3880
+ // The check goes on every run, so a Synapse made without one gets it. Synapse
3881
+ // is deployed before MAS here. Its `/health` answers with MAS unreachable
3882
+ // (shown with a throwaway Synapse of the same image, MAS endpoint a name that
3883
+ // does not resolve: 200, healthy), and the start period covers first-boot migrations.
3884
+ await this.ports.coolify.updateApplication(synapse.appId, {
3885
+ ...(synapse.existed ? {} : { custom_internal_name: names.synapse }),
3886
+ ...healthCheckSettings('/health', 8008, SYNAPSE_HEALTH_CHECK_START_PERIOD_SECONDS),
3887
+ });
3420
3888
  context.recordScopeId(CALLS_SYNAPSE_SCOPE_KEY, synapse.appId);
3421
3889
  // #251: only on create -- nothing here patches `docker_registry_image_tag`
3422
3890
  // on an app `findOrCreateCallsApp` found already running, so that is the
@@ -3424,21 +3892,21 @@ export class FoundryStepExecutor {
3424
3892
  // the app id above is, would make `versionNotAppliedLine` compare a
3425
3893
  // bumped `VERSIONS.synapse` against a value never actually applied.
3426
3894
  if (!synapse.existed) {
3427
- context.recordScopeId(CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, SYNAPSE_TAG);
3895
+ context.recordScopeId(CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, synapseTag);
3428
3896
  }
3429
3897
  const mas = await this.findOrCreateCallsApp(names.mas, environment.id, () => this.ports.coolify.createDockerfileApplication({
3430
3898
  project_uuid: projectUuid,
3431
3899
  server_uuid: server.uuid,
3432
3900
  environment_name: environment.name,
3433
3901
  environment_uuid: environment.uuid,
3434
- dockerfile: CALLS_MAS_DOCKERFILE,
3902
+ dockerfile: masDockerfile(),
3435
3903
  name: names.mas,
3436
3904
  description: `matrix-authentication-service for ${org.slug}'s calls stack`,
3437
3905
  instant_deploy: false,
3438
3906
  }));
3439
3907
  if (!mas.existed) {
3440
3908
  await this.ports.coolify.updateApplication(mas.appId, {
3441
- health_check_enabled: false,
3909
+ ...IMAGE_HEALTHCHECK_SETTINGS,
3442
3910
  custom_internal_name: names.mas,
3443
3911
  });
3444
3912
  }
@@ -3875,7 +4343,7 @@ export class FoundryStepExecutor {
3875
4343
  server_uuid: server.uuid,
3876
4344
  environment_name: environment.name,
3877
4345
  environment_uuid: environment.uuid,
3878
- dockerfile: CALLS_MAS_DOCKERFILE,
4346
+ dockerfile: masDockerfile(),
3879
4347
  name: names.mas,
3880
4348
  description: `matrix-authentication-service for ${org.slug}'s chat`,
3881
4349
  instant_deploy: false,
@@ -3895,21 +4363,26 @@ export class FoundryStepExecutor {
3895
4363
  autogenerate_domain: false,
3896
4364
  }), { startIfStopped: false });
3897
4365
  // Fixed internal names, set on every run: the three apps and Agora
3898
- // address each other by them. Neither Synapse's nor MAS's health check
3899
- // can pass before the other is up, so both are off (the calls stack's own
3900
- // rule).
4366
+ // address each other by them. Both are Dockerfile apps with a HEALTHCHECK
4367
+ // of their own (MAS's has a copied-in busybox, its image has no shell), so
4368
+ // Coolify's check is off and the image's is the live one. MAS is deployed
4369
+ // first and its `/health` needs only its database, so Synapse's check can
4370
+ // pass.
3901
4371
  await this.ports.coolify.updateApplication(synapse.appId, {
3902
4372
  custom_internal_name: names.synapse,
3903
- ...(synapse.existed ? {} : { health_check_enabled: false }),
4373
+ // Its Dockerfile's HEALTHCHECK (curl on /health) is the live check; MAS is deployed first.
4374
+ ...IMAGE_HEALTHCHECK_SETTINGS,
3904
4375
  });
3905
4376
  await this.ports.coolify.updateApplication(mas.appId, {
3906
4377
  custom_internal_name: names.mas,
3907
- ...(mas.existed ? {} : { health_check_enabled: false }),
4378
+ // Its Dockerfile's HEALTHCHECK (`masDockerfile`) is the live check.
4379
+ ...IMAGE_HEALTHCHECK_SETTINGS,
3908
4380
  });
3909
4381
  await this.ports.coolify.updateApplication(service.appId, {
3910
4382
  custom_internal_name: names.service,
3911
- // No Coolify health check on the chat service (#543 review): its image
3912
- // is node:22-alpine with no curl, which Coolify's container check runs.
4383
+ // A command, not Coolify's HTTP check: the image is node:22-alpine with
4384
+ // no curl, and its busybox `wget` tries `localhost` as `::1` first.
4385
+ ...commandHealthCheckSettings(wgetProbe(CHAT_SERVICE_PORT, CHAT_SERVICE_HEALTH_CHECK_PATH)),
3913
4386
  });
3914
4387
  context.recordScopeId(CHAT_SYNAPSE_SCOPE_KEY, synapse.appId);
3915
4388
  context.recordScopeId(CHAT_SERVICE_SCOPE_KEY, service.appId);
@@ -4084,18 +4557,20 @@ export class FoundryStepExecutor {
4084
4557
  server_uuid: server.uuid,
4085
4558
  environment_name: environment.name,
4086
4559
  environment_uuid: environment.uuid,
4087
- dockerfile: CALLS_MAS_DOCKERFILE,
4560
+ dockerfile: masDockerfile(),
4088
4561
  name: names.mas,
4089
4562
  description: `matrix-authentication-service for ${org.slug}'s re-keyed chat (#463)`,
4090
4563
  instant_deploy: false,
4091
4564
  }), { startIfStopped: false });
4092
4565
  await this.ports.coolify.updateApplication(synapse.appId, {
4093
4566
  custom_internal_name: names.synapse,
4094
- ...(synapse.existed ? {} : { health_check_enabled: false }),
4567
+ // Its Dockerfile's HEALTHCHECK (curl on /health) is the live check; MAS is deployed first.
4568
+ ...IMAGE_HEALTHCHECK_SETTINGS,
4095
4569
  });
4096
4570
  await this.ports.coolify.updateApplication(mas.appId, {
4097
4571
  custom_internal_name: names.mas,
4098
- ...(mas.existed ? {} : { health_check_enabled: false }),
4572
+ // Its Dockerfile's HEALTHCHECK (`masDockerfile`) is the live check.
4573
+ ...IMAGE_HEALTHCHECK_SETTINGS,
4099
4574
  });
4100
4575
  context.recordScopeId(CHAT_REKEY_SYNAPSE_SCOPE_KEY, synapse.appId);
4101
4576
  context.recordScopeId(CHAT_REKEY_MAS_SCOPE_KEY, mas.appId);
@@ -4289,14 +4764,16 @@ export class FoundryStepExecutor {
4289
4764
  // step again for a live app whose marker is missing or stale, so an app
4290
4765
  // made before this existed gets its name here. The marker, recorded last,
4291
4766
  // tells `desiredAgoraEnv` that the name and the env are both in place.
4292
- // No Coolify health check on the calls service (#543 review): its image
4293
- // is node:24-alpine with no curl, which Coolify's container check runs.
4767
+ // A command, not Coolify's HTTP check: the image is node:24-alpine with no
4768
+ // curl, and its busybox `wget` tries `localhost` as `::1` first.
4294
4769
  // A found app may predate `ports_exposes` on create; patch a differing one.
4295
4770
  const portDiffers = app.existed &&
4296
4771
  (await this.ports.coolify.getApplication(app.appId)).ports_exposes !== CALLS_SERVICE_PORT;
4297
4772
  await this.ports.coolify.updateApplication(app.appId, {
4298
4773
  custom_internal_name: name,
4774
+ ...commandHealthCheckSettings(wgetProbe(CALLS_SERVICE_PORT, CALLS_HEALTH_CHECK_PATH)),
4299
4775
  ...(portDiffers ? { ports_exposes: CALLS_SERVICE_PORT } : {}),
4776
+ ...(await this.routedLabels(name, callsHostnameFor(org), Number(CALLS_SERVICE_PORT))),
4300
4777
  });
4301
4778
  // Every app foundry deploys keeps its own logs (#75), reused as-is here.
4302
4779
  await this.ensureLogVolume(app.appId, org, app.existed);
@@ -4677,6 +5154,7 @@ export class FoundryStepExecutor {
4677
5154
  await this.ports.coolify.updateApplication(app.appId, {
4678
5155
  ...(portDiffers ? { ports_exposes: AGORA_APP_PORTS_EXPOSES } : {}),
4679
5156
  ...healthCheckSettings('/', Number(AGORA_APP_PORTS_EXPOSES)),
5157
+ ...(await this.routedLabels(name, agoraHostnameFor(org), Number(AGORA_APP_PORTS_EXPOSES))),
4680
5158
  });
4681
5159
  // #442: read before overwriting -- `bulkUpsertEnvs` below always leaves
4682
5160
  // Coolify's own env store matching `env`, which is why that store alone
@@ -4703,7 +5181,8 @@ export class FoundryStepExecutor {
4703
5181
  // in the branch below, and this deploy already carries this run's env,
4704
5182
  // pushed above, so the two never both run).
4705
5183
  const runtime = this.ports.runtime ?? {};
4706
- 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));
4707
5186
  context.recordScopeId(AGORA_DEPLOYED_SCOPE_KEY, '1');
4708
5187
  }
4709
5188
  else if (app.existed && (envChanged || !alreadyActivated)) {
@@ -4784,6 +5263,7 @@ export class FoundryStepExecutor {
4784
5263
  // `EXPOSE 3000` ({@link BOULE_PORT}'s own doc comment).
4785
5264
  await this.ports.coolify.updateApplication(app.appId, {
4786
5265
  ...healthCheckSettings('/api/health', BOULE_PORT),
5266
+ ...(await this.routedLabels(name, bouleHostnameFor(org), BOULE_PORT)),
4787
5267
  });
4788
5268
  const liveEnv = new Map((await this.ports.coolify.listEnvs(app.appId))
4789
5269
  .filter((e) => !e.is_preview)
@@ -4841,7 +5321,8 @@ export class FoundryStepExecutor {
4841
5321
  if (!context.recordedIds()[BOULE_DEPLOYED_SCOPE_KEY]) {
4842
5322
  // #543: every deploy waits for health -- Boule's own public hostname.
4843
5323
  const runtime = this.ports.runtime ?? {};
4844
- 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));
4845
5326
  context.recordScopeId(BOULE_DEPLOYED_SCOPE_KEY, '1');
4846
5327
  }
4847
5328
  else if (app.existed &&
@@ -5022,6 +5503,367 @@ export class FoundryStepExecutor {
5022
5503
  }
5023
5504
  return port;
5024
5505
  }
5506
+ /**
5507
+ * `plan/own-diff.ts`'s `planOwnJobs`: the org's own scheduler, in the org's
5508
+ * own project, with the names and the held-key ref of `orgTarget(org)`. The
5509
+ * admin token goes to `jobs:org:<slug>:adminToken` and nowhere else.
5510
+ */
5511
+ async ownJobs(action, org, context) {
5512
+ const own = this.ports.ownServices;
5513
+ if (!own)
5514
+ throw new Error(OWN_SERVICES_PORT_ABSENT);
5515
+ return provisionJobsHost(scopeFromOrg(context, {}), {
5516
+ coolify: limited(this.placedForCompany(own.provision.jobs, org, context), LIMITS.ownJobs),
5517
+ deliverSecret: this.ports.deliverSecret,
5518
+ readSecret: async (ref) => (await this.ports.heldKeys?.resolve(ref)) ?? null,
5519
+ runtime: this.ports.runtime ?? {},
5520
+ }, orgTarget(org), {
5521
+ hostname: null,
5522
+ gitRef: action.desired.gitRef,
5523
+ project: { ensure: () => this.ensureCoolifyProject(org, context) },
5524
+ });
5525
+ }
5526
+ /**
5527
+ * `plan/own-diff.ts`'s `planOwnFiles`: the org's own files host in the
5528
+ * org's own project, trusting the company's issuer `https://auth.<domain>`.
5529
+ * Its ZITADEL project `<slug>-files` and introspection app are made in the
5530
+ * company's instance (`ownServices.zitadelFor`), never the shared one. The
5531
+ * bucket comes from the host's bucket port under the target's name. The
5532
+ * management key goes to `files:org:<slug>:managementKey` and the
5533
+ * introspection secret to `zitadel:org:<slug>:files:introspection`, both
5534
+ * once, and neither is recorded or returned.
5535
+ */
5536
+ async ownFiles(action, org, context) {
5537
+ const own = this.ports.ownServices;
5538
+ if (!own)
5539
+ throw new Error(OWN_SERVICES_PORT_ABSENT);
5540
+ const at = `coolify:own-files for ${org.slug}`;
5541
+ const zitadelOrgId = context.recordedIds()[ZITADEL_ORG_SCOPE_KEY];
5542
+ if (!zitadelOrgId)
5543
+ throw new Error(`${at}: the org's ZITADEL organisation is not recorded yet`);
5544
+ const { r2Buckets, r2Keys: _r2Keys, keysMint, readSecret, ...ownFiles } = own.provision.files;
5545
+ const files = {
5546
+ ...ownFiles,
5547
+ coolify: limited(this.placedForCompany(ownFiles.coolify, org, context), LIMITS.ownFiles),
5548
+ };
5549
+ // Never the platform's credential in its place.
5550
+ if (!keysMint)
5551
+ throw new OwnServicesBlockedError('files', 'keysMint');
5552
+ if (!r2Buckets)
5553
+ throw new Error(`${at}: the host supplies no bucket port`);
5554
+ const target = orgTarget(org);
5555
+ let r2;
5556
+ try {
5557
+ r2 = await withTimeout(r2Buckets.ensureBucket(target.names.filesBucket), BUCKET_TIMEOUT_MS);
5558
+ }
5559
+ catch {
5560
+ // The cause is dropped: a host port's message is not ours to repeat.
5561
+ throw new Error(`${at}: the bucket ${target.names.filesBucket} could not be ensured`);
5562
+ }
5563
+ const issuer = ownHostnames(org).issuerUrl;
5564
+ const deps = {
5565
+ ...files,
5566
+ // The platform's keys settings stay out of a company's containers; the minted keyring replaces them.
5567
+ keys: undefined,
5568
+ jobsSigningSecrets: undefined,
5569
+ r2,
5570
+ zitadel: own.zitadelFor({ issuerUrl: issuer, bearerRef: target.refs.instanceBearer }),
5571
+ zitadelForInstance: undefined,
5572
+ deliverSecret: this.ports.deliverSecret,
5573
+ runtime: {
5574
+ ...files.runtime,
5575
+ onSecret: (value) => {
5576
+ this.handledSecrets.add(value);
5577
+ },
5578
+ },
5579
+ };
5580
+ const invalid = filesDepsInvalid(deps);
5581
+ if (invalid)
5582
+ throw new Error(`${at}: ${invalid}`);
5583
+ const scope = scopeFromOrg(context, {});
5584
+ const mintOwn = async () => {
5585
+ try {
5586
+ return await withTimeout(keysMint.mint(`files-${org.slug}`, randomUUID()), KEYS_MINT_TIMEOUT_MS);
5587
+ }
5588
+ catch (error) {
5589
+ // The cause is dropped: a mint client's message is not ours to repeat.
5590
+ throw new Error(error instanceof Error && error.message === 'timeout'
5591
+ ? `${at}: the keys mint did not answer within ${KEYS_MINT_TIMEOUT_MS / 1000} seconds`
5592
+ : `${at}: the keys mint failed`);
5593
+ }
5594
+ };
5595
+ const { keys, guardId } = await resolveOwnFilesKeys(scope, {
5596
+ deliverSecret: deps.deliverSecret,
5597
+ readSecret: readSecret ?? (async (ref) => (await this.ports.heldKeys?.resolve(ref)) ?? null),
5598
+ runtime: deps.runtime,
5599
+ }, target, mintOwn, keysMint.workerUrl, `own step ${at}`);
5600
+ return provisionFilesHost(scope, { ...deps, keys }, target, {
5601
+ keysGuardId: guardId,
5602
+ hostname: action.desired.hostname,
5603
+ gitRef: action.desired.gitRef,
5604
+ project: { ensure: () => this.ensureCoolifyProject(org, context) },
5605
+ issuer,
5606
+ zitadelOrg: { id: zitadelOrgId },
5607
+ zitadelProject: {
5608
+ name: `${org.slug}-files`,
5609
+ scopeKey: ZITADEL_FILES_PROJECT_SCOPE_KEY,
5610
+ },
5611
+ shareDomains: filesShareDomains([org]),
5612
+ bootstrapOperator: action.desired.bootstrapOperator,
5613
+ });
5614
+ }
5615
+ /**
5616
+ * `plan/own-diff.ts`'s `planOwnFilesR2`: the storage key of the org's own files bucket. A token
5617
+ * recorded as undelivered is deleted first (its secret cannot be read back, so it can never be
5618
+ * finished), then one is minted, registered as a secret, recorded by id as undelivered and
5619
+ * persisted, and only then sent to the files host, the same pair on every retry. The secret is
5620
+ * never recorded and never written to a Coolify env. The id is recorded once the host has it.
5621
+ */
5622
+ async ownFilesR2(org, context) {
5623
+ const own = this.ports.ownServices;
5624
+ if (!own)
5625
+ throw new Error(OWN_SERVICES_PORT_ABSENT);
5626
+ const at = `coolify:own-files-r2 for ${org.slug}`;
5627
+ const { r2Keys, runtime } = own.provision.files;
5628
+ if (!r2Keys)
5629
+ throw new OwnServicesBlockedError('files', 'r2Keys');
5630
+ const scoped = await own.portsFor(org, context.recordedIds());
5631
+ const organisation = scoped?.filesOrganisation;
5632
+ if (!organisation?.storeR2Credentials) {
5633
+ throw new Error(`${at}: the host gave the company no storeR2Credentials on its files port`);
5634
+ }
5635
+ const store = organisation.storeR2Credentials.bind(organisation);
5636
+ const bucket = orgTarget(org).names.filesBucket;
5637
+ const timeoutText = `did not answer within ${R2_KEY_TIMEOUT_MS / 1000} seconds`;
5638
+ const failure = (what, error) =>
5639
+ // The cause is dropped: a port's message is not ours to repeat, and may hold the secret.
5640
+ new Error(`${at}: ${what} ${error instanceof Error && error.message === 'timeout' ? timeoutText : 'failed'}`);
5641
+ const recorded = context.recordedIds();
5642
+ if (recorded[OWN_FILES_R2_SCOPE_KEY])
5643
+ return { resourceId: recorded[OWN_FILES_R2_SCOPE_KEY] };
5644
+ // A leftover token is deleted before anything is minted: never two live tokens.
5645
+ const leftover = recorded[OWN_FILES_R2_UNDELIVERED_SCOPE_KEY];
5646
+ if (leftover) {
5647
+ try {
5648
+ await withTimeout(r2Keys.deleteBucketKey(leftover), R2_KEY_TIMEOUT_MS);
5649
+ }
5650
+ catch (error) {
5651
+ if (!isNotFoundError(error)) {
5652
+ throw failure(`deleting the earlier storage key ${leftover}, so no new one was made:`, error);
5653
+ }
5654
+ }
5655
+ forgetScopeId(context, OWN_FILES_R2_UNDELIVERED_SCOPE_KEY);
5656
+ await context.persistScopeIds();
5657
+ }
5658
+ let key;
5659
+ try {
5660
+ key = await withTimeout(r2Keys.mintBucketKey(bucket), R2_KEY_TIMEOUT_MS);
5661
+ }
5662
+ catch (error) {
5663
+ throw failure('the storage key mint', error);
5664
+ }
5665
+ const text = (value) => typeof value === 'string' && value.length > 0 && value.length <= 200;
5666
+ if (text(key?.secretAccessKey))
5667
+ this.handledSecrets.add(key.secretAccessKey);
5668
+ // A token id we can read is recorded even when the rest of the answer is unusable, so it is deleted next run.
5669
+ if (text(key?.tokenId)) {
5670
+ context.recordScopeId(OWN_FILES_R2_UNDELIVERED_SCOPE_KEY, key.tokenId);
5671
+ await context.persistScopeIds();
5672
+ }
5673
+ if (!text(key?.accessKeyId) || !text(key?.secretAccessKey) || !text(key?.tokenId)) {
5674
+ throw new Error(`${at}: the storage key mint answered with an unusable key`);
5675
+ }
5676
+ const pair = {
5677
+ accessKeyId: key.accessKeyId,
5678
+ secretAccessKey: key.secretAccessKey,
5679
+ tokenId: key.tokenId,
5680
+ };
5681
+ const attempts = runtime?.deliverAttempts ?? 3;
5682
+ const sleep = runtime?.sleep ?? defaultSleep;
5683
+ for (let attempt = 1;; attempt += 1) {
5684
+ try {
5685
+ await withTimeout(store({ ...pair }), R2_KEY_TIMEOUT_MS);
5686
+ break;
5687
+ }
5688
+ catch (error) {
5689
+ if (attempt >= attempts) {
5690
+ const cause = redactSecrets(error instanceof Error ? error.message : String(error), [
5691
+ pair.secretAccessKey,
5692
+ pair.accessKeyId,
5693
+ ]);
5694
+ throw new Error(`${at}: the storage key ${pair.tokenId} could not be delivered after ${attempts} tries; the next run deletes it and makes a new one (last try: ${cause})`);
5695
+ }
5696
+ await sleep(1000 * attempt);
5697
+ }
5698
+ }
5699
+ context.recordScopeId(OWN_FILES_R2_SCOPE_KEY, pair.tokenId);
5700
+ forgetScopeId(context, OWN_FILES_R2_UNDELIVERED_SCOPE_KEY);
5701
+ await context.persistScopeIds();
5702
+ return { resourceId: pair.tokenId };
5703
+ }
5704
+ /**
5705
+ * `plan/own-diff.ts`'s `planOwnAuth`: the org's own sign-in at
5706
+ * `auth.<domain>`, in the org's own project, through the org's own edge
5707
+ * routed DNS and tunnel ports. The instance owner token goes to
5708
+ * `zitadel:org:<slug>:instanceBearer` and is never recorded. A changed
5709
+ * declared auth ref patches both git apps' branch and redeploys them; the
5710
+ * applied marker comes last, so a run that died halfway is planned again.
5711
+ */
5712
+ async ownAuth(action, org, context) {
5713
+ const own = this.ports.ownServices;
5714
+ if (!own)
5715
+ throw new Error(OWN_SERVICES_PORT_ABSENT);
5716
+ const hostname = action.desired.hostname;
5717
+ const gitRef = action.desired.gitRef;
5718
+ const hn = ownHostnames(org);
5719
+ const scope = scopeFromOrg(context, {});
5720
+ // The target (host, port, tls, user) is not secret; the platform's SMTP
5721
+ // password is, so the company's own token is the password instead.
5722
+ const { smtp, ...authDeps } = own.provision.auth;
5723
+ const deps = {
5724
+ ...authDeps,
5725
+ ...(smtp
5726
+ ? { smtp: { host: smtp.host, port: smtp.port, tls: smtp.tls, user: smtp.user } }
5727
+ : {}),
5728
+ coolify: limited(this.placedForCompany(own.provision.auth.coolify, org, context), LIMITS.ownAuth),
5729
+ runtime: {
5730
+ ...own.provision.auth.runtime,
5731
+ onSecret: (value) => {
5732
+ this.handledSecrets.add(value);
5733
+ },
5734
+ },
5735
+ };
5736
+ const before = scope.recordedIds();
5737
+ const refChanged = before[OWN_AUTH_APPLIED_GIT_REF_SCOPE_KEY] !== undefined &&
5738
+ before[OWN_AUTH_APPLIED_GIT_REF_SCOPE_KEY] !== gitRef;
5739
+ const gitApps = [
5740
+ before[PLATFORM_AUTH_LOGIN_SCOPE_KEY],
5741
+ before[PLATFORM_AUTH_BROKER_SCOPE_KEY],
5742
+ ].filter((id) => Boolean(id));
5743
+ if (refChanged) {
5744
+ for (const id of gitApps)
5745
+ await deps.coolify.updateApplication(id, { git_branch: gitRef });
5746
+ }
5747
+ await this.ensureOwnAuthMailToken(org, hn.mailDomain, deps);
5748
+ const result = await provisionAuthStack(scope, deps, orgTarget(org), {
5749
+ hostname,
5750
+ apex: hn.zone,
5751
+ gitRef,
5752
+ project: { ensure: () => this.ensureCoolifyProject(org, context) },
5753
+ network: orgNetwork({ dns: this.dnsPort(), tunnels: this.tunnelPort(), zones: this.zonePort() }, scope, `${org.slug}:auth`),
5754
+ smtpSender: `noreply@${hn.mailDomain}`,
5755
+ defaultRedirectUri: `https://${agoraHostnameFor(org)}`,
5756
+ ownerCompany: null,
5757
+ });
5758
+ if (refChanged) {
5759
+ const now = scope.recordedIds();
5760
+ const brokerDeployed = Boolean(now[PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY]);
5761
+ for (const id of gitApps) {
5762
+ // The broker crash-loops until its inputs exist; it was never deployed, so it is not now.
5763
+ if (id === now[PLATFORM_AUTH_BROKER_SCOPE_KEY] && !brokerDeployed)
5764
+ continue;
5765
+ await deps.coolify.deploy(id, true);
5766
+ }
5767
+ }
5768
+ scope.recordScopeId(OWN_AUTH_APPLIED_HOSTNAME_SCOPE_KEY, hostname);
5769
+ scope.recordScopeId(OWN_AUTH_APPLIED_GIT_REF_SCOPE_KEY, gitRef);
5770
+ scope.recordScopeId(OWN_SERVICES_APPLIED_SCOPE_KEY, org.id);
5771
+ await scope.persist();
5772
+ return result;
5773
+ }
5774
+ /**
5775
+ * `plan/own-diff.ts`'s `planOwnMail`: the org's own Stalwart in the org's own
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
5779
+ * `orgTarget(org).refs.mailAdminKey` and nowhere else. The address the relay
5780
+ * routes to is recorded last, and the step's marker after it.
5781
+ */
5782
+ async ownMail(action, org, context) {
5783
+ const own = this.ports.ownServices;
5784
+ if (!own)
5785
+ throw new Error(OWN_SERVICES_PORT_ABSENT);
5786
+ const hn = ownHostnames(org);
5787
+ const target = orgTarget(org);
5788
+ const scope = scopeFromOrg(context, {});
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);
5799
+ const result = await provisionMailServer(scope, {
5800
+ ...own.provision.mail,
5801
+ coolify: limited(coolify, LIMITS.ownMail),
5802
+ deliverSecret: (ref, secret) => {
5803
+ this.handledSecrets.add(secret);
5804
+ return this.ports.deliverSecret(ref, secret);
5805
+ },
5806
+ stalwartForInstance: (input) => {
5807
+ this.handledSecrets.add(input.password);
5808
+ return (own.provision.mail.stalwartForInstance ?? defaultStalwartForInstance)(input);
5809
+ },
5810
+ zitadel: own.zitadelFor({ issuerUrl, bearerRef: target.refs.instanceBearer }),
5811
+ zitadelForInstance: undefined,
5812
+ }, target, {
5813
+ hostname: action.desired.hostname,
5814
+ apex: hn.mailDomain,
5815
+ zone: hn.zone,
5816
+ mailHost: action.desired.mailHost,
5817
+ imageTag: action.desired.gitRef,
5818
+ hostPorts: bind ? `${bind}:${smtpPort}:25` : `${smtpPort}:25`,
5819
+ issuerUrl,
5820
+ mxRecord: null,
5821
+ project: { ensure: () => this.ensureCoolifyProject(org, context) },
5822
+ network: orgNetwork({ dns: this.dnsPort(), tunnels: this.tunnelPort(), zones: this.zonePort() }, scope, `${org.slug}:mail`),
5823
+ });
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);
5835
+ await scope.persist();
5836
+ return result;
5837
+ }
5838
+ /**
5839
+ * `plan/own-diff.ts`'s `planOwnSfu`: the org's own LiveKit server in the
5840
+ * org's project, on the ports of its slot. The company's key pair goes to
5841
+ * `livekit:org:<slug>:keys` and the app's env, nowhere else.
5842
+ */
5843
+ async ownSfu(action, org, context) {
5844
+ const own = this.ports.ownServices;
5845
+ if (!own)
5846
+ throw new Error(OWN_SERVICES_PORT_ABSENT);
5847
+ const scope = scopeFromOrg(context, {});
5848
+ const { sfu } = own.provision;
5849
+ return provisionSfuApp(scope, {
5850
+ coolify: limited(this.placedForCompany(sfu.coolify, org, context), LIMITS.ownSfu),
5851
+ network: orgNetwork({ dns: this.dnsPort(), tunnels: this.tunnelPort(), zones: this.zonePort() }, scope, `${org.slug}:sfu`),
5852
+ deliverSecret: sfu.deliverSecret,
5853
+ readSecret: async (ref) => (await this.ports.heldKeys?.resolve(ref)) ?? null,
5854
+ runtime: this.ports.runtime ?? {},
5855
+ ...(sfu.mintLivekitCredentials
5856
+ ? { mintLivekitCredentials: sfu.mintLivekitCredentials }
5857
+ : {}),
5858
+ onSecret: (value) => this.handledSecrets.add(value),
5859
+ }, orgTarget(org), {
5860
+ slot: action.desired.slot,
5861
+ hostname: action.desired.hostname,
5862
+ apex: ownHostnames(org).zone,
5863
+ version: action.desired.version,
5864
+ project: { ensure: () => this.ensureCoolifyProject(org, context) },
5865
+ });
5866
+ }
5025
5867
  async jobsRegistration(action, org, context) {
5026
5868
  const scheduler = await this.jobsPort(org);
5027
5869
  const name = action.desired.name;
@@ -5138,15 +5980,19 @@ export class FoundryStepExecutor {
5138
5980
  // login app (the same container the platform auth tunnel routes to), not
5139
5981
  // through Coolify's proxy, which has no route for it.
5140
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]));
5141
5986
  const ingress = tunnelIngressHostnames(org, loginHost).map((hostname) => ({
5142
5987
  hostname,
5143
5988
  service: hostname === loginHost
5144
5989
  ? `http://${PLATFORM_AUTH_LOGIN_INTERNAL_NAME}:${AUTH_LOGIN_PORT}`
5145
- : COOLIFY_PROXY_ORIGIN,
5990
+ : (entryOrigins.get(hostname) ?? COOLIFY_PROXY_ORIGIN),
5146
5991
  }));
5147
5992
  await this.tunnelPort().putTunnelConfig(tunnel.id, {
5148
5993
  ingress: [...ingress, { service: 'http_status:404' }],
5149
5994
  });
5995
+ context.recordScopeId(CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, entryRoutesMarker(org));
5150
5996
  // #235: a connector for this org's own tunnel, the same call every
5151
5997
  // platform tunnel step already makes (Decided, William, 2026-10-01:
5152
5998
  // one connector per org tunnel, never a shared tunnel with appended
@@ -5245,8 +6091,77 @@ export class FoundryStepExecutor {
5245
6091
  context.recordScopeId(CLOUDFLARE_TUNNEL_PREVIOUS_IDS_SCOPE_KEY, '');
5246
6092
  }
5247
6093
  }
6094
+ await this.publishOikosEntryRecords(org, tunnel.id, previousIds, context);
5248
6095
  return { resourceId: tunnel.id };
5249
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
+ }
5250
6165
  /**
5251
6166
  * #236: when this tunnel's DNS was last published under a domain that
5252
6167
  * differs from the one `org` now declares, deletes every CNAME in the
@@ -5563,7 +6478,7 @@ export class FoundryStepExecutor {
5563
6478
  }
5564
6479
  const mailOrg = mailOrgFor(org, usesDomain, mode);
5565
6480
  const result = await this.ports.mail.applyOrgMail(mailOrg, options);
5566
- await this.publishMailDnsRecords(org, result);
6481
+ await this.publishMailDnsRecords(org, result, context);
5567
6482
  await this.deleteStaleLettermintDomain(context, result.domain, platformApex);
5568
6483
  context.recordScopeId(MAIL_DOMAIN_SCOPE_KEY, result.domain);
5569
6484
  await this.ensureOwnerMailbox(org, mailOrg, options, context);
@@ -5694,15 +6609,49 @@ export class FoundryStepExecutor {
5694
6609
  * call, since Stalwart mints the key on a background task; the next apply
5695
6610
  * publishes it.
5696
6611
  */
5697
- async publishMailDnsRecords(org, result) {
6612
+ async publishMailDnsRecords(org, result, context) {
5698
6613
  const zone = await this.zonePort().findZoneByApex(result.domain);
5699
6614
  if (!zone) {
5700
6615
  throw new Error(`coolify:mail for ${org.slug}: no Cloudflare zone found for ${result.domain}`);
5701
6616
  }
6617
+ if (ownsServices(org) && result.domain.toLowerCase() === `${org.slug}.${NO_DOMAIN_ZONE}`) {
6618
+ await this.ensureOwnWebAddressRecord(org, zone.name, context);
6619
+ }
5702
6620
  for (const record of result.records) {
5703
6621
  await this.upsertMailDnsRecord(zone.name, record);
5704
6622
  }
5705
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
+ }
5706
6655
  /**
5707
6656
  * Creates or updates one mail record (Stalwart's or Lettermint's).
5708
6657
  *
@@ -5759,6 +6708,11 @@ export class FoundryStepExecutor {
5759
6708
  * #491: when `mailDomain` is the platform's own apex, this must never
5760
6709
  * delete the platform's own project (the delete loop skips it by id) or
5761
6710
  * the platform's own domain (returns before the domain lookup/delete).
6711
+ *
6712
+ * An org whose own sign-in is applied keeps its `<slug>-auth` project, and
6713
+ * keeps the Lettermint domain when that is the domain the sign-in sends
6714
+ * from. Every other org deletes `<slug>-auth` too, which only a failed
6715
+ * first sign-in run can have left behind.
5762
6716
  */
5763
6717
  async parkLettermintMail(org, mailOrg, context, platformApex, platformLettermintProjectId) {
5764
6718
  const mailDomain = context.recordedIds()[MAIL_DOMAIN_SCOPE_KEY];
@@ -5768,7 +6722,13 @@ export class FoundryStepExecutor {
5768
6722
  // #345: `mailOrg.domain` is absent for a level-1 org -- the previously
5769
6723
  // recorded mail domain is the one `MailPort.removeOrgRelay` needs.
5770
6724
  await this.ports.mail.removeOrgRelay({ ...mailOrg, domain: mailDomain });
6725
+ // An own org's sign-in sends its mail through `<slug>-auth` and the domain it
6726
+ // applied at; parking the org's mail leaves both, or the sign-in can no
6727
+ // longer send (its held token would still resolve, so nothing re-mints it).
6728
+ const ownSignIn = ownsServices(org) && ownAuthApplied(org, context.recordedIds());
5771
6729
  const names = new Set([`${org.slug}-app`, `${org.slug}-relay`]);
6730
+ if (!ownSignIn)
6731
+ names.add(`${org.slug}-auth`);
5772
6732
  const projects = await this.ports.lettermint.listProjects();
5773
6733
  for (const project of projects) {
5774
6734
  if (names.has(project.name) && project.id !== platformLettermintProjectId) {
@@ -5777,6 +6737,8 @@ export class FoundryStepExecutor {
5777
6737
  }
5778
6738
  if (isPlatformApexOrg)
5779
6739
  return;
6740
+ if (ownSignIn && mailDomain.toLowerCase() === ownHostnames(org).mailDomain.toLowerCase())
6741
+ return;
5780
6742
  const domain = await this.findExactLettermintDomain(mailDomain);
5781
6743
  if (domain)
5782
6744
  await this.ports.lettermint.deleteDomain(domain.id);
@@ -6711,6 +7673,29 @@ export class FoundryStepExecutor {
6711
7673
  throw error;
6712
7674
  }
6713
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
+ }
6714
7699
  await this.tunnelPort().deleteTunnel(target.id);
6715
7700
  return;
6716
7701
  }
@@ -6779,6 +7764,23 @@ export class FoundryStepExecutor {
6779
7764
  case 'coolify:database':
6780
7765
  await this.ports.coolify.deleteDatabase(target.id);
6781
7766
  return;
7767
+ case 'coolify:own-files-r2': {
7768
+ // An own files bucket's storage key; a 404 is caught below as already gone.
7769
+ const r2Keys = this.ports.ownServices?.provision.files.r2Keys;
7770
+ if (!r2Keys) {
7771
+ throw new Error(`coolify:own-files-r2 for ${org.slug}: this host supplies no R2 key port, cannot delete token ${target.id}`);
7772
+ }
7773
+ try {
7774
+ await withTimeout(r2Keys.deleteBucketKey(target.id), R2_KEY_TIMEOUT_MS);
7775
+ }
7776
+ catch (error) {
7777
+ if (error instanceof Error && error.message === 'timeout') {
7778
+ throw new Error(`coolify:own-files-r2 for ${org.slug}: deleting token ${target.id} did not answer within ${R2_KEY_TIMEOUT_MS / 1000} seconds`);
7779
+ }
7780
+ throw error;
7781
+ }
7782
+ return;
7783
+ }
6782
7784
  case 'zitadel:app': {
6783
7785
  const zitadelOrgId = recordedIds.zitadel_org;
6784
7786
  const projectId = recordedIds.zitadel_project;