@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
package/dist/plan/diff.js CHANGED
@@ -1,21 +1,27 @@
1
1
  import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
2
- import { APP_ENTRY_KEY, ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, ownerMailboxRecorded, ownerMailboxes, } from '../org.js';
2
+ import { APP_ENTRY_KEY, ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, ownerMailboxRecorded, ownerMailboxes, ownsServices, } from '../org.js';
3
3
  import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_DEPLOYED_SCOPE_KEY, CHAT_CALLS_APPLIED_SCOPE_KEY, CHAT_CALLS_TOKEN_SCOPE_KEY, CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostMissing, agoraHostnameFor, callsServiceHasName, callsServiceHasTokenEnv, callsTokenEnvMovedMessage, callsTokenHostFrom, chatCallsApplied, chatHasUpstreams, desiredAgoraEnv, withRecordedMessagingProject, } from './agora.js';
4
4
  import { BOULE_BRIDGE_SECRET_ENV, BOULE_BROKER_ENTRY_KEY, BOULE_MAIL_TOKEN_ENV, BOULE_PEER_PASSWORD_ENV, BOULE_SECRETS_RESTARTED_SCOPE_KEY, bouleEnvDrift, bouleMailTokenDrift, bouleMailWanted, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
5
5
  import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, } from './broker-entries.js';
6
6
  import { chatInternalNames, chatUpstreams } from './chat-matrix.js';
7
+ import { planCompanyBackups } from './company-backups.js';
8
+ import { onPlatformNetwork } from './destinations.js';
7
9
  import { planEntryAiKeys } from './entry-ai-key.js';
8
10
  import { planEdgeReservedLabels, planEntryEdgeKeys } from './entry-edge-key.js';
9
11
  import { planFilesKeys } from './entry-files-key.js';
10
12
  import { hasWildcardGate, planGates } from './gates.js';
11
- import { appHostname, entryHostnames, tunnelDnsHostnames, tunnelDomainList } from './hostnames.js';
13
+ import { appHostname, tunnelDnsHostnames } from './hostnames.js';
12
14
  import { lockedHostnames } from './locked-hostnames.js';
13
- import { LOGIN_BINDING_SCOPE_KEY, companyLoginTunnelHost, loginBindingState, loginHostClientIds, loginHostnameFor, pendingRemovalHosts, } from './login-host.js';
15
+ import { LOGIN_BINDING_SCOPE_KEY, agoraIssuerHostFor, companyLoginTunnelHost, loginBindingState, loginHostClientIds, loginHostServesAgora, loginHostnameFor, ownAuthApplied, pendingRemovalHosts, } from './login-host.js';
14
16
  import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, } from './mail-copy.js';
15
- 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, appEnvAppliedScopeKey, appFolderDiffers, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, versionNotAppliedLine, zitadelAppIdTokenUserinfoAppliedScopeKey, } from './platform-diff.js';
17
+ import { hasAdoptedResources, networkMoveNeeded, planNetworkMove } from './network-move.js';
18
+ import { OWN_AUTH_BLOCKED_KEY, OWN_FILES_BLOCKED_KEY, OWN_FILES_R2_BLOCKED_KEY, OWN_JOBS_BLOCKED_KEY, OWN_MAIL_BLOCKED_KEY, OWN_SFU_BLOCKED_KEY, OWN_SFU_FIREWALL_KEY, planOwnAuth, planOwnFiles, planOwnFilesR2, planOwnJobs, planOwnMail, planOwnSfu, } from './own-diff.js';
19
+ import { ownDnsHostnames } from './own-dns.js';
20
+ import { ownHostnames } from './own-hostnames.js';
21
+ 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, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, appEnvAppliedScopeKey, appFolderDiffers, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, versionNotAppliedLine, zitadelAppIdTokenUserinfoAppliedScopeKey, } from './platform-diff.js';
16
22
  import { DEFAULT_ORG_PLATFORM_READINESS, } from './platform-org.js';
17
23
  import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
18
- import { appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec } from './proxy-labels.js';
24
+ import { CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY, appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec, entryOikosHostnames, entryRoutes, entryRoutesMarker, } from './proxy-labels.js';
19
25
  import { ZITADEL_ORG_DOMAIN_BLOCKED_SCOPE_KEY, ZITADEL_ORG_DOMAIN_SCOPE_KEY, ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY, signInDomainBlockedText, signInOrgDomain, } from './sign-in-org.js';
20
26
  import { withStepSystem } from './step-system.js';
21
27
  import { planWorkers } from './worker.js';
