@wtfalch/foundry 0.41.2 → 0.43.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 (71) hide show
  1. package/dist/bootstrap/versions.d.ts +2 -0
  2. package/dist/index.d.ts +7 -3
  3. package/dist/index.js +4 -2
  4. package/dist/migrations/0054_org_signin_in_app.sql +6 -0
  5. package/dist/migrations/0055_release_requests.sql +20 -0
  6. package/dist/migrations/0056_platform_archon_blue_green.sql +7 -0
  7. package/dist/org.d.ts +30 -0
  8. package/dist/org.js +52 -1
  9. package/dist/plan/agora.d.ts +4 -1
  10. package/dist/plan/agora.js +16 -2
  11. package/dist/plan/archon-blue-green.d.ts +54 -0
  12. package/dist/plan/archon-blue-green.js +72 -0
  13. package/dist/plan/blue-green-apply.d.ts +82 -0
  14. package/dist/plan/blue-green-apply.js +360 -0
  15. package/dist/plan/blue-green.d.ts +223 -0
  16. package/dist/plan/blue-green.js +444 -0
  17. package/dist/plan/boule.d.ts +5 -2
  18. package/dist/plan/boule.js +14 -2
  19. package/dist/plan/cloudflared-connector.d.ts +26 -0
  20. package/dist/plan/cloudflared-connector.js +37 -0
  21. package/dist/plan/coolify-previews.d.ts +10 -0
  22. package/dist/plan/coolify-previews.js +14 -0
  23. package/dist/plan/diff.d.ts +6 -0
  24. package/dist/plan/diff.js +123 -11
  25. package/dist/plan/edge-ratelimits.d.ts +5 -0
  26. package/dist/plan/edge-ratelimits.js +16 -6
  27. package/dist/plan/executor.d.ts +79 -0
  28. package/dist/plan/executor.js +527 -31
  29. package/dist/plan/login-host.d.ts +23 -2
  30. package/dist/plan/login-host.js +30 -5
  31. package/dist/plan/own-diff.js +6 -0
  32. package/dist/plan/plan-apply.d.ts +51 -0
  33. package/dist/plan/plan-apply.js +222 -0
  34. package/dist/plan/platform-diff.d.ts +9 -0
  35. package/dist/plan/platform-diff.js +28 -1
  36. package/dist/plan/platform-org.d.ts +6 -0
  37. package/dist/plan/platform-org.js +5 -0
  38. package/dist/plan/platform-providers.d.ts +31 -2
  39. package/dist/plan/platform-providers.js +345 -13
  40. package/dist/plan/providers-readers.js +6 -2
  41. package/dist/plan/proxy-labels.d.ts +2 -2
  42. package/dist/plan/proxy-labels.js +12 -4
  43. package/dist/plan/release-host.d.ts +59 -0
  44. package/dist/plan/release-host.js +239 -0
  45. package/dist/plan/remote-database.d.ts +124 -0
  46. package/dist/plan/remote-database.js +373 -0
  47. package/dist/plan/sign-in-archon.d.ts +46 -0
  48. package/dist/plan/sign-in-archon.js +71 -0
  49. package/dist/plan/sign-in-bindings.d.ts +16 -0
  50. package/dist/plan/sign-in-bindings.js +44 -0
  51. package/dist/plan/sign-in.d.ts +135 -0
  52. package/dist/plan/sign-in.js +172 -0
  53. package/dist/platform.d.ts +11 -1
  54. package/dist/platform.js +4 -1
  55. package/dist/release/decide.d.ts +95 -0
  56. package/dist/release/decide.js +242 -0
  57. package/dist/release/health.d.ts +48 -0
  58. package/dist/release/health.js +61 -0
  59. package/dist/release/run.d.ts +40 -0
  60. package/dist/release/run.js +56 -0
  61. package/dist/release/state.d.ts +64 -0
  62. package/dist/release/state.js +126 -0
  63. package/dist/release/types.d.ts +54 -0
  64. package/dist/release/types.js +4 -0
  65. package/dist/store/postgres.d.ts +8 -0
  66. package/dist/store/postgres.js +62 -3
  67. package/dist/store/schema.d.ts +171 -0
  68. package/dist/store/schema.js +14 -0
  69. package/package.json +1 -1
  70. package/versions.digests.json +2 -1
  71. package/versions.json +2 -1
@@ -7,6 +7,8 @@ import { resolveCallsSfuUrls } from '../calls-sfu.js';
7
7
  import { APP_ENTRY_KEY, appEntry, filesCollectionEnvName, heldEnvRef, ownerMailboxRecorded, ownerMailboxes, ownsServices, sharedSecretRef, } from '../org.js';
8
8
  import { adoptedKeysUnreadable, parseAdoptSnapshot } from './adopt.js';
