@wtfalch/foundry 0.45.0 → 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 +5 -3
  2. package/dist/index.js +3 -2
  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
@@ -4,21 +4,25 @@ import { URL } from 'node:url';
4
4
  import { coolifyImageFor } from '../bootstrap/versions.js';
5
5
  import { redactSecrets } from '../calls-sfu-deploy-support.js';
6
6
  import { resolveCallsSfuUrls } from '../calls-sfu.js';
7
- import { APP_ENTRY_KEY, appEntry, filesCollectionEnvName, heldEnvRef, ownerMailboxRecorded, ownerMailboxes, ownsServices, sharedSecretRef, } from '../org.js';
7
+ import { APP_ENTRY_KEY, ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, appEntry, filesCollectionEnvName, heldEnvRef, ownerMailboxRecorded, ownerMailboxes, ownsServices, sharedSecretRef, } from '../org.js';
8
8
  import { adoptedKeysUnreadable, parseAdoptSnapshot } from './adopt.js';
9
- import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, AGORA_CLIENT_ID_SCOPE_KEY, AGORA_DEPLOYED_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_DEPLOYED_SCOPE_KEY, CALLS_SERVICE_NAME_SCOPE_KEY, CALLS_SERVICE_PORT, CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY, CHAT_CALLS_APPLIED_SCOPE_KEY, CHAT_CALLS_TOKEN_SCOPE_KEY, CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, CHAT_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CHAT_SERVICE_URL_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, ZITADEL_MESSAGING_PROJECT_SCOPE_KEY, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostnameFor, callsIntrospectionAppName, callsServiceHasName, callsServiceInternalName, callsTokenEnvMarker, callsTokenEnvMovedMessage, chatCallsApplied, chatHasUpstreams, chatIntrospectionAppName, desiredAgoraEnv, entryClientIdScopeKey, } from './agora.js';
9
+ import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, AGORA_CLIENT_ID_SCOPE_KEY, AGORA_DEPLOYED_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_DEPLOYED_SCOPE_KEY, CALLS_SERVICE_NAME_SCOPE_KEY, CALLS_SERVICE_PORT, CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY, CHAT_CALLS_APPLIED_SCOPE_KEY, CHAT_CALLS_TOKEN_SCOPE_KEY, CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, CHAT_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CHAT_SERVICE_URL_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, DESKTOP_AUTH_PORT, ENTRY_CLIENT_ID_SCOPE_PREFIX, ZITADEL_MESSAGING_PROJECT_SCOPE_KEY, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostnameFor, callsIntrospectionAppName, callsServiceHasName, callsServiceInternalName, callsTokenEnvMarker, callsTokenEnvMovedMessage, chatCallsApplied, chatHasUpstreams, chatIntrospectionAppName, desiredAgoraEnv, desktopAuthInternalName, entryClientIdScopeKey, } from './agora.js';
10
10
  import { applyBlueGreenConnectors, applyBlueGreenServer, applyBlueGreenWeights, } from './blue-green-apply.js';
11
11
  import { bgKey, colorAppName, entrySubject, healthTokenName, liveAppNameFor, splitServers, } from './blue-green.js';
