@wtfalch/foundry 0.34.0 → 0.35.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +1 -1
  2. package/dist/bootstrap/bootstrap.js +49 -11
  3. package/dist/bootstrap/declare.d.ts +12 -5
  4. package/dist/bootstrap/declare.js +33 -18
  5. package/dist/bootstrap/versions.d.ts +20 -0
  6. package/dist/bootstrap/versions.js +46 -0
  7. package/dist/calls-sfu.js +3 -3
  8. package/dist/index.d.ts +9 -4
  9. package/dist/index.js +5 -2
  10. package/dist/migrations/0053_heartbeats_provider.sql +9 -0
  11. package/dist/migrations/0053_org_version_pin.sql +6 -0
  12. package/dist/org.d.ts +60 -14
  13. package/dist/org.js +118 -28
  14. package/dist/plan/backup-gate.d.ts +93 -0
  15. package/dist/plan/backup-gate.js +261 -0
  16. package/dist/plan/broker-entries.d.ts +9 -1
  17. package/dist/plan/broker-entries.js +20 -3
  18. package/dist/plan/build-record.d.ts +56 -0
  19. package/dist/plan/build-record.js +109 -0
  20. package/dist/plan/calls-matrix.js +3 -3
  21. package/dist/plan/cloudflared-connector.js +2 -2
  22. package/dist/plan/company-backups.d.ts +31 -0
  23. package/dist/plan/company-backups.js +101 -0
  24. package/dist/plan/deploy-health.d.ts +12 -2
  25. package/dist/plan/deploy-health.js +3 -1
  26. package/dist/plan/destinations.d.ts +73 -0
  27. package/dist/plan/destinations.js +217 -0
  28. package/dist/plan/diff.d.ts +17 -8
  29. package/dist/plan/diff.js +131 -131
  30. package/dist/plan/edge-access.d.ts +15 -0
  31. package/dist/plan/edge-access.js +21 -2
  32. package/dist/plan/executor.d.ts +166 -7
  33. package/dist/plan/executor.js +547 -106
  34. package/dist/plan/health-targets.d.ts +1 -3
  35. package/dist/plan/health-targets.js +36 -36
  36. package/dist/plan/held-key-refs.d.ts +5 -0
  37. package/dist/plan/held-key-refs.js +5 -0
  38. package/dist/plan/hostnames.d.ts +10 -0
  39. package/dist/plan/hostnames.js +14 -0
  40. package/dist/plan/index.d.ts +5 -0
  41. package/dist/plan/index.js +3 -0
  42. package/dist/plan/live.d.ts +6 -0
  43. package/dist/plan/live.js +3 -2
  44. package/dist/plan/locked-hostnames.d.ts +2 -1
  45. package/dist/plan/locked-hostnames.js +16 -3
  46. package/dist/plan/login-host.d.ts +9 -20
  47. package/dist/plan/login-host.js +10 -25
  48. package/dist/plan/mail-relay.d.ts +48 -29
  49. package/dist/plan/mail-relay.js +145 -68
  50. package/dist/plan/network-move.d.ts +20 -0
  51. package/dist/plan/network-move.js +41 -0
  52. package/dist/plan/oikos-ingress.d.ts +111 -0
  53. package/dist/plan/oikos-ingress.js +231 -0
  54. package/dist/plan/org-names.d.ts +8 -2
  55. package/dist/plan/org-names.js +57 -1
  56. package/dist/plan/own-diff.d.ts +9 -8
  57. package/dist/plan/own-diff.js +42 -34
  58. package/dist/plan/own-hostnames.d.ts +27 -0
  59. package/dist/plan/own-hostnames.js +24 -0
  60. package/dist/plan/own-limits.d.ts +198 -7
  61. package/dist/plan/own-limits.js +163 -32
  62. package/dist/plan/own-services.js +3 -1
  63. package/dist/plan/own-sfu.js +3 -2
  64. package/dist/plan/plan-apply.d.ts +47 -7
  65. package/dist/plan/plan-apply.js +184 -38
  66. package/dist/plan/platform-diff.d.ts +133 -17
  67. package/dist/plan/platform-diff.js +231 -68
  68. package/dist/plan/platform-org.d.ts +79 -10
  69. package/dist/plan/platform-org.js +93 -13
  70. package/dist/plan/platform-providers.d.ts +118 -73
  71. package/dist/plan/platform-providers.js +799 -241
  72. package/dist/plan/proxy-labels.d.ts +57 -11
  73. package/dist/plan/proxy-labels.js +94 -14
  74. package/dist/plan/run-options.d.ts +19 -0
  75. package/dist/plan/run-options.js +46 -0
  76. package/dist/plan/zitadel-login-policy.d.ts +1 -1
  77. package/dist/plan/zitadel-login-policy.js +76 -8
  78. package/dist/platform.d.ts +32 -0
  79. package/dist/platform.js +16 -0
  80. package/dist/provision.d.ts +2 -0
  81. package/dist/provision.js +3 -1
  82. package/dist/slug.d.ts +3 -1
  83. package/dist/slug.js +25 -3
  84. package/dist/store/postgres.js +43 -1
  85. package/dist/store/schema.d.ts +36 -0
  86. package/dist/store/schema.js +4 -0
  87. package/package.json +3 -3
  88. package/versions.digests.json +11 -0