9
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';
10
+ import { applyBlueGreenConnectors, applyBlueGreenServer, applyBlueGreenWeights, } from './blue-green-apply.js';
11
+ import { bgKey, colorAppName, entrySubject, healthTokenName, liveAppNameFor, splitServers, } from './blue-green.js';
10
12
  import { BOULE_BRIDGE_SECRET_ENV, BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_DATABASE_SCOPE_KEY, BOULE_DEPLOYED_SCOPE_KEY, BOULE_PEER_PASSWORD_ENV, BOULE_SECRETS_RESTARTED_SCOPE_KEY, bouleEnvDrift, bouleHostnameFor, bouleMailWanted, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
11
13
  import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
12
14
  import { recordBuiltCommit } from './build-record.js';
@@ -14,6 +16,7 @@ import { MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerNam
14
16
  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';
15
17
  import { ensureCloudflaredConnector } from './cloudflared-connector.js';
16
18
  import { BACKUP_SCOPE_PREFIX, COMPANY_BACKUP_DATABASE_NAMES, backupScopeKey, } from './company-backups.js';
19
+ import { previewsOn } from './coolify-previews.js';
17
20
  import { CORE_FILES_COLLECTION_NAME, FILES_CORE_COLLECTION_SCOPE_KEY, ZITADEL_FILES_PROJECT_SCOPE_KEY, } from './core-files.js';
18
21
  import { waitForNewDeployment, } from './deploy-health.js';
19
22
  import { PLATFORM_NETWORK, companyNetworkName, ensureDestination, findDestination, onPlatformNetwork, placed, placedByScope, } from './destinations.js';
@@ -43,7 +46,11 @@ import { PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY
43
46
  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';
44
47
  import { AUTH_LOGIN_PORT, PLATFORM_AUTH_BROKER_MAIL_KEY_REF, PLATFORM_AUTH_LOGIN_INTERNAL_NAME, PLATFORM_AUTH_LOGIN_SCOPE_KEY, PLATFORM_MAIL_SERVER_SCOPE_KEY, defaultStalwartForInstance, filesDepsInvalid, filesShareDomains, provisionAuthStack, provisionDatabaseBackup, provisionFilesHost, provisionJobsHost, provisionMailServer, resolveOwnFilesKeys, } from './platform-providers.js';
45
48
  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';
49
+ import { createReleaseHost } from './release-host.js';
50
+ import { TUNNEL_HTTP_INGRESS_SCOPE_KEY, applyRemoteDatabase, mergeTcpIngress, recordedTcpRoutes, } from './remote-database.js';
51
+ import { editSignInBindings } from './sign-in-bindings.js';
46
52
  import { ZITADEL_ORG_DOMAIN_SCOPE_KEY, ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY, closeSignInOrgRegistration, ensureSignInOrg, inviteOwnerWithLink, registerSignInDomainOrBlock, signInOrgDomain, } from './sign-in-org.js';
53
+ import { SIGN_IN_BASE_PATH, SIGN_IN_ENV_LIVE_SCOPE_PREFIX, SIGN_IN_IN_APP_SCOPE_KEY, SIGN_IN_LOGIN_APP_SCOPE_KEY, SIGN_IN_RATE_LIMIT_DESCRIPTION, SIGN_IN_RATE_LIMIT_EXPRESSION, SIGN_IN_RATE_LIMIT_SCOPE_KEY, SIGN_IN_TRUSTED_SCOPE_KEY, SIGN_IN_UPSTREAM_ENV, inAppBindingFor, inAppSignIn, inAppStateHosts, inAppStatePairs, inAppTargets, signInEnvLiveHosts, } from './sign-in.js';
47
54
  import { moveSigninDatabase } from './signin-db-move.js';
48
55
  import { entryWorkerAppliedScopeKey, entryWorkerExclusionsScopeKey, entryWorkerRetiredScopeKey, entryWorkerRouteIdScopeKey, entryWorkerRoutePatternScopeKey, entryWorkerRoutePendingScopeKey, entryWorkerRouteZoneScopeKey, entryWorkerUploadedScopeKey, } from './worker.js';
49
56
  /**
@@ -122,6 +129,32 @@ export class NotBuiltError extends Error {
122
129
  * so a host is never read as "not in use" purely because ZITADEL's own
123
130
  * casing or slashing differs from this step's.
124
131
  */
132
+ /**
133
+ * The in-app hosts a login-host step carries, read back and checked: a plan from an older build carries
134
+ * none, and a malformed entry is refused by name (never echoed) rather than bound.
135
+ */
136
+ function inAppAppsOf(raw, slug) {
137
+ if (raw === undefined)
138
+ return [];
139
+ if (!Array.isArray(raw))
140
+ throw new Error(`zitadel:login-host for ${slug}: apps is not a list`);
141
+ return raw.map((entry) => {
142
+ const e = entry;
143
+ if (!e ||
144
+ (e.app !== 'agora' && e.app !== 'boule') ||
145
+ typeof e.host !== 'string' ||
146
+ !e.host ||
147
+ typeof e.clientId !== 'string' ||
148
+ !e.clientId) {
149
+ throw new Error(`zitadel:login-host for ${slug}: an in-app host entry is malformed`);
150
+ }
151
+ return { app: e.app, host: e.host, clientId: e.clientId };
152
+ });
153
+ }
154
+ /** The ZITADEL ids of the company's Agora and Boule clients: the apps in-app sign-in can move. */
155
+ function ownInAppAppIds(recorded) {
156
+ return new Set([recorded['zitadel:app:agora'], recorded['zitadel:app:boule']].filter((id) => Boolean(id)));
157
+ }
125
158
  function normalizedLoginBaseUri(uri) {
126
159
  return uri.toLowerCase().replace(/\/$/, '');
127
160
  }
@@ -854,7 +887,11 @@ function limitProfileOf(action) {
854
887
  case 'coolify:boule':
855
888
  case 'coolify:boule-broker-entry':
856
889
  return LIMITS.boule;
890
+ case 'coolify:bluegreen':
891
+ case 'coolify:bluegreen-lb':
892
+ return LIMITS.companyApp;
857
893
  case 'cloudflare:tunnel':
894
+ case 'coolify:bluegreen-connector':
858
895
  return LIMITS.connector;
859
896
  default:
860
897
  return action.system === 'coolify' ? LIMITS.companyOther : undefined;
@@ -1080,6 +1117,10 @@ export class FoundryStepExecutor {
1080
1117
  return this.coolifyApp(action, org, context);
1081
1118
  case 'coolify:network':
1082
1119
  return this.coolifyNetwork(action, org, context);
1120
+ case 'coolify:bluegreen':
1121
+ case 'coolify:bluegreen-lb':
1122
+ case 'coolify:bluegreen-connector':
1123
+ return this.coolifyBlueGreen(action, org, context);
1083
1124
  case 'coolify:broker-entry':
1084
1125
  return this.coolifyBrokerEntry(action, org, context);
1085
1126
  case 'coolify:database':
@@ -1101,7 +1142,7 @@ export class FoundryStepExecutor {
1101
1142
  case 'cloudflare:gate':
1102
1143
  return this.cloudflareGate(action, org, context);
1103
1144
  case 'cloudflare:ratelimit':
1104
- return this.cloudflareRateLimit(org);
1145
+ return this.cloudflareRateLimit(org, context);
1105
1146
  case 'cloudflare:worker':
1106
1147
  return this.cloudflareWorker(action, org, context);
1107
1148
  case 'files:organisation':
@@ -1140,6 +1181,8 @@ export class FoundryStepExecutor {
1140
1181
  return this.bouleBrokerEntry(org, context);
1141
1182
  case 'jobs:registration':
1142
1183
  return this.jobsRegistration(action, org, context);
1184
+ case 'cloudflare:db-route':
1185
+ return this.remoteDatabaseRoute(action, org, context);
1143
1186
  case 'coolify:own-auth':
1144
1187
  return this.ownAuth(action, org, context);
1145
1188
  case 'coolify:own-auth-db-move':
@@ -1310,7 +1353,7 @@ export class FoundryStepExecutor {
1310
1353
  * `cloudflareAccess`'s own refusal when a foreign Access app already
1311
1354
  * holds the hostname.
1312
1355
  */
1313
- async cloudflareRateLimit(org) {
1356
+ async cloudflareRateLimit(org, context) {
1314
1357
  if (!org.domain) {
1315
1358
  throw new Error(`cloudflare:ratelimit for ${org.slug}: hosting.usesDomain is set but domain is null`);
1316
1359
  }
@@ -1319,20 +1362,41 @@ export class FoundryStepExecutor {
1319
1362
  if (!zone) {
1320
1363
  throw new Error(`cloudflare:ratelimit for ${org.slug}: no Cloudflare zone found for ${org.domain} -- cloudflare:zone must provision it first`);
1321
1364
  }
1365
+ // The zone's one Free-plan rule: the base one, or with in-app sign-in the same limit that also
1366
+ // covers POSTs under `/sign-in/` (static assets there stay unlimited).
1367
+ const inApp = inAppSignIn(org);
1368
+ const wanted = inApp
1369
+ ? { description: SIGN_IN_RATE_LIMIT_DESCRIPTION, expression: SIGN_IN_RATE_LIMIT_EXPRESSION }
1370
+ : { description: RATE_LIMIT_RULE_DESCRIPTION, expression: SIGN_IN_PATH_EXPRESSION };
1371
+ const ours = [RATE_LIMIT_RULE_DESCRIPTION, SIGN_IN_RATE_LIMIT_DESCRIPTION];
1322
1372
  const rateLimits = this.rateLimitPort();
1323
1373
  const rules = await rateLimits.listRateLimitRules(zone.id);
1324
- const own = rules.find((r) => r.description === RATE_LIMIT_RULE_DESCRIPTION);
1374
+ const own = rules.find((r) => r.description === wanted.description);
1325
1375
  if (!own) {
1326
- const foreign = rules[0];
1376
+ const foreign = rules.find((r) => !ours.includes(r.description ?? ''));
1327
1377
  if (foreign) {
1328
1378
  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`);
1329
1379
  }
1380
+ // The other variant of foundry's own rule holds the zone's one slot: remove it, then make this one.
1381
+ const stale = rules.find((r) => ours.includes(r.description ?? ''));
1382
+ if (stale) {
1383
+ if (!rateLimits.deleteRateLimitRule) {
1384
+ throw new Error(`cloudflare:ratelimit for ${org.slug}: the rate limit rule must change but this host's rate limit port cannot delete a rule`);
1385
+ }
1386
+ try {
1387
+ await rateLimits.deleteRateLimitRule(zone.id, stale.id);
1388
+ }
1389
+ catch (error) {
1390
+ if (!isNotFoundError(error))
1391
+ throw error;
1392
+ }
1393
+ }
1330
1394
  }
1331
1395
  // Edge adopts a live rule with no edge row (made before edge owned rate
1332
1396
  // limits) and then makes the rule once: a repeat returns it unchanged.
1333
1397
  const created = await rateLimits.createRateLimitRule(zone.id, {
1334
- description: RATE_LIMIT_RULE_DESCRIPTION,
1335
- expression: SIGN_IN_PATH_EXPRESSION,
1398
+ description: wanted.description,
1399
+ expression: wanted.expression,
1336
1400
  action: 'block',
1337
1401
  ratelimit: {
1338
1402
  characteristics: ['cf.colo.id', 'ip.src'],
@@ -1341,6 +1405,8 @@ export class FoundryStepExecutor {
1341
1405
  mitigation_timeout: RATE_LIMIT_FREE_PLAN_PERIOD_SECONDS,
1342
1406
  },
1343
1407
  });
1408
+ // Last: a crash before here plans the step again.
1409
+ context.recordScopeId(SIGN_IN_RATE_LIMIT_SCOPE_KEY, inApp ? '1' : '');
1344
1410
  return { resourceId: created.id };
1345
1411
  }
1346
1412
  /**
@@ -1745,6 +1811,13 @@ export class FoundryStepExecutor {
1745
1811
  * client id that binding holds. Apps created later are not in it yet:
1746
1812
  * the next run replans (`diff.ts`'s `planLoginHost`) and moves them.
1747
1813
  *
1814
+ * In-app sign-in (`signIn.inApp`, `plan/sign-in.ts` has the order): an app host is trusted first. It is
1815
+ * bound, and its app pointed at `/sign-in/`, only once its app runs `SIGNIN_UPSTREAM` (the app step
1816
+ * records that). The bind moves the app's client id off the company's binding in the same write (the
1817
+ * login refuses a client id in two bindings); a company binding left with no client is removed.
1818
+ * Switched off, the client id goes back and the base URI returns to the login host, and a trusted
1819
+ * domain is given up only after the app dropped the env.
1820
+ *
1748
1821
  * #274: when the org's host has moved (`recorded[key]`, the *previous*
1749
1822
  * run's resourceId, differs from this run's `host`), also removes every
1750
1823
  * host that is no longer the org's current one from both places --
@@ -1812,8 +1885,13 @@ export class FoundryStepExecutor {
1812
1885
  if (!zitadelOrgId) {
1813
1886
  throw new Error(`zitadel:login-host for ${org.slug}: no ZITADEL org recorded yet -- the issuer must be provisioned first`);
1814
1887
  }
1815
- // `planLoginHost` blocks the step while this port is not wired.
1816
- const bindings = this.ports.loginHostBindings;
1888
+ // A company that runs its own services writes the host table of its own in-app login app; every
1889
+ // other company, the shared one's through the host's port. `planLoginHost` blocks the step while
1890
+ // the shared port is not wired.
1891
+ const own = ownsServices(org);
1892
+ const bindings = own
1893
+ ? this.ownSignInBindings(org, recorded, action.desired.host)
1894
+ : this.ports.loginHostBindings;
1817
1895
  if (!bindings) {
1818
1896
  throw new Error(`zitadel:login-host for ${org.slug}: the loginHostBindings port is not wired`);
1819
1897
  }
@@ -1821,13 +1899,84 @@ export class FoundryStepExecutor {
1821
1899
  // The platform's own shared sign-in host: already trusted and routed by
1822
1900
  // the shared instance, so it gets no trusted domain or binding here.
1823
1901
  const shared = action.desired.shared === true;
1824
- const clientIds = loginHostClientIds(org, recorded);
1902
+ // In-app sign-in: the plan carries the app hosts to bind (empty with the switch off).
1903
+ const inApp = inAppAppsOf(action.desired.apps, org.slug);
1904
+ // The instance ignores an app's own base URI unless it is told not to: a port that cannot say so
1905
+ // stops the step before anything is written, rather than recording a base URI that has no effect.
1906
+ if (!shared && own && inApp.length > 0 && !this.ports.zitadel.allowPerAppLoginBaseUri) {
1907
+ throw new Error(`zitadel:login-host for ${org.slug}: the ZITADEL port cannot allow a per-app login base URI, so the app hosts cannot sign in on their own addresses`);
1908
+ }
1909
+ // The app hosts that are bound and get the app's own address as base URI: only once their app runs
1910
+ // with `SIGNIN_UPSTREAM` (the app steps record it), never before. Every other app host is trusted
1911
+ // and waits, and the company's own binding keeps its client id, so the old path signs in meanwhile.
1912
+ const targets = inAppTargets(inApp, recorded);
1913
+ const activePairs = inAppStatePairs(targets.active);
1914
+ const activeHosts = new Set(activePairs.map((pair) => pair.host));
1915
+ const envLive = signInEnvLiveHosts(recorded);
1916
+ // The login refuses a client id held by two bindings: an active app host's client id is in its own binding only.
1917
+ const clientIds = loginHostClientIds(org, recorded, activePairs.map((pair) => pair.clientId));
1825
1918
  if (!shared) {
1826
- await this.ports.zitadel.ensureTrustedDomain(host);
1919
+ // The company's own login host is its instance's external domain: trusted and routed already.
1920
+ if (!own)
1921
+ await this.ports.zitadel.ensureTrustedDomain(host);
1922
+ // Each app host is trusted first.
1923
+ for (const app of inApp)
1924
+ await this.ports.zitadel.ensureTrustedDomain(app.host);
1925
+ // An app host bound before and not active now gives its client id back to the company's binding:
1926
+ // its own binding goes first, so no write leaves the id in two bindings.
1927
+ let unbound = false;
1928
+ for (const old of inAppStateHosts(recorded[SIGN_IN_IN_APP_SCOPE_KEY])) {
1929
+ if (activeHosts.has(old))
1930
+ continue;
1931
+ try {
1932
+ await bindings.deleteBinding(old);
1933
+ }
1934
+ catch (error) {
1935
+ if (!isNotFoundError(error))
1936
+ throw error;
1937
+ }
1938
+ unbound = true;
1939
+ }
1940
+ if (unbound)
1941
+ await bindings.flush?.();
1827
1942
  // zitadel-login's own routing table, written before any app is repointed
1828
1943
  // so no app ever points at a host with no binding.
1829
- await bindings.upsertBinding({ host, organizationId: zitadelOrgId, clientIds });
1944
+ if (!own) {
1945
+ if (clientIds.length > 0 || activePairs.length === 0) {
1946
+ await bindings.upsertBinding({ host, organizationId: zitadelOrgId, clientIds });
1947
+ }
1948
+ else {
1949
+ // Every client of the company signs in on its app's own address: the login refuses a binding
1950
+ // with no client ids, so the company's own binding goes (nothing signs in on this host).
1951
+ try {
1952
+ await bindings.deleteBinding(host);
1953
+ }
1954
+ catch (error) {
1955
+ if (!isNotFoundError(error))
1956
+ throw error;
1957
+ }
1958
+ }
1959
+ }
1960
+ for (const app of inApp) {
1961
+ if (!activeHosts.has(app.host))
1962
+ continue;
1963
+ await bindings.upsertBinding({ ...inAppBindingFor(app), organizationId: zitadelOrgId });
1964
+ }
1965
+ // One write for every upsert above (one redeploy of the login), before any app is repointed.
1966
+ await bindings.flush?.();
1967
+ if (own && activeHosts.size > 0) {
1968
+ await this.ports.zitadel.allowPerAppLoginBaseUri?.(`https://${host}/`);
1969
+ }
1970
+ }
1971
+ // The base URI each app's sign-in starts from: its own address under `/sign-in/` in-app, else the login host.
1972
+ const appBaseUris = new Map();
1973
+ for (const app of inApp) {
1974
+ const appId = recorded[`zitadel:app:${app.app}`];
1975
+ if (appId && activeHosts.has(app.host)) {
1976
+ appBaseUris.set(appId, `https://${app.host}${SIGN_IN_BASE_PATH}/`);
1977
+ }
1830
1978
  }
1979
+ const baseUriOf = (appId) => appBaseUris.get(appId) ?? `https://${host}/`;
1831
1980
  // Only apps whose client id the binding now holds: any other app in the
1832
1981
  // project would lose sign-in. The rest move on a later run, once the
1833
1982
  // recorded client ids differ from the binding and replan this step.
@@ -1858,7 +2007,10 @@ export class FoundryStepExecutor {
1858
2007
  for (const app of liveApps) {
1859
2008
  if (!boundAppIds.has(app.id))
1860
2009
  continue;
1861
- await this.ports.zitadel.updateOidcAppLoginBaseUri(zitadelOrgId, projectId, app.id, `https://${host}/`);
2010
+ // A company's own instance has the login host as its default; only the in-app apps move.
2011
+ if (own && !appBaseUris.has(app.id) && !ownInAppAppIds(recorded).has(app.id))
2012
+ continue;
2013
+ await setOidcAppLoginBaseUri(this.ports.zitadel, zitadelOrgId, projectId, app.id, baseUriOf(app.id));
1862
2014
  }
1863
2015
  }
1864
2016
  // Fix round 3 point 5: a bound app's current base URI is `host` for
@@ -1870,7 +2022,7 @@ export class FoundryStepExecutor {
1870
2022
  // compare against, same as an app this loop never visits at all.
1871
2023
  const currentLoginBaseUris = new Set();
1872
2024
  for (const app of liveApps) {
1873
- const uri = boundAppIds.has(app.id) ? `https://${host}/` : app.loginBaseUri;
2025
+ const uri = boundAppIds.has(app.id) ? baseUriOf(app.id) : app.loginBaseUri;
1874
2026
  if (typeof uri === 'string')
1875
2027
  currentLoginBaseUris.add(normalizedLoginBaseUri(uri));
1876
2028
  }
@@ -1897,6 +2049,19 @@ export class FoundryStepExecutor {
1897
2049
  candidates.delete(host);
1898
2050
  if (previousHost && previousHost !== host)
1899
2051
  candidates.add(previousHost);
2052
+ // An in-app host applied before and no longer wanted (switch off, or the app is gone) is owed a removal
2053
+ // the same way an old login host is; a host still wanted is never a candidate.
2054
+ // An app that still holds `SIGNIN_UPSTREAM` keeps its binding and trusted domain: it is switched off
2055
+ // in reverse order, the base URI first, then the app's env, then these.
2056
+ for (const old of [
2057
+ ...inAppStateHosts(recorded[SIGN_IN_TRUSTED_SCOPE_KEY]),
2058
+ ...inAppStateHosts(recorded[SIGN_IN_IN_APP_SCOPE_KEY]),
2059
+ ]) {
2060
+ if (!envLive.has(old))
2061
+ candidates.add(old);
2062
+ }
2063
+ for (const app of inApp)
2064
+ candidates.delete(app.host);
1900
2065
  // #274: a removal, once the new host is fully live (trusted, bound, and
1901
2066
  // every repointable app repointed) -- never the other way round, so an
1902
2067
  // org is never left with no trusted host at all if this throws partway
@@ -1905,9 +2070,11 @@ export class FoundryStepExecutor {
1905
2070
  // its connector app and its tunnel): a domain already gone must not skip
1906
2071
  // the binding delete that follows it.
1907
2072
  const stillPending = [];
2073
+ let removed = false;
1908
2074
  for (const candidate of candidates) {
1909
2075
  if (!canDecideSafely ||
1910
- currentLoginBaseUris.has(normalizedLoginBaseUri(`https://${candidate}/`))) {
2076
+ currentLoginBaseUris.has(normalizedLoginBaseUri(`https://${candidate}/`)) ||
2077
+ currentLoginBaseUris.has(normalizedLoginBaseUri(`https://${candidate}${SIGN_IN_BASE_PATH}/`))) {
1911
2078
  stillPending.push(candidate);
1912
2079
  continue;
1913
2080
  }
@@ -1918,6 +2085,7 @@ export class FoundryStepExecutor {
1918
2085
  if (!isNotFoundError(error))
1919
2086
  throw error;
1920
2087
  }
2088
+ removed = true;
1921
2089
  try {
1922
2090
  await bindings.deleteBinding(candidate);
1923
2091
  }
@@ -1926,6 +2094,9 @@ export class FoundryStepExecutor {
1926
2094
  throw error;
1927
2095
  }
1928
2096
  }
2097
+ // Nothing queued, nothing to write: a step that only added hosts has already flushed once.
2098
+ if (removed)
2099
+ await bindings.flush?.();
1929
2100
  // Both last, so a crash above reruns the whole step rather than losing
1930
2101
  // track of either. A client id recorded after this point differs from
1931
2102
  // the binding and replans the step next run; a nonempty pending list
@@ -1933,11 +2104,44 @@ export class FoundryStepExecutor {
1933
2104
  // normally alone decides it.
1934
2105
  if (shared)
1935
2106
  context.recordScopeId(LOGIN_HOST_SHARED_SCOPE_KEY, '1');
1936
- else
1937
- context.recordScopeId(LOGIN_BINDING_SCOPE_KEY, loginBindingState(host, clientIds));
2107
+ else {
2108
+ if (!own)
2109
+ context.recordScopeId(LOGIN_BINDING_SCOPE_KEY, loginBindingState(host, clientIds));
2110
+ context.recordScopeId(SIGN_IN_TRUSTED_SCOPE_KEY, targets.trusted);
2111
+ context.recordScopeId(SIGN_IN_IN_APP_SCOPE_KEY, targets.active);
2112
+ }
1938
2113
  context.recordScopeId(LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, pendingRemovalState(stillPending));
1939
2114
  return { resourceId: host };
1940
2115
  }
2116
+ /**
2117
+ * The host table of a company's own in-app login app as a bindings port that collects its edits and
2118
+ * writes them once on `flush` (one redeploy, none when nothing changed). Used for a company that runs
2119
+ * its own services, which has no host-supplied bindings port.
2120
+ */
2121
+ ownSignInBindings(org, recorded, instanceHost) {
2122
+ const appId = recorded[SIGN_IN_LOGIN_APP_SCOPE_KEY];
2123
+ if (!appId) {
2124
+ throw new Error(`zitadel:login-host for ${org.slug}: the company's in-app login app is not recorded yet`);
2125
+ }
2126
+ const upsert = [];
2127
+ const remove = [];
2128
+ return {
2129
+ upsertBinding: async (binding) => {
2130
+ upsert.push(binding);
2131
+ },
2132
+ deleteBinding: async (host) => {
2133
+ remove.push(host);
2134
+ },
2135
+ flush: async () => {
2136
+ if (upsert.length === 0 && remove.length === 0)
2137
+ return;
2138
+ const edits = { appId, instanceHost, upsert: [...upsert], remove: [...remove] };
2139
+ upsert.length = 0;
2140
+ remove.length = 0;
2141
+ await editSignInBindings(this.ports.coolify, edits);
2142
+ },
2143
+ };
2144
+ }
1941
2145
  /**
1942
2146
  * `plan/diff.ts`'s `planCustomers` (plan gap A9): the org's customers sign
1943
2147
  * in through a second ZITADEL org, `<slug> customers`, with
@@ -2311,6 +2515,140 @@ export class FoundryStepExecutor {
2311
2515
  context.recordScopeId(COOLIFY_SERVER_SCOPE_KEY, server.uuid);
2312
2516
  return server;
2313
2517
  }
2518
+ /**
2519
+ * Contract C3: every usable, reachable Coolify server, the org's primary first. The primary is
2520
+ * {@link resolveServer}'s (databases and single-copy services stay on it, unchanged). A server that is
2521
+ * down is named in `down` and left out of `servers`; it is not an error.
2522
+ */
2523
+ async resolveServers(org, context) {
2524
+ const primary = await this.resolveServer(org, context);
2525
+ const { up, down } = splitServers(await this.blueGreenServers());
2526
+ return {
2527
+ primary,
2528
+ servers: [primary, ...up.filter((uuid) => uuid !== primary.uuid).map((uuid) => ({ uuid }))],
2529
+ down,
2530
+ };
2531
+ }
2532
+ /**
2533
+ * The release runner's outside calls for one blue/green entry (`plan/release-host.ts`), or `null` while its
2534
+ * blue copy is not recorded. The health token is only read here, never minted.
2535
+ */
2536
+ releaseHost(org, subject, context, renew) {
2537
+ const recorded = context.recordedIds();
2538
+ const primaryBlueAppId = recorded[`coolify:app:${appStepKey(org, subject.id)}`];
2539
+ const primaryUuid = recorded[COOLIFY_SERVER_SCOPE_KEY];
2540
+ if (!primaryBlueAppId || !primaryUuid)
2541
+ return null;
2542
+ return createReleaseHost({
2543
+ subject,
2544
+ context,
2545
+ coolify: this.ports.coolify,
2546
+ healthToken: async () => (await this.ports.heldKeys?.resolve(sharedSecretRef(org.id, healthTokenName(subject.id)))) ?? null,
2547
+ secret: (value) => void this.handledSecrets.add(value),
2548
+ primaryUuid,
2549
+ primaryBlueAppId,
2550
+ runtime: this.ports.runtime ?? {},
2551
+ ...(this.ports.runtime?.fetch ? { fetch: this.ports.runtime.fetch } : {}),
2552
+ renew,
2553
+ label: `coolify:release:${subject.id}`,
2554
+ });
2555
+ }
2556
+ /** Every server Coolify lists with whether it is usable and reachable, for the planner. */
2557
+ async blueGreenServers() {
2558
+ return (await this.ports.coolify.listServers()).map((s) => ({
2559
+ uuid: s.uuid,
2560
+ usable: s.is_usable,
2561
+ reachable: s.is_reachable,
2562
+ }));
2563
+ }
2564
+ /**
2565
+ * The blue/green steps (`plan/blue-green.ts`): `bluegreen` makes the apps and the balancer on one server,
2566
+ * `bluegreen-lb` only writes the balancer's weights, `bluegreen-connector` runs the tunnel's connector on a
2567
+ * server. A server that went down since the plan is skipped by `bluegreen-connector`'s caller, not here:
2568
+ * a step for it fails, which the next plan reports instead.
2569
+ */
2570
+ async coolifyBlueGreen(action, org, context) {
2571
+ const recorded = () => context.recordedIds();
2572
+ const projectUuid = await this.ensureCoolifyProject(org, context);
2573
+ const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
2574
+ const serverUuid = String(action.desired.server ?? '');
2575
+ const primaryUuid = String(action.desired.primary ?? '');
2576
+ const live = await this.resolveServers(org, context);
2577
+ if (!live.servers.some((s) => s.uuid === serverUuid)) {
2578
+ throw new Error(`${action.kind} for ${org.slug}: Coolify server ${serverUuid} is not usable and reachable`);
2579
+ }
2580
+ if (live.primary.uuid !== primaryUuid) {
2581
+ throw new Error(`${action.kind} for ${org.slug}: the plan's primary server is not the recorded one`);
2582
+ }
2583
+ if (action.kind === 'bluegreen-connector') {
2584
+ const tunnelId = recorded()[`cloudflare:tunnel:${org.id}`];
2585
+ if (!tunnelId)
2586
+ throw new Error(`bluegreen-connector for ${org.slug}: the tunnel is not recorded yet`);
2587
+ await applyBlueGreenConnectors({
2588
+ coolify: this.ports.coolify,
2589
+ tunnels: this.tunnelPort(),
2590
+ tunnelId,
2591
+ baseAppName: `${org.slug}-cloudflared`,
2592
+ baseScopeKey: CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY,
2593
+ primaryUuid,
2594
+ serverUuids: [serverUuid],
2595
+ projectUuid,
2596
+ environment,
2597
+ recorded,
2598
+ record: (key, id) => context.recordScopeId(key, id),
2599
+ persist: () => context.persistScopeIds(),
2600
+ });
2601
+ return undefined;
2602
+ }
2603
+ const entry = org.hosting.entries.find((e) => e.key === action.desired.entry);
2604
+ if (!entry || !entrySubject(org, entry)) {
2605
+ throw new Error(`${action.kind} for ${org.slug}: no blue/green entry '${String(action.desired.entry)}'`);
2606
+ }
2607
+ const primaryBlueAppId = recorded()[`coolify:app:${appStepKey(org, entry.key)}`];
2608
+ if (!primaryBlueAppId) {
2609
+ throw new Error(`${action.kind} for ${org.slug}: entry '${entry.key}' has no blue app recorded yet`);
2610
+ }
2611
+ // The blue app keeps the name it has: an app that ran before blue/green is adopted under its own name.
2612
+ if (!recorded()[bgKey.blueName(entry.key)]) {
2613
+ const blue = await this.ports.coolify.getApplication(primaryBlueAppId);
2614
+ if (blue.name) {
2615
+ context.recordScopeId(bgKey.blueName(entry.key), blue.name);
2616
+ await context.persistScopeIds();
2617
+ }
2618
+ }
2619
+ const subject = entrySubject(org, entry, recorded());
2620
+ const runtime = this.ports.runtime ?? {};
2621
+ const deps = {
2622
+ coolify: this.ports.coolify,
2623
+ recorded,
2624
+ record: (key, value) => context.recordScopeId(key, value),
2625
+ persist: () => context.persistScopeIds(),
2626
+ projectUuid,
2627
+ environment,
2628
+ healthToken: async () => {
2629
+ if (typeof this.ports.heldKeys?.put !== 'function') {
2630
+ throw new Error(`${action.kind} for ${org.slug}: the executor's heldKeys cannot put the health token`);
2631
+ }
2632
+ const token = await this.ensureSharedSecret(org, healthTokenName(entry.key), recorded());
2633
+ this.handledSecrets.add(token);
2634
+ return token;
2635
+ },
2636
+ secret: (value) => void this.handledSecrets.add(value),
2637
+ runtime,
2638
+ sleep: runtime.sleep ?? defaultSleep,
2639
+ label: `${action.system}:${action.kind}:${entry.key}`,
2640
+ primaryBlueAppId,
2641
+ logDir: LOG_DIR,
2642
+ isRunning: isRunningOrStarting,
2643
+ };
2644
+ if (action.kind === 'bluegreen-lb') {
2645
+ await applyBlueGreenWeights(deps, subject, { uuid: serverUuid, primaryUuid });
2646
+ }
2647
+ else {
2648
+ await applyBlueGreenServer(deps, subject, { uuid: serverUuid, primaryUuid });
2649
+ }
2650
+ return undefined;
2651
+ }
2314
2652
  async coolifyApp(action, org, context) {
2315
2653
  const recordedAppId = context.recordedIds()[`coolify:app:${action.key}`];
2316
2654
  if (action.desired.enabled === false) {
@@ -2348,6 +2686,11 @@ export class FoundryStepExecutor {
2348
2686
  }
2349
2687
  const projectUuid = await this.ensureCoolifyProject(org, context);
2350
2688
  const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
2689
+ // A blue/green entry's app is its blue copy. An app that ran before blue/green keeps its own name and is
2690
+ // adopted as blue; an entry with no app yet gets `<base>-blue`. The name is recorded once decided.
2691
+ const blueNameKey = bgKey.blueName(entry.key);
2692
+ const blueNameRecorded = Boolean(context.recordedIds()[blueNameKey]);
2693
+ let appName = liveAppNameFor(org, entry, context.recordedIds());
2351
2694
  // The recorded id first; the name when nothing is recorded, or when the
2352
2695
  // recorded app is gone.
2353
2696
  let existing = null;
@@ -2356,17 +2699,35 @@ export class FoundryStepExecutor {
2356
2699
  try {
2357
2700
  live = await this.ports.coolify.getApplication(recordedAppId);
2358
2701
  existing = { uuid: recordedAppId };
2702
+ if (entry.blueGreen &&
2703
+ !blueNameRecorded &&
2704
+ live.name === colorAppName(appNameFor(org, entry), 'blue')) {
2705
+ appName = live.name;
2706
+ }
2359
2707
  }
2360
2708
  catch (error) {
2361
2709
  if (!isNotFoundError(error))
2362
2710
  throw error;
2363
2711
  }
2364
2712
  }
2713
+ if (!existing && entry.blueGreen && !blueNameRecorded) {
2714
+ const own = await this.ports.coolify.findApplicationByName(appNameFor(org, entry), {
2715
+ environmentIds: [environment.id],
2716
+ });
2717
+ if (own) {
2718
+ existing = own;
2719
+ appName = appNameFor(org, entry);
2720
+ }
2721
+ }
2365
2722
  if (!existing) {
2366
- existing = await this.ports.coolify.findApplicationByName(appNameFor(org, entry), {
2723
+ existing = await this.ports.coolify.findApplicationByName(appName, {
2367
2724
  environmentIds: [environment.id],
2368
2725
  });
2369
2726
  }
2727
+ if (entry.blueGreen && !blueNameRecorded) {
2728
+ context.recordScopeId(blueNameKey, appName);
2729
+ await context.persistScopeIds();
2730
+ }
2370
2731
  // No entry gets Coolify `domains`: Coolify would add a Traefik router for them. The company's
2371
2732
  // tunnel routes an entry's names straight to its container (also `<slug>.oikos.host`, through a
2372
2733
  // record in the platform's oikos.host zone), which every entry reaches as `<slug>-<key>` (its
@@ -2403,6 +2764,7 @@ export class FoundryStepExecutor {
2403
2764
  const server = await this.resolveServer(org, context);
2404
2765
  const githubApp = await this.ports.coolify.requireGithubApp();
2405
2766
  const created = await this.ports.coolify.createGithubAppApplication({
2767
+ is_preview_deployments_enabled: false,
2406
2768
  project_uuid: projectUuid,
2407
2769
  server_uuid: server.uuid,
2408
2770
  environment_name: environment.name,
@@ -2411,7 +2773,7 @@ export class FoundryStepExecutor {
2411
2773
  git_repository: appRepo,
2412
2774
  git_branch: entry.branch,
2413
2775
  build_pack: entry.buildPack,
2414
- name: appNameFor(org, entry),
2776
+ name: appName,
2415
2777
  autogenerate_domain: false,
2416
2778
  // A static site is served by Coolify's own web server on port 80.
2417
2779
  ports_exposes: isStatic ? '80' : entry.port === null ? undefined : String(entry.port),
@@ -2438,6 +2800,10 @@ export class FoundryStepExecutor {
2438
2800
  }
2439
2801
  if (want.autoDeploy !== have.autoDeploy)
2440
2802
  patch.is_auto_deploy_enabled = want.autoDeploy;
2803
+ // #788: an app found with previews on gets them off (`planApp` plans the
2804
+ // step for it). A created app has them off from the create.
2805
+ if (previewsOn(live))
2806
+ patch.is_preview_deployments_enabled = false;
2441
2807
  // #543: the health-check quartet too, every run -- the same reason
2442
2808
  // Archon's own doc comment gives (without one, Coolify's rolling update
2443
2809
  // has nothing to wait on and stops the old container immediately). The
@@ -2460,7 +2826,7 @@ export class FoundryStepExecutor {
2460
2826
  patch.domains = '';
2461
2827
  }
2462
2828
  if (!existing || patch.domains !== undefined) {
2463
- patch.custom_internal_name = appNameFor(org, entry);
2829
+ patch.custom_internal_name = appName;
2464
2830
  }
2465
2831
  // A `*` entry's proxy labels: Coolify takes `custom_labels` base64 encoded
2466
2832
  // and uses them instead of the labels it generates from `domains`. Rule 4:
@@ -2514,8 +2880,8 @@ export class FoundryStepExecutor {
2514
2880
  // `ensureLogVolume` whether this app was already running before this
2515
2881
  // call -- see its own doc comment for why that decides whether to
2516
2882
  // restart it.
2517
- await this.ensureLogVolume(appId, org, Boolean(existing), appNameFor(org, entry));
2518
- await this.ensureEntryVolumes(appId, entry, Boolean(existing), appNameFor(org, entry));
2883
+ await this.ensureLogVolume(appId, org, Boolean(existing), appName);
2884
+ await this.ensureEntryVolumes(appId, entry, Boolean(existing), appName);
2519
2885
  // The app receives whatever it needs to sign in (gap P2). Unconditional,
2520
2886
  // unlike the database push above: every app has a hostname, and an
2521
2887
  // upsert of the same value on a later apply is a no-op, not a change.
@@ -2546,8 +2912,10 @@ export class FoundryStepExecutor {
2546
2912
  // The public name answers from the container only once the tunnel step has written the
2547
2913
  // direct route; before that (a first apply, or the one that moves it) the deployment is the check.
2548
2914
  hostname &&
2915
+ // The public name goes through the balancer, which may answer from the other color.
2916
+ !entry.blueGreen &&
2549
2917
  (context.recordedIds()[CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY] ?? '[]') ===
2550
- entryRoutesMarker(org)
2918
+ entryRoutesMarker(org, context.recordedIds())
2551
2919
  ? publicHealthCheck(org, context.recordedIds(), hostname, healthPath)
2552
2920
  : { kind: 'deployment-only' }, runtime, runtime.sleep ?? defaultSleep, `coolify:app:${entry.key}`, `${org.slug}'s ${entry.key === APP_ENTRY_KEY ? 'app' : `${entry.key} app`} stays at its previous deploy`);
2553
2921
  await recordBuiltCommit(this.ports.coolify, { appId: appId, deploymentUuid: built.deploymentUuid, name: entry.key }, (key, value) => context.recordScopeId(key, value));
@@ -3857,6 +4225,7 @@ export class FoundryStepExecutor {
3857
4225
  const server = await this.resolveServer(input.org, input.context);
3858
4226
  const githubApp = await this.ports.coolify.requireGithubApp();
3859
4227
  const created = await this.ports.coolify.createGithubAppApplication({
4228
+ is_preview_deployments_enabled: false,
3860
4229
  project_uuid: input.projectUuid,
3861
4230
  server_uuid: server.uuid,
3862
4231
  environment_name: input.environment.name,
@@ -4389,6 +4758,7 @@ export class FoundryStepExecutor {
4389
4758
  }
4390
4759
  const githubApp = await this.ports.coolify.requireGithubApp();
4391
4760
  const synapse = await this.findOrCreateCallsApp(names.synapse, environment.id, () => this.ports.coolify.createGithubAppApplication({
4761
+ is_preview_deployments_enabled: false,
4392
4762
  project_uuid: projectUuid,
4393
4763
  server_uuid: server.uuid,
4394
4764
  environment_name: environment.name,
@@ -4413,6 +4783,7 @@ export class FoundryStepExecutor {
4413
4783
  instant_deploy: false,
4414
4784
  }), { startIfStopped: false });
4415
4785
  const service = await this.findOrCreateCallsApp(names.service, environment.id, () => this.ports.coolify.createGithubAppApplication({
4786
+ is_preview_deployments_enabled: false,
4416
4787
  project_uuid: projectUuid,
4417
4788
  server_uuid: server.uuid,
4418
4789
  environment_name: environment.name,
@@ -4613,6 +4984,7 @@ export class FoundryStepExecutor {
4613
4984
  }
4614
4985
  const githubApp = await this.ports.coolify.requireGithubApp();
4615
4986
  const synapse = await this.findOrCreateCallsApp(names.synapse, environment.id, () => this.ports.coolify.createGithubAppApplication({
4987
+ is_preview_deployments_enabled: false,
4616
4988
  project_uuid: projectUuid,
4617
4989
  server_uuid: server.uuid,
4618
4990
  environment_name: environment.name,
@@ -4819,6 +5191,7 @@ export class FoundryStepExecutor {
4819
5191
  const server = await this.resolveServer(org, context);
4820
5192
  const githubApp = await this.ports.coolify.requireGithubApp();
4821
5193
  const app = await this.findOrCreateCallsApp(name, environment.id, () => this.ports.coolify.createGithubAppApplication({
5194
+ is_preview_deployments_enabled: false,
4822
5195
  project_uuid: projectUuid,
4823
5196
  server_uuid: server.uuid,
4824
5197
  environment_name: environment.name,
@@ -5199,6 +5572,7 @@ export class FoundryStepExecutor {
5199
5572
  const server = await this.resolveServer(org, context);
5200
5573
  const githubApp = await this.ports.coolify.requireGithubApp();
5201
5574
  const app = await this.findOrCreateCallsApp(name, environment.id, () => this.ports.coolify.createGithubAppApplication({
5575
+ is_preview_deployments_enabled: false,
5202
5576
  project_uuid: projectUuid,
5203
5577
  server_uuid: server.uuid,
5204
5578
  environment_name: environment.name,
@@ -5233,10 +5607,10 @@ export class FoundryStepExecutor {
5233
5607
  // #442: read before overwriting -- `bulkUpsertEnvs` below always leaves
5234
5608
  // Coolify's own env store matching `env`, which is why that store alone
5235
5609
  // can no longer tell a changed value from one already live.
5236
- const liveEnv = new Map((await this.ports.coolify.listEnvs(app.appId))
5237
- .filter((e) => !e.is_preview)
5238
- .map((e) => [e.key, e.value]));
5610
+ const liveRows = (await this.ports.coolify.listEnvs(app.appId)).filter((e) => !e.is_preview);
5611
+ const liveEnv = new Map(liveRows.map((e) => [e.key, e.value]));
5239
5612
  const envChanged = agoraEnvDrift(env, Object.fromEntries(liveEnv)).length > 0;
5613
+ await this.dropStaleSignInUpstream(app.appId, env, liveRows);
5240
5614
  await this.ports.coolify.bulkUpsertEnvs(app.appId, Object.entries(env).map(([key, value]) => ({ key, value })));
5241
5615
  const revision = agoraEnvRevision(env);
5242
5616
  const alreadyActivated = context.recordedIds()[AGORA_ACTIVATED_ENV_SCOPE_KEY] === revision;
@@ -5262,9 +5636,43 @@ export class FoundryStepExecutor {
5262
5636
  else if (app.existed && (envChanged || !alreadyActivated)) {
5263
5637
  await this.ports.coolify.restart(app.appId);
5264
5638
  }
5639
+ // The login-host step points the sign-in at this app's own address only once the env is running. Before the
5640
+ // activation marker, so a crash between the two plans this step again and records it.
5641
+ this.recordSignInEnvLive(context, 'agora', agoraHostnameFor(org), env);
5265
5642
  context.recordScopeId(AGORA_ACTIVATED_ENV_SCOPE_KEY, revision);
5266
5643
  return { resourceId: app.appId };
5267
5644
  }
5645
+ /**
5646
+ * Records whether an app's running env holds `SIGNIN_UPSTREAM` (the app's host while it does, `''` once it
5647
+ * does not), so the login-host step knows when to point the base URI at the app and when it may give the
5648
+ * host up. Written only when the value changes.
5649
+ */
5650
+ recordSignInEnvLive(context, app, host, env) {
5651
+ const key = `${SIGN_IN_ENV_LIVE_SCOPE_PREFIX}${app}`;
5652
+ const value = env[SIGN_IN_UPSTREAM_ENV] !== undefined ? host : '';
5653
+ if ((context.recordedIds()[key] ?? '') !== value)
5654
+ context.recordScopeId(key, value);
5655
+ }
5656
+ /**
5657
+ * In-app sign-in switched off (or its host given up): the app's live `SIGNIN_UPSTREAM` is deleted, because
5658
+ * `bulkUpsertEnvs` never removes a key. Left in place, the app would keep proxying `/sign-in/*` to the login.
5659
+ * Nothing happens while the env is desired or the app does not hold it.
5660
+ */
5661
+ async dropStaleSignInUpstream(appId, desired, liveRows) {
5662
+ if (desired[SIGN_IN_UPSTREAM_ENV] !== undefined)
5663
+ return;
5664
+ for (const row of liveRows) {
5665
+ if (row.key !== SIGN_IN_UPSTREAM_ENV || !row.uuid)
5666
+ continue;
5667
+ try {
5668
+ await this.ports.coolify.deleteEnv(appId, row.uuid);
5669
+ }
5670
+ catch (error) {
5671
+ if (!isNotFoundError(error))
5672
+ throw error;
5673
+ }
5674
+ }
5675
+ }
5268
5676
  // ---- boule (#161) --------------------------------------------------------
5269
5677
  /**
5270
5678
  * `plan/diff.ts`'s `planBoule`: provisions the org's own Boule
@@ -5318,6 +5726,7 @@ export class FoundryStepExecutor {
5318
5726
  const server = await this.resolveServer(org, context);
5319
5727
  const githubApp = await this.ports.coolify.requireGithubApp();
5320
5728
  const app = await this.findOrCreateCallsApp(name, environment.id, () => this.ports.coolify.createGithubAppApplication({
5729
+ is_preview_deployments_enabled: false,
5321
5730
  project_uuid: projectUuid,
5322
5731
  server_uuid: server.uuid,
5323
5732
  environment_name: environment.name,
@@ -5339,10 +5748,10 @@ export class FoundryStepExecutor {
5339
5748
  ...healthCheckSettings('/api/health', BOULE_PORT),
5340
5749
  ...(await this.routedLabels(name, bouleHostnameFor(org), BOULE_PORT)),
5341
5750
  });
5342
- const liveEnv = new Map((await this.ports.coolify.listEnvs(app.appId))
5343
- .filter((e) => !e.is_preview)
5344
- .map((e) => [e.key, e.value]));
5751
+ const liveRows = (await this.ports.coolify.listEnvs(app.appId)).filter((e) => !e.is_preview);
5752
+ const liveEnv = new Map(liveRows.map((e) => [e.key, e.value]));
5345
5753
  const envChanged = bouleEnvDrift(env, Object.fromEntries(liveEnv)).length > 0;
5754
+ await this.dropStaleSignInUpstream(app.appId, env, liveRows);
5346
5755
  const envs = Object.entries(env).map(([key, value]) => ({ key, value }));
5347
5756
  const cookieSecret = await this.ensureAuthCookieSecret(app.appId);
5348
5757
  if (cookieSecret)
@@ -5416,6 +5825,8 @@ export class FoundryStepExecutor {
5416
5825
  if (action.desired.platformOwner === true) {
5417
5826
  context.recordScopeId(BOULE_SECRETS_RESTARTED_SCOPE_KEY, '1');
5418
5827
  }
5828
+ // Last: the login-host step points the sign-in at this app's own address only once the env is running.
5829
+ this.recordSignInEnvLive(context, 'boule', bouleHostnameFor(org), env);
5419
5830
  return { resourceId: app.appId };
5420
5831
  }
5421
5832
  /**
@@ -5858,6 +6269,9 @@ export class FoundryStepExecutor {
5858
6269
  smtpSender: `noreply@${hn.mailDomain}`,
5859
6270
  defaultRedirectUri: `https://${agoraHostnameFor(org)}`,
5860
6271
  ownerCompany: null,
6272
+ // In-app sign-in: the company's own stack gets the second login app. Its host table starts
6273
+ // empty; the login-host step writes it once the apps' clients exist.
6274
+ ...(inAppSignIn(org) ? { signInLogin: { bindings: null } } : {}),
5861
6275
  });
5862
6276
  if (refChanged) {
5863
6277
  const now = scope.recordedIds();
@@ -6017,6 +6431,42 @@ export class FoundryStepExecutor {
6017
6431
  }
6018
6432
  return { resourceId: registered.id };
6019
6433
  }
6434
+ /** The blue/green database route (`plan/remote-database.ts`). */
6435
+ async remoteDatabaseRoute(action, org, context) {
6436
+ const projectUuid = await this.ensureCoolifyProject(org, context);
6437
+ const desired = action.desired;
6438
+ // The tunnel's TCP rule names the database by the host the apps' own `DATABASE_URL` uses.
6439
+ const db = await this.ports.coolify.getDatabase(desired.database);
6440
+ if (db.internal_db_url)
6441
+ this.handledSecrets.add(db.internal_db_url);
6442
+ let databaseHost = '';
6443
+ try {
6444
+ databaseHost = new URL(db.internal_db_url ?? '').hostname;
6445
+ }
6446
+ catch {
6447
+ // Fixed text below: the URL holds the database password.
6448
+ }
6449
+ if (!databaseHost) {
6450
+ throw new Error(`cloudflare:db-route for ${org.slug}: the database reports no usable address`);
6451
+ }
6452
+ await applyRemoteDatabase({
6453
+ tunnels: this.tunnelPort(),
6454
+ access: this.accessPort(),
6455
+ dns: this.dnsPort(),
6456
+ zones: this.zonePort(),
6457
+ coolify: this.ports.coolify,
6458
+ secret: (value) => void this.handledSecrets.add(value),
6459
+ }, {
6460
+ org,
6461
+ desired,
6462
+ databaseHost,
6463
+ projectUuid,
6464
+ recorded: () => context.recordedIds(),
6465
+ record: (key, value) => context.recordScopeId(key, value),
6466
+ persist: () => context.persistScopeIds(),
6467
+ });
6468
+ return {};
6469
+ }
6020
6470
  /** `deliverSecret`, tried up to {@link JOBS_DELIVERY_ATTEMPTS} times; true once one attempt succeeds. */
6021
6471
  async deliverWithRetry(ref, value) {
6022
6472
  for (let attempt = 1; attempt <= JOBS_DELIVERY_ATTEMPTS; attempt++) {
@@ -6096,17 +6546,19 @@ export class FoundryStepExecutor {
6096
6546
  const loginHost = action.desired.loginHost ?? null;
6097
6547
  // A hosting entry's names (and its wildcard) go straight to its container on the company network:
6098
6548
  // the container carries `traefik.enable=false`, so the proxy has no route for it.
6099
- const entryOrigins = new Map(entryRoutes(org).map((r) => [r.hostname, r.service]));
6549
+ const entryOrigins = new Map(entryRoutes(org, context.recordedIds()).map((r) => [r.hostname, r.service]));
6100
6550
  const ingress = tunnelIngressHostnames(org, loginHost).map((hostname) => ({
6101
6551
  hostname,
6102
6552
  service: hostname === loginHost
6103
6553
  ? `http://${PLATFORM_AUTH_LOGIN_INTERNAL_NAME}:${AUTH_LOGIN_PORT}`
6104
6554
  : (entryOrigins.get(hostname) ?? COOLIFY_PROXY_ORIGIN),
6105
6555
  }));
6556
+ const httpIngress = [...ingress, { service: 'http_status:404' }];
6106
6557
  await this.tunnelPort().putTunnelConfig(tunnel.id, {
6107
- ingress: [...ingress, { service: 'http_status:404' }],
6558
+ ingress: mergeTcpIngress(httpIngress, recordedTcpRoutes(context.recordedIds())),
6108
6559
  });
6109
- context.recordScopeId(CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, entryRoutesMarker(org));
6560
+ context.recordScopeId(TUNNEL_HTTP_INGRESS_SCOPE_KEY, JSON.stringify(httpIngress));
6561
+ context.recordScopeId(CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, entryRoutesMarker(org, context.recordedIds()));
6110
6562
  // #235: a connector for this org's own tunnel, the same call every
6111
6563
  // platform tunnel step already makes (Decided, William, 2026-10-01:
6112
6564
  // one connector per org tunnel, never a shared tunnel with appended
@@ -7873,8 +8325,32 @@ export class FoundryStepExecutor {
7873
8325
  return;
7874
8326
  }
7875
8327
  case 'coolify:app':
8328
+ case 'coolify:bg-app':
7876
8329
  await this.ports.coolify.deleteApplication(target.id);
7877
8330
  return;
8331
+ case 'cloudflare:bg-access':
8332
+ await this.accessPort().deleteAccessApp(target.id);
8333
+ return;
8334
+ case 'cloudflare:bg-token':
8335
+ await this.accessPort().removeServiceToken(target.id);
8336
+ return;
8337
+ case 'cloudflare:bg-dns': {
8338
+ // `target.id` is the recorded route ({ hostname, service }); its name lives in the org's zone.
8339
+ const hostname = JSON.parse(target.id).hostname;
8340
+ const zone = org.domain ? await this.zonePort().findZoneByApex(org.domain) : undefined;
8341
+ if (typeof hostname !== 'string' || !zone)
8342
+ return;
8343
+ const dns = this.dnsPort();
8344
+ for (const record of await dns.findDnsRecord(zone.name, hostname, 'CNAME')) {
8345
+ if (record.content.endsWith('.cfargotunnel.com')) {
8346
+ await dns.deleteDnsRecord(zone.name, record.id);
8347
+ }
8348
+ }
8349
+ return;
8350
+ }
8351
+ case 'coolify:bg-database':
8352
+ await this.ports.coolify.deleteDatabase(target.id);
8353
+ return;
7878
8354
  case 'coolify:database':
7879
8355
  await this.ports.coolify.deleteDatabase(target.id);
7880
8356
  return;
@@ -7953,3 +8429,23 @@ export function isNotFoundError(error) {
7953
8429
  const message = error instanceof Error ? error.message : String(error);
7954
8430
  return /^\S+ \S+ \S+ → 404:/.test(message);
7955
8431
  }
8432
+ /** ZITADEL's error code for an update that changes nothing (`COMMAND-1m88i`, "No changes"). */
8433
+ const ZITADEL_NO_CHANGES_CODE = 'COMMAND-1m88i';
8434
+ /** True when ZITADEL refused an update because the value already is the wanted one. Matches the error code, never the free text. */
8435
+ export function isNoChangesError(error) {
8436
+ const message = error instanceof Error ? error.message : String(error);
8437
+ return message.includes(ZITADEL_NO_CHANGES_CODE);
8438
+ }
8439
+ /**
8440
+ * {@link ZitadelPort.updateOidcAppLoginBaseUri} that counts an app already holding the wanted base URI as
8441
+ * done: ZITADEL answers that write with "No changes" and a repeat apply must not fail on it.
8442
+ */
8443
+ export async function setOidcAppLoginBaseUri(zitadel, orgId, projectId, appId, loginBaseUri) {
8444
+ try {
8445
+ await zitadel.updateOidcAppLoginBaseUri(orgId, projectId, appId, loginBaseUri);
8446
+ }
8447
+ catch (error) {
8448
+ if (!isNoChangesError(error))
8449
+ throw error;
8450
+ }
8451
+ }