@wtfalch/foundry 0.45.1 → 0.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/index.d.ts +4 -2
  2. package/dist/index.js +2 -1
  3. package/dist/org.d.ts +37 -0
  4. package/dist/org.js +135 -10
  5. package/dist/plan/agora.d.ts +14 -0
  6. package/dist/plan/agora.js +21 -0
  7. package/dist/plan/backup-gate.d.ts +15 -0
  8. package/dist/plan/backup-gate.js +38 -0
  9. package/dist/plan/boule-owners-http.d.ts +12 -0
  10. package/dist/plan/boule-owners-http.js +59 -0
  11. package/dist/plan/boule.d.ts +38 -2
  12. package/dist/plan/boule.js +72 -1
  13. package/dist/plan/company-backups.d.ts +7 -1
  14. package/dist/plan/company-backups.js +42 -2
  15. package/dist/plan/cookie-domain.d.ts +41 -0
  16. package/dist/plan/cookie-domain.js +71 -0
  17. package/dist/plan/desktop-auth.d.ts +58 -0
  18. package/dist/plan/desktop-auth.js +144 -0
  19. package/dist/plan/desktop-callback.d.ts +26 -0
  20. package/dist/plan/desktop-callback.js +59 -0
  21. package/dist/plan/diff.d.ts +13 -4
  22. package/dist/plan/diff.js +272 -84
  23. package/dist/plan/executor.d.ts +136 -6
  24. package/dist/plan/executor.js +725 -98
  25. package/dist/plan/health-gaps.js +5 -0
  26. package/dist/plan/index.d.ts +3 -0
  27. package/dist/plan/index.js +2 -0
  28. package/dist/plan/job-app.d.ts +23 -0
  29. package/dist/plan/job-app.js +41 -0
  30. package/dist/plan/live.d.ts +6 -0
  31. package/dist/plan/live.js +16 -0
  32. package/dist/plan/login-host.d.ts +28 -0
  33. package/dist/plan/login-host.js +36 -0
  34. package/dist/plan/org-names.js +9 -1
  35. package/dist/plan/org-postgres-job.d.ts +22 -0
  36. package/dist/plan/org-postgres-job.js +91 -0
  37. package/dist/plan/org-postgres-migrate.d.ts +115 -0
  38. package/dist/plan/org-postgres-migrate.js +466 -0
  39. package/dist/plan/org-postgres.d.ts +90 -0
  40. package/dist/plan/org-postgres.js +293 -0
  41. package/dist/plan/plan-apply.d.ts +30 -2
  42. package/dist/plan/plan-apply.js +104 -7
  43. package/dist/plan/platform-diff.d.ts +45 -0
  44. package/dist/plan/platform-diff.js +159 -4
  45. package/dist/plan/platform-org.d.ts +2 -0
  46. package/dist/plan/platform-org.js +4 -0
  47. package/dist/plan/platform-providers.d.ts +12 -2
  48. package/dist/plan/platform-providers.js +279 -9
  49. package/dist/plan/post-logout-uris.d.ts +22 -0
  50. package/dist/plan/post-logout-uris.js +43 -0
  51. package/dist/plan/providers-readers.d.ts +10 -1
  52. package/dist/plan/providers-readers.js +41 -1
  53. package/dist/plan/sign-in-bindings.d.ts +4 -0
  54. package/dist/plan/sign-in-bindings.js +10 -0
  55. package/dist/plan/sign-in-org.d.ts +19 -6
  56. package/dist/plan/sign-in-org.js +35 -10
  57. package/dist/plan/sign-in.d.ts +12 -5
  58. package/dist/plan/sign-in.js +12 -5
  59. package/dist/plan/signin-db-move.js +10 -34
  60. package/package.json +5 -5
package/dist/plan/diff.js CHANGED
@@ -1,30 +1,37 @@
1
1
  import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
2
- import { APP_ENTRY_KEY, ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, heldEnvRef, ownerMailboxRecorded, ownerMailboxes, ownsServices, } from '../org.js';
2
+ import { APP_ENTRY_KEY, ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, heldEnvRef, ownerMailboxRecorded, ownerMailboxes, ownerNameProblem, ownsServices, } from '../org.js';
3
3
  import { entryHasDatabase } from '../worker-app-config.js';
4
4
  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';
5
5
  import { planBlueGreen } from './blue-green.js';
6
- import { BOULE_ARCHON_CREDENTIAL_ENV, BOULE_BRIDGE_SECRET_ENV, BOULE_BROKER_ENTRY_KEY, BOULE_CUSTOMERS_CREDENTIAL_ENV, BOULE_MAIL_TOKEN_ENV, BOULE_PEER_PASSWORD_ENV, BOULE_SECRETS_RESTARTED_SCOPE_KEY, CUSTOMERS_SIGN_IN_SCOPE_KEY, CUSTOMERS_SIGN_UP_SCOPE_KEY, archonCredentialRenewDue, bouleEnvDrift, bouleMailTokenDrift, bouleMailWanted, customersOrgIdOf, desiredBouleEnv, staleCustomersEnv, wantedBouleBrokerEntry, } from './boule.js';
6
+ import { BOULE_ARCHON_CREDENTIAL_ENV, BOULE_BRIDGE_SECRET_ENV, BOULE_BROKER_ENTRY_KEY, BOULE_CUSTOMERS_CREDENTIAL_ENV, BOULE_MAIL_TOKEN_ENV, BOULE_OWNER_SEED_SECRET_ENV, BOULE_PEER_PASSWORD_ENV, BOULE_SECRETS_RESTARTED_SCOPE_KEY, CUSTOMERS_SIGN_IN_SCOPE_KEY, CUSTOMERS_SIGN_UP_SCOPE_KEY, archonCredentialRenewDue, bouleEnvDrift, bouleMailTokenDrift, bouleMailWanted, customersOrgIdOf, desiredBouleEnv, planBouleOwners, staleCustomersEnv, wantedBouleBrokerEntry, } from './boule.js';
7
7
  import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, } from './broker-entries.js';
8
8
  import { chatInternalNames, chatUpstreams } from './chat-matrix.js';