package/dist/plan/diff.js CHANGED
@@ -4,20 +4,24 @@ import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, CALLS_INTROSPEC
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, agoraIssuerHostFor, companyLoginTunnelHost, loginBindingState, loginHostClientIds, loginHostServesAgora, loginHostnameFor, orgLoginHost, ownAuthApplied, 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';
17
+ import { hasAdoptedResources, networkMoveNeeded, planNetworkMove } from './network-move.js';
15
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';
16
19
  import { ownDnsHostnames } from './own-dns.js';
20
+ import { ownHostnames } from './own-hostnames.js';
17
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';
18
22
  import { DEFAULT_ORG_PLATFORM_READINESS, } from './platform-org.js';
19
23
  import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
20
- 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';
21
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';
22
26
  import { withStepSystem } from './step-system.js';
23
27
  import { planWorkers } from './worker.js';
@@ -182,26 +186,22 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
182
186
  const byKey = new Map(live.map((r) => [resourceKey(r.system, r.kind, r.key), r]));
183
187
  const claimed = new Set();
184
188
  const plannedSteps = [];
185
- // The platform's own org: its domain is the platform apex (the same fact
186
- // #491 reads for mail). It signs in on the shared instance's own host, so
187
- // `issuer.usesDomain` never moves it.
188
- const isPlatformOrg = isPlatformOwnOrg(org, platformReadiness.platformOwnerOrgId);
189
189
  // An own org's sign-in comes before every step that signs in through it.
190
190
  const ownAuth = planOwnAuth({
191
191
  org,
192
192
  recordedIds,
193
193
  readiness: platformReadiness,
194
- isPlatformOrg,
195
194
  ownServices: options.ownServices ?? false,
196
195
  zone: org.domain ? zoneReadiness(org.domain, byKey) : null,
196
+ oikosZoneReady: options.oikosZoneReady ?? false,
197
197
  mailKeyHeld: options.ownMailKeyHeld ?? false,
198
198
  mailKeyMintable: options.ownMailKeyMintable ?? false,
199
199
  });
200
200
  if (ownAuth.step)
201
201
  plannedSteps.push(ownAuth.step);
202
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) && !isPlatformOrg && !ownAuthApplied(org, recordedIds);
204
- const issuer = planIssuer(org, byKey, claimed, platformReadiness.authReady, isPlatformOrg);
203
+ const ownSignInPending = ownsServices(org) && !ownAuthApplied(org, recordedIds);
204
+ const issuer = planIssuer(org, byKey, claimed, platformReadiness.authReady);
205
205
  if (issuer.step)
206
206
  plannedSteps.push(issuer.step);
207
207
  // Before the domain and the owners: no invite runs on an org still open to registration.
@@ -216,12 +216,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
216
216
  // #267: after the ZITADEL org step, before any app's own issuer
217
217
  // application. Apps created later in this run are not in the binding yet;
218
218
  // the next run replans this step and repoints them.
219
- const loginHost = planLoginHost(org, recordedIds, platformReadiness.authReady, issuer.step !== null, options.loginHostBindings ?? false,
220
- // #488: `agoraHost.issuer` is the shared ZITADEL instance's own issuer
221
- // URL (`https://<platform.auth.hostname>`), the same value every org's
222
- // sign-in already reads through `planOwners` above -- no separate port
223
- // or plumbing needed to know the platform's own auth hostname.
224
- platformAuthHostnameFrom('error' in agoraHost ? null : agoraHost.issuer), isPlatformOrg);
219
+ const loginHost = planLoginHost(org, recordedIds, platformReadiness.authReady, issuer.step !== null, options.loginHostBindings ?? false);
225
220
  if (loginHost.step)
226
221
  plannedSteps.push(loginHost.step);
227
222
  // #444a: the org's one shared Postgres server and its fixed schema
@@ -233,6 +228,10 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
233
228
  // is pushed just below, before mail.
234
229
  const zone = planZone(org, byKey, claimed);
235
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);
236
235
  const zoneBlocked = [];