@@ -56,6 +62,10 @@ function resourceKey(system, kind, key) {
56
62
  function madeByFoundry(org, r, recordedIds, recordedValues, platformNames) {
57
63
  if (PROVIDER_KINDS[r.system]?.includes(r.kind) && recordedValues.has(r.key))
58
64
  return true;
65
+ // The reader maps an applied own step's app onto its marker (`coolify:own-<service>:<org.id>`); only this org's id is its own.
66
+ if (r.system === 'coolify' && OWN_MARKER_KINDS.has(r.kind) && r.key === org.id) {
67
+ return Boolean(recordedIds[`coolify:${r.kind}:${org.id}`]);
68
+ }
59
69
  if (r.system === 'coolify') {
60
70
  if (r.kind === 'environment')
61
71
  return true;
@@ -78,6 +88,11 @@ function madeByFoundry(org, r, recordedIds, recordedValues, platformNames) {
78
88
  if (type === 'CNAME' && content.endsWith('.cfargotunnel.com') && tunnelHostnames.includes(name)) {
79
89
  return true;
80
90
  }
91
+ if (type === 'CNAME' &&
92
+ content.endsWith('.cfargotunnel.com') &&
93
+ ownDnsHostnames(recordedIds).includes(name)) {
94
+ return true;
95
+ }
81
96
  const mailDomain = recordedIds[MAIL_DOMAIN_SCOPE_KEY]?.toLowerCase();
82
97
  if (!mailDomain)
83
98
  return false;
@@ -86,6 +101,14 @@ function madeByFoundry(org, r, recordedIds, recordedValues, platformNames) {
86
101
  }
87
102
  return name === `_dmarc.${mailDomain}` || name.endsWith(`._domainkey.${mailDomain}`);
88
103
  }
104
+ const OWN_MARKER_KINDS = new Set([
105
+ 'own-auth',
106
+ 'own-mail',
107
+ 'own-files',
108
+ 'own-files-r2',
109
+ 'own-jobs',
110
+ 'own-sfu',
111
+ ]);
89
112
  /** The kinds a provider lists a resource under before a recorded `system:kind:key` maps it to foundry's own. */
90
113
  const PROVIDER_KINDS = {
91
114
  coolify: ['application', 'database'],
@@ -163,11 +186,22 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
163
186
  const byKey = new Map(live.map((r) => [resourceKey(r.system, r.kind, r.key), r]));
164
187
  const claimed = new Set();
165
188
  const plannedSteps = [];
166
- // The platform's own org: its domain is the platform apex (the same fact
167
- // #491 reads for mail). It signs in on the shared instance's own host, so
168
- // `issuer.usesDomain` never moves it.
169
- const isPlatformOrg = isPlatformOwnOrg(org, platformReadiness.platformOwnerOrgId);
170
- const issuer = planIssuer(org, byKey, claimed, platformReadiness.authReady, isPlatformOrg);
189
+ // An own org's sign-in comes before every step that signs in through it.
190
+ const ownAuth = planOwnAuth({
191
+ org,
192
+ recordedIds,
193
+ readiness: platformReadiness,
194
+ ownServices: options.ownServices ?? false,
195
+ zone: org.domain ? zoneReadiness(org.domain, byKey) : null,
196
+ oikosZoneReady: options.oikosZoneReady ?? false,
197
+ mailKeyHeld: options.ownMailKeyHeld ?? false,
198
+ mailKeyMintable: options.ownMailKeyMintable ?? false,
199
+ });
200
+ if (ownAuth.step)
201
+ plannedSteps.push(ownAuth.step);
202
+ // An own org's ZITADEL is the company's own instance: nothing that needs it plans before it exists.
203
+ const ownSignInPending = ownsServices(org) && !ownAuthApplied(org, recordedIds);
204
+ const issuer = planIssuer(org, byKey, claimed, platformReadiness.authReady);
171
205
  if (issuer.step)
172
206
  plannedSteps.push(issuer.step);
173
207
  // Before the domain and the owners: no invite runs on an org still open to registration.
@@ -182,12 +216,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
182
216
  // #267: after the ZITADEL org step, before any app's own issuer
183
217
  // application. Apps created later in this run are not in the binding yet;
184
218
  // the next run replans this step and repoints them.
185
- const loginHost = planLoginHost(org, recordedIds, platformReadiness.authReady, issuer.step !== null, options.loginHostBindings ?? false,
186
- // #488: `agoraHost.issuer` is the shared ZITADEL instance's own issuer
187
- // URL (`https://<platform.auth.hostname>`), the same value every org's
188
- // sign-in already reads through `planOwners` above -- no separate port
189
- // or plumbing needed to know the platform's own auth hostname.
190
- platformAuthHostnameFrom('error' in agoraHost ? null : agoraHost.issuer), isPlatformOrg);
219
+ const loginHost = planLoginHost(org, recordedIds, platformReadiness.authReady, issuer.step !== null, options.loginHostBindings ?? false);
191
220
  if (loginHost.step)
192
221
  plannedSteps.push(loginHost.step);
193
222
  // #444a: the org's one shared Postgres server and its fixed schema
@@ -199,6 +228,10 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
199
228
  // is pushed just below, before mail.
200
229
  const zone = planZone(org, byKey, claimed);
201
230
  const zoneWaiting = zone.blocked !== null;
231
+ // A company without a domain has no zone of its own: it waits for the `oikos.host` zone at the platform's edge.
232
+ const ownZoneActive = org.domain
233
+ ? zone.step === null && !zoneWaiting
234
+ : (options.oikosZoneReady ?? false);
202
235
  const zoneBlocked = [];
203
236
  // #555: steps that write DNS records, which go through edge.
204
237
  const dnsBlocked = [];
@@ -213,7 +246,20 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
213
246
  else if (zone.step) {
214
247
  plannedSteps.push(zone.step);
215
248
  }
216
- const mail = planMail(org, byKey, claimed, recordedIds, platformReadiness.mailHost, platformReadiness.platformApex, platformReadiness.platformLettermintProjectId);
249
+ const ownMail = planOwnMail({
250
+ org,
251
+ recordedIds,
252
+ readiness: platformReadiness,
253
+ ownServices: options.ownServices ?? false,
254
+ mailRelayApplied: options.mailRelayApplied ?? false,
255
+ zoneActive: ownZoneActive,
256
+ ...(options.ownMailSmtpPort !== undefined ? { smtpPort: options.ownMailSmtpPort } : {}),
257
+ });
258
+ if (ownMail.step)
259
+ plannedSteps.push(ownMail.step);
260
+ const mail = planMail(org, byKey, claimed, recordedIds, platformReadiness.mailHost, platformReadiness.platformApex, platformReadiness.platformLettermintProjectId, options.mailRelayRoute === false
261
+ ? `the mail relay has no route for ${ownHostnames(org).mailDomain} yet: apply the platform`
262
+ : undefined);
217
263
  // #238: own-domain mail publishes DNS records into the zone, so it waits for
218
264
  // an active zone -- blocked, not planned (rule 2).
219
265
  if (mail.step && zoneWaiting && org.mail.usesDomain && org.domain) {
@@ -232,12 +278,25 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
232
278
  const mailCopy = planMailCopy(org, byKey, recordedIds);
233
279
  if (mailCopy)
234
280
  plannedSteps.push(mailCopy);
281
+ const versionPin = org.versionPin;
235
282
  const chat = planChat(org, byKey, claimed, recordedIds, agoraHost, issuer.step !== null);
236
283
  if (chat.step)
237
284
  plannedSteps.push(chat.step);
238
- const calls = planCalls(org, byKey, claimed, platformReadiness.callsReady);
285
+ const calls = planCalls(org, byKey, claimed, platformReadiness.callsReady, versionPin?.calls);
239
286
  if (calls.step)
240
287
  plannedSteps.push(calls.step);
288
+ // An own org's media server, before the calls service that reads its addresses.
289
+ const ownSfu = planOwnSfu({
290
+ org,
291
+ recordedIds,
292
+ ownServices: options.ownServices ?? false,
293
+ zoneActive: ownZoneActive,
294
+ readiness: platformReadiness,
295
+ sfuPortsOpen: options.sfuPortsOpen ?? false,
296
+ keysHeld: options.ownSfuKeysHeld ?? false,
297
+ });
298
+ if (ownSfu.step)
299
+ plannedSteps.push(ownSfu.step);
241
300
  // Right after the Matrix stack (#145): its own executor
242
301
  // (`executor.ts`'s `coolifyCallsService`) reads the Synapse/MAS addresses
243
302
  // and MAS client credentials `coolifyCalls` just recorded, the same
@@ -262,14 +321,35 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
262
321
  const versionChanges = [];
263
322
  if (org.calls.enabled && !calls.blocked) {
264
323
  const callsAppLive = Boolean(byKey.get(resourceKey('coolify', 'calls', org.id)));
265
- const synapseChange = versionNotAppliedLine('synapse', callsAppLive, recordedIds[CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY], VERSIONS.synapse);
324
+ const synapseChange = versionNotAppliedLine('synapse', callsAppLive, recordedIds[CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY], versionPin?.calls ?? VERSIONS.synapse);
266
325
  if (synapseChange)
267
326
  versionChanges.push(synapseChange);
268
327
  const masChange = versionNotAppliedLine('mas', callsAppLive, recordedIds[CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY], VERSIONS.mas);
269
328
  if (masChange)
270
329
  versionChanges.push(masChange);
271
330
  }
272
- const filesOrganisation = planFilesOrganisation(org, byKey, claimed, platformReadiness.filesReady, platformReadiness.missingKeys.files, platformReadiness.noKeyReader);
331
+ // An own org's files host, before its organisation on it.
332
+ const ownFiles = planOwnFiles({
333
+ org,
334
+ recordedIds,
335
+ readiness: platformReadiness,
336
+ ownServices: options.ownServices ?? false,
337
+ filesDeps: options.ownFilesDeps ?? { keys: false, bucket: false },
338
+ });
339
+ if (ownFiles.step)
340
+ plannedSteps.push(ownFiles.step);
341
+ // After the host is applied: its storage key is delivered to the running host.
342
+ const ownFilesR2 = options.ownFilesR2Ports
343
+ ? planOwnFilesR2({
344
+ org,
345
+ recordedIds,
346
+ ownServices: options.ownServices ?? false,
347
+ ports: options.ownFilesR2Ports,
348
+ })
349
+ : { step: null, blocked: null };
350
+ if (ownFilesR2.step)
351
+ plannedSteps.push(ownFilesR2.step);
352
+ const filesOrganisation = planFilesOrganisation(org, byKey, claimed, platformReadiness.filesReady, platformReadiness.missingKeys.files, platformReadiness.noKeyReader, ownsServices(org) && !recordedIds[`coolify:own-files:${org.id}`]);
273
353
  if (filesOrganisation.step)
274
354
  plannedSteps.push(filesOrganisation.step);
275
355
  const aiOrganisation = planAiOrganisation(org, byKey, claimed, platformReadiness.aiReady, platformReadiness.missingKeys.ai, platformReadiness.noKeyReader);
@@ -360,7 +440,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
360
440
  const edgeReservedLabels = planEdgeReservedLabels(org, recordedIds, platformReadiness, options.platformHostnames ?? []);
361
441
  if (edgeReservedLabels.step)
362
442
  plannedSteps.push(edgeReservedLabels.step);
363
- const tunnel = planTunnel(org, byKey, claimed, recordedIds, isPlatformOrg, platformReadiness.platformApex);
443
+ const tunnel = planTunnel(org, byKey, claimed, recordedIds, platformReadiness.platformApex);
364
444
  // #238: a tunnel that is being provisioned waits for an active zone; parking
365
445
  // one never does.
366
446
  // #555: provisioning writes the tunnel config, the zone lookup and DNS; a
@@ -411,7 +491,9 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
411
491
  const agoraGuarded = guardApp(agoraClient);
412
492
  if (agoraGuarded)
413
493
  plannedSteps.push(agoraGuarded);
414
- const agora = planAgora(org, byKey, claimed, recordedIds, agoraHost);
494
+ const agora = planAgora(org, byKey, claimed, recordedIds, agoraHost, {
495
+ servesDiscovery: options.loginHostServesDiscovery ?? false,
496
+ });
415
497
  if (agora.step)
416
498
  plannedSteps.push(agora.step);
417
499
  // #161 (Boule part): every org's own Boule -- always on, no toggle, the same
@@ -433,10 +515,22 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
433
515
  const bouleBroker = planBouleBrokerEntry(org, recordedIds, plannedSteps, options.broker ?? false);
434
516
  if (bouleBroker.step)
435
517
  plannedSteps.push(bouleBroker.step);
518
+ // An own org's scheduler, before its registration below.
519
+ const ownJobs = planOwnJobs({
520
+ org,
521
+ recordedIds,
522
+ readiness: platformReadiness,
523
+ ownServices: options.ownServices ?? false,
524
+ tokenHeld: options.ownJobsTokenHeld ?? false,
525
+ });
526
+ if (ownJobs.step)
527
+ plannedSteps.push(ownJobs.step);
436
528
  // #173: register the org's own app with the platform's jobs scheduler.
437
529
  // After Agora, so a failing registration can never stop Agora from
438
530
  // provisioning (`runSteps` stops at the first failing step).
439
- const jobsRegistration = planJobsRegistration(org, recordedIds, jobsAvailable || platformReadiness.jobsReady, platformReadiness.missingKeys.jobs, platformReadiness.noKeyReader, platformReadiness.jobsUrlMissing ?? false);
531
+ const jobsRegistration = planJobsRegistration(org, recordedIds,
532
+ // An own org never registers with the shared scheduler the host supplies.
533
+ (jobsAvailable && !ownsServices(org)) || platformReadiness.jobsReady, platformReadiness.missingKeys.jobs, platformReadiness.noKeyReader, platformReadiness.jobsUrlMissing ?? false);
440
534
  if (jobsRegistration.step)
441
535
  plannedSteps.push(jobsRegistration.step);
442
536
  // F8 (#318), after Agora and Boule, not right after the zone: a route
@@ -476,6 +570,11 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
476
570
  const recordedValues = new Set(Object.values(recordedIds));
477
571
  for (const own of ['', org.id, org.slug, org.domain ?? ''])
478
572
  recordedValues.delete(own);
573
+ // Archon's and the Access provider's project and client live in the owner company's ZITADEL.
574
+ if (platformReadiness.platformOwnerOrgId === org.id) {
575
+ for (const id of platformReadiness.platformZitadelIds ?? [])
576
+ recordedValues.add(id);
577
+ }
479
578
  const platformNames = new Set([...(options.platformHostnames ?? []), platformReadiness.mailHost?.mailHost ?? ''].map((h) => h.toLowerCase()));
480
579
  platformNames.delete('');
481
580
  const unmanaged = live
@@ -489,6 +588,8 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
489
588
  // item list, so it rides along with the unmanaged items.
490
589
  if (agora.blocked)
491
590
  unmanaged.push({ description: agora.blocked, key: 'coolify:agora' });
591
+ if (agora.issuerNote)
592
+ unmanaged.push({ description: agora.issuerNote, key: 'coolify:agora' });
492
593
  if (boule.blocked)
493
594
  unmanaged.push({ description: boule.blocked, key: 'coolify:boule' });
494
595
  if (boule.mailBlocked) {
@@ -503,6 +604,22 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
503
604
  unmanaged.push(...edgeWorkers.blocked);
504
605
  unmanaged.push(...entryEdgeKeys.blocked);
505
606
  unmanaged.push(...edgeReservedLabels.blocked);
607
+ if (ownAuth.blocked)
608
+ unmanaged.push({ description: ownAuth.blocked, key: OWN_AUTH_BLOCKED_KEY });
609
+ if (ownJobs.blocked)
610
+ unmanaged.push({ description: ownJobs.blocked, key: OWN_JOBS_BLOCKED_KEY });
611
+ if (ownMail.blocked)
612
+ unmanaged.push({ description: ownMail.blocked, key: OWN_MAIL_BLOCKED_KEY });
613
+ if (ownSfu.blocked)
614
+ unmanaged.push({ description: ownSfu.blocked, key: OWN_SFU_BLOCKED_KEY });
615
+ if (ownSfu.firewall)
616
+ unmanaged.push({ description: ownSfu.firewall, key: OWN_SFU_FIREWALL_KEY });
617
+ if (ownFiles.blocked) {
618
+ unmanaged.push({ description: ownFiles.blocked, key: OWN_FILES_BLOCKED_KEY });
619
+ }
620
+ if (ownFilesR2.blocked) {
621
+ unmanaged.push({ description: ownFilesR2.blocked, key: OWN_FILES_R2_BLOCKED_KEY });
622
+ }
506
623
  // Same for a jobs registration that cannot run (no scheduler wired) or must
507
624
  // not run again (minted, never delivered).
508
625
  if (jobsRegistration.blocked) {
@@ -513,9 +630,20 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
513
630
  // `platformReadiness` instead of a host-supplied port. Each is reported at
514
631
  // most once even though a dependent step (owner, calls-service, storage,
515
632
  // budget, key) would otherwise also have nothing to act on.
516
- if (issuer.blocked)
517
- unmanaged.push({ description: issuer.blocked, key: 'zitadel:issuer' });
518
- unmanaged.push(...owners.blocked);
633
+ if (ownSignInPending) {
634
+ // The sign-in step's own reason, when it has one, already says why the rest waits.
635
+ if (!ownAuth.blocked) {
636
+ unmanaged.push({
637
+ description: `${org.slug}'s ZITADEL steps are blocked, not planned: its own sign-in service is not applied yet`,
638
+ key: 'coolify:own-auth',
639
+ });
640
+ }
641
+ }
642
+ else {
643
+ if (issuer.blocked)
644
+ unmanaged.push({ description: issuer.blocked, key: 'zitadel:issuer' });
645
+ unmanaged.push(...owners.blocked);
646
+ }
519
647
  const domainBlocked = orgDomainBlocked(org, recordedIds);
520
648
  if (domainBlocked)
521
649
  unmanaged.push(domainBlocked);
@@ -566,14 +694,50 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
566
694
  unmanaged.push(...app.blocked);
567
695
  unmanaged.push(...brokerEntries.blocked);
568
696
  unmanaged.push(...bouleBroker.blocked);
697
+ // F6: last, so every database step above runs before the backup of its database.
698
+ const backups = planCompanyBackups(org, recordedIds, options.backupStorageUuid);
699
+ plannedSteps.push(...backups.steps);
700
+ unmanaged.push(...backups.blocked);
701
+ // A company built on the shared network plans the move and nothing else: its other steps would act on
702
+ // resources the move deletes.
703
+ // The platform's own company stays on the platform network, so it is never moved.
704
+ const ownerCompany = onPlatformNetwork(isPlatformOwnOrg(org, platformReadiness.platformOwnerOrgId));
705
+ const networkMove = ownerCompany ? null : planNetworkMove(org, recordedIds);
706
+ if (!ownerCompany && networkMoveNeeded(recordedIds) && hasAdoptedResources(recordedIds)) {
707
+ unmanaged.push({
708
+ description: `${org.slug}'s adopted app and database stay on the shared Docker network: foundry did not create them and does not move them`,
709
+ key: 'coolify:network',
710
+ });
711
+ }
569
712
  return {
570
713
  orgId: org.id,
571
- plannedSteps: plannedSteps.map(withStepSystem),
714
+ plannedSteps: (networkMove
715
+ ? [networkMove]
716
+ : ownSignInPending
717
+ ? plannedSteps.filter((s) => !NEEDS_OWN_ZITADEL.has(`${s.action.system}:${s.action.kind}`))
718
+ : plannedSteps).map(withStepSystem),
572
719
  unmanaged,
573
720
  versionChanges,
574
721
  orgPostgres,
575
722
  };
576
723
  }
724
+ /** Steps that run against the org's own ZITADEL instance (its projects, apps and broker entries). */
725
+ const NEEDS_OWN_ZITADEL = new Set([
726
+ 'zitadel:issuer',
727
+ 'zitadel:registration-closed',
728
+ 'zitadel:domain',
729
+ 'zitadel:owner',
730
+ 'zitadel:app',
731
+ 'zitadel:customers',
732
+ 'zitadel:id-token-userinfo',
733
+ 'zitadel:login-host',
734
+ 'coolify:chat',
735
+ 'coolify:calls-service',
736
+ 'coolify:calls-chat',
737
+ 'coolify:broker-entry',
738
+ 'coolify:boule-broker-entry',
739
+ 'files:organisation',
740
+ ]);
577
741
  /**
578
742
  * #553: a plain ZITADEL app provision (not a `recreate`) is planned only when
579
743
  * the live listing lacks the app by name. If an app id is recorded for it,
@@ -643,7 +807,7 @@ function edgeBlockedItem(org, what, key, reason) {
643
807
  * already exists live is unaffected: its own domain-move logic runs
644
808
  * regardless, since that never re-provisions the ZITADEL org itself.
645
809
  */
646
- function planIssuer(org, byKey, claimed, authReady, isPlatformOrg = false) {
810
+ function planIssuer(org, byKey, claimed, authReady) {
647
811
  const key = resourceKey('zitadel', 'issuer', org.id);
648
812
  const live = byKey.get(key);
649
813
  const desiredUsesDomain = org.issuer.usesDomain;
@@ -681,10 +845,8 @@ function planIssuer(org, byKey, claimed, authReady, isPlatformOrg = false) {
681
845
  Boolean(org.domain) &&
682
846
  liveHost !== null &&
683
847
  liveHost !== loginHostnameFor(org);
684
- // The platform's own org already signs in on the shared host: no move. A
685
- // live issuer that does not say is not read as a different one either.
686
- if (isPlatformOrg ||
687
- live.config.usesDomain === undefined ||
848
+ // A live issuer that does not say is not read as a different one either.
849
+ if (live.config.usesDomain === undefined ||
688
850
  (Boolean(live.config.usesDomain) === desiredUsesDomain && !domainMoved)) {
689
851
  return { step: null, blocked: null };
690
852
  }
@@ -867,27 +1029,10 @@ function planOwners(org, recordedIds, authReady, authIssuer, ownerInviteMail, re
867
1029
  });
868
1030
  return { steps, blocked };
869
1031
  }
870
- /**
871
- * #488: the bare hostname out of an issuer URL (`https://<hostname>`, the
872
- * only shape {@link agoraHostValuesFromPlatform} or a host-supplied
873
- * `AgoraHostPort` ever hands back), or `null` when there is no issuer to
874
- * read one from. What {@link loginHostnameFor} compares the org's own
875
- * computed `auth.<domain>` against, so the platform's own org never plans
876
- * its login host equal to the shared ZITADEL instance's own.
877
- */
1032
+ /** True when `org` is the platform's owner company. */
878
1033
  export function isPlatformOwnOrg(org, platformOwnerOrgId) {
879
1034
  return Boolean(platformOwnerOrgId && org.id === platformOwnerOrgId);
880
1035
  }
881
- function platformAuthHostnameFrom(issuer) {
882
- if (!issuer)
883
- return null;
884
- try {
885
- return new URL(issuer).hostname;
886
- }
887
- catch {
888
- return null;
889
- }
890
- }
891
1036
  /**
892
1037
  * #267: the org's own company login host (`login-host.ts`'s
893
1038
  * `loginHostnameFor`) -- `auth.<domain>` once the org has one,
@@ -924,51 +1069,24 @@ function platformAuthHostnameFrom(issuer) {
924
1069
  * platform's own domain, never plans the shared ZITADEL instance's own login
925
1070
  * host as its own -- `auth.companyincluded.com` colliding with itself.
926
1071
  */
927
- function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired, platformAuthHostname, isPlatformOrg) {
1072
+ function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired) {
928
1073
  const none = { step: null, blocked: null };
1074
+ // An own org's instance has no shared login host to bind: its issuer is the sign-in service's own.
1075
+ if (ownsServices(org))
1076
+ return none;
929
1077
  const key = resourceKey('zitadel', 'login-host', org.id);
930
- // The platform's own org signs in on the shared instance's host whatever
931
- // `issuer.usesDomain` says (turning it on would move the issuer).
932
- // Before that host is known, plan nothing: a guessed host would delete the
933
- // working one, and a normal step would trust a domain `destroy` later deletes.
934
- if (isPlatformOrg && platformAuthHostname === null) {
935
- return {
936
- step: null,
937
- blocked: `${org.slug}'s login host is waiting for the platform auth host`,
938
- };
939
- }
940
- const sharedInstanceHost = isPlatformOrg && platformAuthHostname !== null;
941
- const host = sharedInstanceHost ? platformAuthHostname : loginHostnameFor(org);
942
- // #488: the guard in `loginHostnameFor` fired -- the org's own domain host
943
- // would have been the platform's shared ZITADEL instance host.
944
- if (sharedInstanceHost) {
945
- // The host is the shared instance's own: nothing to trust or bind, and an
946
- // org with no recorded host already signs in there. Only an org recorded
947
- // on another host (the old `<slug>-auth.oikos.host`) plans one step, to
948
- // move its apps over and remove the old host; then it plans nothing.
949
- const recordedHost = recordedIds[key];
950
- const moving = recordedHost !== undefined && recordedHost !== host;
951
- const removalOwed = pendingRemovalHosts(recordedIds).length > 0;
952
- if (!authReady || !(moving || removalOwed))
953
- return none;
954
- // The same gates as the normal branch: the executor's shared step also
955
- // needs the binding port and the ZITADEL org.
956
- const waiting = loginHostWaiting(org, host, recordedIds, bindingsWired, issuerPlanned);
957
- return waiting ?? stepFor(true);
958
- }
959
- function stepFor(shared) {
1078
+ const host = loginHostnameFor(org);
1079
+ function stepFor() {
960
1080
  return {
961
1081
  step: {
962
1082
  action: {
963
1083
  system: 'zitadel',
964
1084
  kind: 'login-host',
965
1085
  key: org.id,
966
- desired: shared ? { host, shared: true } : { host },
1086
+ desired: { host },
967
1087
  },
968
1088
  step: {
969
- description: shared
970
- ? `move ${org.slug}'s apps onto the platform's own sign-in host ${host}`
971
- : `set ${org.slug}'s login host to ${host}`,
1089
+ description: `set ${org.slug}'s login host to ${host}`,
972
1090
  destroys: false,
973
1091
  migrates: false,
974
1092
  },
@@ -996,7 +1114,7 @@ function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired
996
1114
  // Isolation between orgs rests on zitadel-login's binding, and repointing
997
1115
  // an app at a host with no binding breaks its sign-in: trust nothing and
998
1116
  // repoint nothing until the port that writes the binding is wired.
999
- return loginHostWaiting(org, host, recordedIds, bindingsWired, issuerPlanned) ?? stepFor(false);
1117
+ return loginHostWaiting(org, host, recordedIds, bindingsWired, issuerPlanned) ?? stepFor();
1000
1118
  }
1001
1119
  /**
1002
1120
  * Every sign-in app this org already has live (web or native, not api) gets
@@ -1126,7 +1244,9 @@ export const JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY = 'jobs_registration_undeli
1126
1244
  */
1127
1245
  function planMail(org, byKey, claimed, recordedIds, mailHost,
1128
1246
  /** #491 (review round 2): read off `platformReadiness` directly, not off `mailHost` -- see `OrgPlatformReadiness.platformApex`'s own doc comment for why. */
1129
- platformApex, platformLettermintProjectId) {
1247
+ platformApex, platformLettermintProjectId,
1248
+ /** Why the MX cannot be published yet: the relay does not route this org's mail domain. */
1249
+ relayBlocked) {
1130
1250
  const key = resourceKey('coolify', 'mail', org.id);
1131
1251
  const live = byKey.get(key);
1132
1252
  // `off` means mail is not foundry's concern here (org.ts's MailMode doc);
@@ -1142,7 +1262,14 @@ platformApex, platformLettermintProjectId) {
1142
1262
  if (!mailHost) {
1143
1263
  return {
1144
1264
  step: null,
1145
- blocked: `${org.slug}'s mail is blocked, not planned: the platform's mail server is off or not yet applied`,
1265
+ blocked: `${org.slug}'s mail is blocked, not planned: its own mail server is not applied yet`,
1266
+ mailboxBlocked: null,
1267
+ };
1268
+ }
1269
+ if (relayBlocked) {
1270
+ return {
1271
+ step: null,
1272
+ blocked: `${org.slug}'s mail is blocked, not planned: ${relayBlocked}`,
1146
1273
  mailboxBlocked: null,
1147
1274
  };
1148
1275
  }
@@ -1490,12 +1617,14 @@ function planChat(org, byKey, claimed, recordedIds, reading, issuerPlanned) {
1490
1617
  * `sfu:livekit` step is declared. Parking, and an already-provisioned stack,
1491
1618
  * are unaffected.
1492
1619
  */
1493
- function planCalls(org, byKey, claimed, callsReady) {
1620
+ function planCalls(org, byKey, claimed, callsReady, synapseTag) {
1494
1621
  const key = resourceKey('coolify', 'calls', org.id);
1495
1622
  if (org.calls.enabled && !byKey.get(key) && !callsReady) {
1496
1623
  return {
1497
1624
  step: null,
1498
- blocked: `${org.slug}'s calls Matrix stack is blocked, not planned: the platform's shared calls SFU is off`,
1625
+ blocked: ownsServices(org)
1626
+ ? `${org.slug}'s calls Matrix stack is blocked, not planned: its own media server (coolify:own-sfu) is not applied yet`
1627
+ : `${org.slug}'s calls Matrix stack is blocked, not planned: the platform owner's calls SFU (the platform's) is off`,
1499
1628
  };
1500
1629
  }
1501
1630
  // #248: the two images this stack's own apply would run, checked the same
@@ -1503,7 +1632,7 @@ function planCalls(org, byKey, claimed, callsReady) {
1503
1632
  // own tag -- a re-apply of the same declaration must not silently run a
1504
1633
  // different version with no diff shown anywhere.
1505
1634
  if (org.calls.enabled && !byKey.get(key)) {
1506
- const synapseBlocked = floatingTagBlocked('Synapse', VERSIONS.synapse, 'v1.161.0');
1635
+ const synapseBlocked = floatingTagBlocked('Synapse', synapseTag ?? VERSIONS.synapse, 'v1.161.0');
1507
1636
  if (synapseBlocked) {
1508
1637
  return {
1509
1638
  step: null,
@@ -1518,10 +1647,12 @@ function planCalls(org, byKey, claimed, callsReady) {
1518
1647
  };
1519
1648
  }
1520
1649
  }
1521
- return {
1522
- step: planHostingToggle('coolify', 'calls', org.calls.enabled, org, byKey, claimed, () => `provision ${org.slug}'s calls Matrix stack (Synapse + matrix-authentication-service)`, () => `park ${org.slug}'s calls Matrix stack (stops serving; data is retained, not deleted -- #7 corrections 1)`),
1523
- blocked: null,
1524
- };
1650
+ const toggle = planHostingToggle('coolify', 'calls', org.calls.enabled, org, byKey, claimed, () => `provision ${org.slug}'s calls Matrix stack (Synapse + matrix-authentication-service)`, () => `park ${org.slug}'s calls Matrix stack (stops serving; data is retained, not deleted -- #7 corrections 1)`);
1651
+ // A held Synapse tag rides in the provisioning step; a parking step carries none.
1652
+ if (toggle && synapseTag && toggle.action.desired.enabled === true) {
1653
+ toggle.action.desired = { ...toggle.action.desired, synapseTag };
1654
+ }
1655
+ return { step: toggle, blocked: null };
1525
1656
  }
1526
1657
  /**
1527
1658
  * The org's own calls service (#145, part 2 of #128) -- `wtfalch/calls`'s
@@ -1544,6 +1675,13 @@ function planCallsService(org, byKey, claimed, callsReady, recordedIds, callsBlo
1544
1675
  if (org.calls.enabled && !byKey.get(key) && !callsReady) {
1545
1676
  return { step: null, blocked: null };
1546
1677
  }
1678
+ // An own org's service names its own media server, so it waits for it even when already provisioned.
1679
+ if (org.calls.enabled && ownsServices(org) && !callsReady) {
1680
+ return {
1681
+ step: null,
1682
+ blocked: `${org.slug}'s calls service is blocked, not planned: its own media server (coolify:own-sfu) is not applied yet`,
1683
+ };
1684
+ }
1547
1685
  // The introspection secret is shown once. One minted but never written to
1548
1686
  // the app is lost, so this reports it and mints no second (platform rule 1).
1549
1687
  const undelivered = recordedIds[CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY];
@@ -1771,13 +1909,21 @@ function notReadyReason(service, keyName, missingKeyRef, noKeyReader) {
1771
1909
  * Files organisation already exists -- the old origin is left allowed
1772
1910
  * rather than silently dropped.
1773
1911
  */
1774
- function planFilesOrganisation(org, byKey, claimed, filesReady, missingKeyRef, noKeyReader = false) {
1912
+ function planFilesOrganisation(org, byKey, claimed, filesReady, missingKeyRef, noKeyReader = false,
1913
+ /** An own org whose files host (`coolify:own-files:<org.id>`) is not recorded yet. */
1914
+ ownFilesPending = false) {
1775
1915
  const key = resourceKey('files', 'organisation', org.id);
1776
1916
  const live = byKey.get(key);
1777
1917
  if (live) {
1778
1918
  claimed.add(key);
1779
1919
  return { step: null, blocked: null };
1780
1920
  }
1921
+ if (ownFilesPending) {
1922
+ return {
1923
+ step: null,
1924
+ blocked: `${org.slug}'s Files organisation is blocked, not planned: its own files host is not applied yet`,
1925
+ };
1926
+ }
1781
1927
  if (!filesReady) {
1782
1928
  return {
1783
1929
  step: null,
@@ -2150,14 +2296,32 @@ function planAgoraClient(org, byKey, claimed) {
2150
2296
  * message naming each missing value, which `computePlanDetail` lists with the
2151
2297
  * plan's non-step items, so every other step still applies.
2152
2298
  */
2153
- function planAgora(org, byKey, claimed, recordedIds, reading) {
2299
+ function planAgora(org, byKey, claimed, recordedIds, reading, issuerSwitch) {
2154
2300
  const key = resourceKey('coolify', 'agora', org.id);
2155
2301
  const live = byKey.get(key);
2156
2302
  if (live)
2157
2303
  claimed.add(key);
2304
+ const ownSignIn = ownsServices(org);
2305
+ if (ownSignIn && !ownAuthApplied(org, recordedIds)) {
2306
+ return {
2307
+ step: null,
2308
+ blocked: `${org.slug}'s Agora is blocked, not planned: its own sign-in service is not applied yet`,
2309
+ issuerNote: null,
2310
+ };
2311
+ }
2312
+ // The files host records its internal URL only while it applies, and Agora's
2313
+ // env takes its files upstream from it: planned in the same pass, the first
2314
+ // deploy would start without one.
2315
+ if (ownSignIn && !recordedIds[PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY]) {
2316
+ return {
2317
+ step: null,
2318
+ blocked: `${org.slug}'s Agora is blocked, not planned: its own files host is not applied yet`,
2319
+ issuerNote: null,
2320
+ };
2321
+ }
2158
2322
  const error = 'error' in reading ? reading.error : null;
2159
- const host = withRecordedMessagingProject('error' in reading ? NO_AGORA_HOST_VALUES : reading, recordedIds);
2160
- const missing = agoraHostMissing(host);
2323
+ const platformHost = withRecordedMessagingProject('error' in reading ? NO_AGORA_HOST_VALUES : reading, recordedIds);
2324
+ const missing = agoraHostMissing(platformHost);
2161
2325
  if (missing.length > 0) {
2162
2326
  // A fixed text, never the port's own message: this line is part of the
2163
2327
  // stale-plan comparison, and an error that varies per call would refuse
@@ -2166,8 +2330,41 @@ function planAgora(org, byKey, claimed, recordedIds, reading) {
2166
2330
  return {
2167
2331
  step: null,
2168
2332
  blocked: `${org.slug}'s Agora is blocked, not planned: the host supplies no ${missing.join(', ')}${why}`,
2333
+ issuerNote: null,
2169
2334
  };
2170
2335
  }
2336
+ // Agora signs in at the company's own login host, and refuses any issuer off
2337
+ // its own address's domain. Only this value changes: the calls token env,
2338
+ // chat and Boule keep reading the platform issuer from `reading`, and Agora
2339
+ // always plans. The company host is written only once the login host is
2340
+ // applied with Agora's client AND a live read said it serves discovery as
2341
+ // that issuer. An Agora already on the company host keeps it while the host
2342
+ // stays applied: a later failing read never flips it back (no oscillation).
2343
+ let host = platformHost;
2344
+ let issuerNote = null;
2345
+ const choice = agoraIssuerHostFor(org);
2346
+ if (choice.mismatch) {
2347
+ issuerNote = `${org.slug}'s Agora cannot sign in: its login host ${loginHostnameFor(org)} is not on its Agora address ${agoraHostnameFor(org)}; the login host must be on the org's domain`;
2348
+ }
2349
+ else if (choice.host) {
2350
+ const loginHost = choice.host;
2351
+ const target = `https://${loginHost}`;
2352
+ const applied = loginHostServesAgora(loginHost, org, recordedIds);
2353
+ if (applied && (liveAgoraIssuer(live) === target || issuerSwitch.servesDiscovery)) {
2354
+ host = { ...platformHost, issuer: target };
2355
+ }
2356
+ else if (ownSignIn) {
2357
+ // No platform issuer to fall back on: Agora waits for the company's own.
2358
+ return {
2359
+ step: null,
2360
+ blocked: `${org.slug}'s Agora is blocked, not planned: ${target} does not answer OIDC discovery yet`,
2361
+ issuerNote: null,
2362
+ };
2363
+ }
2364
+ else {
2365
+ issuerNote = `${org.slug}'s Agora keeps the platform issuer; the switch to ${target} is blocked, not planned, until ${loginHost} is applied with Agora's client and answers OIDC discovery`;
2366
+ }
2367
+ }
2171
2368
  // The executor writes exactly these host values, so what the plan showed
2172
2369
  // is what gets applied; the ids only the apply itself records are read at
2173
2370
  // run time.
@@ -2188,6 +2385,7 @@ function planAgora(org, byKey, claimed, recordedIds, reading) {
2188
2385
  },
2189
2386
  },
2190
2387
  blocked: null,
2388
+ issuerNote,
2191
2389
  };
2192
2390
  }
2193
2391
  const liveEnv = (live.config.env ?? {});
@@ -2215,8 +2413,9 @@ function planAgora(org, byKey, claimed, recordedIds, reading) {
2215
2413
  // actually serving this exact `env`; while it disagrees, the step stays
2216
2414
  // planned no matter what Coolify's env store already shows.
2217
2415
  const activated = recordedIds[AGORA_ACTIVATED_ENV_SCOPE_KEY] === agoraEnvRevision(env);
2218
- if (drift.length === 0 && activated && !portDrift)
2219
- return { step: null, blocked: null };
2416
+ if (drift.length === 0 && activated && !portDrift) {
2417
+ return { step: null, blocked: null, issuerNote };
2418
+ }
2220
2419
  const reasons = [
2221
2420
  ...(drift.length > 0 ? [`${drift.join(', ')} missing or different`] : []),
2222
2421
  ...(portDrift ? ["ports_exposes wrong -- Traefik can't reach it"] : []),
@@ -2233,8 +2432,22 @@ function planAgora(org, byKey, claimed, recordedIds, reading) {
2233
2432
  },
2234
2433
  },
2235
2434
  blocked: null,
2435
+ issuerNote,
2236
2436
  };
2237
2437
  }
2438
+ /** The issuer a live Agora's `AGORA_CONFIG` names, or null when it has none or is not JSON. */
2439
+ function liveAgoraIssuer(live) {
2440
+ const raw = (live?.config.env ?? {}).AGORA_CONFIG;
2441
+ if (raw === undefined)
2442
+ return null;
2443
+ try {
2444
+ const issuer = JSON.parse(raw)?.issuer;
2445
+ return typeof issuer === 'string' ? issuer : null;
2446
+ }
2447
+ catch {
2448
+ return null;
2449
+ }
2450
+ }
2238
2451
  /**
2239
2452
  * #161 (Boule part): every org gets one fixed ZITADEL web client named
2240
2453
  * `boule`, so its Boule (https://github.com/wtfalch/boule) can sign anyone in.
@@ -2486,7 +2699,6 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
2486
2699
  const blockedKey = (rest) => entryKey === APP_ENTRY_KEY ? `coolify:app:${rest}` : `coolify:app:${entryKey}:${rest}`;
2487
2700
  const { marker: wantedEnv, unresolved, unresolvedSecrets, } = entry
2488
2701
  ? resolveAppEnv(org, entry, recordedIds, {
2489
- platformMailKey: readiness.mailKeyHeld === true,
2490
2702
  sharedSecret: readiness.canHoldSecrets === true,
2491
2703
  })
2492
2704
  : { marker: {}, unresolved: [], unresolvedSecrets: [] };
@@ -2502,9 +2714,7 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
2502
2714
  .concat(
2503
2715
  // Never a value: the ref of the missing key, or what the host lacks.
2504
2716
  unresolvedSecrets.map(([name, source]) => ({
2505
- description: `${org.slug}'s ${label} env ${name} is blocked, not planned: ${source === 'platformMailKey'
2506
- ? `the platform's Lettermint key is not held yet (held-key ref ${PLATFORM_AUTH_BROKER_MAIL_KEY_REF})`
2507
- : `its source ${source} needs a held-key store that can put, and the host has none`}`,
2717
+ description: `${org.slug}'s ${label} env ${name} is blocked, not planned: ${`its source ${source} needs a held-key store that can put, and the host has none`}`,
2508
2718
  key: blockedKey(`env:${name}`),
2509
2719
  })))
2510
2720
  : [];
@@ -2544,7 +2754,18 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
2544
2754
  key: blockedKey('repo'),
2545
2755
  });
2546
2756
  }
2547
- const toggled = planHostingToggle('coolify', 'app', entry !== undefined && !noRepo, org, byKey, claimed, () => `provision ${org.slug}'s ${label}`, () => `park ${org.slug}'s ${label} (stops serving; app data is retained, not deleted -- #7 corrections 1)`, stepKey);
2757
+ // A compose file can set `privileged`, host networking or a Docker socket mount, and its labels are
2758
+ // its own. `validateHosting` refuses the build pack on write; an entry stored before that rule is
2759
+ // held here too, and a live app of it is parked like one whose entry was removed.
2760
+ const composeBuilt = entry !== undefined && entry.buildPack === 'dockercompose';
2761
+ if (composeBuilt) {
2762
+ blocked.push({
2763
+ description: `${org.slug}'s ${label} is blocked, not planned: a compose-built app is refused until its compose file is checked`,
2764
+ key: blockedKey('compose'),
2765
+ });
2766
+ }
2767
+ const refused = noRepo || composeBuilt;
2768
+ const toggled = planHostingToggle('coolify', 'app', entry !== undefined && !refused, org, byKey, claimed, () => `provision ${org.slug}'s ${label}`, () => `park ${org.slug}'s ${label} (stops serving; app data is retained, not deleted -- #7 corrections 1)`, stepKey);
2548
2769
  // #447: only the live app's own `git_repository` decides, never the
2549
2770
  // declared entry (a `repo: null` entry can sit over a bespoke app, which is
2550
2771
  // parked as before). Only the `app` key ever held the default app, and an
@@ -2576,7 +2797,7 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
2576
2797
  }
2577
2798
  if (toggled)
2578
2799
  return { step: toggled, blocked };
2579
- if (!entry || noRepo)
2800
+ if (!entry || refused)
2580
2801
  return { step: null, blocked };
2581
2802
  const live = byKey.get(resourceKey('coolify', 'app', stepKey));
2582
2803
  if (!live)
@@ -2691,15 +2912,14 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
2691
2912
  blocked,
2692
2913
  };
2693
2914
  }
2694
- // A changed hostname reaches an app that is already live: its domains are
2695
- // patched and it is redeployed.
2696
- if (JSON.stringify(tunnelDomainList(entryHostnames(org, entry))) !==
2697
- JSON.stringify(appliedAppDomains(org, recordedIds, entryKey))) {
2915
+ // No entry carries Coolify `domains`: the company's tunnel routes its names straight to the container.
2916
+ // An app that still holds some (made before that) has them cleared and is redeployed.
2917
+ if (appliedAppDomains(org, recordedIds, entryKey).length > 0) {
2698
2918
  return {
2699
2919
  step: {
2700
2920
  action,
2701
2921
  step: {
2702
- description: `apply ${org.slug}'s ${label} hostnames and redeploy the app`,
2922
+ description: `remove ${org.slug}'s ${label} Coolify domains (the tunnel routes it directly) and redeploy the app`,
2703
2923
  destroys: false,
2704
2924
  migrates: false,
2705
2925
  },
@@ -2707,17 +2927,16 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
2707
2927
  blocked,
2708
2928
  };
2709
2929
  }
2710
- // A `*` entry's proxy labels reach an app that is already live: the label
2711
- // set is patched and the app redeployed once its hash differs from the one
2712
- // `coolifyApp` recorded.
2930
+ // A changed hostname, wildcard or port, or a container still open to Traefik: the label set
2931
+ // (`traefik.enable=false`) is patched and the app redeployed once the marker `coolifyApp`
2932
+ // recorded differs from the wanted one.
2713
2933
  const appliedLabels = recordedIds[appLabelsAppliedScopeKey(entryKey)];
2714
- const labelSpec = entryLabelSpec(org, entry, appliedLabels !== undefined);
2715
- if (labelSpec && entryLabelHash(labelSpec) !== appliedLabels) {
2934
+ if (entryLabelHash(entryLabelSpec(org, entry)) !== appliedLabels) {
2716
2935
  return {
2717
2936
  step: {
2718
2937
  action,
2719
2938
  step: {
2720
- description: `apply ${org.slug}'s ${label} proxy labels and redeploy the app`,
2939
+ description: `apply ${org.slug}'s ${label} routing (hostnames, port, no Traefik route) and redeploy the app`,
2721
2940
  destroys: false,
2722
2941
  migrates: false,
2723
2942
  },
@@ -2783,12 +3002,11 @@ function planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer, re
2783
3002
  // The same availability the plan's marker used: the executor records its
2784
3003
  // marker from this, never from its own port, so the two cannot disagree.
2785
3004
  const usesSecrets = org.hosting.entries.some((e) => e.key === key &&
2786
- Object.values(e.env).some((v) => typeof v !== 'string' && (v.from === 'sharedSecret' || v.from === 'platformMailKey')));
3005
+ Object.values(e.env).some((v) => typeof v !== 'string' && v.from === 'sharedSecret'));
2787
3006
  if (usesSecrets && app.step && app.step.action.desired.enabled !== false) {
2788
3007
  app.step.action.desired = {
2789
3008
  ...app.step.action.desired,
2790
3009
  secretSources: {
2791
- platformMailKey: readiness.mailKeyHeld === true,
2792
3010
  sharedSecret: readiness.canHoldSecrets === true,
2793
3011
  },
2794
3012
  };
@@ -2871,6 +3089,16 @@ function planJobsRegistration(org, recordedIds, jobsAvailable, missingKeyRef = n
2871
3089
  // The host says whether its executor has a scheduler to register with. A
2872
3090
  // fixed text: it is part of the stale-plan comparison.
2873
3091
  if (!jobsAvailable) {
3092
+ if (ownsServices(org)) {
3093
+ return {
3094
+ step: null,
3095
+ blocked: `${org.slug}'s jobs registration is blocked, not planned: ${addressMissing
3096
+ ? `${org.slug}'s own jobs scheduler has not recorded its internal address yet; apply its own jobs step first`
3097
+ : missingKeyRef
3098
+ ? `${org.slug}'s own jobs admin token ${missingKeyRef} is not held yet`
3099
+ : `${org.slug}'s own jobs scheduler is not applied yet`}`,
3100
+ };
3101
+ }
2874
3102
  return {
2875
3103
  step: null,
2876
3104
  blocked: addressMissing
@@ -2943,11 +3171,13 @@ function sortedNames(names) {
2943
3171
  * domain) plans no per-org tunnel for Agora: `<slug>-agora.oikos.host` rides
2944
3172
  * the shared `*.oikos.host` wildcard tunnel (`scripts/shared-ingress.mjs`).
2945
3173
  */
2946
- function planTunnel(org, byKey, claimed, recordedIds, isPlatformOrg, platformApex) {
3174
+ function planTunnel(org, byKey, claimed, recordedIds, platformApex) {
2947
3175
  const desired = (org.hosting.tunnel && org.hosting.usesDomain && Boolean(org.domain)) ||
2948
3176
  (org.site.enabled && Boolean(org.domain)) ||
2949
3177
  (org.calls.enabled && org.calls.usesDomain && Boolean(org.domain)) ||
2950
- Boolean(org.domain);
3178
+ Boolean(org.domain) ||
3179
+ // A company without a domain still gets a tunnel of its own for an entry at `<slug>.oikos.host`.
3180
+ entryRoutes(org).length > 0;
2951
3181
  const toggled = planHostingToggle('cloudflare', 'tunnel', desired, org, byKey, claimed, () => `provision a Cloudflare tunnel for ${org.slug}'s app`, () => `park ${org.slug}'s Cloudflare tunnel (configuration is retained, not deleted -- #7 corrections 1)`, org.id,
2952
3182
  // Cloudflare's status, read against what is declared: a park cannot change
2953
3183
  // it, so judging on/off by status alone would never let the plan empty.
@@ -2963,7 +3193,7 @@ function planTunnel(org, byKey, claimed, recordedIds, isPlatformOrg, platformApe
2963
3193
  // #568: the company's own login host is routed by the step, so a first
2964
3194
  // provision carries it too; a park does not. Without a known platform apex
2965
3195
  // the platform's own org cannot be told apart, so none is planned.
2966
- const loginHost = platformApex ? companyLoginTunnelHost(org, isPlatformOrg) : null;
3196
+ const loginHost = platformApex ? companyLoginTunnelHost(org) : null;
2967
3197
  if (toggled && toggled.action.desired.enabled !== false && loginHost) {
2968
3198
  return {
2969
3199
  ...toggled,
@@ -2995,8 +3225,17 @@ function planTunnel(org, byKey, claimed, recordedIds, isPlatformOrg, platformApe
2995
3225
  org.domain !== null &&
2996
3226
  sortedNames(published.split(',')) !==
2997
3227
  sortedNames(tunnelDnsHostnames(org, org.domain, loginHost));
2998
- const dnsStale = recordedIds[CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY] !== org.domain || edgeChanged;
2999
- if (!connectorMissing && !dnsStale)
3228
+ // The entry names in the platform's oikos.host zone are recorded apart from the company's own zone.
3229
+ const oikosStale = (recordedIds[CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY] ?? '') !==
3230
+ entryOikosHostnames(org).join(',');
3231
+ const dnsStale = (org.domain !== null && recordedIds[CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY] !== org.domain) ||
3232
+ edgeChanged ||
3233
+ oikosStale;
3234
+ // An entry's hostname goes straight to its container, so a changed port, key or name, or an ingress
3235
+ // written before entries were routed that way, needs the ingress written again. Nothing recorded
3236
+ // equals no routes.
3237
+ const routesStale = (recordedIds[CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY] ?? '[]') !== entryRoutesMarker(org);
3238
+ if (!connectorMissing && !dnsStale && !routesStale)
3000
3239
  return null;
3001
3240
  return {
3002
3241
  action: {
@@ -3008,7 +3247,9 @@ function planTunnel(org, byKey, claimed, recordedIds, isPlatformOrg, platformApe
3008
3247
  step: {
3009
3248
  description: connectorMissing
3010
3249
  ? `run a cloudflared connector for ${org.slug}'s Cloudflare tunnel`
3011
- : `point ${org.slug}'s hostnames at its Cloudflare tunnel`,
3250
+ : dnsStale
3251
+ ? `point ${org.slug}'s hostnames at its Cloudflare tunnel`
3252
+ : `route ${org.slug}'s entry hostnames straight to their containers`,
3012
3253
  destroys: false,
3013
3254
  migrates: false,
3014
3255
  },