9
9
  import { planCompanyBackups } from './company-backups.js';
10
+ import { COOKIE_DOMAIN_WAITING_REASONS, LOGIN_COOKIE_DOMAIN_SCOPE_KEY, MIN_AUTH_FOR_CLIENT_ORGS, authAtLeast, cookieDomainAllowed, cookieDomainWaitingLine, } from './cookie-domain.js';
11
+ import { DESKTOP_AUTH_HOME, planDesktopAuth } from './desktop-auth.js';
12
+ import { planDesktopCallbacks } from './desktop-callback.js';
10
13
  import { onPlatformNetwork } from './destinations.js';
11
14
  import { planEntryAiKeys } from './entry-ai-key.js';
12
15
  import { planEdgeReservedLabels, planEntryEdgeKeys } from './entry-edge-key.js';
13
16
  import { planFilesKeys } from './entry-files-key.js';
14
17
  import { hasWildcardGate, planGates } from './gates.js';
15
18
  import { appHostname, tunnelDnsHostnames } from './hostnames.js';
19
+ import { APP_GONE_KIND } from './live.js';
16
20
  import { lockedHostnames } from './locked-hostnames.js';
17
- import { LOGIN_BINDING_SCOPE_KEY, agoraIssuerHostFor, companyLoginTunnelHost, inAppApps, loginBindingState, loginHostClientIds, loginHostServesAgora, loginHostnameFor, ownAuthApplied, pendingRemovalHosts, } from './login-host.js';
21
+ import { LOGIN_BINDING_SCOPE_KEY, LOGIN_CLIENT_ORGS_SCOPE_KEY, agoraIssuerHostFor, clientOrganizationsState, companyLoginTunnelHost, customersClientOrganizations, inAppApps, loginBindingState, loginHostClientIds, loginHostServesAgora, loginHostnameFor, ownAuthApplied, pendingRemovalHosts, } from './login-host.js';
18
22
  import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, } from './mail-copy.js';
19
23
  import { mailStoreSwitchChange } from './mail-store.js';
20
24
  import { planMcp } from './mcp.js';
21
25
  import { hasAdoptedResources, networkMoveNeeded, planNetworkMove } from './network-move.js';
26
+ import { planOrgPostgresMigration } from './org-postgres-migrate.js';
27
+ import { legacyUnmigrated, orgPostgresUndeliveredScopeKey, planOrgPostgresSteps, serviceOnServer, } from './org-postgres.js';
22
28
  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, OWN_SIGNIN_DB_MOVE_BLOCKED_KEY, planOwnAuth, planOwnFiles, planOwnFilesR2, planOwnJobs, planOwnMail, planOwnSfu, planSigninDbMove, } from './own-diff.js';
23
29
  import { ownDnsHostnames } from './own-dns.js';
24
30
  import { ownHostnames } from './own-hostnames.js';
25
31
  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, appSecretAvailability, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, versionNotAppliedLine, zitadelAppIdTokenUserinfoAppliedScopeKey, } from './platform-diff.js';
26
32
  import { DEFAULT_ORG_PLATFORM_READINESS, } from './platform-org.js';
27
33
  import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
34
+ import { companyAppPostLogoutUris, postLogoutUpdateOwed, zitadelAppPostLogoutAppliedScopeKey, } from './post-logout-uris.js';
28
35
  import { CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY, appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec, entryOikosHostnames, entryRoutes, entryRoutesMarker, } from './proxy-labels.js';
29
36
  import { planRemoteDatabase } from './remote-database.js';
30
37
  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';
@@ -234,14 +241,24 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
234
241
  // #267: after the ZITADEL org step, before any app's own issuer
235
242
  // application. Apps created later in this run are not in the binding yet;
236
243
  // the next run replans this step and repoints them.
237
- const loginHost = planLoginHost(org, recordedIds, platformReadiness.authReady, issuer.step !== null, options.loginHostBindings ?? false, platformReadiness.signInLoginReady ?? false);
244
+ const loginHost = planLoginHost(org, recordedIds, {
245
+ authReady: platformReadiness.authReady,
246
+ issuerPlanned: issuer.step !== null,
247
+ bindingsWired: options.loginHostBindings ?? false,
248
+ signInLoginReady: platformReadiness.signInLoginReady ?? false,
249
+ gates: {
250
+ authGitRef: platformReadiness.serviceGitRefs?.auth ?? '',
251
+ bindKeyReady: platformReadiness.signInBindKeyReady ?? false,
252
+ clientOrganizationsPort: options.loginHostClientOrganizations ?? false,
253
+ },
254
+ });
238
255
  if (loginHost.step)
239
256
  plannedSteps.push(loginHost.step);
240
- // #444a: the org's one shared Postgres server and its fixed schema
241
- // topology -- dependent on nothing else planned here, but its own
242
- // `OrgPostgresPlan`, never `plannedSteps` ({@link OrgPostgresPlan}'s own
243
- // doc comment says why).
257
+ // #444: the org's one shared Postgres server and its databases. They need
258
+ // nothing else planned here; the report stays on `OrgPostgresPlan` too.
244
259
  const orgPostgres = planOrgPostgres(org, recordedIds);
260
+ const orgPostgresSteps = planOrgPostgresSteps(org, orgPostgres);
261
+ const orgPostgresMigration = planOrgPostgresMigration(org, recordedIds, orgPostgres.legacyUnmigrated);
245
262
  // Planned ahead of everything that needs the zone live (#238); its own step
246
263
  // is pushed just below, before mail.
247
264
  const zone = planZone(org, byKey, claimed);
@@ -254,7 +271,8 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
254
271
  // #555: steps that write DNS records, which go through edge.
255
272
  const dnsBlocked = [];
256
273
  // #553: a ZITADEL app provision whose app id is recorded needs the typed confirmation.
257
- const guardApp = (step) => guardAppProvision(step, org, recordedIds);
274
+ const appBlocked = [];
275
+ const guardApp = (step) => guardAppProvision(step, org, recordedIds, byKey, claimed, appBlocked);
258
276
  // #567: before the first step that reads the zone (mail, the tunnel, the