12
- import { BOULE_ARCHON_CREDENTIAL_ENV, BOULE_ARCHON_PUSHED_AT_SCOPE_KEY, BOULE_BRIDGE_SECRET_ENV, BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_CUSTOMERS_CREDENTIAL_ENV, BOULE_CUSTOMERS_ORG_ENV, BOULE_DATABASE_SCOPE_KEY, BOULE_DEPLOYED_SCOPE_KEY, BOULE_PEER_PASSWORD_ENV, BOULE_SECRETS_RESTARTED_SCOPE_KEY, CUSTOMERS_DIRECTORY_USER_SCOPE_KEY, CUSTOMERS_SIGN_IN_METADATA_KEY, CUSTOMERS_SIGN_IN_SCOPE_KEY, CUSTOMERS_SIGN_UP_SCOPE_KEY, archonCredentialRenewDue, bouleArchonCredentialRef, bouleEnvDrift, bouleHostnameFor, bouleMailWanted, customersDirectoryRef, customersOrgIdOf, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
12
+ import { BouleOwnersError, httpBouleOwners } from './boule-owners-http.js';
13
+ import { BOULE_ARCHON_CREDENTIAL_ENV, BOULE_ARCHON_PUSHED_AT_SCOPE_KEY, BOULE_BRIDGE_SECRET_ENV, BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_CUSTOMERS_CREDENTIAL_ENV, BOULE_CUSTOMERS_ORG_ENV, BOULE_DATABASE_SCOPE_KEY, BOULE_DEPLOYED_SCOPE_KEY, BOULE_OWNER_SEED_SECRET_ENV, BOULE_PEER_PASSWORD_ENV, BOULE_SECRETS_RESTARTED_SCOPE_KEY, CUSTOMERS_DIRECTORY_USER_SCOPE_KEY, CUSTOMERS_SIGN_IN_METADATA_KEY, CUSTOMERS_SIGN_IN_SCOPE_KEY, CUSTOMERS_SIGN_UP_SCOPE_KEY, archonCredentialRenewDue, bouleArchonCredentialRef, bouleEnvDrift, bouleHostnameFor, bouleMailWanted, bouleOwnerRefusedScopeKey, bouleOwnerSeededScopeKey, bouleOwnerSeeds, customersDirectoryRef, customersOrgIdOf, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
13
14
  import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, mcpCallbackUri, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
14
15
  import { recordBuiltCommit } from './build-record.js';
15
16
  import { MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, masDockerfile, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
16
17
  import { CHAT_MAS_PORT, CHAT_REPO, CHAT_SERVICE_DOCKERFILE, CHAT_SERVICE_PORT, CHAT_SYNAPSE_DOCKERFILE, CHAT_SYNAPSE_PORT, assertChatServerName, chatInternalNames, chatMasConfigYaml, chatRekeyedInternalNames, chatServerName, chatServiceEnvs, chatSynapseHomeserverYaml, chatUpstreams, } from './chat-matrix.js';
17
18
  import { ensureCloudflaredConnector } from './cloudflared-connector.js';
18
- import { BACKUP_SCOPE_PREFIX, COMPANY_BACKUP_DATABASE_NAMES, backupScopeKey, } from './company-backups.js';
19
+ import { BACKUP_POSTGRES_DATABASES_SCOPE_KEY, BACKUP_SCOPE_PREFIX, COMPANY_BACKUP_DATABASE_NAMES, backupScopeKey, } from './company-backups.js';
20
+ import { LOGIN_COOKIE_DOMAIN_SCOPE_KEY } from './cookie-domain.js';
19
21
  import { previewsOn } from './coolify-previews.js';
20
22
  import { CORE_FILES_COLLECTION_NAME, FILES_CORE_COLLECTION_SCOPE_KEY, ZITADEL_FILES_PROJECT_SCOPE_KEY, } from './core-files.js';
21
- import { waitForNewDeployment, } from './deploy-health.js';
23
+ import { waitForHealthyVersion, waitForNewDeployment, } from './deploy-health.js';
24
+ import { DESKTOP_AUTH_APP_SCOPE_KEY, DESKTOP_AUTH_DEPLOYED_SCOPE_KEY, DESKTOP_AUTH_DOCKERFILE, DESKTOP_AUTH_HOME, DESKTOP_AUTH_ORIGIN_SCOPE_KEY, DESKTOP_AUTH_READY_PATH, DESKTOP_AUTH_READY_SCOPE_KEY, DESKTOP_AUTH_REDIRECT_SCOPE_KEY, DESKTOP_AUTH_REVISION_SCOPE_KEY, desktopAuthCallbackUri, desktopAuthEnv, desktopAuthInputs, desktopAuthRedirectUnsupported, desktopAuthRevision, } from './desktop-auth.js';
25
+ import { desktopCallbackUri, unionRedirectUris, zitadelAppDesktopCallbackAppliedScopeKey, } from './desktop-callback.js';
22
26
  import { PLATFORM_NETWORK, companyNetworkName, ensureDestination, findDestination, onPlatformNetwork, placed, placedByScope, } from './destinations.js';
23
27
  import { DEFAULT_AI_LIMIT_MICROS } from './diff.js';
24
28
  import { createEdgeAccess, isNotFound as isEdgeNotFound, } from './edge-access.js';
@@ -33,11 +37,13 @@ import { GATE_CLAIM_NAME, gateHostnamesScopeKey, gateIdRecordKey, isBehindGate }
33
37
  import { IMAGE_HEALTHCHECK_SETTINGS, commandHealthCheckSettings, healthCheckSettings, wgetProbe, } from './health-check-settings.js';
34
38
  import { PROXY_LABEL_TOKEN_REF } from './held-key-refs.js';
35
39
  import { appHostname, cmsStudioHostnameFor, coolifyTunnelDomains, entryHostnames, siteHostnameFor, tunnelDnsHostnames, tunnelIngressHostnames, } from './hostnames.js';
36
- import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, LOGIN_HOST_SHARED_SCOPE_KEY, customersEntryKeys, loginBindingState, loginHostClientIds, ownAuthApplied, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
40
+ import { LOGIN_BINDING_SCOPE_KEY, LOGIN_CLIENT_ORGS_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, LOGIN_HOST_SHARED_SCOPE_KEY, clientOrganizationIds, clientOrganizationsState, customersClientOrganizations, customersEntryKeys, loginBindingState, loginHostClientIds, ownAuthApplied, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
37
41
  import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
38
42
  import { OWN_MAIL_SMTP_INTERNAL_SCOPE_KEY, OWN_MAIL_SMTP_PORT_SCOPE_KEY, isOwnMailSmtpPort, normalizeMailTarget, } from './mail-relay.js';
39
43
  import { applyAccessTokenLifetime, applyMcpAccess, applyMcpProject } from './mcp.js';
40
44
  import { NETWORK_APPLIED_SCOPE_KEY } from './network-move.js';
45
+ import { ORG_POSTGRES_MIGRATION_CONFIRMED_SCOPE_KEY, OrgPostgresMigrationNotConfirmedError, migrateHomeOf, migrateOrgPostgresHome, parseMigrationConfirmation, } from './org-postgres-migrate.js';
46
+ import { orgPostgresSecretRef, provisionOrgPostgresDatabases, provisionOrgPostgresServer, serviceEndpoint, serviceOnServer, serviceUrls, } from './org-postgres.js';
41
47
  import { OWN_AUTH_APPLIED_GIT_REF_SCOPE_KEY, OWN_AUTH_APPLIED_HOSTNAME_SCOPE_KEY, } from './own-diff.js';
42
48
  import { NO_DOMAIN_ZONE, ownHostnames } from './own-hostnames.js';
43
49
  import { LIMITS, limited } from './own-limits.js';
@@ -46,6 +52,7 @@ import { ownSfuUrls, provisionSfuApp } from './own-sfu.js';
46
52
  import { PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, } from './platform-diff.js';
47
53
  import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, ORG_MAIL_APPLIED_MODE_SCOPE_KEY, ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY, appDomainsAppliedScopeKey, appEnvAppliedScopeKey, appFolderDiffers, appNameFor, appSettingsAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, zitadelAppIdTokenUserinfoAppliedScopeKey, } from './platform-diff.js';
48
54
  import { AUTH_LOGIN_PORT, PLATFORM_AUTH_BROKER_MAIL_KEY_REF, PLATFORM_AUTH_LOGIN_INTERNAL_NAME, PLATFORM_AUTH_LOGIN_SCOPE_KEY, PLATFORM_MAIL_SERVER_SCOPE_KEY, defaultStalwartForInstance, filesDepsInvalid, filesShareDomains, provisionAuthStack, provisionDatabaseBackup, provisionFilesHost, provisionJobsHost, provisionMailServer, resolveOwnFilesKeys, } from './platform-providers.js';
55
+ import { companyAppPostLogoutUris, postLogoutAppliedValue, zitadelAppPostLogoutAppliedScopeKey, } from './post-logout-uris.js';
49
56
  import { CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY, ENTRY_CONTAINER_LABELS, appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec, entryOikosHostnames, entryProxyLabels, entryRoutes, entryRoutesMarker, ownsOikosName, routesWithoutToken, sameLabelSets, } from './proxy-labels.js';
50
57
  import { createReleaseHost } from './release-host.js';
51
58
  import { TUNNEL_HTTP_INGRESS_SCOPE_KEY, applyRemoteDatabase, mergeTcpIngress, recordedTcpRoutes, } from './remote-database.js';
@@ -86,8 +93,8 @@ export function coolifyProjectMark(org) {
86
93
  * listApps` -- a plain array, with no `loginBaseUri` or completeness signal
87
94
  * -- into {@link ZitadelPort.listApps}'s own shape, so a host wiring that
88
95
  * real client in satisfies this port without reaching into `executor.ts`
89
- * itself to build the wrapper by hand. `complete: true` on every call:
90
- * since providers 0.7.4 the real client pages `_search` to the end. A host
96
+ * itself to build the wrapper by hand. `complete` is true only when the client
97
+ * has `getApp` (#557; providers 0.12.0, which pages `_search` to the end). A host
91
98
  * that supplies its own `listApps` keeps whatever `complete` it reports.
92
99
  * Each app's `clientId` (providers 0.7.5) passes through. `loginBaseUri` comes back `undefined`
93
100
  * for every app for the identical reason -- the real client does not read
@@ -99,9 +106,10 @@ export function coolifyProjectMark(org) {
99
106
  export function adaptZitadelListApps(client) {
100
107
  return async (orgId, projectId) => ({
101
108
  apps: await client.listApps(orgId, projectId),
102
- // Sound because package.json requires providers >=0.7.4 as a peer: from
103
- // there `listApps` pages `_search` to the end, so this read every app.
104
- complete: true,
109
+ // #557: `getApp` arrived with providers 0.12.0, after `listApps` paged to
110
+ // the end (0.7.4), so a client that has it read every app. An older one
111
+ // may have returned a first page only; the peer range alone cannot tell.
112
+ complete: typeof client.getApp === 'function',
105
113
  });
106
114
  }
107
115
  /** An outside call for Boule's credentials that has not answered in this long fails the step; the next apply repairs it. */
@@ -890,6 +898,7 @@ function limitProfileOf(action) {
890
898
  case 'coolify:calls-chat':
891
899
  return LIMITS.calls;
892
900
  case 'coolify:agora':
901
+ case 'coolify:desktop-auth':
893
902
  return LIMITS.agora;
894
903
  case 'coolify:boule':
895
904
  case 'coolify:boule-broker-entry':
@@ -904,6 +913,10 @@ function limitProfileOf(action) {
904
913
  return action.system === 'coolify' ? LIMITS.companyOther : undefined;
905
914
  }
906
915
  }
916
+ /** The cookie domain a login-host action carries: a bare lower-case domain of two or more labels, else none. */
917
+ function inAppCookieDomain(value) {
918
+ return typeof value === 'string' && /^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(value) ? value : undefined;
919
+ }
907
920
  export class FoundryStepExecutor {
908
921
  edgeDns;
909
922
  edgeTunnels;
@@ -1127,6 +1140,10 @@ export class FoundryStepExecutor {
1127
1140
  return this.zitadelApp(action, org, context);
1128
1141
  case 'zitadel:id-token-userinfo':
1129
1142
  return this.zitadelIdTokenUserinfo(action, org, context);
1143
+ case 'zitadel:post-logout-uris':
1144
+ return this.zitadelPostLogoutUris(action, org, context);
1145
+ case 'zitadel:desktop-callback':
1146
+ return this.zitadelDesktopCallback(action, org, context);
1130
1147
  case 'zitadel:customers':
1131
1148
  return this.zitadelCustomers(action, org, context);
1132
1149
  case 'zitadel:mcp-project':
@@ -1147,6 +1164,12 @@ export class FoundryStepExecutor {
1147
1164
  return this.coolifyBrokerEntry(action, org, context);
1148
1165
  case 'coolify:database':
1149
1166
  return this.coolifyDatabase(action, org, context);
1167
+ case 'coolify:org-postgres':
1168
+ return this.orgPostgresServer(org, context);
1169
+ case 'coolify:org-postgres-databases':
1170
+ return this.orgPostgresDatabases(action, org, context);
1171
+ case 'coolify:org-postgres-migrate':
1172
+ return this.orgPostgresMigrate(action, org, context);
1150
1173
  case 'coolify:backup':
1151
1174
  return this.coolifyBackup(action, org, context);
1152
1175
  case 'coolify:calls':
@@ -1204,8 +1227,12 @@ export class FoundryStepExecutor {
1204
1227
  return this.cmsSite(action, org, context);
1205
1228
  case 'coolify:agora':
1206
1229
  return this.coolifyAgora(action, org, context);
1230
+ case 'coolify:desktop-auth':
1231
+ return this.coolifyDesktopAuth(action, org, context);
1207
1232
  case 'coolify:boule':
1208
1233
  return this.coolifyBoule(action, org, context);
1234
+ case 'coolify:boule-owners':
1235
+ return this.bouleOwners(org, context);
1209
1236
  case 'coolify:boule-broker-entry':
1210
1237
  return this.bouleBrokerEntry(org, context);
1211
1238
  case 'jobs:registration':
@@ -1407,6 +1434,7 @@ export class FoundryStepExecutor {
1407
1434
  throw new Error(`cloudflare:ratelimit for ${org.slug}: zone ${org.domain} already has a rate limiting rule ('${foreign.description ?? foreign.id}') foundry did not create; the Free plan allows only one rule per zone, so foundry does not adopt or replace it`);
1408
1435
  }
1409
1436
  // The other variant of foundry's own rule holds the zone's one slot: remove it, then make this one.
1437
+ // Known gap: between the delete and the create the zone has no rate limit (the Free plan has one slot).
1410
1438
  const stale = rules.find((r) => ours.includes(r.description ?? ''));
1411
1439
  if (stale) {
1412
1440
  if (!rateLimits.deleteRateLimitRule) {
@@ -1874,6 +1902,96 @@ export class FoundryStepExecutor {
1874
1902
  }
1875
1903
  return {};
1876
1904
  }
1905
+ /**
1906
+ * Replaces the post-logout redirect URIs of each live app the planner named.
1907
+ * An app's marker (its id and the set it holds) is recorded and persisted
1908
+ * only after its call returned, so a call that throws leaves none and the
1909
+ * next plan names the app again. The error names ids only, never the cause.
1910
+ */
1911
+ async zitadelPostLogoutUris(action, org, context) {
1912
+ const update = this.ports.zitadel.updateOidcAppPostLogoutRedirectUris?.bind(this.ports.zitadel);
1913
+ // The planner plans nothing without the method; a stale plan does nothing.
1914
+ if (!update)
1915
+ return {};
1916
+ const recorded = context.recordedIds();
1917
+ const zitadelOrgId = recorded.zitadel_org;
1918
+ const projectId = recorded.zitadel_project;
1919
+ if (!zitadelOrgId || !projectId) {
1920
+ throw new Error(`zitadel:post-logout-uris for ${org.slug}: no ZITADEL org and project recorded yet -- the issuer must be provisioned first`);
1921
+ }
1922
+ for (const appKey of action.desired.apps) {
1923
+ const appId = recorded[`zitadel:app:${appKey}`];
1924
+ if (!appId)
1925
+ continue;
1926
+ // From the declared org, never from the plan, so a stale plan writes the current set.
1927
+ const uris = companyAppPostLogoutUris(org, appKey);
1928
+ if (uris.length === 0)
1929
+ continue;
1930
+ try {
1931
+ await update(zitadelOrgId, projectId, appId, uris);
1932
+ }
1933
+ catch {
1934
+ throw new Error(`zitadel:post-logout-uris for ${org.slug}: updating the post-logout addresses failed for app '${appKey}' (${appId})`);
1935
+ }
1936
+ context.recordScopeId(zitadelAppPostLogoutAppliedScopeKey(appKey), postLogoutAppliedValue(appId, uris));
1937
+ await context.persistScopeIds();
1938
+ }
1939
+ return {};
1940
+ }
1941
+ /**
1942
+ * #339: registers `/auth/desktop/callback` on each live web sign-in app the
1943
+ * planner named, keeping every redirect URI the app already holds (the
1944
+ * union of the current list and the wanted one; a write only when the
1945
+ * union is larger). An app's marker (its ZITADEL app id) is recorded and
1946
+ * persisted only after its read and write resolved, so a failure leaves no
1947
+ * marker and the next plan names the app again. The error names ids only,
1948
+ * never the cause.
1949
+ */
1950
+ async zitadelDesktopCallback(action, org, context) {
1951
+ const read = this.ports.zitadel.getOidcAppRedirectUris?.bind(this.ports.zitadel);
1952
+ const write = this.ports.zitadel.updateOidcAppRedirectUris?.bind(this.ports.zitadel);
1953
+ // The planner plans nothing without the methods; a stale plan does nothing.
1954
+ if (!read || !write)
1955
+ return {};
1956
+ const recorded = context.recordedIds();
1957
+ const zitadelOrgId = recorded.zitadel_org;
1958
+ const projectId = recorded.zitadel_project;
1959
+ if (!zitadelOrgId || !projectId) {
1960
+ throw new Error(`zitadel:desktop-callback for ${org.slug}: no ZITADEL org and project recorded yet -- the issuer must be provisioned first`);
1961
+ }
1962
+ for (const appKey of action.desired.apps) {
1963
+ const appId = recorded[`zitadel:app:${appKey}`];
1964
+ if (!appId)
1965
+ continue;
1966
+ const wanted = this.signInHostnames(org, appKey).map(desktopCallbackUri);
1967
+ try {
1968
+ const current = await read(zitadelOrgId, projectId, appId);
1969
+ const union = unionRedirectUris(current, wanted);
1970
+ if (union.length > current.length)
1971
+ await write(zitadelOrgId, projectId, appId, union);
1972
+ }
1973
+ catch {
1974
+ throw new Error(`zitadel:desktop-callback for ${org.slug}: registering the desktop callback failed for app '${appKey}' (${appId})`);
1975
+ }
1976
+ context.recordScopeId(zitadelAppDesktopCallbackAppliedScopeKey(appKey), appId);
1977
+ await context.persistScopeIds();
1978
+ }
1979
+ return {};
1980
+ }
1981
+ /**
1982
+ * The hostnames a company web app signs in at: the same rule `zitadelApp`
1983
+ * uses for the redirect URIs it creates the app with.
1984
+ */
1985
+ signInHostnames(org, appKey) {
1986
+ const authEntry = org.hosting.entries.find((e) => e.key === appKey && e.uses?.auth);
1987
+ if (appKey === 'cms' && org.site.enabled)
1988
+ return [cmsHostnameFor(org)];
1989
+ if (appKey === 'boule')
1990
+ return [bouleHostnameFor(org)];
1991
+ if (authEntry)
1992
+ return entryHostnames(org, authEntry);
1993
+ return [appHostname(org)];
1994
+ }
1877
1995
  /**
1878
1996
  * `plan/diff.ts`'s `planLoginHost` (#267): adds the org's own company
1879
1997
  * login host (`action.desired.host`) to the ZITADEL instance's trusted
@@ -1883,10 +2001,12 @@ export class FoundryStepExecutor {
1883
2001
  *
1884
2002
  * In-app sign-in (`signIn.inApp`, `plan/sign-in.ts` has the order): an app host is trusted first. It is
1885
2003
  * bound, and its app pointed at `/sign-in/`, only once its app runs `SIGNIN_UPSTREAM` (the app step
1886
- * records that). The bind moves the app's client id off the company's binding in the same write (the
1887
- * login refuses a client id in two bindings); a company binding left with no client is removed.
1888
- * Switched off, the client id goes back and the base URI returns to the login host, and a trusted
1889
- * domain is given up only after the app dropped the env.
2004
+ * records that). From auth 0.28.0 the move is three writes (the login then allows one client in two
2005
+ * bindings of one company): the app host's binding with the id kept on the company's, the base URI, then
2006
+ * the company's binding without the id. Before it, one write moves the id (the login refuses a client in
2007
+ * two bindings) and a short gap follows. A company binding left with no client is removed.
2008
+ * Switched off, the id goes back to the company binding, then the base URI returns to the login host, then
2009
+ * the app binding goes; a trusted domain is given up only after the app dropped the env.
1890
2010
  *
1891
2011
  * #274: when the org's host has moved (`recorded[key]`, the *previous*
1892
2012
  * run's resourceId, differs from this run's `host`), also removes every
@@ -1985,6 +2105,55 @@ export class FoundryStepExecutor {
1985
2105
  const envLive = signInEnvLiveHosts(recorded);
1986
2106
  // The login refuses a client id held by two bindings: an active app host's client id is in its own binding only.
1987
2107
  const clientIds = loginHostClientIds(org, recorded, activePairs.map((pair) => pair.clientId));
2108
+ let deferredUnbound = [];
2109
+ // From the login release that lets one client sit in two bindings of one company, an app host's client id
2110
+ // moves in three writes: the app host's binding with the id kept on the company's, then the base URI, then
2111
+ // the company's binding without it. Sign-in works at every point. Before that release: one write, and a
2112
+ // short gap between it and the base URI.
2113
+ const authReleased = action.desired.authReleased === true;
2114
+ const companyIdsBefore = new Set((recorded[LOGIN_BINDING_SCOPE_KEY]?.startsWith(`${host} `)
2115
+ ? recorded[LOGIN_BINDING_SCOPE_KEY].slice(host.length + 1)
2116
+ : '')
2117
+ .split(',')
2118
+ .filter((id) => id.length > 0));
2119
+ const movedIds = new Set(!own && authReleased
2120
+ ? activePairs.map((pair) => pair.clientId).filter((id) => companyIdsBefore.has(id))
2121
+ : []);
2122
+ const threeStep = movedIds.size > 0;
2123
+ const firstWriteIds = threeStep
2124
+ ? loginHostClientIds(org, recorded, activePairs.map((pair) => pair.clientId).filter((id) => !movedIds.has(id)))
2125
+ : clientIds;
2126
+ const cookieDomain = inAppCookieDomain(action.desired.cookieDomain);
2127
+ const writeCompanyBinding = async (ids) => {
2128
+ if (ids.length > 0 || activePairs.length === 0) {
2129
+ await bindings.upsertBinding({ host, organizationId: zitadelOrgId, clientIds: ids });
2130
+ }
2131
+ else {
2132
+ // Every client of the company signs in on its app's own address: the login refuses a binding
2133
+ // with no client ids, so the company's own binding goes (nothing signs in on this host).
2134
+ try {
2135
+ await bindings.deleteBinding(host);
2136
+ }
2137
+ catch (error) {
2138
+ if (!isNotFoundError(error))
2139
+ throw error;
2140
+ }
2141
+ }
2142
+ };
2143
+ // The customers clients and the organisation each signs in to, for the shared login host to check.
2144
+ const wantedOrgs = customersClientOrganizations(org, recorded);
2145
+ const staleOrgIds = clientOrganizationIds(recorded[LOGIN_CLIENT_ORGS_SCOPE_KEY]).filter((id) => !(id in wantedOrgs));
2146
+ const orgsWritable = (Object.keys(wantedOrgs).length === 0 || Boolean(bindings.upsertClientOrganizations)) &&
2147
+ (staleOrgIds.length === 0 || Boolean(bindings.deleteClientOrganizations));
2148
+ const writeClientOrgs = async () => {
2149
+ if (!orgsWritable)
2150
+ return;
2151
+ if (Object.keys(wantedOrgs).length > 0) {
2152
+ await bindings.upsertClientOrganizations?.(wantedOrgs);
2153
+ }
2154
+ if (staleOrgIds.length > 0)
2155
+ await bindings.deleteClientOrganizations?.(staleOrgIds);
2156
+ };
1988
2157
  if (!shared) {
1989
2158
  // The company's own login host is its instance's external domain: trusted and routed already.
1990
2159
  if (!own)
@@ -1994,10 +2163,12 @@ export class FoundryStepExecutor {
1994
2163
  await this.ports.zitadel.ensureTrustedDomain(app.host);
1995
2164
  // An app host bound before and not active now gives its client id back to the company's binding:
1996
2165
  // its own binding goes first, so no write leaves the id in two bindings.
2166
+ // From the release that allows it the company's binding takes the id back first and the app host's
2167
+ // binding goes after the base URI (below), so no write leaves a client without a binding.
2168
+ const unboundHosts = inAppStateHosts(recorded[SIGN_IN_IN_APP_SCOPE_KEY]).filter((old) => !activeHosts.has(old));
2169
+ deferredUnbound = authReleased && !own ? unboundHosts : [];
1997
2170
  let unbound = false;
1998
- for (const old of inAppStateHosts(recorded[SIGN_IN_IN_APP_SCOPE_KEY])) {
1999
- if (activeHosts.has(old))
2000
- continue;
2171
+ for (const old of authReleased && !own ? [] : unboundHosts) {
2001
2172
  try {
2002
2173
  await bindings.deleteBinding(old);
2003
2174
  }
@@ -2011,27 +2182,20 @@ export class FoundryStepExecutor {
2011
2182
  await bindings.flush?.();
2012
2183
  // zitadel-login's own routing table, written before any app is repointed
2013
2184
  // so no app ever points at a host with no binding.
2014
- if (!own) {
2015
- if (clientIds.length > 0 || activePairs.length === 0) {
2016
- await bindings.upsertBinding({ host, organizationId: zitadelOrgId, clientIds });
2017
- }
2018
- else {
2019
- // Every client of the company signs in on its app's own address: the login refuses a binding
2020
- // with no client ids, so the company's own binding goes (nothing signs in on this host).
2021
- try {
2022
- await bindings.deleteBinding(host);
2023
- }
2024
- catch (error) {
2025
- if (!isNotFoundError(error))
2026
- throw error;
2027
- }
2028
- }
2029
- }
2185
+ if (!own)
2186
+ await writeCompanyBinding(firstWriteIds);
2030
2187
  for (const app of inApp) {
2031
2188
  if (!activeHosts.has(app.host))
2032
2189
  continue;
2033
- await bindings.upsertBinding({ ...inAppBindingFor(app), organizationId: zitadelOrgId });
2190
+ await bindings.upsertBinding({
2191
+ ...inAppBindingFor(app),
2192
+ organizationId: zitadelOrgId,
2193
+ ...(cookieDomain && (app.host === cookieDomain || app.host.endsWith(`.${cookieDomain}`))
2194
+ ? { cookieDomain }
2195
+ : {}),
2196
+ });
2034
2197
  }
2198
+ await writeClientOrgs();
2035
2199
  // One write for every upsert above (one redeploy of the login), before any app is repointed.
2036
2200
  await bindings.flush?.();
2037
2201
  if (own && activeHosts.size > 0) {
@@ -2070,11 +2234,28 @@ export class FoundryStepExecutor {
2070
2234
  // on), and nothing to repoint either.
2071
2235
  let liveApps = [];
2072
2236
  let listingComplete = true;
2237
+ // #580: a customers entry's app signs in on the shared host, wherever its base URI points now.
2238
+ // A company's own instance has no shared host to move to.
2239
+ const customersAppIds = new Set();
2240
+ const sharedBaseUri = own
2241
+ ? null
2242
+ : await this.ports.platformOrg?.authHostname?.().then((h) => (h ? `https://${h}/` : null));
2243
+ if (sharedBaseUri) {
2244
+ for (const key of customersKeys) {
2245
+ const appId = recorded[`zitadel:app:${key}`];
2246
+ if (appId)
2247
+ customersAppIds.add(appId);
2248
+ }
2249
+ }
2073
2250
  if (projectId) {
2074
2251
  const listed = await this.ports.zitadel.listApps(zitadelOrgId, projectId);
2075
2252
  listingComplete = listed.complete;
2076
2253
  liveApps = listed.apps.filter((app) => app.state !== 'APP_STATE_REMOVED');
2077
2254
  for (const app of liveApps) {
2255
+ if (sharedBaseUri && customersAppIds.has(app.id)) {
2256
+ await setOidcAppLoginBaseUri(this.ports.zitadel, zitadelOrgId, projectId, app.id, sharedBaseUri);
2257
+ continue;
2258
+ }
2078
2259
  if (!boundAppIds.has(app.id))
2079
2260
  continue;
2080
2261
  // A company's own instance has the login host as its default; only the in-app apps move.
@@ -2083,6 +2264,27 @@ export class FoundryStepExecutor {
2083
2264
  await setOidcAppLoginBaseUri(this.ports.zitadel, zitadelOrgId, projectId, app.id, baseUriOf(app.id));
2084
2265
  }
2085
2266
  }
2267
+ // The base URIs are set: the company's binding gives up the ids that moved to the app hosts, and an app
2268
+ // host given up goes last.
2269
+ if (!shared) {
2270
+ if (threeStep)
2271
+ await writeCompanyBinding(clientIds);
2272
+ for (const old of deferredUnbound) {
2273
+ try {
2274
+ await bindings.deleteBinding(old);
2275
+ }
2276
+ catch (error) {
2277
+ if (!isNotFoundError(error))
2278
+ throw error;
2279
+ }
2280
+ }
2281
+ if (threeStep || deferredUnbound.length > 0)
2282
+ await bindings.flush?.();
2283
+ }
2284
+ else if (orgsWritable) {
2285
+ await writeClientOrgs();
2286
+ await bindings.flush?.();
2287
+ }
2086
2288
  // Fix round 3 point 5: a bound app's current base URI is `host` for
2087
2289
  // certain, just set above, whatever `listApps` read before that --
2088
2290
  // never its stale pre-repoint snapshot. Every other live app's is
@@ -2092,7 +2294,11 @@ export class FoundryStepExecutor {
2092
2294
  // compare against, same as an app this loop never visits at all.
2093
2295
  const currentLoginBaseUris = new Set();
2094
2296
  for (const app of liveApps) {
2095
- const uri = boundAppIds.has(app.id) ? baseUriOf(app.id) : app.loginBaseUri;
2297
+ const uri = customersAppIds.has(app.id) && sharedBaseUri
2298
+ ? sharedBaseUri
2299
+ : boundAppIds.has(app.id)
2300
+ ? baseUriOf(app.id)
2301
+ : app.loginBaseUri;
2096
2302
  if (typeof uri === 'string')
2097
2303
  currentLoginBaseUris.add(normalizedLoginBaseUri(uri));
2098
2304
  }
@@ -2177,9 +2383,15 @@ export class FoundryStepExecutor {
2177
2383
  else {
2178
2384
  if (!own)
2179
2385
  context.recordScopeId(LOGIN_BINDING_SCOPE_KEY, loginBindingState(host, clientIds));
2386
+ if (!own) {
2387
+ context.recordScopeId(LOGIN_COOKIE_DOMAIN_SCOPE_KEY, activePairs.length > 0 && cookieDomain ? cookieDomain : '');
2388
+ }
2180
2389
  context.recordScopeId(SIGN_IN_TRUSTED_SCOPE_KEY, targets.trusted);
2181
2390
  context.recordScopeId(SIGN_IN_IN_APP_SCOPE_KEY, targets.active);
2182
2391
  }
2392
+ if (orgsWritable) {
2393
+ context.recordScopeId(LOGIN_CLIENT_ORGS_SCOPE_KEY, clientOrganizationsState(wantedOrgs));
2394
+ }
2183
2395
  context.recordScopeId(LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, pendingRemovalState(stillPending));
2184
2396
  return { resourceId: host };
2185
2397
  }
@@ -2195,6 +2407,8 @@ export class FoundryStepExecutor {
2195
2407
  }
2196
2408
  const upsert = [];
2197
2409
  const remove = [];
2410
+ let orgsUpsert = {};
2411
+ const orgsRemove = [];
2198
2412
  return {
2199
2413
  upsertBinding: async (binding) => {
2200
2414
  upsert.push(binding);
@@ -2202,12 +2416,29 @@ export class FoundryStepExecutor {
2202
2416
  deleteBinding: async (host) => {
2203
2417
  remove.push(host);
2204
2418
  },
2419
+ upsertClientOrganizations: async (entries) => {
2420
+ Object.assign(orgsUpsert, entries);
2421
+ },
2422
+ deleteClientOrganizations: async (clientIds) => {
2423
+ orgsRemove.push(...clientIds);
2424
+ },
2205
2425
  flush: async () => {
2206
- if (upsert.length === 0 && remove.length === 0)
2426
+ const orgsEmpty = Object.keys(orgsUpsert).length === 0 && orgsRemove.length === 0;
2427
+ if (upsert.length === 0 && remove.length === 0 && orgsEmpty)
2207
2428
  return;
2208
- const edits = { appId, instanceHost, upsert: [...upsert], remove: [...remove] };
2429
+ const edits = {
2430
+ appId,
2431
+ instanceHost,
2432
+ upsert: [...upsert],
2433
+ remove: [...remove],
2434
+ ...(orgsEmpty
2435
+ ? {}
2436
+ : { clientOrganizations: { upsert: { ...orgsUpsert }, remove: [...orgsRemove] } }),
2437
+ };
2209
2438
  upsert.length = 0;
2210
2439
  remove.length = 0;
2440
+ orgsUpsert = {};
2441
+ orgsRemove.length = 0;
2211
2442
  await editSignInBindings(this.ports.coolify, edits);
2212
2443
  },
2213
2444
  };
@@ -2420,6 +2651,9 @@ export class FoundryStepExecutor {
2420
2651
  // An entry with `uses.auth` signs in at its own exact hostnames, one pair
2421
2652
  // of addresses each; a `*` adds none (`entryHostnames`).
2422
2653
  const hosts = authEntry ? entryHostnames(org, authEntry) : null;
2654
+ const postLogoutUris = appType === 'native' ? [] : companyAppPostLogoutUris(org, action.key);
2655
+ // #339: every `@wtfalch/auth` web client also takes the desktop callback (Agora's own client does not).
2656
+ const desktopCallbacks = appType === 'web' && !isAgoraClient ? (hosts ?? [hostname]).map(desktopCallbackUri) : [];
2423
2657
  const created = await this.ports.zitadel.createOidcApp(zitadelOrgId, projectId, {
2424
2658
  name: action.key,
2425
2659
  appType,
@@ -2428,14 +2662,11 @@ export class FoundryStepExecutor {
2428
2662
  : hosts
2429
2663
  ? [
2430
2664
  ...hosts.map((host) => `https://${host}/auth/callback`),
2665
+ ...desktopCallbacks,
2431
2666
  ...(authEntry?.uses?.auth?.mcp ? [mcpCallbackUri(org)] : []),
2432
2667
  ]
2433
- : [redirectUri],
2434
- postLogoutRedirectUris: appType === 'native'
2435
- ? []
2436
- : hosts
2437
- ? hosts.map((host) => `https://${host}/`)
2438
- : [`https://${hostname}/`],
2668
+ : [redirectUri, ...desktopCallbacks],
2669
+ postLogoutRedirectUris: postLogoutUris,
2439
2670
  ...(authEntry?.uses?.auth?.mcp ? { accessTokenType: 'jwt' } : {}),
2440
2671
  });
2441
2672
  if (appType === 'web') {
@@ -2451,6 +2682,12 @@ export class FoundryStepExecutor {
2451
2682
  }
2452
2683
  // Made with the setting on (providers 0.9.0): nothing to repair on this id.
2453
2684
  context.recordScopeId(zitadelAppIdTokenUserinfoAppliedScopeKey(action.key), created.appId);
2685
+ if (appType === 'web') {
2686
+ context.recordScopeId(zitadelAppPostLogoutAppliedScopeKey(action.key), postLogoutAppliedValue(created.appId, postLogoutUris));
2687
+ }
2688
+ if (desktopCallbacks.length > 0) {
2689
+ context.recordScopeId(zitadelAppDesktopCallbackAppliedScopeKey(action.key), created.appId);
2690
+ }
2454
2691
  return { resourceId: created.appId };
2455
2692
  }
2456
2693
  /**
@@ -2979,7 +3216,11 @@ export class FoundryStepExecutor {
2979
3216
  let dbPush = { pushed: false, changed: false };
2980
3217
  if (entry.database) {
2981
3218
  const dbId = context.recordedIds()[`coolify:database:${org.id}`];
2982
- if (dbId) {
3219
+ if (this.homeOnServer(org, context, 'app')) {
3220
+ const onServer = await this.serverHome(org, context, 'app');
3221
+ dbPush = await this.pushDatabaseUrl(appId, '', org.slug.replace(/-/g, '_'), onServer);
3222
+ }
3223
+ else if (dbId) {
2983
3224
  dbPush = await this.pushDatabaseUrl(appId, dbId, org.slug.replace(/-/g, '_'));
2984
3225
  }
2985
3226
  }
@@ -3141,7 +3382,7 @@ export class FoundryStepExecutor {
3141
3382
  * the connection URL or a response body.
3142
3383
  */
3143
3384
  async coolifyBackup(action, org, context) {
3144
- const { databaseId, database, storageUuid } = action.desired;
3385
+ const { databaseId, database, storageUuid, databaseNames } = action.desired;
3145
3386
  if (typeof databaseId !== 'string' ||
3146
3387
  typeof database !== 'string' ||
3147
3388
  typeof storageUuid !== 'string' ||
@@ -3153,11 +3394,115 @@ export class FoundryStepExecutor {
3153
3394
  databaseUuid: databaseId,
3154
3395
  label: `${org.slug} ${database}`,
3155
3396
  storageUuid,
3156
- databaseName: COMPANY_BACKUP_DATABASE_NAMES[database],
3397
+ databaseName: typeof databaseNames === 'string' && databaseNames
3398
+ ? databaseNames
3399
+ : COMPANY_BACKUP_DATABASE_NAMES[database],
3157
3400
  });
3158
3401
  context.recordScopeId(backupScopeKey(database), uuid);
3402
+ if (typeof databaseNames === 'string' && databaseNames) {
3403
+ context.recordScopeId(BACKUP_POSTGRES_DATABASES_SCOPE_KEY, databaseNames);
3404
+ }
3159
3405
  return undefined;
3160
3406
  }
3407
+ orgPostgresDeps(org, context) {
3408
+ return {
3409
+ coolify: this.ports.coolify,
3410
+ ensureProject: () => this.ensureCoolifyProject(org, context),
3411
+ serverUuid: async () => (await this.resolveServer(org, context)).uuid,
3412
+ startDatabase: (uuid) => getDatabaseStarted(this.ports.coolify, uuid),
3413
+ deliverSecret: (ref, secret) => this.ports.deliverSecret(ref, secret),
3414
+ onSecret: (value) => void this.handledSecrets.add(value),
3415
+ runtime: {
3416
+ ...(this.ports.runtime?.sleep ? { sleep: this.ports.runtime.sleep } : {}),
3417
+ ...(this.ports.runtime?.now ? { now: this.ports.runtime.now } : {}),
3418
+ },
3419
+ };
3420
+ }
3421
+ /** `coolify:org-postgres` (#444): the company's one Postgres server. */
3422
+ async orgPostgresServer(org, context) {
3423
+ return provisionOrgPostgresServer(scopeFromOrg(context, {}), this.orgPostgresDeps(org, context), org);
3424
+ }
3425
+ /** `coolify:org-postgres-databases` (#444): a database and its roles for each home that has none. */
3426
+ async orgPostgresDatabases(action, org, context) {
3427
+ const homes = action.desired.homes;
3428
+ if (!Array.isArray(homes) || !homes.every((home) => typeof home === 'string')) {
3429
+ throw new Error(`coolify:org-postgres-databases for ${org.slug}: the plan named no homes`);
3430
+ }
3431
+ await provisionOrgPostgresDatabases(scopeFromOrg(context, {}), this.orgPostgresDeps(org, context), org, homes);
3432
+ return {};
3433
+ }
3434
+ /**
3435
+ * `coolify:org-postgres-migrate` (#444): one home's data from its old Coolify database to the
3436
+ * company's server. Refuses before any port call unless the staging confirmation is recorded.
3437
+ */
3438
+ async orgPostgresMigrate(action, org, context) {
3439
+ const scope = scopeFromOrg(context, {});
3440
+ if (!parseMigrationConfirmation(scope.recordedIds()[ORG_POSTGRES_MIGRATION_CONFIRMED_SCOPE_KEY])) {
3441
+ throw new OrgPostgresMigrationNotConfirmedError();
3442
+ }
3443
+ const home = migrateHomeOf(action);
3444
+ await migrateOrgPostgresHome(scope, {
3445
+ coolify: this.ports.coolify,
3446
+ ensureProject: () => this.ensureCoolifyProject(org, context),
3447
+ serverUuid: async () => (await this.resolveServer(org, context)).uuid,
3448
+ startServer: (uuid) => getDatabaseStarted(this.ports.coolify, uuid),
3449
+ readLegacyDatabase: (uuid) => this.ports.coolify.getDatabase(uuid),
3450
+ resolveSecret: async (ref) => (await this.ports.heldKeys?.resolve(ref)) ?? undefined,
3451
+ consumers: (schema) => this.migrationConsumers(org, context, schema.schema),
3452
+ onSecret: (value) => void this.handledSecrets.add(value),
3453
+ runtime: {
3454
+ ...(this.ports.runtime?.sleep ? { sleep: this.ports.runtime.sleep } : {}),
3455
+ ...(this.ports.runtime?.now ? { now: this.ports.runtime.now } : {}),
3456
+ },
3457
+ }, org, home);
3458
+ return {};
3459
+ }
3460
+ /** The apps that use a home's database, found the way each service finds its own app. */
3461
+ async migrationConsumers(org, context, schema) {
3462
+ const recorded = context.recordedIds();
3463
+ const byName = async (...names) => {
3464
+ const projectUuid = await this.ensureCoolifyProject(org, context);
3465
+ const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
3466
+ const ids = [];
3467
+ for (const name of names) {
3468
+ const found = await this.ports.coolify.findApplicationByName(name, {
3469
+ environmentIds: [environment.id],
3470
+ });
3471
+ if (found)
3472
+ ids.push(found.uuid);
3473
+ }
3474
+ return ids;
3475
+ };
3476
+ const chat = chatInternalNames(org.slug);
3477
+ switch (schema) {
3478
+ case 'app': {
3479
+ const entry = org.hosting.entries.find((e) => e.database);
3480
+ const id = entry && recorded[`coolify:app:${appStepKey(org, entry.key)}`];
3481
+ return { appIds: id ? [id] : [], template: true };
3482
+ }
3483
+ case 'cms': {
3484
+ const id = recorded[CMS_APP_SCOPE_KEY];
3485
+ return { appIds: id ? [id] : [], template: true };
3486
+ }
3487
+ case 'boule':
3488
+ return { appIds: await byName(`${org.slug}-boule`), template: true };
3489
+ case 'chat_service':
3490
+ return { appIds: await byName(chat.service), template: true };
3491
+ case 'calls_service':
3492
+ return { appIds: await byName(callsServiceInternalName(org)), template: true };
3493
+ case 'chat_synapse':
3494
+ case 'chat_mas':
3495
+ return { appIds: await byName(chat.synapse, chat.mas), template: false };
3496
+ case 'calls_synapse':
3497
+ case 'calls_mas':
3498
+ return {
3499
+ appIds: await byName(`${org.slug}-calls-synapse`, `${org.slug}-calls-mas`),
3500
+ template: false,
3501
+ };
3502
+ default:
3503
+ throw new Error(`${org.slug}: ${schema} has no apps to stop`);
3504
+ }
3505
+ }
3161
3506
  async coolifyDatabase(action, org, context) {
3162
3507
  if (action.desired.enabled === false) {
3163
3508
  const dbId = context.recordedIds()[`coolify:database:${action.key}`];
@@ -3169,6 +3514,10 @@ export class FoundryStepExecutor {
3169
3514
  }
3170
3515
  const existing = context.recordedIds()[`coolify:database:${action.key}`];
3171
3516
  let dbId;
3517
+ if (this.homeOnServer(org, context, 'app')) {
3518
+ // #444: the app's database is on the company's server; this step makes none.
3519
+ return {};
3520
+ }
3172
3521
  if (existing) {
3173
3522
  dbId = existing;
3174
3523
  }
@@ -3229,22 +3578,91 @@ export class FoundryStepExecutor {
3229
3578
  * recorded in `serviceIds` (hence never in the store or an audit record),
3230
3579
  * never logged, never interpolated into a thrown error.
3231
3580
  */
3232
- async pushDatabaseUrl(appId, dbId, dbName) {
3233
- const db = await getDatabaseStarted(this.ports.coolify, dbId);
3581
+ async pushDatabaseUrl(appId, dbId, dbName, onServer) {
3582
+ // On the company's server (#444) the owner URL is already built from the
3583
+ // held password; `dbId` is then unused.
3584
+ const ownerUrl = onServer
3585
+ ? onServer.ownerUrl
3586
+ : (await getDatabaseStarted(this.ports.coolify, dbId)).internal_db_url;
3234
3587
  // Nothing to push; `planApp` reports this as blocked from the live reader.
3235
- if (!db.internal_db_url)
3588
+ if (!ownerUrl)
3236
3589
  return { pushed: false, changed: false };
3237
3590
  const envs = [
3238
- { key: 'DATABASE_URL_OWNER', value: db.internal_db_url },
3591
+ { key: 'DATABASE_URL_OWNER', value: ownerUrl },
3239
3592
  ];
3240
- const runtimeUrl = await this.ensureAppRuntimeUrl(appId, db.internal_db_url, dbName);
3593
+ const runtimeUrl = await this.ensureAppRuntimeUrl(appId, ownerUrl, onServer?.runtimeUser ?? `${dbName}_rt`);
3241
3594
  if (runtimeUrl)
3242
3595
  envs.push({ key: 'DATABASE_URL', value: runtimeUrl });
3243
3596
  // True when a value actually changed, so the caller restarts the app only
3244
3597
  // then (the owner URL is upserted on every apply).
3245
3598
  const ownerBefore = (await this.ports.coolify.listEnvs(appId)).find((e) => e.key === 'DATABASE_URL_OWNER' && !e.is_preview)?.value;
3246
3599
  await this.ports.coolify.bulkUpsertEnvs(appId, envs);
3247
- return { pushed: true, changed: runtimeUrl !== null || ownerBefore !== db.internal_db_url };
3600
+ return { pushed: true, changed: runtimeUrl !== null || ownerBefore !== ownerUrl };
3601
+ }
3602
+ /**
3603
+ * #444: the home's owner URL on the company's Postgres server, built from
3604
+ * the server's live host and the owner password the host holds
3605
+ * (`deliverSecret` under `org-postgres:<slug>:<home>:owner`). Only for a
3606
+ * home {@link homeLocation} puts on the server. The password is registered
3607
+ * for redaction and goes only into the returned URL.
3608
+ */
3609
+ async serverHome(org, context, schema, options = {}) {
3610
+ const home = ORG_SERVICE_SCHEMAS.find((h) => h.schema === schema);
3611
+ const serverId = context.recordedIds()[ORG_POSTGRES_SERVER_SCOPE_KEY];
3612
+ if (!home || !serverId) {
3613
+ throw new Error(`${org.slug}: the Postgres server for ${schema} is not recorded yet`);
3614
+ }
3615
+ const server = await getDatabaseStarted(this.ports.coolify, serverId);
3616
+ const endpoint = server.internal_db_url
3617
+ ? serviceEndpoint(org, home, server.internal_db_url)
3618
+ : null;
3619
+ if (!endpoint) {
3620
+ throw new Error(`${org.slug}: the Postgres server has no internal URL yet -- retry this apply once Coolify reports one`);
3621
+ }
3622
+ const password = await this.ports.heldKeys?.resolve(orgPostgresSecretRef(org, schema, 'owner'));
3623
+ if (!password) {
3624
+ throw new Error(`${org.slug}: the owner password for ${schema} is not held yet`);
3625
+ }
3626
+ this.handledSecrets.add(password);
3627
+ const runtimePassword = options.runtime
3628
+ ? await this.ports.heldKeys?.resolve(orgPostgresSecretRef(org, schema, 'runtime'))
3629
+ : null;
3630
+ if (options.runtime && !runtimePassword) {
3631
+ throw new Error(`${org.slug}: the runtime password for ${schema} is not held yet`);
3632
+ }
3633
+ if (runtimePassword)
3634
+ this.handledSecrets.add(runtimePassword);
3635
+ const { owner, runtime } = serviceUrls(endpoint, password, runtimePassword ?? null);
3636
+ this.handledSecrets.add(owner);
3637
+ if (runtime)
3638
+ this.handledSecrets.add(runtime);
3639
+ return { ownerUrl: owner, runtimeUser: endpoint.runtimeUser, runtimeUrl: runtime };
3640
+ }
3641
+ /** #444: whether `schema`'s database is on the company's server (recorded, and not waiting for its migration). */
3642
+ homeOnServer(org, context, schema) {
3643
+ const home = ORG_SERVICE_SCHEMAS.find((h) => h.schema === schema);
3644
+ return home !== undefined && serviceOnServer(org, home, context.recordedIds());
3645
+ }
3646
+ /**
3647
+ * #444: a home's database. On the company's server nothing is made or
3648
+ * recorded (the legacy id stays unset, or the home would read as unmigrated)
3649
+ * and `ensureLegacy` is never called, so no `POSTGRES_INITDB_ARGS` is set:
3650
+ * the provisioning job creates the database with its collation. Otherwise
3651
+ * today's calls, unchanged.
3652
+ */
3653
+ async ensureHomeDatabase(org, context, schema, scopeKey, ensureLegacy) {
3654
+ if (this.homeOnServer(org, context, schema)) {
3655
+ return { id: '', server: await this.serverHome(org, context, schema) };
3656
+ }
3657
+ const id = await ensureLegacy();
3658
+ context.recordScopeId(scopeKey, id);
3659
+ return { id, server: null };
3660
+ }
3661
+ /** The owner URL of a home from {@link ensureHomeDatabase}; the legacy one is read live and started. */
3662
+ async homeOwnerUrl(home) {
3663
+ return home.server
3664
+ ? home.server.ownerUrl
3665
+ : (await getDatabaseStarted(this.ports.coolify, home.id)).internal_db_url;
3248
3666
  }
3249
3667
  /**
3250
3668
  * `null` once the app already has a `DATABASE_URL` -- never rotated, the
@@ -3256,7 +3674,7 @@ export class FoundryStepExecutor {
3256
3674
  * hands the app next. See `ensureCmsRuntimeUrl`'s own doc comment for the
3257
3675
  * parsing and encoding choices this mirrors exactly.
3258
3676
  */
3259
- async ensureAppRuntimeUrl(appId, dbInternalUrl, dbName) {
3677
+ async ensureAppRuntimeUrl(appId, dbInternalUrl, runtimeUser) {
3260
3678
  const envs = await this.ports.coolify.listEnvs(appId);
3261
3679
  // An existing `DATABASE_URL` that is still the superuser string is what an
3262
3680
  // app got before the split (#239): treat it as absent so the runtime role
@@ -3274,7 +3692,7 @@ export class FoundryStepExecutor {
3274
3692
  return null;
3275
3693
  }
3276
3694
  const password = randomBytes(24).toString('base64url');
3277
- return `postgres://${dbName}_rt:${password}@${host}${pathname}${search}`;
3695
+ return `postgres://${runtimeUser}:${password}@${host}${pathname}${search}`;
3278
3696
  }
3279
3697
  /**
3280
3698
  * Pushes what the org's app needs to sign a user in through ZITADEL (gap
@@ -3525,6 +3943,51 @@ export class FoundryStepExecutor {
3525
3943
  * hosting entry's is: with the `AUTH_APP_KEY` `coolifyBoule` put in the
3526
3944
  * Boule app's env.
3527
3945
  */
3946
+ async bouleOwners(org, context) {
3947
+ const at = `coolify:boule-owners for ${org.slug}`;
3948
+ const recorded = context.recordedIds();
3949
+ const appId = recorded[`coolify:boule:${org.id}`];
3950
+ const organisationId = recorded[ZITADEL_ORG_SCOPE_KEY];
3951
+ if (!appId || !organisationId) {
3952
+ throw new Error(`${at}: the Boule app or its sign-in organisation is not recorded yet`);
3953
+ }
3954
+ const secret = (await this.ports.coolify.listEnvs(appId)).find((e) => e.key === BOULE_OWNER_SEED_SECRET_ENV && !e.is_preview)?.value;
3955
+ if (!secret)
3956
+ throw new Error(`${at}: the app has no ${BOULE_OWNER_SEED_SECRET_ENV} yet`);
3957
+ this.handledSecrets.add(secret);
3958
+ const owners = bouleOwnerSeeds(org, recorded);
3959
+ if (owners.length === 0)
3960
+ return {};
3961
+ let results;
3962
+ try {
3963
+ results = (await (this.ports.bouleOwners ?? httpBouleOwners()).seed({
3964
+ hostname: bouleHostnameFor(org),
3965
+ organisationId,
3966
+ secret,
3967
+ owners,
3968
+ })).results;
3969
+ }
3970
+ catch (error) {
3971
+ const status = error instanceof BouleOwnersError ? error.status : 0;
3972
+ throw new Error(status === 404
3973
+ ? `Boule for ${org.slug} has no owner-seeding door yet: its deploy is older than the foundry release; retry once Boule has deployed main`
3974
+ : `${at}: ${status === 0 ? 'Boule did not answer' : `Boule answered ${status}`}`);
3975
+ }
3976
+ // One at a time, only after Boule answered for that owner.
3977
+ owners.forEach((owner, index) => {
3978
+ const result = results.find((r) => r.subject === owner.subject);
3979
+ if (!result)
3980
+ throw new Error(`${at}: Boule gave no result for owner ${index + 1}`);
3981
+ context.recordScopeId(result.outcome === 'refused'
3982
+ ? bouleOwnerRefusedScopeKey(owner.email)
3983
+ : bouleOwnerSeededScopeKey(owner.email), result.outcome === 'refused'
3984
+ ? result.code === 'platform_frozen' || result.code === 'holds_other_role'
3985
+ ? result.code
3986
+ : 'refused'
3987
+ : '1');
3988
+ });
3989
+ return {};
3990
+ }
3528
3991
  async bouleBrokerEntry(org, context) {
3529
3992
  const broker = this.ports.broker;
3530
3993
  if (!broker) {
@@ -4193,8 +4656,15 @@ export class FoundryStepExecutor {
4193
4656
  // and retried on the next one, rather than silently skipped -- the two
4194
4657
  // `bulkUpsertEnvs` calls above have already landed by the time either
4195
4658
  // could throw.
4196
- const dbId = await this.ensureCmsDatabase(org, context, projectUuid, environment);
4197
- context.recordScopeId(CMS_DATABASE_SCOPE_KEY, dbId);
4659
+ // #444: on the company's server there is no Coolify database to make, and the
4660
+ // legacy id stays unset (recording it would mark the home as unmigrated).
4661
+ const cmsOnServer = this.homeOnServer(org, context, 'cms');
4662
+ const cmsServer = cmsOnServer ? await this.serverHome(org, context, 'cms') : null;
4663
+ const dbId = cmsOnServer
4664
+ ? ''
4665
+ : await this.ensureCmsDatabase(org, context, projectUuid, environment);
4666
+ if (!cmsOnServer)
4667
+ context.recordScopeId(CMS_DATABASE_SCOPE_KEY, dbId);
4198
4668
  // Read back from Coolify's own record (`pushDatabaseUrl`'s own doc
4199
4669
  // comment on why: live state, not `createPostgresDatabase`'s return
4200
4670
  // value, since this run may have just found the database rather than
@@ -4203,12 +4673,14 @@ export class FoundryStepExecutor {
4203
4673
  // recorded, logged, or interpolated into a thrown error. Its own call,
4204
4674
  // separate from the two app-env calls above, since it has nothing to
4205
4675
  // push once `internal_db_url` is absent.
4206
- const db = await getDatabaseStarted(this.ports.coolify, dbId);
4207
- if (db.internal_db_url) {
4676
+ const ownerUrl = cmsServer
4677
+ ? cmsServer.ownerUrl
4678
+ : (await getDatabaseStarted(this.ports.coolify, dbId)).internal_db_url;
4679
+ if (ownerUrl) {
4208
4680
  const dbEnvs = [
4209
- { key: 'DATABASE_URL_OWNER', value: db.internal_db_url },
4681
+ { key: 'DATABASE_URL_OWNER', value: ownerUrl },
4210
4682
  ];
4211
- const runtimeUrl = await this.ensureCmsRuntimeUrl(cms.appId, db.internal_db_url);
4683
+ const runtimeUrl = await this.ensureCmsRuntimeUrl(cms.appId, ownerUrl);
4212
4684
  if (runtimeUrl)
4213
4685
  dbEnvs.push({ key: 'DATABASE_URL', value: runtimeUrl });
4214
4686
  await this.ports.coolify.bulkUpsertEnvs(cms.appId, dbEnvs);
@@ -4400,18 +4872,14 @@ export class FoundryStepExecutor {
4400
4872
  synapseDb: `${org.slug}-calls-synapse-db`,
4401
4873
  masDb: `${org.slug}-calls-mas-db`,
4402
4874
  };
4403
- const synapseDbId = await this.ensureCallsDatabase(org, context, projectUuid, environment, names.synapseDb, 'synapse', CALLS_SYNAPSE_DB_SCOPE_KEY, CALLS_SYNAPSE_DB_INITDB_ARGS);
4404
- context.recordScopeId(CALLS_SYNAPSE_DB_SCOPE_KEY, synapseDbId);
4405
- const masDbId = await this.ensureCallsDatabase(org, context, projectUuid, environment, names.masDb, 'mas', CALLS_MAS_DB_SCOPE_KEY);
4406
- context.recordScopeId(CALLS_MAS_DB_SCOPE_KEY, masDbId);
4407
- const [synapseDbRow, masDbRow] = await Promise.all([
4408
- getDatabaseStarted(this.ports.coolify, synapseDbId),
4409
- getDatabaseStarted(this.ports.coolify, masDbId),
4875
+ const synapseHome = await this.ensureHomeDatabase(org, context, 'calls_synapse', CALLS_SYNAPSE_DB_SCOPE_KEY, () => this.ensureCallsDatabase(org, context, projectUuid, environment, names.synapseDb, 'synapse', CALLS_SYNAPSE_DB_SCOPE_KEY, CALLS_SYNAPSE_DB_INITDB_ARGS));
4876
+ const masHome = await this.ensureHomeDatabase(org, context, 'calls_mas', CALLS_MAS_DB_SCOPE_KEY, () => this.ensureCallsDatabase(org, context, projectUuid, environment, names.masDb, 'mas', CALLS_MAS_DB_SCOPE_KEY));
4877
+ const [synapseDbUrl, masDbUrl] = await Promise.all([
4878
+ this.homeOwnerUrl(synapseHome),
4879
+ this.homeOwnerUrl(masHome),
4410
4880
  ]);
4411
- const synapseDb = synapseDbRow.internal_db_url
4412
- ? parseCallsDbUrl(synapseDbRow.internal_db_url)
4413
- : null;
4414
- const masDb = masDbRow.internal_db_url ? parseCallsDbUrl(masDbRow.internal_db_url) : null;
4881
+ const synapseDb = synapseDbUrl ? parseCallsDbUrl(synapseDbUrl) : null;
4882
+ const masDb = masDbUrl ? parseCallsDbUrl(masDbUrl) : null;
4415
4883
  if (!synapseDb || !masDb) {
4416
4884
  // Static config check before the two apps below are ever touched
4417
4885
  // (issue #128's "static config checks run before the first Coolify
@@ -4790,10 +5258,11 @@ export class FoundryStepExecutor {
4790
5258
  if (action.desired.refreshDatabaseSplit === true) {
4791
5259
  const serviceAppId = recorded[CHAT_SERVICE_SCOPE_KEY];
4792
5260
  const serviceDbId = recorded[CHAT_SERVICE_DB_SCOPE_KEY];
4793
- if (!serviceAppId || !serviceDbId) {
5261
+ const serviceOnServer = this.homeOnServer(org, context, 'chat_service');
5262
+ if (!serviceAppId || (!serviceDbId && !serviceOnServer)) {
4794
5263
  throw new Error(`coolify:chat database split for ${org.slug}: the chat service app or its database is not recorded yet`);
4795
5264
  }
4796
- const push = await this.pushDatabaseUrl(serviceAppId, serviceDbId, 'chat');
5265
+ const push = await this.pushDatabaseUrl(serviceAppId, serviceDbId ?? '', 'chat', serviceOnServer ? await this.serverHome(org, context, 'chat_service') : undefined);
4797
5266
  const markerKey = serviceDbEnvAppliedScopeKey('chat');
4798
5267
  if (!push.pushed) {
4799
5268
  // Unlike a fresh provision, this chat was already fully up and
@@ -4860,22 +5329,17 @@ export class FoundryStepExecutor {
4860
5329
  const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
4861
5330
  const server = await this.resolveServer(org, context);
4862
5331
  const names = chatInternalNames(org.slug);
4863
- const synapseDbId = await this.ensureCallsDatabase(org, context, projectUuid, environment, names.synapseDb, 'synapse', CHAT_SYNAPSE_DB_SCOPE_KEY, CALLS_SYNAPSE_DB_INITDB_ARGS);
4864
- context.recordScopeId(CHAT_SYNAPSE_DB_SCOPE_KEY, synapseDbId);
4865
- const masDbId = await this.ensureCallsDatabase(org, context, projectUuid, environment, names.masDb, 'mas', CHAT_MAS_DB_SCOPE_KEY);
4866
- context.recordScopeId(CHAT_MAS_DB_SCOPE_KEY, masDbId);
4867
- const serviceDbId = await this.ensureCallsDatabase(org, context, projectUuid, environment, names.serviceDb, 'chat', CHAT_SERVICE_DB_SCOPE_KEY);
4868
- context.recordScopeId(CHAT_SERVICE_DB_SCOPE_KEY, serviceDbId);
4869
- const [synapseDbRow, masDbRow, serviceDbRow] = await Promise.all([
4870
- getDatabaseStarted(this.ports.coolify, synapseDbId),
4871
- getDatabaseStarted(this.ports.coolify, masDbId),
4872
- getDatabaseStarted(this.ports.coolify, serviceDbId),
5332
+ const synapseHome = await this.ensureHomeDatabase(org, context, 'chat_synapse', CHAT_SYNAPSE_DB_SCOPE_KEY, () => this.ensureCallsDatabase(org, context, projectUuid, environment, names.synapseDb, 'synapse', CHAT_SYNAPSE_DB_SCOPE_KEY, CALLS_SYNAPSE_DB_INITDB_ARGS));
5333
+ const masHome = await this.ensureHomeDatabase(org, context, 'chat_mas', CHAT_MAS_DB_SCOPE_KEY, () => this.ensureCallsDatabase(org, context, projectUuid, environment, names.masDb, 'mas', CHAT_MAS_DB_SCOPE_KEY));
5334
+ const serviceHome = await this.ensureHomeDatabase(org, context, 'chat_service', CHAT_SERVICE_DB_SCOPE_KEY, () => this.ensureCallsDatabase(org, context, projectUuid, environment, names.serviceDb, 'chat', CHAT_SERVICE_DB_SCOPE_KEY));
5335
+ const [synapseDbUrl, masDbUrl, serviceDbUrl] = await Promise.all([
5336
+ this.homeOwnerUrl(synapseHome),
5337
+ this.homeOwnerUrl(masHome),
5338
+ this.homeOwnerUrl(serviceHome),
4873
5339
  ]);
4874
- const synapseDb = synapseDbRow.internal_db_url
4875
- ? parseCallsDbUrl(synapseDbRow.internal_db_url)
4876
- : null;
4877
- const masDb = masDbRow.internal_db_url ? parseCallsDbUrl(masDbRow.internal_db_url) : null;
4878
- if (!synapseDb || !masDb || !serviceDbRow.internal_db_url) {
5340
+ const synapseDb = synapseDbUrl ? parseCallsDbUrl(synapseDbUrl) : null;
5341
+ const masDb = masDbUrl ? parseCallsDbUrl(masDbUrl) : null;
5342
+ if (!synapseDb || !masDb || !serviceDbUrl) {
4879
5343
  // Before any app is touched: Coolify can take a moment to report a new
4880
5344
  // database's url, and the next apply retries from a clean state.
4881
5345
  throw new Error(`coolify:chat for ${org.slug}: a database has no internal_db_url yet -- retry this apply once Coolify reports one`);
@@ -5004,7 +5468,7 @@ export class FoundryStepExecutor {
5004
5468
  // `DATABASE_URL` is a minted `chat_rt` role -- `chat` is the literal
5005
5469
  // `postgres_db`/`postgres_user` `ensureCallsDatabase` created the service
5006
5470
  // database with above, not the Coolify resource name `names.serviceDb`.
5007
- const serviceDbPush = await this.pushDatabaseUrl(service.appId, serviceDbId, 'chat');
5471
+ const serviceDbPush = await this.pushDatabaseUrl(service.appId, serviceHome.id, 'chat', serviceHome.server ?? undefined);
5008
5472
  // A file-storage write alone never reaches a running container. Deploy an
5009
5473
  // app that is new, whose config was freshly minted, or that is not
5010
5474
  // running now. The last test reads the live status, not what this run
@@ -5306,10 +5770,8 @@ export class FoundryStepExecutor {
5306
5770
  const synapseHost = `${org.slug}-calls-synapse`;
5307
5771
  const masHost = `${org.slug}-calls-mas`;
5308
5772
  const dbName = `${name}-db`;
5309
- const dbId = await this.ensureCallsDatabase(org, context, projectUuid, environment, dbName, dbName.replace(/-/g, '_'), CALLS_SERVICE_DB_SCOPE_KEY);
5310
- context.recordScopeId(CALLS_SERVICE_DB_SCOPE_KEY, dbId);
5311
- const db = await getDatabaseStarted(this.ports.coolify, dbId);
5312
- if (!db.internal_db_url) {
5773
+ const serviceHome = await this.ensureHomeDatabase(org, context, 'calls_service', CALLS_SERVICE_DB_SCOPE_KEY, () => this.ensureCallsDatabase(org, context, projectUuid, environment, dbName, dbName.replace(/-/g, '_'), CALLS_SERVICE_DB_SCOPE_KEY));
5774
+ if (!(await this.homeOwnerUrl(serviceHome))) {
5313
5775
  throw new Error(`coolify:calls-service for ${org.slug}: its database has no internal_db_url yet -- retry this apply once Coolify reports one`);
5314
5776
  }
5315
5777
  const server = await this.resolveServer(org, context);
@@ -5393,7 +5855,7 @@ export class FoundryStepExecutor {
5393
5855
  // `DATABASE_URL` is a minted `<dbName>_rt` role -- the real Postgres
5394
5856
  // database name `ensureCallsDatabase` created this database with, not
5395
5857
  // the Coolify resource name.
5396
- const dbPush = await this.pushDatabaseUrl(app.appId, dbId, dbName.replace(/-/g, '_'));
5858
+ const dbPush = await this.pushDatabaseUrl(app.appId, serviceHome.id, dbName.replace(/-/g, '_'), serviceHome.server ?? undefined);
5397
5859
  // #456: a crash between the push above and the deploy/marker below
5398
5860
  // leaves the env right and no marker, so this replans and deploys once
5399
5861
  // more -- the same rule `coolifyApp`'s own `dbEnvAppliedScopeKey` marker
@@ -5766,6 +6228,155 @@ export class FoundryStepExecutor {
5766
6228
  context.recordScopeId(AGORA_ACTIVATED_ENV_SCOPE_KEY, revision);
5767
6229
  return { resourceId: app.appId };
5768
6230
  }
6231
+ // ---- desktop-auth (#440) -------------------------------------------------
6232
+ /**
6233
+ * `plan/desktop-auth.ts`'s `planDesktopAuth`: Agora's desktop sign-in backend, built from the agora repo's
6234
+ * `apps/desktop-auth`, internal only (Agora proxies `/auth/desktop/*` to `<slug>-desktop-auth:8080`).
6235
+ *
6236
+ * Env: the non-secret values from `desktopAuthEnv`, the owner and runtime URLs of the company server's
6237
+ * `desktop_auth` home (rebuilt from the held passwords on every run; the owner URL is for the migrate that
6238
+ * runs first in the image, which drops it before the server starts), and two secrets generated once. A secret
6239
+ * is minted only when the app's env lacks it, and both go into the one write with the rest, so a reapply or a
6240
+ * crash before the write never leaves two. Neither is delivered anywhere else.
6241
+ *
6242
+ * Deployed once after creation, again when the app is not running, and restarted when only the env changed
6243
+ * (decided from the live app, not from this run). The readiness probe goes through Agora once Agora carries
6244
+ * the upstream; the first apply has none to probe, and the next plan turns the upstream on. The callback URI is
6245
+ * added to Agora's client last, as the union of what the client holds and the callback (never removing one).
6246
+ */
6247
+ async coolifyDesktopAuth(action, org, context) {
6248
+ const host = action.desired.host;
6249
+ if (!host) {
6250
+ throw new Error(`desktop sign-in for ${org.slug} cannot be configured: the step carries no host values`);
6251
+ }
6252
+ const { inputs, missing } = desktopAuthInputs(org, context.recordedIds(), host);
6253
+ if (!inputs) {
6254
+ throw new Error(`desktop sign-in for ${org.slug} cannot be configured: missing ${missing.join(', ')}. Nothing was written.`);
6255
+ }
6256
+ const home = await this.serverHome(org, context, DESKTOP_AUTH_HOME, { runtime: true });
6257
+ if (!home.runtimeUrl) {
6258
+ throw new Error(`desktop sign-in for ${org.slug}: the runtime URL could not be built`);
6259
+ }
6260
+ const env = desktopAuthEnv(inputs, home.runtimeUser);
6261
+ const projectUuid = await this.ensureCoolifyProject(org, context);
6262
+ const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
6263
+ const name = desktopAuthInternalName(org);
6264
+ const recordedApp = context.recordedIds()[DESKTOP_AUTH_APP_SCOPE_KEY];
6265
+ let app;
6266
+ if (recordedApp) {
6267
+ app = { appId: recordedApp, existed: true };
6268
+ }
6269
+ else {
6270
+ const server = await this.resolveServer(org, context);
6271
+ const githubApp = await this.ports.coolify.requireGithubApp();
6272
+ app = await this.findOrCreateCallsApp(name, environment.id, () => this.ports.coolify.createGithubAppApplication({
6273
+ is_preview_deployments_enabled: false,
6274
+ project_uuid: projectUuid,
6275
+ server_uuid: server.uuid,
6276
+ environment_name: environment.name,
6277
+ environment_uuid: environment.uuid,
6278
+ github_app_uuid: githubApp.uuid,
6279
+ git_repository: AGORA_REPO,
6280
+ git_branch: 'main',
6281
+ build_pack: 'dockerfile',
6282
+ name,
6283
+ dockerfile_location: DESKTOP_AUTH_DOCKERFILE,
6284
+ ports_exposes: DESKTOP_AUTH_PORT,
6285
+ autogenerate_domain: false,
6286
+ }), { startIfStopped: false });
6287
+ }
6288
+ context.recordScopeId(DESKTOP_AUTH_APP_SCOPE_KEY, app.appId);
6289
+ const live = app.existed ? await this.ports.coolify.getApplication(app.appId) : null;
6290
+ const portDiffers = live !== null && live.ports_exposes !== DESKTOP_AUTH_PORT;
6291
+ // The image carries its own HEALTHCHECK; Coolify's generated one would replace it.
6292
+ await this.ports.coolify.updateApplication(app.appId, {
6293
+ custom_internal_name: name,
6294
+ health_check_enabled: false,
6295
+ ...(portDiffers ? { ports_exposes: DESKTOP_AUTH_PORT } : {}),
6296
+ });
6297
+ const liveRows = (await this.ports.coolify.listEnvs(app.appId)).filter((e) => !e.is_preview);
6298
+ const liveEnv = new Map(liveRows.map((e) => [e.key, e.value]));
6299
+ const secrets = [];
6300
+ if (!liveEnv.has('AUTH_COOKIE_SECRET')) {
6301
+ secrets.push({ key: 'AUTH_COOKIE_SECRET', value: randomBytes(32).toString('base64url') });
6302
+ }
6303
+ if (!liveEnv.has('MESSAGING_HANDOFF_KEY')) {
6304
+ secrets.push({ key: 'MESSAGING_HANDOFF_KEY', value: randomBytes(32).toString('hex') });
6305
+ }
6306
+ for (const secret of secrets)
6307
+ this.handledSecrets.add(secret.value);
6308
+ const desired = {
6309
+ ...env,
6310
+ MESSAGING_DATABASE_URL_OWNER: home.ownerUrl,
6311
+ MESSAGING_DATABASE_URL: home.runtimeUrl,
6312
+ };
6313
+ const envChanged = Object.entries(desired).some(([key, value]) => liveEnv.get(key) !== value);
6314
+ await this.ports.coolify.bulkUpsertEnvs(app.appId, [
6315
+ ...Object.entries(desired).map(([key, value]) => ({ key, value })),
6316
+ ...secrets,
6317
+ ]);
6318
+ const recorded = context.recordedIds();
6319
+ const upstreamLive = await this.agoraUpstreamLive(org, recorded);
6320
+ const health = upstreamLive
6321
+ ? publicHealthCheck(org, recorded, agoraHostnameFor(org), DESKTOP_AUTH_READY_PATH, {
6322
+ viaAgora: true,
6323
+ })
6324
+ : { kind: 'deployment-only' };
6325
+ const runtime = this.ports.runtime ?? {};
6326
+ const sleep = runtime.sleep ?? defaultSleep;
6327
+ const running = live !== null && isRunningOrStarting(live.status);
6328
+ const label = 'coolify:desktop-auth';
6329
+ const previous = `${org.slug}'s desktop sign-in service stays at its previous deploy`;
6330
+ if (!app.existed || !recorded[DESKTOP_AUTH_DEPLOYED_SCOPE_KEY] || !running || portDiffers) {
6331
+ await waitForNewDeployment(this.ports.coolify, app.appId, health, runtime, sleep, label, previous);
6332
+ context.recordScopeId(DESKTOP_AUTH_DEPLOYED_SCOPE_KEY, '1');
6333
+ }
6334
+ else {
6335
+ if (envChanged)
6336
+ await this.ports.coolify.restart(app.appId);
6337
+ await waitForHealthyVersion(app.appId, health, runtime, sleep, label, previous);
6338
+ }
6339
+ if (upstreamLive)
6340
+ context.recordScopeId(DESKTOP_AUTH_READY_SCOPE_KEY, '1');
6341
+ await this.addDesktopAuthCallback(org, context, desktopAuthCallbackUri(inputs.origin));
6342
+ context.recordScopeId(DESKTOP_AUTH_ORIGIN_SCOPE_KEY, inputs.origin);
6343
+ context.recordScopeId(DESKTOP_AUTH_REVISION_SCOPE_KEY, desktopAuthRevision(env));
6344
+ return { resourceId: app.appId };
6345
+ }
6346
+ /** Whether the org's running Agora carries `AGORA_DESKTOP_AUTH_UPSTREAM`, read from its live env. */
6347
+ async agoraUpstreamLive(org, recorded) {
6348
+ const agoraId = recorded[`coolify:agora:${org.id}`];
6349
+ if (!agoraId)
6350
+ return false;
6351
+ const rows = await this.ports.coolify.listEnvs(agoraId);
6352
+ return rows.some((e) => e.key === 'AGORA_DESKTOP_AUTH_UPSTREAM' && !e.is_preview && e.value);
6353
+ }
6354
+ /**
6355
+ * Adds `callback` to the Agora client's redirect URIs, keeping the ones it has. A providers client older
6356
+ * than 0.12.0 cannot do it: the step records that and the planner reports it blocked, rather than failing.
6357
+ */
6358
+ async addDesktopAuthCallback(org, context, callback) {
6359
+ const zitadel = this.ports.zitadel;
6360
+ const read = zitadel.getOidcAppRedirectUris?.bind(zitadel);
6361
+ const write = zitadel.updateOidcAppRedirectUris?.bind(zitadel);
6362
+ if (!read || !write) {
6363
+ context.recordScopeId(DESKTOP_AUTH_REDIRECT_SCOPE_KEY, desktopAuthRedirectUnsupported(callback));
6364
+ return;
6365
+ }
6366
+ const recorded = context.recordedIds();
6367
+ const zitadelOrgId = recorded[ZITADEL_ORG_SCOPE_KEY];
6368
+ const clientAppId = recorded['zitadel:app:agora'];
6369
+ if (!zitadelOrgId || !clientAppId) {
6370
+ throw new Error(`desktop sign-in for ${org.slug}: the Agora ZITADEL client is not recorded yet -- nothing was changed on it`);
6371
+ }
6372
+ const projectId = await this.ensureZitadelProject(org, zitadelOrgId, context);
6373
+ const at = `desktop sign-in for ${org.slug}`;
6374
+ const current = await withCallTimeout(WORKER_APP_CALL_TIMEOUT_MS, `${at}: read redirect URIs`, () => read(zitadelOrgId, projectId, clientAppId));
6375
+ if (!current.includes(callback)) {
6376
+ await withCallTimeout(WORKER_APP_CALL_TIMEOUT_MS, `${at}: add redirect URI`, () => write(zitadelOrgId, projectId, clientAppId, [...current, callback]));
6377
+ }
6378
+ context.recordScopeId(DESKTOP_AUTH_REDIRECT_SCOPE_KEY, callback);
6379
+ }
5769
6380
  /**
5770
6381
  * Records whether an app's running env holds `SIGNIN_UPSTREAM` (the app's host while it does, `''` once it
5771
6382
  * does not), so the login-host step knows when to point the base URI at the app and when it may give the
@@ -5850,8 +6461,16 @@ export class FoundryStepExecutor {
5850
6461
  const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
5851
6462
  const name = `${org.slug}-boule`;
5852
6463
  const dbName = `${org.slug}-boule-db`.replace(/-/g, '_');
5853
- const dbId = await this.ensureCallsDatabase(org, context, projectUuid, environment, `${org.slug}-boule-db`, dbName, BOULE_DATABASE_SCOPE_KEY);
5854
- context.recordScopeId(BOULE_DATABASE_SCOPE_KEY, dbId);
6464
+ // #444: on the company's server there is no Coolify database to make, and the
6465
+ // legacy id stays unset (recording it would mark the home as unmigrated).
6466
+ const bouleServer = this.homeOnServer(org, context, 'boule')
6467
+ ? await this.serverHome(org, context, 'boule')
6468
+ : null;
6469
+ const dbId = bouleServer
6470
+ ? ''
6471
+ : await this.ensureCallsDatabase(org, context, projectUuid, environment, `${org.slug}-boule-db`, dbName, BOULE_DATABASE_SCOPE_KEY);
6472
+ if (!bouleServer)
6473
+ context.recordScopeId(BOULE_DATABASE_SCOPE_KEY, dbId);
5855
6474
  const server = await this.resolveServer(org, context);
5856
6475
  const githubApp = await this.ports.coolify.requireGithubApp();
5857
6476
  const app = await this.findOrCreateCallsApp(name, environment.id, () => this.ports.coolify.createGithubAppApplication({
@@ -5907,6 +6526,12 @@ export class FoundryStepExecutor {
5907
6526
  this.handledSecrets.add(bridgeSecret);
5908
6527
  envs.push({ key: BOULE_BRIDGE_SECRET_ENV, value: bridgeSecret });
5909
6528
  }
6529
+ // The owner-seeding door's secret, for every org's Boule: minted once, read back from this env when seeding.
6530
+ const ownerSeedSecret = await this.mintIfAbsent(app.appId, BOULE_OWNER_SEED_SECRET_ENV, 32);
6531
+ if (ownerSeedSecret) {
6532
+ this.handledSecrets.add(ownerSeedSecret);
6533
+ envs.push({ key: BOULE_OWNER_SEED_SECRET_ENV, value: ownerSeedSecret });
6534
+ }
5910
6535
  // Held, minted once (C3). Compared with the live value so a changed one restarts the app.
5911
6536
  const credentialEnvs = [];
5912
6537
  let archonPushedAt = null;
@@ -5938,13 +6563,14 @@ export class FoundryStepExecutor {
5938
6563
  const minted = [
5939
6564
  peerPassword,
5940
6565
  bridgeSecret,
6566
+ ownerSeedSecret,
5941
6567
  ...credentialEnvs.map((credential) => credential.value),
5942
6568
  ].filter((v) => v !== null);
5943
6569
  if (minted.length === 0)
5944
6570
  throw error;
5945
6571
  throw new Error(redactSecrets(error instanceof Error ? error.message : String(error), minted));
5946
6572
  }
5947
- const dbPush = await this.pushDatabaseUrl(app.appId, dbId, dbName);
6573
+ const dbPush = await this.pushDatabaseUrl(app.appId, dbId, dbName, bouleServer ?? undefined);
5948
6574
  if (!dbPush.pushed) {
5949
6575
  throw new Error(`Boule for ${org.slug}: its database has no internal_db_url yet -- retry this apply once Coolify reports one`);
5950
6576
  }
@@ -5975,6 +6601,7 @@ export class FoundryStepExecutor {
5975
6601
  appKey !== null ||
5976
6602
  peerPassword !== null ||
5977
6603
  bridgeSecret !== null ||
6604
+ ownerSeedSecret !== null ||
5978
6605
  secretsNotRestarted ||
5979
6606
  dbPush.changed ||
5980
6607
  mail.changed)) {