237
236
  // #555: steps that write DNS records, which go through edge.
238
237
  const dnsBlocked = [];
@@ -251,14 +250,16 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
251
250
  org,
252
251
  recordedIds,
253
252
  readiness: platformReadiness,
254
- isPlatformOrg,
255
253
  ownServices: options.ownServices ?? false,
256
254
  mailRelayApplied: options.mailRelayApplied ?? false,
257
- zoneActive: zone.step === null && !zoneWaiting,
255
+ zoneActive: ownZoneActive,
256
+ ...(options.ownMailSmtpPort !== undefined ? { smtpPort: options.ownMailSmtpPort } : {}),
258
257
  });
259
258
  if (ownMail.step)
260
259
  plannedSteps.push(ownMail.step);
261
- const mail = planMail(org, byKey, claimed, recordedIds, platformReadiness.mailHost, platformReadiness.platformApex, platformReadiness.platformLettermintProjectId);
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);
262
263
  // #238: own-domain mail publishes DNS records into the zone, so it waits for
263
264
  // an active zone -- blocked, not planned (rule 2).
264
265
  if (mail.step && zoneWaiting && org.mail.usesDomain && org.domain) {
@@ -277,19 +278,19 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
277
278
  const mailCopy = planMailCopy(org, byKey, recordedIds);
278
279
  if (mailCopy)
279
280
  plannedSteps.push(mailCopy);
281
+ const versionPin = org.versionPin;
280
282
  const chat = planChat(org, byKey, claimed, recordedIds, agoraHost, issuer.step !== null);
281
283
  if (chat.step)
282
284
  plannedSteps.push(chat.step);
283
- const calls = planCalls(org, byKey, claimed, platformReadiness.callsReady);
285
+ const calls = planCalls(org, byKey, claimed, platformReadiness.callsReady, versionPin?.calls);
284
286
  if (calls.step)
285
287
  plannedSteps.push(calls.step);
286
288
  // An own org's media server, before the calls service that reads its addresses.
287
289
  const ownSfu = planOwnSfu({
288
290
  org,
289
291
  recordedIds,
290
- isPlatformOrg,
291
292
  ownServices: options.ownServices ?? false,
292
- zoneActive: zone.step === null && !zoneWaiting,
293
+ zoneActive: ownZoneActive,
293
294
  readiness: platformReadiness,
294
295
  sfuPortsOpen: options.sfuPortsOpen ?? false,
295
296
  keysHeld: options.ownSfuKeysHeld ?? false,
@@ -320,7 +321,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
320
321
  const versionChanges = [];
321
322
  if (org.calls.enabled && !calls.blocked) {
322
323
  const callsAppLive = Boolean(byKey.get(resourceKey('coolify', 'calls', org.id)));
323
- 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);
324
325
  if (synapseChange)
325
326
  versionChanges.push(synapseChange);
326
327
  const masChange = versionNotAppliedLine('mas', callsAppLive, recordedIds[CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY], VERSIONS.mas);
@@ -332,7 +333,6 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
332
333
  org,
333
334
  recordedIds,
334
335
  readiness: platformReadiness,
335
- isPlatformOrg,
336
336
  ownServices: options.ownServices ?? false,
337
337
  filesDeps: options.ownFilesDeps ?? { keys: false, bucket: false },
338
338
  });
@@ -343,14 +343,13 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
343
343
  ? planOwnFilesR2({
344
344
  org,
345
345
  recordedIds,
346
- isPlatformOrg,
347
346
  ownServices: options.ownServices ?? false,
348
347
  ports: options.ownFilesR2Ports,
349
348
  })
350
349
  : { step: null, blocked: null };
351
350
  if (ownFilesR2.step)
352
351
  plannedSteps.push(ownFilesR2.step);
353
- const filesOrganisation = planFilesOrganisation(org, byKey, claimed, platformReadiness.filesReady, platformReadiness.missingKeys.files, platformReadiness.noKeyReader, ownsServices(org) && !isPlatformOrg && !recordedIds[`coolify:own-files:${org.id}`]);
352
+ const filesOrganisation = planFilesOrganisation(org, byKey, claimed, platformReadiness.filesReady, platformReadiness.missingKeys.files, platformReadiness.noKeyReader, ownsServices(org) && !recordedIds[`coolify:own-files:${org.id}`]);
354
353
  if (filesOrganisation.step)
355
354
  plannedSteps.push(filesOrganisation.step);
356
355
  const aiOrganisation = planAiOrganisation(org, byKey, claimed, platformReadiness.aiReady, platformReadiness.missingKeys.ai, platformReadiness.noKeyReader);
@@ -441,7 +440,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
441
440
  const edgeReservedLabels = planEdgeReservedLabels(org, recordedIds, platformReadiness, options.platformHostnames ?? []);
442
441
  if (edgeReservedLabels.step)
443
442
  plannedSteps.push(edgeReservedLabels.step);
444
- const tunnel = planTunnel(org, byKey, claimed, recordedIds, isPlatformOrg, platformReadiness.platformApex);
443
+ const tunnel = planTunnel(org, byKey, claimed, recordedIds, platformReadiness.platformApex);
445
444
  // #238: a tunnel that is being provisioned waits for an active zone; parking
446
445
  // one never does.
447
446
  // #555: provisioning writes the tunnel config, the zone lookup and DNS; a
@@ -492,7 +491,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
492
491
  const agoraGuarded = guardApp(agoraClient);
493
492
  if (agoraGuarded)
494
493
  plannedSteps.push(agoraGuarded);
495
- const agora = planAgora(org, byKey, claimed, recordedIds, agoraHost, isPlatformOrg, {
494
+ const agora = planAgora(org, byKey, claimed, recordedIds, agoraHost, {
496
495
  servesDiscovery: options.loginHostServesDiscovery ?? false,
497
496
  });
498
497
  if (agora.step)
@@ -521,7 +520,6 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
521
520
  org,
522
521
  recordedIds,
523
522
  readiness: platformReadiness,
524
- isPlatformOrg,
525
523
  ownServices: options.ownServices ?? false,
526
524
  tokenHeld: options.ownJobsTokenHeld ?? false,
527
525
  });
@@ -572,6 +570,11 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
572
570
  const recordedValues = new Set(Object.values(recordedIds));
573
571
  for (const own of ['', org.id, org.slug, org.domain ?? ''])
574
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
+ }
575
578
  const platformNames = new Set([...(options.platformHostnames ?? []), platformReadiness.mailHost?.mailHost ?? ''].map((h) => h.toLowerCase()));