259
277
  // rate limit), so a domain with no zone yet gets it provisioned first.
260
278
  const zoneReason = zone.step ? edgeNeeded(platformReadiness, ['zones']) : null;
@@ -444,7 +462,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
444
462
  // string into the app's environment once the database exists -- it can
445
463
  // only do that if the database step already ran and recorded the
446
464
  // database's resource id.
447
- const database = planDatabase(org, byKey, claimed);
465
+ const database = planDatabase(org, byKey, claimed, recordedIds);
448
466
  if (database)
449
467
  plannedSteps.push(database);
450
468
  const authIssuer = 'error' in agoraHost ? null : agoraHost.issuer;
@@ -548,6 +566,18 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
548
566
  const boule = planBoule(org, byKey, claimed, recordedIds, authIssuer, options.otherSlugs ?? [], options.archonUrl ?? null, platformReadiness, bouleCredentials.wants);
549
567
  if (boule.step)
550
568
  plannedSteps.push(boule.step);
569
+ // Boule owners, after the Boule step: needs the app deployed and the seeding secret in its env.
570
+ const bouleOwners = planBouleOwners(org, recordedIds, (byKey.get(resourceKey('coolify', 'boule', org.id))?.config.env ?? null));
571
+ if (bouleOwners.seed.length > 0) {
572
+ plannedSteps.push({
573
+ action: { system: 'coolify', kind: 'boule-owners', key: org.id, desired: {} },
574
+ step: {
575
+ description: `seed ${org.slug}'s declared owners into its Boule`,
576
+ destroys: false,
577
+ migrates: false,
578
+ },
579
+ });
580
+ }
551
581
  // After the apps and Boule -- an edge key needs its app recorded or planned,
552
582
  // the same dependency `planEntryAiKeys`/`planFilesKeys` have.
553
583
  const entryEdgeKeys = planEntryEdgeKeys(org, recordedIds, plannedSteps, platformReadiness, (options.now ?? Date.now)());
@@ -557,6 +587,17 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
557
587
  const bouleBroker = planBouleBrokerEntry(org, recordedIds, plannedSteps, options.broker ?? false);
558
588
  if (bouleBroker.step)
559
589
  plannedSteps.push(bouleBroker.step);
590
+ // #440: Agora's desktop sign-in backend, after Agora and Boule: its readiness probe goes through Agora's proxy, and
591
+ // Agora writes the upstream only once the service ran healthy (so a first apply needs a second to turn it on).
592
+ const desktopAuth = planDesktopAuth(org, recordedIds, {
593
+ host: agora.host ?? null,
594
+ liveAgoraEnv: agora.liveEnv ?? null,
595
+ homePlanned: orgPostgresSteps.some((s) => s.action.kind === 'org-postgres-databases' &&
596
+ (s.action.desired.homes ?? []).includes(DESKTOP_AUTH_HOME)),
597
+ });
598
+ claimed.add(resourceKey('coolify', 'desktop-auth', org.id));
599
+ if (desktopAuth.step)
600
+ plannedSteps.push(desktopAuth.step);
560
601
  // An own org's scheduler, before its registration below.
561
602
  const ownJobs = planOwnJobs({
562
603
  org,
@@ -661,6 +702,15 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
661
702
  const idTokenUserinfo = planIdTokenUserinfo(org, live, recordedIds, plannedSteps, platformReadiness.authReady, options.zitadelIdTokenUserinfo ?? false);
662
703
  if (idTokenUserinfo)
663
704
  plannedSteps.push(idTokenUserinfo);
705
+ const postLogoutUris = planPostLogoutUris(org, live, recordedIds, plannedSteps, platformReadiness.authReady, options.zitadelPostLogoutUris ?? false);
706
+ if (postLogoutUris)
707
+ plannedSteps.push(postLogoutUris);
708
+ const desktopCallbacks = planDesktopCallbacks(org, live, recordedIds, plannedSteps, platformReadiness.authReady, options.zitadelRedirectUris ?? false);
709
+ if (desktopCallbacks)
710
+ plannedSteps.push(desktopCallbacks);
711
+ // #444, last: a Postgres step that fails must not stop the zone, mail, app, Agora and Boule steps of a live company.
712
+ plannedSteps.push(...orgPostgresSteps);
713
+ plannedSteps.push(...orgPostgresMigration.steps);
664
714
  // #10 (resolved): "foundry manages only what the declared org names";
665
715
  // anything left unclaimed is listed, never proposed for deletion.
666
716
  // A resource foundry made for the org is not a finding: only a resource
@@ -682,6 +732,16 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
682
732
  .map((r) => ({
683
733
  description: `${r.system} ${r.kind} "${liveResourceName(r)}" is live but not named by the declared org`,
684
734
  }));
735
+ // A password the job set but nothing delivered: the step is still planned, it sets and delivers a new one.
736
+ for (const home of orgPostgres.missingSchemas) {
737
+ if (recordedIds[orgPostgresUndeliveredScopeKey(home.schema)]) {
738
+ unmanaged.push({
739
+ description: `${org.slug}'s ${home.schema} database password was set but not delivered; the next apply sets and delivers a new one`,
740
+ key: `org-postgres-undelivered:${org.slug}:${home.schema}`,
741
+ });
742
+ }
743
+ }
744
+ unmanaged.push(...orgPostgresMigration.unmanaged);
685
745
  // A blocked Agora is reported here, not planned: a step the executor must
686
746
  // refuse would halt every step after it. This is the plan's one non-step
687
747
  // item list, so it rides along with the unmanaged items.
@@ -689,8 +749,14 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
689
749
  unmanaged.push({ description: agora.blocked, key: 'coolify:agora' });
690
750
  if (agora.issuerNote)
691
751
  unmanaged.push({ description: agora.issuerNote, key: 'coolify:agora' });
752
+ if (desktopAuth.blocked) {
753
+ unmanaged.push({ description: desktopAuth.blocked, key: 'coolify:desktop-auth' });
754
+ }
692
755
  if (boule.blocked)
693
756
  unmanaged.push({ description: boule.blocked, key: 'coolify:boule' });
757
+ for (const line of bouleOwners.blocked) {
758
+ unmanaged.push({ description: line, key: 'coolify:boule-owners' });
759
+ }
694
760
  for (const description of bouleCredentials.blocked) {
695
761
  unmanaged.push({ description, key: 'coolify:boule:credentials' });
696
762
  }
@@ -762,6 +828,8 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
762
828
  if (loginHost.blocked) {
763
829
  unmanaged.push({ description: loginHost.blocked, key: 'zitadel:login-host' });
764
830
  }
831
+ for (const note of loginHost.notes)
832
+ unmanaged.push({ description: note, key: 'zitadel:login-host' });
765
833
  if (mail.blocked)
766
834
  unmanaged.push({ description: mail.blocked, key: 'coolify:mail' });
767
835
  // #162: reported separately from `mail.blocked` -- missing an owner
@@ -793,6 +861,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
793
861
  unmanaged.push({ description: zone.blocked, key: 'cloudflare:zone' });
794
862
  unmanaged.push(...zoneBlocked);
795
863
  unmanaged.push(...dnsBlocked);
864
+ unmanaged.push(...appBlocked);
796
865
  // A legacy email-list Access app is no longer planned or removed: an operator deletes it by hand.
797
866
  const legacyAccessKey = resourceKey('cloudflare', 'access', appStepKey(org, APP_ENTRY_KEY));
798
867
  if (recordedIds[legacyAccessKey]) {
@@ -848,6 +917,7 @@ const NEEDS_OWN_ZITADEL = new Set([
848
917
  'zitadel:app',
849
918
  'zitadel:customers',
850
919
  'zitadel:id-token-userinfo',
920
+ 'zitadel:desktop-callback',
851
921
  'zitadel:login-host',
852
922
  'coolify:chat',
853
923
  'coolify:calls-service',
@@ -857,17 +927,19 @@ const NEEDS_OWN_ZITADEL = new Set([
857
927
  'files:organisation',
858
928
  ]);
859
929
  /**
860
- * #553: a plain ZITADEL app provision (not a `recreate`) is planned only when
861
- * the live listing lacks the app by name. If an app id is recorded for it,
862
- * the listing may simply have missed a live app, and nothing can read an app
863
- * by id, so applying silently could replace a live sign-in client. The way
864
- * out is the destroying `recreate` path: the step is planned as a
865
- * replacement that asks for the typed confirmation, so a person who has
866
- * checked the app is gone (deleted by hand in ZITADEL) says so, and apply
867
- * then deletes the recorded id (a 404 is fine) and creates a new app.
868
- * (A failed recreate clears the recorded id, so it is not caught here.)
930
+ * #553, #557: a plain ZITADEL app provision (not a `recreate`) is planned only
931
+ * when the live listing lacks the app by name. If an app id is recorded for
932
+ * it, the listing may simply have missed a live app. The replacement is
933
+ * offered only when the reader proved the app gone with a direct read by app
934
+ * id (a live `zitadel:app-gone:<name>` resource, see
935
+ * `ProvidersZitadelReader`); without that proof the step is blocked and
936
+ * nothing is planned. With the proof, the way out is the destroying
937
+ * `recreate` path: the step is planned as a replacement that asks for the
938
+ * typed confirmation, then apply deletes the recorded id (a 404 is fine) and
939
+ * creates a new app. (A failed recreate clears the recorded id, so it is not
940
+ * caught here.)
869
941
  */
870
- function guardAppProvision(step, org, recordedIds) {
942
+ function guardAppProvision(step, org, recordedIds, byKey, claimed, blocked) {
871
943
  if (!step || step.action.system !== 'zitadel' || step.action.kind !== 'app')
872
944
  return step;
873
945
  if (step.action.desired.recreate === true)
@@ -875,10 +947,19 @@ function guardAppProvision(step, org, recordedIds) {
875
947
  const recorded = recordedIds[`zitadel:app:${step.action.key}`];
876
948
  if (!recorded)
877
949
  return step;
950
+ const goneKey = resourceKey('zitadel', APP_GONE_KIND, step.action.key);
951
+ if (!byKey.has(goneKey)) {
952
+ blocked.push({
953
+ description: `${org.slug}'s ZITADEL app '${step.action.key}' is blocked, not planned: recorded app ${recorded} is not in ZITADEL's listing, and a direct read of it did not answer not-found, so it may be live (a listing can miss a live app)`,
954
+ key: `zitadel:app:${step.action.key}`,
955
+ });
956
+ return null;
957
+ }
958
+ claimed.add(goneKey);
878
959
  return {
879
960
  action: { ...step.action, desired: { ...step.action.desired, recreate: true } },
880
961
  step: {
881
- description: `replace ZITADEL app '${step.action.key}' for ${org.slug}: recorded app ${recorded} is not in ZITADEL's listing (a listing can miss a live app; if it is live, do not confirm -- apply deletes it and creates a new client id)`,
962
+ description: `replace ZITADEL app '${step.action.key}' for ${org.slug}: recorded app ${recorded} is not in ZITADEL's listing and a direct read answered not-found`,
882
963
  destroys: true,
883
964
  migrates: false,
884
965
  },
@@ -1113,6 +1194,14 @@ function planOwners(org, recordedIds, authReady, authIssuer, ownerInviteMail, re
1113
1194
  });
1114
1195
  return;
1115
1196
  }
1197
+ const nameProblem = ownerNameProblem(mailboxAddress, owner.givenName, owner.familyName);
1198
+ if (nameProblem) {
1199
+ blocked.push({
1200
+ description: `${org.slug}'s owner ${position} is blocked, not planned: ${nameProblem}`,
1201
+ key: `zitadel:owner:${position}`,
1202
+ });
1203
+ return;
1204
+ }
1116
1205
  if (!ownerInviteMail) {
1117
1206
  blocked.push({
1118
1207
  description: `${org.slug}'s owner ${position} is blocked, not planned: the host has no ownerInviteMail port, and the owner cannot open their work mailbox yet`,
@@ -1149,48 +1238,55 @@ function planOwners(org, recordedIds, authReady, authIssuer, ownerInviteMail, re
1149
1238
  export function isPlatformOwnOrg(org, platformOwnerOrgId) {
1150
1239
  return Boolean(platformOwnerOrgId && org.id === platformOwnerOrgId);
1151
1240
  }
1152
- /**
1153
- * #267: the org's own company login host (`login-host.ts`'s
1154
- * `loginHostnameFor`) -- `auth.<domain>` once the org has one,
1155
- * `<slug>-auth.oikos.host` for tier 1. Idempotent on the *recorded*
1156
- * resource id, the same shape `planOwners` above uses and for the same
1157
- * reason: `ZitadelReader.listResources` has no notion of a trusted domain
1158
- * or a login binding, so there is nothing in `live` this step could ever
1159
- * claim or compare against.
1160
- *
1161
- * Unlike `planOwners`, a re-run is not simply "already done or not": the
1162
- * recorded value is compared against {@link loginHostnameFor}'s own value
1163
- * for *this* apply, so an org moving from tier 1 onto its own domain (or
1164
- * losing one) replans this step rather than staying silent forever once
1165
- * first applied -- `executor.ts`'s `zitadelLoginHost` then also moves every
1166
- * already-live app onto the new base URI, and (#274) removes the *old* host
1167
- * from ZITADEL's trusted domains and zitadel-login's binding, reading it
1168
- * back from this same recorded value before overwriting it -- no separate
1169
- * action field or planning step of its own needed for that half. Mints
1170
- * nothing either way: adding a trusted domain is not a one-time secret, so
1171
- * there is no "undelivered"
1172
- * guard to check the way `planJobsRegistration`'s own does.
1173
- *
1174
- * Ordered right after `planOwners` (not after mail, chat, or any hosting
1175
- * step), for the same reason `planOwners`'s own doc comment gives: on a
1176
- * first-ever apply the ZITADEL org this step's executor needs
1177
- * (`context.recordedIds().zitadel_org`) has already been recorded by the
1178
- * issuer step two steps before it in the same run, and this step itself
1179
- * runs well before `planIssuerApps`/`planCmsClient`/`planAgoraClient`
1180
- * create any app under the org's project -- #267's own "before any app's
1181
- * issuer application".
1182
- *
1183
- * `platformAuthHostname` (#488) is threaded through to {@link
1184
- * loginHostnameFor} so the platform's own org, declared with-domain on the
1185
- * platform's own domain, never plans the shared ZITADEL instance's own login
1186
- * host as its own -- `auth.companyincluded.com` colliding with itself.
1187
- */
1188
- function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired, signInLoginReady = false) {
1241
+ function planLoginHost(org, recordedIds, options) {
1242
+ const gates = options.gates ?? {
1243
+ authGitRef: '',
1244
+ bindKeyReady: false,
1245
+ clientOrganizationsPort: false,
1246
+ };
1247
+ const core = planLoginHostCore(org, recordedIds, {
1248
+ authReady: options.authReady,
1249
+ issuerPlanned: options.issuerPlanned,
1250
+ bindingsWired: options.bindingsWired,
1251
+ signInLoginReady: options.signInLoginReady ?? false,
1252
+ gates,
1253
+ });
1254
+ return { ...core, notes: loginHostNotes(org, recordedIds, gates) };
1255
+ }
1256
+ /** Fixed lines for what the login host cannot yet do: the organisation check (577) and the cookie domain (795). */
1257
+ function loginHostNotes(org, recordedIds, gates) {
1258
+ const notes = [];
1259
+ const mapped = Object.keys(customersClientOrganizations(org, recordedIds)).length > 0;
1260
+ if (mapped && !authAtLeast(gates.authGitRef, MIN_AUTH_FOR_CLIENT_ORGS)) {
1261
+ notes.push(`${org.slug}: the shared login host does not yet check customers clients' organisation: auth <${MIN_AUTH_FOR_CLIENT_ORGS}`);
1262
+ }
1263
+ else if (mapped && !ownsServices(org) && !gates.clientOrganizationsPort) {
1264
+ notes.push(`${org.slug}: the shared login host does not yet check customers clients' organisation: the host cannot write the map`);
1265
+ }
1266
+ const cookie = cookieDomainAllowed({
1267
+ org,
1268
+ recorded: recordedIds,
1269
+ authGitRef: gates.authGitRef,
1270
+ bindKeyReady: gates.bindKeyReady,
1271
+ });
1272
+ if (cookie.domain === null && COOKIE_DOMAIN_WAITING_REASONS.has(cookie.reason)) {
1273
+ notes.push(cookieDomainWaitingLine(org.slug, cookie.reason));
1274
+ }
1275
+ return notes;
1276
+ }
1277
+ /** True when the recorded organisation map differs from the wanted one and a port can write it. */
1278
+ function clientOrganizationsOwed(org, recordedIds, writable) {
1279
+ return (writable &&
1280
+ clientOrganizationsState(customersClientOrganizations(org, recordedIds)) !==
1281
+ (recordedIds[LOGIN_CLIENT_ORGS_SCOPE_KEY] ?? ''));
1282
+ }
1283
+ function planLoginHostCore(org, recordedIds, options) {
1284
+ const { authReady, issuerPlanned, bindingsWired, signInLoginReady, gates } = options;
1189
1285
  const none = { step: null, blocked: null };
1190
1286
  // An own org's instance has no shared login host to bind: its issuer is the sign-in service's own.
1191
1287
  // Only in-app sign-in plans a step for it: the app hosts, through its own in-app login app.
1192
1288
  if (ownsServices(org))
1193
- return planOwnLoginHost(org, recordedIds);
1289
+ return planOwnLoginHost(org, recordedIds, gates);
1194
1290
  const key = resourceKey('zitadel', 'login-host', org.id);
1195
1291
  const host = loginHostnameFor(org);
1196
1292
  // In-app sign-in: each app host gets its own binding, trusted domain and base URI. Until the second
@@ -1200,6 +1296,23 @@ function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired
1200
1296
  const inAppWaiting = wantedApps.length > 0 && !signInLoginReady
1201
1297
  ? `${org.slug}'s in-app sign-in waits for the login app that serves ${SIGN_IN_BASE_PATH} (${SIGN_IN_LOGIN_INTERNAL_NAME}), which the platform has not applied`
1202
1298
  : null;
1299
+ const targets = inAppTargets(apps, recordedIds);
1300
+ const authReleased = authAtLeast(gates.authGitRef, MIN_AUTH_FOR_CLIENT_ORGS);
1301
+ const cookie = cookieDomainAllowed({
1302
+ org,
1303
+ recorded: recordedIds,
1304
+ authGitRef: gates.authGitRef,
1305
+ bindKeyReady: gates.bindKeyReady,
1306
+ });
1307
+ const cookieDomain = targets.active !== '' && cookie.domain !== null ? cookie.domain : '';
1308
+ const settled = recordedIds[key] === host &&
1309
+ (recordedIds[LOGIN_COOKIE_DOMAIN_SCOPE_KEY] ?? '') === cookieDomain &&
1310
+ !clientOrganizationsOwed(org, recordedIds, gates.clientOrganizationsPort) &&
1311
+ recordedIds[LOGIN_BINDING_SCOPE_KEY] ===
1312
+ loginBindingState(host, loginHostClientIds(org, recordedIds, inAppStatePairs(targets.active).map((pair) => pair.clientId))) &&
1313
+ (recordedIds[SIGN_IN_TRUSTED_SCOPE_KEY] ?? '') === targets.trusted &&
1314
+ (recordedIds[SIGN_IN_IN_APP_SCOPE_KEY] ?? '') === targets.active;
1315
+ const owed = pendingRemovalHosts(recordedIds);
1203
1316
  function stepFor() {
1204
1317
  return {
1205
1318
  step: {
@@ -1207,14 +1320,20 @@ function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired
1207
1320
  system: 'zitadel',
1208
1321
  kind: 'login-host',
1209
1322
  key: org.id,
1210
- desired: apps.length > 0 ||
1211
- recordedIds[SIGN_IN_IN_APP_SCOPE_KEY] ||
1212
- recordedIds[SIGN_IN_TRUSTED_SCOPE_KEY]
1213
- ? { host, apps }
1214
- : { host },
1323
+ desired: {
1324
+ ...(apps.length > 0 ||
1325
+ recordedIds[SIGN_IN_IN_APP_SCOPE_KEY] ||
1326
+ recordedIds[SIGN_IN_TRUSTED_SCOPE_KEY]
1327
+ ? { host, apps }
1328
+ : { host }),
1329
+ ...(authReleased ? { authReleased: true } : {}),
1330
+ ...(cookieDomain ? { cookieDomain } : {}),
1331
+ },
1215
1332
  },
1216
1333
  step: {
1217
- description: `set ${org.slug}'s login host to ${host}`,
1334
+ description: settled && owed.length > 0
1335
+ ? `retire ${org.slug}'s old login host ${owed.join(', ')} and record ${host}`
1336
+ : `set ${org.slug}'s login host to ${host}`,
1218
1337
  destroys: false,
1219
1338
  migrates: false,
1220
1339
  },
@@ -1228,13 +1347,7 @@ function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired
1228
1347
  // `zitadelLoginHost` always advances the recorded host to the current one,
1229
1348
  // so a pending removal is the only way to know one is still owed, and
1230
1349
  // nothing else here would replan the step to retry it.
1231
- const targets = inAppTargets(apps, recordedIds);
1232
- if (recordedIds[key] === host &&
1233
- recordedIds[LOGIN_BINDING_SCOPE_KEY] ===
1234
- loginBindingState(host, loginHostClientIds(org, recordedIds, inAppStatePairs(targets.active).map((pair) => pair.clientId))) &&
1235
- (recordedIds[SIGN_IN_TRUSTED_SCOPE_KEY] ?? '') === targets.trusted &&
1236
- (recordedIds[SIGN_IN_IN_APP_SCOPE_KEY] ?? '') === targets.active &&
1237
- pendingRemovalHosts(recordedIds).length === 0) {
1350
+ if (settled && owed.length === 0) {
1238
1351
  return { step: null, blocked: inAppWaiting };
1239
1352
  }
1240
1353
  // #177: nothing to add a trusted domain to yet -- `planIssuer` already
@@ -1254,10 +1367,11 @@ function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired
1254
1367
  * applied and that login app is recorded; until then one blocked line says what it waits for.
1255
1368
  * Archon's host is not here: the platform's Archon step owns it.
1256
1369
  */
1257
- function planOwnLoginHost(org, recordedIds) {
1370
+ function planOwnLoginHost(org, recordedIds, gates) {
1258
1371
  const none = { step: null, blocked: null };
1372
+ const mapOwed = clientOrganizationsOwed(org, recordedIds, true);
1259
1373
  // Nothing wanted and nothing owed.
1260
- if (!inAppSignIn(org) && !recordedIds[SIGN_IN_IN_APP_SCOPE_KEY])
1374
+ if (!inAppSignIn(org) && !recordedIds[SIGN_IN_IN_APP_SCOPE_KEY] && !mapOwed)
1261
1375
  return none;
1262
1376
  // The company's own sign-in is reported on its own line (`coolify:own-auth`).
1263
1377
  if (!ownAuthApplied(org, recordedIds))
@@ -1273,19 +1387,31 @@ function planOwnLoginHost(org, recordedIds) {
1273
1387
  const targets = inAppTargets(apps, recordedIds);
1274
1388
  if ((recordedIds[SIGN_IN_TRUSTED_SCOPE_KEY] ?? '') === targets.trusted &&
1275
1389
  (recordedIds[SIGN_IN_IN_APP_SCOPE_KEY] ?? '') === targets.active &&
1390
+ !mapOwed &&
1276
1391
  pendingRemovalHosts(recordedIds).length === 0) {
1277
1392
  return { step: null, blocked: waiting };
1278
1393
  }
1279
1394
  // Nothing to trust a domain in, or to bind in, before the org exists in the company's ZITADEL.
1280
- if (!recordedIds.zitadel_org)
1281
- return { step: null, blocked: waiting };
1395
+ if (!recordedIds.zitadel_org) {
1396
+ return {
1397
+ step: null,
1398
+ blocked: waiting ??
1399
+ `${org.slug}'s in-app sign-in waits for the company's ZITADEL organisation, which is not recorded yet`,
1400
+ };
1401
+ }
1282
1402
  return {
1283
1403
  step: {
1284
1404
  action: {
1285
1405
  system: 'zitadel',
1286
1406
  kind: 'login-host',
1287
1407
  key: org.id,
1288
- desired: { host, apps },
1408
+ desired: {
1409
+ host,
1410
+ apps,
1411
+ ...(authAtLeast(gates.authGitRef, MIN_AUTH_FOR_CLIENT_ORGS)
1412
+ ? { authReleased: true }
1413
+ : {}),
1414
+ },
1289
1415
  },
1290
1416
  step: {
1291
1417
  description: `set ${org.slug}'s app hosts to sign in on their own addresses`,
@@ -1335,6 +1461,46 @@ function planIdTokenUserinfo(org, live, recordedIds, plannedSteps, authReady, wi
1335
1461
  },
1336
1462
  };
1337
1463
  }
1464
+ /**
1465
+ * Every live `@wtfalch/auth` web app (not Agora, which signs out locally) holds
1466
+ * its root and `/auth/signed-out` as post-logout addresses. One
1467
+ * `zitadel:post-logout-uris` step names the apps whose marker
1468
+ * ({@link zitadelAppPostLogoutAppliedScopeKey}: id and set) differs from what
1469
+ * the declared org wants, so a client made with only the root, or a host
1470
+ * change, is updated once and the next plan is clean. Plans nothing when the
1471
+ * host's ZITADEL client lacks the method, before the org and project are
1472
+ * recorded, or while the platform's sign-in is not ready.
1473
+ */
1474
+ function planPostLogoutUris(org, live, recordedIds, plannedSteps, authReady, wired) {
1475
+ if (!wired || !authReady || !recordedIds.zitadel_org || !recordedIds.zitadel_project)
1476
+ return null;
1477
+ const apps = live
1478
+ .filter((r) => r.system === 'zitadel' && r.kind === 'app' && r.config.appType === 'web')
1479
+ .map((r) => r.key)
1480
+ .filter((appKey) => appKey !== 'agora')
1481
+ // An app this plan recreates gets its marker when it is made.
1482
+ .filter((appKey) => !plannedSteps.some((s) => s.action.system === 'zitadel' &&
1483
+ s.action.kind === 'app' &&
1484
+ s.action.key === appKey &&
1485
+ s.action.desired.recreate === true))
1486
+ .filter((appKey) => {
1487
+ const appId = recordedIds[`zitadel:app:${appKey}`];
1488
+ const uris = companyAppPostLogoutUris(org, appKey);
1489
+ return (Boolean(appId) &&
1490
+ uris.length > 0 &&
1491
+ postLogoutUpdateOwed(recordedIds[zitadelAppPostLogoutAppliedScopeKey(appKey)], appId, uris));
1492
+ });
1493
+ if (apps.length === 0)
1494
+ return null;
1495
+ return {
1496
+ action: { system: 'zitadel', kind: 'post-logout-uris', key: org.id, desired: { apps } },
1497
+ step: {
1498
+ description: `update the post-logout addresses of ${org.slug}'s sign-in apps (${apps.join(', ')})`,
1499
+ destroys: false,
1500
+ migrates: false,
1501
+ },
1502
+ };
1503
+ }
1338
1504
  /** Why a login-host step cannot run yet, or `null` when nothing is missing. */
1339
1505
  function loginHostWaiting(org, host, recordedIds, bindingsWired, issuerPlanned) {
1340
1506
  if (!bindingsWired) {
@@ -1374,6 +1540,7 @@ function orgServiceSchemaEnabled(org, schema) {
1374
1540
  return org.calls.enabled;
1375
1541
  if (schema.schema === 'app')
1376
1542
  return org.hosting.entries.some(entryHasDatabase);
1543
+ // Agora always plans (planAgora): desktop-auth shares its gate, so it is on like Boule.
1377
1544
  return true;
1378
1545
  }
1379
1546
  /**
@@ -1395,6 +1562,7 @@ function planOrgPostgres(org, recordedIds) {
1395
1562
  return {
1396
1563
  serverMissing: !recordedIds[ORG_POSTGRES_SERVER_SCOPE_KEY],
1397
1564
  missingSchemas: ORG_SERVICE_SCHEMAS.filter((schema) => orgServiceSchemaEnabled(org, schema) && !recordedIds[schema.scopeKey]),
1565
+ legacyUnmigrated: ORG_SERVICE_SCHEMAS.filter((schema) => orgServiceSchemaEnabled(org, schema) && legacyUnmigrated(org, schema, recordedIds)),
1398
1566
  };
1399
1567
  }
1400
1568
  /** #230: restated from `executor.ts`'s `MAIL_ROLE_MAILBOXES_SCOPE_KEY`, same rule: comma-joined local parts already created. */
@@ -2566,6 +2734,7 @@ function planAgora(org, byKey, claimed, recordedIds, reading, issuerSwitch) {
2566
2734
  },
2567
2735
  blocked: null,
2568
2736
  issuerNote,
2737
+ host,
2569
2738
  };
2570
2739
  }
2571
2740
  const liveEnv = (live.config.env ?? {});
@@ -2594,7 +2763,7 @@ function planAgora(org, byKey, claimed, recordedIds, reading, issuerSwitch) {
2594
2763
  // planned no matter what Coolify's env store already shows.
2595
2764
  const activated = recordedIds[AGORA_ACTIVATED_ENV_SCOPE_KEY] === agoraEnvRevision(env);
2596
2765
  if (drift.length === 0 && activated && !portDrift) {
2597
- return { step: null, blocked: null, issuerNote };
2766
+ return { step: null, blocked: null, issuerNote, host, liveEnv };
2598
2767
  }
2599
2768
  const reasons = [
2600
2769
  ...(drift.length > 0 ? [`${drift.join(', ')} missing or different`] : []),
@@ -2613,6 +2782,8 @@ function planAgora(org, byKey, claimed, recordedIds, reading, issuerSwitch) {
2613
2782
  },
2614
2783
  blocked: null,
2615
2784
  issuerNote,
2785
+ host,
2786
+ liveEnv,
2616
2787
  };
2617
2788
  }
2618
2789
  /** The issuer a live Agora's `AGORA_CONFIG` names, or null when it has none or is not JSON. */
@@ -2829,6 +3000,8 @@ function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonU
2829
3000
  drift.push(BOULE_PEER_PASSWORD_ENV);
2830
3001
  if (platformOwner && !liveEnv[BOULE_BRIDGE_SECRET_ENV])
2831
3002
  drift.push(BOULE_BRIDGE_SECRET_ENV);
3003
+ if (!liveEnv[BOULE_OWNER_SEED_SECRET_ENV])
3004
+ drift.push(BOULE_OWNER_SEED_SECRET_ENV);
2832
3005
  // Secrets written but Boule not restarted since: plan the step so it restarts, minting nothing.
2833
3006
  if (platformOwner && !recordedIds[BOULE_SECRETS_RESTARTED_SCOPE_KEY]) {
2834
3007
  drift.push('restart after the peer password and bridge secret');
@@ -3145,8 +3318,10 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
3145
3318
  // names (`envNames` absent) reads as compliant, like `hasLogVolume`.
3146
3319
  const envNames = live.config.envNames;
3147
3320
  const dbLive = byKey.get(resourceKey('coolify', 'database', org.id));
3148
- const dbReady = entry.database && Boolean(recordedIds[`coolify:database:${org.id}`]);
3149
- if (dbReady && dbLive?.config.hasInternalUrl === false) {
3321
+ const appHome = ORG_SERVICE_SCHEMAS.find((home) => home.schema === 'app');
3322
+ const dbOnServer = appHome !== undefined && serviceOnServer(org, appHome, recordedIds);
3323
+ const dbReady = entry.database && (dbOnServer || Boolean(recordedIds[`coolify:database:${org.id}`]));
3324
+ if (!dbOnServer && dbReady && dbLive?.config.hasInternalUrl === false) {
3150
3325
  // Rule 2: the push cannot succeed yet, so report it rather than replan
3151
3326
  // forever.
3152
3327
  blocked.push({
@@ -3157,7 +3332,7 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
3157
3332
  else if (dbReady &&
3158
3333
  // Only a read that saw the URL replans: an unread database (a failed
3159
3334
  // read) would plan a step that pushes nothing, forever.
3160
- dbLive?.config.hasInternalUrl === true &&
3335
+ (dbOnServer || dbLive?.config.hasInternalUrl === true) &&
3161
3336
  ((Array.isArray(envNames) && !envNames.includes('DATABASE_URL_OWNER')) ||
3162
3337
  // A crash between the push and the restart leaves the env right and no
3163
3338
  // marker: replan once more to restart and set it.
@@ -3450,7 +3625,19 @@ function planJobsRegistration(org, recordedIds, jobsAvailable, missingKeyRef = n
3450
3625
  * `hosting.enabled` before this can be true. Same park-not-delete shape as
3451
3626
  * {@link planApp}.
3452
3627
  */
3453
- function planDatabase(org, byKey, claimed) {
3628
+ function planDatabase(org, byKey, claimed, recordedIds) {
3629
+ // #444: only a company whose app database is still its own Coolify database
3630
+ // plans one. A new company, and one that has moved, has it on its Postgres
3631
+ // server: recorded, or provisioned by a step earlier in this plan.
3632
+ // A Worker app's database is reached through the Coolify database's id (its Hyperdrive route
3633
+ // and tunnel), so a Worker app entry with a database always plans that database.
3634
+ const workerAppDatabase = org.hosting.entries.some((entry) => entry.workerApp?.database);
3635
+ const appHome = ORG_SERVICE_SCHEMAS.find((home) => home.schema === 'app');
3636
+ const legacy = appHome?.legacyKey(org);
3637
+ if (!workerAppDatabase &&
3638
+ (!appHome || !legacy || !recordedIds[legacy] || serviceOnServer(org, appHome, recordedIds))) {
3639
+ return null;
3640
+ }
3454
3641
  return planHostingToggle('coolify', 'database', org.hosting.entries.some(entryHasDatabase), org, byKey, claimed, () => `provision a database for ${org.slug}'s app`, () => `park ${org.slug}'s database (data is retained, not deleted -- #7 corrections 1)`);
3455
3642
  }
3456
3643
  function sortedNames(names) {
@@ -3874,7 +4061,7 @@ function planBouleBrokerEntry(org, recordedIds, plannedSteps, brokerWired) {
3874
4061
  }
3875
4062
  /**
3876
4063
  * Plan gap A9: an org's customers sign in through a second ZITADEL org with
3877
- * self-registration on, granted the org's app project. Planned once an entry
4064
+ * self-registration open only when `customers.signUp` is declared (off by default), granted the org's app project. Planned once an entry
3878
4065
  * signs in its customers (`uses.auth.users: 'customers'`).
3879
4066
  * Idempotent on the recorded customers org id, like `planOwners`. Once
3880
4067
  * recorded, the live project grant to that org is claimed so it is not
@@ -4038,7 +4225,8 @@ function accessRuleDescription(org, rule) {
4038
4225
  * time this step's own action runs.
4039
4226
  */
4040
4227
  function planRateLimit(org, recordedIds) {
4041
- if (!(org.hosting.usesDomain || org.site.enabled) || !org.domain)
4228
+ // In-app sign-in serves `/sign-in/` on the company's zone whatever else is hosted there, so it is limited too.
4229
+ if (!(org.hosting.usesDomain || org.site.enabled || inAppSignIn(org)) || !org.domain)
4042
4230
  return null;
4043
4231
  // Recorded, and the rule is the variant the switch asks for (the in-app one also covers POSTs under `/sign-in/`).
4044
4232
  if (recordedIds[resourceKey('cloudflare', 'ratelimit', org.id)] &&