576
579
  platformNames.delete('');
577
580
  const unmanaged = live
@@ -691,11 +694,28 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
691
694
  unmanaged.push(...app.blocked);
692
695
  unmanaged.push(...brokerEntries.blocked);
693
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
+ }
694
712
  return {
695
713
  orgId: org.id,
696
- plannedSteps: (ownSignInPending
697
- ? plannedSteps.filter((s) => !NEEDS_OWN_ZITADEL.has(`${s.action.system}:${s.action.kind}`))
698
- : 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),
699
719
  unmanaged,
700
720
  versionChanges,
701
721
  orgPostgres,
@@ -787,7 +807,7 @@ function edgeBlockedItem(org, what, key, reason) {
787
807
  * already exists live is unaffected: its own domain-move logic runs
788
808
  * regardless, since that never re-provisions the ZITADEL org itself.
789
809
  */
790
- function planIssuer(org, byKey, claimed, authReady, isPlatformOrg = false) {
810
+ function planIssuer(org, byKey, claimed, authReady) {
791
811
  const key = resourceKey('zitadel', 'issuer', org.id);
792
812
  const live = byKey.get(key);
793
813
  const desiredUsesDomain = org.issuer.usesDomain;
@@ -825,10 +845,8 @@ function planIssuer(org, byKey, claimed, authReady, isPlatformOrg = false) {
825
845
  Boolean(org.domain) &&
826
846
  liveHost !== null &&
827
847
  liveHost !== loginHostnameFor(org);
828
- // The platform's own org already signs in on the shared host: no move. A
829
- // live issuer that does not say is not read as a different one either.
830
- if (isPlatformOrg ||
831
- 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 ||
832
850
  (Boolean(live.config.usesDomain) === desiredUsesDomain && !domainMoved)) {
833
851
  return { step: null, blocked: null };
834
852
  }
@@ -1011,27 +1029,10 @@ function planOwners(org, recordedIds, authReady, authIssuer, ownerInviteMail, re
1011
1029
  });
1012
1030
  return { steps, blocked };
1013
1031
  }
1014
- /**
1015
- * #488: the bare hostname out of an issuer URL (`https://<hostname>`, the
1016
- * only shape {@link agoraHostValuesFromPlatform} or a host-supplied
1017
- * `AgoraHostPort` ever hands back), or `null` when there is no issuer to
1018
- * read one from. What {@link loginHostnameFor} compares the org's own
1019
- * computed `auth.<domain>` against, so the platform's own org never plans
1020
- * its login host equal to the shared ZITADEL instance's own.
1021
- */
1032
+ /** True when `org` is the platform's owner company. */
1022
1033
  export function isPlatformOwnOrg(org, platformOwnerOrgId) {
1023
1034
  return Boolean(platformOwnerOrgId && org.id === platformOwnerOrgId);
1024
1035
  }
1025
- function platformAuthHostnameFrom(issuer) {
1026
- if (!issuer)
1027
- return null;
1028
- try {
1029
- return new URL(issuer).hostname;
1030
- }
1031
- catch {
1032
- return null;
1033
- }
1034
- }
1035
1036
  /**
1036
1037
  * #267: the org's own company login host (`login-host.ts`'s
1037
1038
  * `loginHostnameFor`) -- `auth.<domain>` once the org has one,
@@ -1068,54 +1069,24 @@ function platformAuthHostnameFrom(issuer) {
1068
1069
  * platform's own domain, never plans the shared ZITADEL instance's own login
1069
1070
  * host as its own -- `auth.companyincluded.com` colliding with itself.
1070
1071
  */
1071
- function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired, platformAuthHostname, isPlatformOrg) {
1072
+ function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired) {
1072
1073
  const none = { step: null, blocked: null };
1073
1074
  // An own org's instance has no shared login host to bind: its issuer is the sign-in service's own.
1074
- if (ownsServices(org) && !isPlatformOrg)
1075
+ if (ownsServices(org))
1075
1076
  return none;
1076
1077
  const key = resourceKey('zitadel', 'login-host', org.id);
1077
- // The platform's own org signs in on the shared instance's host whatever
1078
- // `issuer.usesDomain` says (turning it on would move the issuer).
1079
- // Before that host is known, plan nothing: a guessed host would delete the
1080
- // working one, and a normal step would trust a domain `destroy` later deletes.
1081
- if (isPlatformOrg && platformAuthHostname === null) {
1082
- return {
1083
- step: null,
1084
- blocked: `${org.slug}'s login host is waiting for the platform auth host`,
1085
- };
1086
- }
1087
- const sharedInstanceHost = isPlatformOrg && platformAuthHostname !== null;
1088
- const host = orgLoginHost(org, sharedInstanceHost ? platformAuthHostname : null);
1089
- // #488: the guard in `loginHostnameFor` fired -- the org's own domain host
1090
- // would have been the platform's shared ZITADEL instance host.
1091
- if (sharedInstanceHost) {
1092
- // The host is the shared instance's own: nothing to trust or bind, and an
1093
- // org with no recorded host already signs in there. Only an org recorded
1094
- // on another host (the old `<slug>-auth.oikos.host`) plans one step, to
1095
- // move its apps over and remove the old host; then it plans nothing.
1096
- const recordedHost = recordedIds[key];
1097
- const moving = recordedHost !== undefined && recordedHost !== host;
1098
- const removalOwed = pendingRemovalHosts(recordedIds).length > 0;
1099
- if (!authReady || !(moving || removalOwed))
1100
- return none;
1101
- // The same gates as the normal branch: the executor's shared step also
1102
- // needs the binding port and the ZITADEL org.
1103
- const waiting = loginHostWaiting(org, host, recordedIds, bindingsWired, issuerPlanned);
1104
- return waiting ?? stepFor(true);
1105
- }
1106
- function stepFor(shared) {
1078
+ const host = loginHostnameFor(org);
1079
+ function stepFor() {
1107
1080
  return {
1108
1081
  step: {
1109
1082
  action: {
1110
1083
  system: 'zitadel',
1111
1084
  kind: 'login-host',
1112
1085
  key: org.id,
1113
- desired: shared ? { host, shared: true } : { host },
1086
+ desired: { host },
1114
1087
  },
1115
1088
  step: {
1116
- description: shared
1117
- ? `move ${org.slug}'s apps onto the platform's own sign-in host ${host}`
1118
- : `set ${org.slug}'s login host to ${host}`,
1089
+ description: `set ${org.slug}'s login host to ${host}`,
1119
1090
  destroys: false,
1120
1091
  migrates: false,
1121
1092
  },
@@ -1143,7 +1114,7 @@ function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired
1143
1114
  // Isolation between orgs rests on zitadel-login's binding, and repointing
1144
1115
  // an app at a host with no binding breaks its sign-in: trust nothing and
1145
1116
  // repoint nothing until the port that writes the binding is wired.
1146
- return loginHostWaiting(org, host, recordedIds, bindingsWired, issuerPlanned) ?? stepFor(false);
1117
+ return loginHostWaiting(org, host, recordedIds, bindingsWired, issuerPlanned) ?? stepFor();
1147
1118
  }
1148
1119
  /**
1149
1120
  * Every sign-in app this org already has live (web or native, not api) gets
@@ -1273,7 +1244,9 @@ export const JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY = 'jobs_registration_undeli
1273
1244
  */
1274
1245
  function planMail(org, byKey, claimed, recordedIds, mailHost,
1275
1246
  /** #491 (review round 2): read off `platformReadiness` directly, not off `mailHost` -- see `OrgPlatformReadiness.platformApex`'s own doc comment for why. */
1276
- 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) {
1277
1250
  const key = resourceKey('coolify', 'mail', org.id);
1278
1251
  const live = byKey.get(key);
1279
1252
  // `off` means mail is not foundry's concern here (org.ts's MailMode doc);
@@ -1289,7 +1262,14 @@ platformApex, platformLettermintProjectId) {
1289
1262
  if (!mailHost) {
1290
1263
  return {
1291
1264
  step: null,
1292
- 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}`,
1293
1273
  mailboxBlocked: null,
1294
1274
  };
1295
1275
  }
@@ -1637,14 +1617,14 @@ function planChat(org, byKey, claimed, recordedIds, reading, issuerPlanned) {
1637
1617
  * `sfu:livekit` step is declared. Parking, and an already-provisioned stack,
1638
1618
  * are unaffected.
1639
1619
  */
1640
- function planCalls(org, byKey, claimed, callsReady) {
1620
+ function planCalls(org, byKey, claimed, callsReady, synapseTag) {
1641
1621
  const key = resourceKey('coolify', 'calls', org.id);
1642
1622
  if (org.calls.enabled && !byKey.get(key) && !callsReady) {
1643
1623
  return {
1644
1624
  step: null,
1645
1625
  blocked: ownsServices(org)
1646
1626
  ? `${org.slug}'s calls Matrix stack is blocked, not planned: its own media server (coolify:own-sfu) is not applied yet`
1647
- : `${org.slug}'s calls Matrix stack is blocked, not planned: the platform's shared calls SFU is off`,
1627
+ : `${org.slug}'s calls Matrix stack is blocked, not planned: the platform owner's calls SFU (the platform's) is off`,
1648
1628
  };
1649
1629
  }
1650
1630
  // #248: the two images this stack's own apply would run, checked the same
@@ -1652,7 +1632,7 @@ function planCalls(org, byKey, claimed, callsReady) {
1652
1632
  // own tag -- a re-apply of the same declaration must not silently run a
1653
1633
  // different version with no diff shown anywhere.
1654
1634
  if (org.calls.enabled && !byKey.get(key)) {
1655
- const synapseBlocked = floatingTagBlocked('Synapse', VERSIONS.synapse, 'v1.161.0');
1635
+ const synapseBlocked = floatingTagBlocked('Synapse', synapseTag ?? VERSIONS.synapse, 'v1.161.0');
1656
1636
  if (synapseBlocked) {
1657
1637
  return {
1658
1638
  step: null,
@@ -1667,10 +1647,12 @@ function planCalls(org, byKey, claimed, callsReady) {
1667
1647
  };
1668
1648
  }
1669
1649
  }
1670
- return {
1671
- 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)`),
1672
- blocked: null,
1673
- };
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 };
1674
1656
  }
1675
1657
  /**
1676
1658
  * The org's own calls service (#145, part 2 of #128) -- `wtfalch/calls`'s
@@ -2314,12 +2296,12 @@ function planAgoraClient(org, byKey, claimed) {
2314
2296
  * message naming each missing value, which `computePlanDetail` lists with the
2315
2297
  * plan's non-step items, so every other step still applies.
2316
2298
  */
2317
- function planAgora(org, byKey, claimed, recordedIds, reading, isPlatformOrg, issuerSwitch) {
2299
+ function planAgora(org, byKey, claimed, recordedIds, reading, issuerSwitch) {
2318
2300
  const key = resourceKey('coolify', 'agora', org.id);
2319
2301
  const live = byKey.get(key);
2320
2302
  if (live)
2321
2303
  claimed.add(key);
2322
- const ownSignIn = ownsServices(org) && !isPlatformOrg;
2304
+ const ownSignIn = ownsServices(org);
2323
2305
  if (ownSignIn && !ownAuthApplied(org, recordedIds)) {
2324
2306
  return {
2325
2307
  step: null,
@@ -2329,8 +2311,8 @@ function planAgora(org, byKey, claimed, recordedIds, reading, isPlatformOrg, iss
2329
2311
  }
2330
2312
  // The files host records its internal URL only while it applies, and Agora's
2331
2313
  // env takes its files upstream from it: planned in the same pass, the first
2332
- // deploy would start without one. The company's files host exists only with a domain.
2333
- if (ownSignIn && org.domain && !recordedIds[PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY]) {
2314
+ // deploy would start without one.
2315
+ if (ownSignIn && !recordedIds[PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY]) {
2334
2316
  return {
2335
2317
  step: null,
2336
2318
  blocked: `${org.slug}'s Agora is blocked, not planned: its own files host is not applied yet`,
@@ -2360,7 +2342,7 @@ function planAgora(org, byKey, claimed, recordedIds, reading, isPlatformOrg, iss
2360
2342
  // stays applied: a later failing read never flips it back (no oscillation).
2361
2343
  let host = platformHost;
2362
2344
  let issuerNote = null;
2363
- const choice = agoraIssuerHostFor(org, isPlatformOrg);
2345
+ const choice = agoraIssuerHostFor(org);
2364
2346
  if (choice.mismatch) {
2365
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`;
2366
2348
  }
@@ -2717,7 +2699,6 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
2717
2699
  const blockedKey = (rest) => entryKey === APP_ENTRY_KEY ? `coolify:app:${rest}` : `coolify:app:${entryKey}:${rest}`;
2718
2700
  const { marker: wantedEnv, unresolved, unresolvedSecrets, } = entry
2719
2701
  ? resolveAppEnv(org, entry, recordedIds, {
2720
- platformMailKey: readiness.mailKeyHeld === true,
2721
2702
  sharedSecret: readiness.canHoldSecrets === true,
2722
2703
  })
2723
2704
  : { marker: {}, unresolved: [], unresolvedSecrets: [] };
@@ -2733,9 +2714,7 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
2733
2714
  .concat(
2734
2715
  // Never a value: the ref of the missing key, or what the host lacks.
2735
2716
  unresolvedSecrets.map(([name, source]) => ({
2736
- description: `${org.slug}'s ${label} env ${name} is blocked, not planned: ${source === 'platformMailKey'
2737
- ? `the platform's Lettermint key is not held yet (held-key ref ${PLATFORM_AUTH_BROKER_MAIL_KEY_REF})`
2738
- : `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`}`,
2739
2718
  key: blockedKey(`env:${name}`),
2740
2719
  })))
2741
2720
  : [];
@@ -2775,7 +2754,18 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
2775
2754
  key: blockedKey('repo'),
2776
2755
  });
2777
2756
  }
2778
- 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);
2779
2769
  // #447: only the live app's own `git_repository` decides, never the
2780
2770
  // declared entry (a `repo: null` entry can sit over a bespoke app, which is
2781
2771
  // parked as before). Only the `app` key ever held the default app, and an
@@ -2807,7 +2797,7 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
2807
2797
  }
2808
2798
  if (toggled)
2809
2799
  return { step: toggled, blocked };
2810
- if (!entry || noRepo)
2800
+ if (!entry || refused)
2811
2801
  return { step: null, blocked };
2812
2802
  const live = byKey.get(resourceKey('coolify', 'app', stepKey));
2813
2803
  if (!live)
@@ -2922,15 +2912,14 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
2922
2912
  blocked,
2923
2913
  };
2924
2914
  }
2925
- // A changed hostname reaches an app that is already live: its domains are
2926
- // patched and it is redeployed.
2927
- if (JSON.stringify(tunnelDomainList(entryHostnames(org, entry))) !==
2928
- 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) {
2929
2918
  return {
2930
2919
  step: {
2931
2920
  action,
2932
2921
  step: {
2933
- 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`,
2934
2923
  destroys: false,
2935
2924
  migrates: false,
2936
2925
  },
@@ -2938,17 +2927,16 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
2938
2927
  blocked,
2939
2928
  };
2940
2929
  }
2941
- // A `*` entry's proxy labels reach an app that is already live: the label
2942
- // set is patched and the app redeployed once its hash differs from the one
2943
- // `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.
2944
2933
  const appliedLabels = recordedIds[appLabelsAppliedScopeKey(entryKey)];
2945
- const labelSpec = entryLabelSpec(org, entry, appliedLabels !== undefined);
2946
- if (labelSpec && entryLabelHash(labelSpec) !== appliedLabels) {
2934
+ if (entryLabelHash(entryLabelSpec(org, entry)) !== appliedLabels) {
2947
2935
  return {
2948
2936
  step: {
2949
2937
  action,
2950
2938
  step: {
2951
- 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`,
2952
2940
  destroys: false,
2953
2941
  migrates: false,
2954
2942
  },
@@ -3014,12 +3002,11 @@ function planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer, re
3014
3002
  // The same availability the plan's marker used: the executor records its
3015
3003
  // marker from this, never from its own port, so the two cannot disagree.
3016
3004
  const usesSecrets = org.hosting.entries.some((e) => e.key === key &&
3017
- 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'));
3018
3006
  if (usesSecrets && app.step && app.step.action.desired.enabled !== false) {
3019
3007
  app.step.action.desired = {
3020
3008
  ...app.step.action.desired,
3021
3009
  secretSources: {
3022
- platformMailKey: readiness.mailKeyHeld === true,
3023
3010
  sharedSecret: readiness.canHoldSecrets === true,
3024
3011
  },
3025
3012
  };
@@ -3184,11 +3171,13 @@ function sortedNames(names) {
3184
3171
  * domain) plans no per-org tunnel for Agora: `<slug>-agora.oikos.host` rides
3185
3172
  * the shared `*.oikos.host` wildcard tunnel (`scripts/shared-ingress.mjs`).
3186
3173
  */
3187
- function planTunnel(org, byKey, claimed, recordedIds, isPlatformOrg, platformApex) {
3174
+ function planTunnel(org, byKey, claimed, recordedIds, platformApex) {
3188
3175
  const desired = (org.hosting.tunnel && org.hosting.usesDomain && Boolean(org.domain)) ||
3189
3176
  (org.site.enabled && Boolean(org.domain)) ||
3190
3177
  (org.calls.enabled && org.calls.usesDomain && Boolean(org.domain)) ||
3191
- 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;
3192
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,
3193
3182
  // Cloudflare's status, read against what is declared: a park cannot change
3194
3183
  // it, so judging on/off by status alone would never let the plan empty.
@@ -3204,7 +3193,7 @@ function planTunnel(org, byKey, claimed, recordedIds, isPlatformOrg, platformApe
3204
3193
  // #568: the company's own login host is routed by the step, so a first
3205
3194
  // provision carries it too; a park does not. Without a known platform apex
3206
3195
  // the platform's own org cannot be told apart, so none is planned.
3207
- const loginHost = platformApex ? companyLoginTunnelHost(org, isPlatformOrg) : null;
3196
+ const loginHost = platformApex ? companyLoginTunnelHost(org) : null;
3208
3197
  if (toggled && toggled.action.desired.enabled !== false && loginHost) {
3209
3198
  return {
3210
3199
  ...toggled,
@@ -3236,8 +3225,17 @@ function planTunnel(org, byKey, claimed, recordedIds, isPlatformOrg, platformApe
3236
3225
  org.domain !== null &&
3237
3226
  sortedNames(published.split(',')) !==
3238
3227
  sortedNames(tunnelDnsHostnames(org, org.domain, loginHost));
3239
- const dnsStale = recordedIds[CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY] !== org.domain || edgeChanged;
3240
- 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)
3241
3239
  return null;
3242
3240
  return {
3243
3241
  action: {
@@ -3249,7 +3247,9 @@ function planTunnel(org, byKey, claimed, recordedIds, isPlatformOrg, platformApe
3249
3247
  step: {
3250
3248
  description: connectorMissing
3251
3249
  ? `run a cloudflared connector for ${org.slug}'s Cloudflare tunnel`
3252
- : `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`,
3253
3253
  destroys: false,
3254
3254
  migrates: false,
3255
3255
  },
@@ -10,6 +10,10 @@ export interface AccessAppSummary {
10
10
  id: string;
11
11
  name: string;
12
12
  domain: string;
13
+ /** The identity providers the app accepts; `[]` means Cloudflare lists none (any provider). */
14
+ allowedIdps: string[];
15
+ /** Whether a visitor goes straight to the one provider. */
16
+ autoRedirectToIdentity: boolean;
13
17
  }
14
18
  /**
15
19
  * Foundry's Cloudflare Access calls. Every Access call in foundry goes through
@@ -19,13 +23,24 @@ export interface AccessAppSummary {
19
23
  export interface AccessPort {
20
24
  /** Every live Access app foundry may use: the platform's own, and ones nobody adopted yet. */
21
25
  listAccessApps(): Promise<AccessAppSummary[]>;
26
+ /**
27
+ * Edge keeps `allowedIdps` of an app that already exists: a repeat create
28
+ * only reports the live value, so a caller compares it from
29
+ * {@link listAccessApps} and rebuilds the app on a difference.
30
+ */
22
31
  createAccessApp(app: {
23
32
  name: string;
24
33
  domain: string;
25
34
  type: 'self_hosted';
35
+ allowedIdps?: string[];
36
+ autoRedirectToIdentity?: boolean;
26
37
  }): Promise<{
27
38
  id: string;
28
39
  }>;
40
+ /** The account's one-time-PIN identity provider (edge 0.13.0 finds or makes it). Safe to repeat. */
41
+ ensureOneTimePinProvider(): Promise<{
42
+ id: string;
43
+ }>;
29
44
  listAccessPolicies(appId: string): Promise<Array<{
30
45
  id: string;
31
46
  name: string;