@wtfalch/foundry 0.42.1 → 0.44.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 (73) hide show
  1. package/dist/bootstrap/versions.d.ts +2 -0
  2. package/dist/index.d.ts +8 -4
  3. package/dist/index.js +4 -2
  4. package/dist/migrations/0055_release_requests.sql +20 -0
  5. package/dist/migrations/0056_platform_archon_blue_green.sql +7 -0
  6. package/dist/migrations/0057_org_customers.sql +29 -0
  7. package/dist/org.d.ts +55 -0
  8. package/dist/org.js +90 -27
  9. package/dist/plan/archon-blue-green.d.ts +54 -0
  10. package/dist/plan/archon-blue-green.js +72 -0
  11. package/dist/plan/blue-green-apply.d.ts +82 -0
  12. package/dist/plan/blue-green-apply.js +360 -0
  13. package/dist/plan/blue-green.d.ts +223 -0
  14. package/dist/plan/blue-green.js +444 -0
  15. package/dist/plan/boule.d.ts +43 -2
  16. package/dist/plan/boule.js +48 -1
  17. package/dist/plan/broker-entries.d.ts +28 -0
  18. package/dist/plan/broker-entries.js +46 -1
  19. package/dist/plan/cloudflared-connector.d.ts +26 -0
  20. package/dist/plan/cloudflared-connector.js +37 -0
  21. package/dist/plan/diff.d.ts +20 -0
  22. package/dist/plan/diff.js +207 -20
  23. package/dist/plan/executor.d.ts +134 -2
  24. package/dist/plan/executor.js +517 -25
  25. package/dist/plan/hostnames.d.ts +7 -0
  26. package/dist/plan/hostnames.js +17 -2
  27. package/dist/plan/locked-hostnames.d.ts +11 -0
  28. package/dist/plan/locked-hostnames.js +13 -0
  29. package/dist/plan/mcp.d.ts +57 -0
  30. package/dist/plan/mcp.js +131 -0
  31. package/dist/plan/org-names.d.ts +2 -2
  32. package/dist/plan/org-names.js +16 -1
  33. package/dist/plan/plan-apply.d.ts +77 -0
  34. package/dist/plan/plan-apply.js +267 -5
  35. package/dist/plan/platform-diff.d.ts +9 -0
  36. package/dist/plan/platform-diff.js +24 -1
  37. package/dist/plan/platform-providers.d.ts +15 -1
  38. package/dist/plan/platform-providers.js +90 -5
  39. package/dist/plan/proxy-labels.d.ts +2 -2
  40. package/dist/plan/proxy-labels.js +14 -4
  41. package/dist/plan/release-host.d.ts +59 -0
  42. package/dist/plan/release-host.js +239 -0
  43. package/dist/plan/remote-database.d.ts +124 -0
  44. package/dist/plan/remote-database.js +373 -0
  45. package/dist/plan/worker-app-database.d.ts +148 -0
  46. package/dist/plan/worker-app-database.js +424 -0
  47. package/dist/plan/worker-app.d.ts +320 -0
  48. package/dist/plan/worker-app.js +792 -0
  49. package/dist/plan/zitadel-machine-user.d.ts +13 -1
  50. package/dist/plan/zitadel-machine-user.js +80 -0
  51. package/dist/platform.d.ts +11 -1
  52. package/dist/platform.js +4 -1
  53. package/dist/release/decide.d.ts +95 -0
  54. package/dist/release/decide.js +242 -0
  55. package/dist/release/health.d.ts +48 -0
  56. package/dist/release/health.js +61 -0
  57. package/dist/release/run.d.ts +40 -0
  58. package/dist/release/run.js +56 -0
  59. package/dist/release/state.d.ts +64 -0
  60. package/dist/release/state.js +126 -0
  61. package/dist/release/types.d.ts +54 -0
  62. package/dist/release/types.js +4 -0
  63. package/dist/requested-changes.d.ts +12 -3
  64. package/dist/requested-changes.js +22 -0
  65. package/dist/store/postgres.d.ts +8 -0
  66. package/dist/store/postgres.js +67 -8
  67. package/dist/store/schema.d.ts +188 -0
  68. package/dist/store/schema.js +15 -0
  69. package/dist/worker-app-config.d.ts +151 -0
  70. package/dist/worker-app-config.js +438 -0
  71. package/package.json +1 -1
  72. package/versions.digests.json +2 -1
  73. package/versions.json +2 -1
@@ -7,8 +7,10 @@ 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 { 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
- import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
10
+ import { applyBlueGreenConnectors, applyBlueGreenServer, applyBlueGreenWeights, } from './blue-green-apply.js';
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_UP_SCOPE_KEY, archonCredentialRenewDue, bouleArchonCredentialRef, bouleEnvDrift, bouleHostnameFor, bouleMailWanted, customersDirectoryRef, customersOrgIdOf, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
13
+ import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, mcpCallbackUri, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
12
14
  import { recordBuiltCommit } from './build-record.js';
13
15
  import { MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, masDockerfile, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
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';
@@ -34,6 +36,7 @@ import { appHostname, cmsStudioHostnameFor, coolifyTunnelDomains, entryHostnames
34
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';
35
37
  import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
36
38
  import { OWN_MAIL_SMTP_INTERNAL_SCOPE_KEY, OWN_MAIL_SMTP_PORT_SCOPE_KEY, isOwnMailSmtpPort, normalizeMailTarget, } from './mail-relay.js';
39
+ import { applyAccessTokenLifetime, applyMcpAccess, applyMcpProject } from './mcp.js';
37
40
  import { NETWORK_APPLIED_SCOPE_KEY } from './network-move.js';
38
41
  import { OWN_AUTH_APPLIED_GIT_REF_SCOPE_KEY, OWN_AUTH_APPLIED_HOSTNAME_SCOPE_KEY, } from './own-diff.js';
39
42
  import { NO_DOMAIN_ZONE, ownHostnames } from './own-hostnames.js';
@@ -44,10 +47,13 @@ import { PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY
44
47
  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';
45
48
  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';
46
49
  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
+ import { createReleaseHost } from './release-host.js';
51
+ import { TUNNEL_HTTP_INGRESS_SCOPE_KEY, applyRemoteDatabase, mergeTcpIngress, recordedTcpRoutes, } from './remote-database.js';
47
52
  import { editSignInBindings } from './sign-in-bindings.js';
48
53
  import { ZITADEL_ORG_DOMAIN_SCOPE_KEY, ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY, closeSignInOrgRegistration, ensureSignInOrg, inviteOwnerWithLink, registerSignInDomainOrBlock, signInOrgDomain, } from './sign-in-org.js';
49
54
  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';
50
55
  import { moveSigninDatabase } from './signin-db-move.js';
56
+ import { WORKER_APP_CALL_TIMEOUT_MS, applyWorkerApp, heldBrokerKey, teardownWorkerApp, withCallTimeout, } from './worker-app.js';
51
57
  import { entryWorkerAppliedScopeKey, entryWorkerExclusionsScopeKey, entryWorkerRetiredScopeKey, entryWorkerRouteIdScopeKey, entryWorkerRoutePatternScopeKey, entryWorkerRoutePendingScopeKey, entryWorkerRouteZoneScopeKey, entryWorkerUploadedScopeKey, } from './worker.js';
52
58
  /**
53
59
  * The real {@link StepExecutor} (#20 "provision Company Factory end to
@@ -98,6 +104,11 @@ export function adaptZitadelListApps(client) {
98
104
  complete: true,
99
105
  });
100
106
  }
107
+ /** An outside call for Boule's credentials that has not answered in this long fails the step; the next apply repairs it. */
108
+ const BOULE_CREDENTIAL_TIMEOUT_MS = 60_000;
109
+ const BOULE_CREDENTIAL_ATTEMPTS = 3;
110
+ /** The ZITADEL machine user that reads one company's customers organisation. */
111
+ const CUSTOMERS_DIRECTORY_USER_NAME = 'foundry-customers-directory';
101
112
  // ---------------------------------------------------------------------------
102
113
  // Errors
103
114
  // ---------------------------------------------------------------------------
@@ -883,7 +894,11 @@ function limitProfileOf(action) {
883
894
  case 'coolify:boule':
884
895
  case 'coolify:boule-broker-entry':
885
896
  return LIMITS.boule;
897
+ case 'coolify:bluegreen':
898
+ case 'coolify:bluegreen-lb':
899
+ return LIMITS.companyApp;
886
900
  case 'cloudflare:tunnel':
901
+ case 'coolify:bluegreen-connector':
887
902
  return LIMITS.connector;
888
903
  default:
889
904
  return action.system === 'coolify' ? LIMITS.companyOther : undefined;
@@ -970,6 +985,15 @@ export class FoundryStepExecutor {
970
985
  ownMailKeyMintable() {
971
986
  return Boolean(this.ports.lettermint && this.ports.ownServices?.provision.auth.heldKeys);
972
987
  }
988
+ /** C3: whether the ZITADEL port can mint and revoke a customers directory credential. */
989
+ customersDirectoryAvailable() {
990
+ return (typeof this.ports.zitadel.mintOrgDirectoryReader === 'function' &&
991
+ typeof this.ports.zitadel.revokeOrgDirectoryReader === 'function');
992
+ }
993
+ /** C3: whether the host supplied `mintBouleArchonCredential`. */
994
+ bouleArchonCredentialAvailable() {
995
+ return typeof this.ports.mintBouleArchonCredential === 'function';
996
+ }
973
997
  /** Whether the held keys can store a new company's sign-in database CA and certificate (#746). */
974
998
  ownSigninDbKeysPuttable() {
975
999
  return Boolean(this.ports.ownServices?.provision.auth.heldKeys?.put);
@@ -1104,11 +1128,21 @@ export class FoundryStepExecutor {
1104
1128
  case 'zitadel:id-token-userinfo':
1105
1129
  return this.zitadelIdTokenUserinfo(action, org, context);
1106
1130
  case 'zitadel:customers':
1107
- return this.zitadelCustomers(org, context);
1131
+ return this.zitadelCustomers(action, org, context);
1132
+ case 'zitadel:mcp-project':
1133
+ return this.zitadelMcpProject(action, org, context);
1134
+ case 'zitadel:mcp-access':
1135
+ return this.zitadelMcpAccess(action, org, context);
1136
+ case 'zitadel:access-token-lifetime':
1137
+ return this.zitadelAccessTokenLifetime(context);
1108
1138
  case 'coolify:app':
1109
1139
  return this.coolifyApp(action, org, context);
1110
1140
  case 'coolify:network':
1111
1141
  return this.coolifyNetwork(action, org, context);
1142
+ case 'coolify:bluegreen':
1143
+ case 'coolify:bluegreen-lb':
1144
+ case 'coolify:bluegreen-connector':
1145
+ return this.coolifyBlueGreen(action, org, context);
1112
1146
  case 'coolify:broker-entry':
1113
1147
  return this.coolifyBrokerEntry(action, org, context);
1114
1148
  case 'coolify:database':
@@ -1133,6 +1167,13 @@ export class FoundryStepExecutor {
1133
1167
  return this.cloudflareRateLimit(org, context);
1134
1168
  case 'cloudflare:worker':
1135
1169
  return this.cloudflareWorker(action, org, context);
1170
+ case 'cloudflare:worker-app': {
1171
+ const workers = this.ports.workerApps;
1172
+ if (!workers) {
1173
+ throw new Error(`cloudflare:worker-app for ${org.slug}: this executor was built without a WorkerAppPort`);
1174
+ }
1175
+ return applyWorkerApp(this.workerAppDeps(workers), org, action, context);
1176
+ }
1136
1177
  case 'files:organisation':
1137
1178
  return this.filesOrganisation(action, org, context);
1138
1179
  case 'files:key':
@@ -1169,6 +1210,8 @@ export class FoundryStepExecutor {
1169
1210
  return this.bouleBrokerEntry(org, context);
1170
1211
  case 'jobs:registration':
1171
1212
  return this.jobsRegistration(action, org, context);
1213
+ case 'cloudflare:db-route':
1214
+ return this.remoteDatabaseRoute(action, org, context);
1172
1215
  case 'coolify:own-auth':
1173
1216
  return this.ownAuth(action, org, context);
1174
1217
  case 'coolify:own-auth-db-move':
@@ -1415,6 +1458,35 @@ export class FoundryStepExecutor {
1415
1458
  * "Declared changes apply" nothing is marked done until the
1416
1459
  * live state actually matches it.
1417
1460
  */
1461
+ workerAppDeps(workers) {
1462
+ return {
1463
+ workers,
1464
+ ...(this.ports.workerAppKeys ? { keys: this.ports.workerAppKeys } : {}),
1465
+ ...(this.ports.workerAppDatabase
1466
+ ? {
1467
+ database: {
1468
+ port: this.ports.workerAppDatabase,
1469
+ tunnels: this.tunnelPort(),
1470
+ access: this.accessPort(),
1471
+ dns: this.dnsPort(),
1472
+ zones: this.zonePort(),
1473
+ ownerUrl: async (databaseId) => {
1474
+ const db = await getDatabaseStarted(this.ports.coolify, databaseId);
1475
+ if (!db.internal_db_url) {
1476
+ throw new Error('the database reports no internal connection URL yet');
1477
+ }
1478
+ this.handledSecrets.add(db.internal_db_url);
1479
+ return db.internal_db_url;
1480
+ },
1481
+ secret: (value) => void this.handledSecrets.add(value),
1482
+ },
1483
+ }
1484
+ : {}),
1485
+ zones: this.zonePort(),
1486
+ dns: this.dnsPort(),
1487
+ ...(this.ports.heldKeys ? { heldKeys: this.ports.heldKeys } : {}),
1488
+ };
1489
+ }
1418
1490
  async cloudflareWorker(action, org, context) {
1419
1491
  const entryKey = action.key.slice(org.id.length + 1);
1420
1492
  const at = `cloudflare:worker for ${org.slug} entry '${entryKey}'`;
@@ -1752,6 +1824,18 @@ export class FoundryStepExecutor {
1752
1824
  }
1753
1825
  return { resourceId: userId };
1754
1826
  }
1827
+ /** `plan/mcp.ts`: the entry's MCP audience project. */
1828
+ zitadelMcpProject(action, org, context) {
1829
+ return applyMcpProject(this.ports.zitadel, org, action, context);
1830
+ }
1831
+ /** `plan/mcp.ts`: JWT access tokens and the broker's callback on the entry's app. */
1832
+ zitadelMcpAccess(action, org, context) {
1833
+ return applyMcpAccess(this.ports.zitadel, org, action, context);
1834
+ }
1835
+ /** `plan/mcp.ts`: the instance access-token lifetime. */
1836
+ zitadelAccessTokenLifetime(context) {
1837
+ return applyAccessTokenLifetime(this.ports.zitadel, context);
1838
+ }
1755
1839
  /**
1756
1840
  * Switches on "user info inside the ID token" for each live sign-in app
1757
1841
  * the planner named. An app's marker (its ZITADEL app id) is recorded and
@@ -2131,20 +2215,27 @@ export class FoundryStepExecutor {
2131
2215
  /**
2132
2216
  * `plan/diff.ts`'s `planCustomers` (plan gap A9): the org's customers sign
2133
2217
  * in through a second ZITADEL org, `<slug> customers`, with
2134
- * self-registration on and a grant to the org's app project. Staff stay in
2218
+ * self-registration as `customers.signUp` says (off by default) and a grant to the org's app project. Staff stay in
2135
2219
  * the org's own invite-only ZITADEL org. The space keeps the name apart from
2136
2220
  * every issuer org, which is named by a slug and a slug has no spaces. Every call here is idempotent, so
2137
2221
  * a re-run after a crash finishes the job.
2138
2222
  */
2139
- async zitadelCustomers(org, context) {
2223
+ async zitadelCustomers(action, org, context) {
2140
2224
  const zitadelOrgId = context.recordedIds().zitadel_org;
2141
2225
  if (!zitadelOrgId) {
2142
2226
  throw new Error(`zitadel:customers for ${org.slug}: no ZITADEL org recorded yet -- the issuer must be provisioned first`);
2143
2227
  }
2144
2228
  const projectId = await this.ensureZitadelProject(org, zitadelOrgId, context);
2145
2229
  const { org: customers } = await this.ports.zitadel.findOrCreateOrg(`${org.slug} customers`);
2146
- await this.ports.zitadel.allowSelfRegistration(customers.id);
2230
+ // Self-registration follows `customers.signUp` (off by default). The marker is written last, so a
2231
+ // run that failed before here is planned again.
2232
+ const signUp = action.desired.signUp === true;
2233
+ if (signUp)
2234
+ await this.ports.zitadel.allowSelfRegistration(customers.id);
2235
+ else
2236
+ await this.ports.zitadel.closeSelfRegistration(customers.id);
2147
2237
  await this.ports.zitadel.findOrCreateProjectGrant(zitadelOrgId, projectId, customers.id);
2238
+ context.recordScopeId(CUSTOMERS_SIGN_UP_SCOPE_KEY, signUp ? '1' : '0');
2148
2239
  return { resourceId: customers.id };
2149
2240
  }
2150
2241
  async zitadelApp(action, org, context) {
@@ -2302,13 +2393,17 @@ export class FoundryStepExecutor {
2302
2393
  redirectUris: appType === 'native'
2303
2394
  ? []
2304
2395
  : hosts
2305
- ? hosts.map((host) => `https://${host}/auth/callback`)
2396
+ ? [
2397
+ ...hosts.map((host) => `https://${host}/auth/callback`),
2398
+ ...(authEntry?.uses?.auth?.mcp ? [mcpCallbackUri(org)] : []),
2399
+ ]
2306
2400
  : [redirectUri],
2307
2401
  postLogoutRedirectUris: appType === 'native'
2308
2402
  ? []
2309
2403
  : hosts
2310
2404
  ? hosts.map((host) => `https://${host}/`)
2311
2405
  : [`https://${hostname}/`],
2406
+ ...(authEntry?.uses?.auth?.mcp ? { accessTokenType: 'jwt' } : {}),
2312
2407
  });
2313
2408
  if (appType === 'web') {
2314
2409
  // `coolifyApp`'s own `pushAppEnv` reads `APP_CLIENT_ID_SCOPE_KEY` to
@@ -2501,6 +2596,140 @@ export class FoundryStepExecutor {
2501
2596
  context.recordScopeId(COOLIFY_SERVER_SCOPE_KEY, server.uuid);
2502
2597
  return server;
2503
2598
  }
2599
+ /**
2600
+ * Contract C3: every usable, reachable Coolify server, the org's primary first. The primary is
2601
+ * {@link resolveServer}'s (databases and single-copy services stay on it, unchanged). A server that is
2602
+ * down is named in `down` and left out of `servers`; it is not an error.
2603
+ */
2604
+ async resolveServers(org, context) {
2605
+ const primary = await this.resolveServer(org, context);
2606
+ const { up, down } = splitServers(await this.blueGreenServers());
2607
+ return {
2608
+ primary,
2609
+ servers: [primary, ...up.filter((uuid) => uuid !== primary.uuid).map((uuid) => ({ uuid }))],
2610
+ down,
2611
+ };
2612
+ }
2613
+ /**
2614
+ * The release runner's outside calls for one blue/green entry (`plan/release-host.ts`), or `null` while its
2615
+ * blue copy is not recorded. The health token is only read here, never minted.
2616
+ */
2617
+ releaseHost(org, subject, context, renew) {
2618
+ const recorded = context.recordedIds();
2619
+ const primaryBlueAppId = recorded[`coolify:app:${appStepKey(org, subject.id)}`];
2620
+ const primaryUuid = recorded[COOLIFY_SERVER_SCOPE_KEY];
2621
+ if (!primaryBlueAppId || !primaryUuid)
2622
+ return null;
2623
+ return createReleaseHost({
2624
+ subject,
2625
+ context,
2626
+ coolify: this.ports.coolify,
2627
+ healthToken: async () => (await this.ports.heldKeys?.resolve(sharedSecretRef(org.id, healthTokenName(subject.id)))) ?? null,
2628
+ secret: (value) => void this.handledSecrets.add(value),
2629
+ primaryUuid,
2630
+ primaryBlueAppId,
2631
+ runtime: this.ports.runtime ?? {},
2632
+ ...(this.ports.runtime?.fetch ? { fetch: this.ports.runtime.fetch } : {}),
2633
+ renew,
2634
+ label: `coolify:release:${subject.id}`,
2635
+ });
2636
+ }
2637
+ /** Every server Coolify lists with whether it is usable and reachable, for the planner. */
2638
+ async blueGreenServers() {
2639
+ return (await this.ports.coolify.listServers()).map((s) => ({
2640
+ uuid: s.uuid,
2641
+ usable: s.is_usable,
2642
+ reachable: s.is_reachable,
2643
+ }));
2644
+ }
2645
+ /**
2646
+ * The blue/green steps (`plan/blue-green.ts`): `bluegreen` makes the apps and the balancer on one server,
2647
+ * `bluegreen-lb` only writes the balancer's weights, `bluegreen-connector` runs the tunnel's connector on a
2648
+ * server. A server that went down since the plan is skipped by `bluegreen-connector`'s caller, not here:
2649
+ * a step for it fails, which the next plan reports instead.
2650
+ */
2651
+ async coolifyBlueGreen(action, org, context) {
2652
+ const recorded = () => context.recordedIds();
2653
+ const projectUuid = await this.ensureCoolifyProject(org, context);
2654
+ const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
2655
+ const serverUuid = String(action.desired.server ?? '');
2656
+ const primaryUuid = String(action.desired.primary ?? '');
2657
+ const live = await this.resolveServers(org, context);
2658
+ if (!live.servers.some((s) => s.uuid === serverUuid)) {
2659
+ throw new Error(`${action.kind} for ${org.slug}: Coolify server ${serverUuid} is not usable and reachable`);
2660
+ }
2661
+ if (live.primary.uuid !== primaryUuid) {
2662
+ throw new Error(`${action.kind} for ${org.slug}: the plan's primary server is not the recorded one`);
2663
+ }
2664
+ if (action.kind === 'bluegreen-connector') {
2665
+ const tunnelId = recorded()[`cloudflare:tunnel:${org.id}`];
2666
+ if (!tunnelId)
2667
+ throw new Error(`bluegreen-connector for ${org.slug}: the tunnel is not recorded yet`);
2668
+ await applyBlueGreenConnectors({
2669
+ coolify: this.ports.coolify,
2670
+ tunnels: this.tunnelPort(),
2671
+ tunnelId,
2672
+ baseAppName: `${org.slug}-cloudflared`,
2673
+ baseScopeKey: CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY,
2674
+ primaryUuid,
2675
+ serverUuids: [serverUuid],
2676
+ projectUuid,
2677
+ environment,
2678
+ recorded,
2679
+ record: (key, id) => context.recordScopeId(key, id),
2680
+ persist: () => context.persistScopeIds(),
2681
+ });
2682
+ return undefined;
2683
+ }
2684
+ const entry = org.hosting.entries.find((e) => e.key === action.desired.entry);
2685
+ if (!entry || !entrySubject(org, entry)) {
2686
+ throw new Error(`${action.kind} for ${org.slug}: no blue/green entry '${String(action.desired.entry)}'`);
2687
+ }
2688
+ const primaryBlueAppId = recorded()[`coolify:app:${appStepKey(org, entry.key)}`];
2689
+ if (!primaryBlueAppId) {
2690
+ throw new Error(`${action.kind} for ${org.slug}: entry '${entry.key}' has no blue app recorded yet`);
2691
+ }
2692
+ // The blue app keeps the name it has: an app that ran before blue/green is adopted under its own name.
2693
+ if (!recorded()[bgKey.blueName(entry.key)]) {
2694
+ const blue = await this.ports.coolify.getApplication(primaryBlueAppId);
2695
+ if (blue.name) {
2696
+ context.recordScopeId(bgKey.blueName(entry.key), blue.name);
2697
+ await context.persistScopeIds();
2698
+ }
2699
+ }
2700
+ const subject = entrySubject(org, entry, recorded());
2701
+ const runtime = this.ports.runtime ?? {};
2702
+ const deps = {
2703
+ coolify: this.ports.coolify,
2704
+ recorded,
2705
+ record: (key, value) => context.recordScopeId(key, value),
2706
+ persist: () => context.persistScopeIds(),
2707
+ projectUuid,
2708
+ environment,
2709
+ healthToken: async () => {
2710
+ if (typeof this.ports.heldKeys?.put !== 'function') {
2711
+ throw new Error(`${action.kind} for ${org.slug}: the executor's heldKeys cannot put the health token`);
2712
+ }
2713
+ const token = await this.ensureSharedSecret(org, healthTokenName(entry.key), recorded());
2714
+ this.handledSecrets.add(token);
2715
+ return token;
2716
+ },
2717
+ secret: (value) => void this.handledSecrets.add(value),
2718
+ runtime,
2719
+ sleep: runtime.sleep ?? defaultSleep,
2720
+ label: `${action.system}:${action.kind}:${entry.key}`,
2721
+ primaryBlueAppId,
2722
+ logDir: LOG_DIR,
2723
+ isRunning: isRunningOrStarting,
2724
+ };
2725
+ if (action.kind === 'bluegreen-lb') {
2726
+ await applyBlueGreenWeights(deps, subject, { uuid: serverUuid, primaryUuid });
2727
+ }
2728
+ else {
2729
+ await applyBlueGreenServer(deps, subject, { uuid: serverUuid, primaryUuid });
2730
+ }
2731
+ return undefined;
2732
+ }
2504
2733
  async coolifyApp(action, org, context) {
2505
2734
  const recordedAppId = context.recordedIds()[`coolify:app:${action.key}`];
2506
2735
  if (action.desired.enabled === false) {
@@ -2538,6 +2767,11 @@ export class FoundryStepExecutor {
2538
2767
  }
2539
2768
  const projectUuid = await this.ensureCoolifyProject(org, context);
2540
2769
  const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
2770
+ // A blue/green entry's app is its blue copy. An app that ran before blue/green keeps its own name and is
2771
+ // adopted as blue; an entry with no app yet gets `<base>-blue`. The name is recorded once decided.
2772
+ const blueNameKey = bgKey.blueName(entry.key);
2773
+ const blueNameRecorded = Boolean(context.recordedIds()[blueNameKey]);
2774
+ let appName = liveAppNameFor(org, entry, context.recordedIds());
2541
2775
  // The recorded id first; the name when nothing is recorded, or when the
2542
2776
  // recorded app is gone.
2543
2777
  let existing = null;
@@ -2546,17 +2780,35 @@ export class FoundryStepExecutor {
2546
2780
  try {
2547
2781
  live = await this.ports.coolify.getApplication(recordedAppId);
2548
2782
  existing = { uuid: recordedAppId };
2783
+ if (entry.blueGreen &&
2784
+ !blueNameRecorded &&
2785
+ live.name === colorAppName(appNameFor(org, entry), 'blue')) {
2786
+ appName = live.name;
2787
+ }
2549
2788
  }
2550
2789
  catch (error) {
2551
2790
  if (!isNotFoundError(error))
2552
2791
  throw error;
2553
2792
  }
2554
2793
  }
2794
+ if (!existing && entry.blueGreen && !blueNameRecorded) {
2795
+ const own = await this.ports.coolify.findApplicationByName(appNameFor(org, entry), {
2796
+ environmentIds: [environment.id],
2797
+ });
2798
+ if (own) {
2799
+ existing = own;
2800
+ appName = appNameFor(org, entry);
2801
+ }
2802
+ }
2555
2803
  if (!existing) {
2556
- existing = await this.ports.coolify.findApplicationByName(appNameFor(org, entry), {
2804
+ existing = await this.ports.coolify.findApplicationByName(appName, {
2557
2805
  environmentIds: [environment.id],
2558
2806
  });
2559
2807
  }
2808
+ if (entry.blueGreen && !blueNameRecorded) {
2809
+ context.recordScopeId(blueNameKey, appName);
2810
+ await context.persistScopeIds();
2811
+ }
2560
2812
  // No entry gets Coolify `domains`: Coolify would add a Traefik router for them. The company's
2561
2813
  // tunnel routes an entry's names straight to its container (also `<slug>.oikos.host`, through a
2562
2814
  // record in the platform's oikos.host zone), which every entry reaches as `<slug>-<key>` (its
@@ -2602,7 +2854,7 @@ export class FoundryStepExecutor {
2602
2854
  git_repository: appRepo,
2603
2855
  git_branch: entry.branch,
2604
2856
  build_pack: entry.buildPack,
2605
- name: appNameFor(org, entry),
2857
+ name: appName,
2606
2858
  autogenerate_domain: false,
2607
2859
  // A static site is served by Coolify's own web server on port 80.
2608
2860
  ports_exposes: isStatic ? '80' : entry.port === null ? undefined : String(entry.port),
@@ -2655,7 +2907,7 @@ export class FoundryStepExecutor {
2655
2907
  patch.domains = '';
2656
2908
  }
2657
2909
  if (!existing || patch.domains !== undefined) {
2658
- patch.custom_internal_name = appNameFor(org, entry);
2910
+ patch.custom_internal_name = appName;
2659
2911
  }
2660
2912
  // A `*` entry's proxy labels: Coolify takes `custom_labels` base64 encoded
2661
2913
  // and uses them instead of the labels it generates from `domains`. Rule 4:
@@ -2709,8 +2961,8 @@ export class FoundryStepExecutor {
2709
2961
  // `ensureLogVolume` whether this app was already running before this
2710
2962
  // call -- see its own doc comment for why that decides whether to
2711
2963
  // restart it.
2712
- await this.ensureLogVolume(appId, org, Boolean(existing), appNameFor(org, entry));
2713
- await this.ensureEntryVolumes(appId, entry, Boolean(existing), appNameFor(org, entry));
2964
+ await this.ensureLogVolume(appId, org, Boolean(existing), appName);
2965
+ await this.ensureEntryVolumes(appId, entry, Boolean(existing), appName);
2714
2966
  // The app receives whatever it needs to sign in (gap P2). Unconditional,
2715
2967
  // unlike the database push above: every app has a hostname, and an
2716
2968
  // upsert of the same value on a later apply is a no-op, not a change.
@@ -2741,8 +2993,10 @@ export class FoundryStepExecutor {
2741
2993
  // The public name answers from the container only once the tunnel step has written the
2742
2994
  // direct route; before that (a first apply, or the one that moves it) the deployment is the check.
2743
2995
  hostname &&
2996
+ // The public name goes through the balancer, which may answer from the other color.
2997
+ !entry.blueGreen &&
2744
2998
  (context.recordedIds()[CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY] ?? '[]') ===
2745
- entryRoutesMarker(org)
2999
+ entryRoutesMarker(org, context.recordedIds())
2746
3000
  ? publicHealthCheck(org, context.recordedIds(), hostname, healthPath)
2747
3001
  : { 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`);
2748
3002
  await recordBuiltCommit(this.ports.coolify, { appId: appId, deploymentUuid: built.deploymentUuid, name: entry.key }, (key, value) => context.recordScopeId(key, value));
@@ -3216,11 +3470,21 @@ export class FoundryStepExecutor {
3216
3470
  if ('missing' in wanted) {
3217
3471
  throw new Error(`coolify:broker-entry for ${org.slug}: ${wanted.missing.map((m) => m.what).join(', ')} not recorded yet`);
3218
3472
  }
3473
+ if (entry.workerApp) {
3474
+ // No Coolify app: the key is the held AUTH_APP_KEY the Worker app was given.
3475
+ const heldKey = await heldBrokerKey(this.ports.heldKeys, org, entry.key);
3476
+ await this.writeAppBrokerEntry(org, broker, entry.key, wanted.want, heldKey, context);
3477
+ return {};
3478
+ }
3219
3479
  const appId = recorded[`coolify:app:${action.key}`];
3220
3480
  if (!appId) {
3221
3481
  throw new Error(`coolify:broker-entry for ${org.slug}: app ${action.key} is not recorded yet`);
3222
3482
  }
3223
- await this.writeAppBrokerEntry(org, broker, entry.key, wanted.want, appId, context);
3483
+ const appKey = (await this.ports.coolify.listEnvs(appId)).find((e) => e.key === 'AUTH_APP_KEY' && !e.is_preview)?.value;
3484
+ if (!appKey || appKey.length < 32) {
3485
+ throw new Error(`coolify:broker-entry for ${org.slug}: app ${appId} has no AUTH_APP_KEY yet`);
3486
+ }
3487
+ await this.writeAppBrokerEntry(org, broker, entry.key, wanted.want, appKey, context);
3224
3488
  return {};
3225
3489
  }
3226
3490
  /**
@@ -3242,15 +3506,15 @@ export class FoundryStepExecutor {
3242
3506
  if (!appId) {
3243
3507
  throw new Error(`coolify:boule-broker-entry for ${org.slug}: the Boule app is not recorded yet`);
3244
3508
  }
3245
- await this.writeAppBrokerEntry(org, broker, BOULE_BROKER_ENTRY_KEY, wanted.want, appId, context);
3246
- return {};
3247
- }
3248
- /** The shared tail of both broker-entry steps: read the app's key, write the entry, record what was written. */
3249
- async writeAppBrokerEntry(org, broker, entryKey, want, appId, context) {
3250
3509
  const appKey = (await this.ports.coolify.listEnvs(appId)).find((e) => e.key === 'AUTH_APP_KEY' && !e.is_preview)?.value;
3251
3510
  if (!appKey || appKey.length < 32) {
3252
3511
  throw new Error(`coolify:broker-entry for ${org.slug}: app ${appId} has no AUTH_APP_KEY yet`);
3253
3512
  }
3513
+ await this.writeAppBrokerEntry(org, broker, BOULE_BROKER_ENTRY_KEY, wanted.want, appKey, context);
3514
+ return {};
3515
+ }
3516
+ /** The shared tail of both broker-entry steps: read the app's key, write the entry, record what was written. */
3517
+ async writeAppBrokerEntry(_org, broker, entryKey, want, appKey, context) {
3254
3518
  const previousRaw = context.recordedIds()[brokerEntryAppliedScopeKey(entryKey)];
3255
3519
  const result = await writeBrokerEntry(broker, {
3256
3520
  want,
@@ -5540,7 +5804,12 @@ export class FoundryStepExecutor {
5540
5804
  if (mailWanted && !('platformApex' in action.desired)) {
5541
5805
  throw new Error(`coolify:boule for ${org.slug}: this step's plan carries no platformApex; plan this org's Boule again`);
5542
5806
  }
5543
- const { env, missing } = desiredBouleEnv(org, context.recordedIds(), issuer, archonUrl, mailWanted);
5807
+ // What the plan decided Boule is given beyond its sign-in env (C3).
5808
+ const credentialWants = {
5809
+ archonCredential: action.desired.archonCredential === true,
5810
+ customersDirectory: action.desired.customersDirectory === true,
5811
+ };
5812
+ const { env, missing } = desiredBouleEnv(org, context.recordedIds(), issuer, archonUrl, mailWanted, credentialWants);
5544
5813
  if (missing.length > 0) {
5545
5814
  throw new Error(`Boule for ${org.slug} cannot be configured: missing ${missing.join(', ')}. Nothing was written.`);
5546
5815
  }
@@ -5577,8 +5846,11 @@ export class FoundryStepExecutor {
5577
5846
  });
5578
5847
  const liveRows = (await this.ports.coolify.listEnvs(app.appId)).filter((e) => !e.is_preview);
5579
5848
  const liveEnv = new Map(liveRows.map((e) => [e.key, e.value]));
5580
- const envChanged = bouleEnvDrift(env, Object.fromEntries(liveEnv)).length > 0;
5849
+ let envChanged = bouleEnvDrift(env, Object.fromEntries(liveEnv)).length > 0;
5581
5850
  await this.dropStaleSignInUpstream(app.appId, env, liveRows);
5851
+ // Customers switched off: revoke the directory credential and take the env away (C3).
5852
+ if (await this.dropCustomersCredential(app.appId, org, context, liveRows))
5853
+ envChanged = true;
5582
5854
  const envs = Object.entries(env).map(([key, value]) => ({ key, value }));
5583
5855
  const cookieSecret = await this.ensureAuthCookieSecret(app.appId);
5584
5856
  if (cookieSecret)
@@ -5602,11 +5874,39 @@ export class FoundryStepExecutor {
5602
5874
  this.handledSecrets.add(bridgeSecret);
5603
5875
  envs.push({ key: BOULE_BRIDGE_SECRET_ENV, value: bridgeSecret });
5604
5876
  }
5877
+ // Held, minted once (C3). Compared with the live value so a changed one restarts the app.
5878
+ const credentialEnvs = [];
5879
+ let archonPushedAt = null;
5880
+ if (credentialWants.archonCredential) {
5881
+ const archon = await this.ensureBouleArchonCredential(org, context);
5882
+ archonPushedAt = archon.pushedAt;
5883
+ credentialEnvs.push({ key: BOULE_ARCHON_CREDENTIAL_ENV, value: archon.value });
5884
+ }
5885
+ if (credentialWants.customersDirectory) {
5886
+ const directory = await this.ensureCustomersDirectoryCredential(org, context);
5887
+ if (directory)
5888
+ credentialEnvs.push({ key: BOULE_CUSTOMERS_CREDENTIAL_ENV, value: directory });
5889
+ }
5890
+ for (const credential of credentialEnvs) {
5891
+ this.handledSecrets.add(credential.value);
5892
+ envs.push(credential);
5893
+ if (liveEnv.get(credential.key) !== credential.value)
5894
+ envChanged = true;
5895
+ }
5605
5896
  try {
5606
5897
  await this.ports.coolify.bulkUpsertEnvs(app.appId, envs);
5898
+ // Only a push that reached Boule's env counts: a failed one is minted and pushed again next apply.
5899
+ if (archonPushedAt) {
5900
+ context.recordScopeId(BOULE_ARCHON_PUSHED_AT_SCOPE_KEY, archonPushedAt);
5901
+ await context.persistScopeIds();
5902
+ }
5607
5903
  }
5608
5904
  catch (error) {
5609
- const minted = [peerPassword, bridgeSecret].filter((v) => v !== null);
5905
+ const minted = [
5906
+ peerPassword,
5907
+ bridgeSecret,
5908
+ ...credentialEnvs.map((credential) => credential.value),
5909
+ ].filter((v) => v !== null);
5610
5910
  if (minted.length === 0)
5611
5911
  throw error;
5612
5912
  throw new Error(redactSecrets(error instanceof Error ? error.message : String(error), minted));
@@ -5656,6 +5956,125 @@ export class FoundryStepExecutor {
5656
5956
  this.recordSignInEnvLive(context, 'boule', bouleHostnameFor(org), env);
5657
5957
  return { resourceId: app.appId };
5658
5958
  }
5959
+ /**
5960
+ * C3: the Archon credential of an org's Boule. Archon's mint rotates on every call, so it is called only
5961
+ * when none was pushed or the recorded push time (no secret) is 335 days old. Otherwise the held value is
5962
+ * written again unchanged. The secret is held first; the caller records `pushedAt` only after the env write
5963
+ * succeeded, so a failed hold or a failed push is mended by the next apply minting again. Errors name no secret.
5964
+ */
5965
+ async ensureBouleArchonCredential(org, context) {
5966
+ const at = `coolify:boule for ${org.slug}`;
5967
+ const mint = this.ports.mintBouleArchonCredential;
5968
+ const heldKeys = this.ports.heldKeys;
5969
+ if (!mint)
5970
+ throw new Error(`${at}: this executor was built without mintBouleArchonCredential`);
5971
+ if (!heldKeys?.put) {
5972
+ throw new Error(`${at}: the Archon credential needs a held-key store that can put`);
5973
+ }
5974
+ const put = heldKeys.put;
5975
+ const ref = bouleArchonCredentialRef(org.id);
5976
+ const held = await withCallTimeout(BOULE_CREDENTIAL_TIMEOUT_MS, `${at}: read the held Archon credential`, () => heldKeys.resolve(ref));
5977
+ const nowMs = context.now?.() ?? Date.now();
5978
+ if (held && !archonCredentialRenewDue(context.recordedIds(), nowMs)) {
5979
+ return { value: held, pushedAt: null };
5980
+ }
5981
+ const secret = await withCallTimeout(BOULE_CREDENTIAL_TIMEOUT_MS, `${at}: mint the Archon credential`, () => mint(org.id));
5982
+ this.handledSecrets.add(secret);
5983
+ if (!(await this.holdWithRetry(ref, secret, put, `${at}: hold the Archon credential`))) {
5984
+ throw new Error(`${at}: the Archon credential could not be held after ${BOULE_CREDENTIAL_ATTEMPTS} attempts; the next apply mints it again`);
5985
+ }
5986
+ return { value: secret, pushedAt: new Date(nowMs).toISOString() };
5987
+ }
5988
+ /**
5989
+ * C3: the read-only credential for the company's customers organisation. Held first. Minting revokes every
5990
+ * earlier token of the machine user, so a hold that failed is repaired by the next apply minting again and
5991
+ * leaves one live token. `null` while the customers organisation is not recorded yet.
5992
+ */
5993
+ async ensureCustomersDirectoryCredential(org, context) {
5994
+ const at = `coolify:boule for ${org.slug}`;
5995
+ const customersOrgId = customersOrgIdOf(org, context.recordedIds());
5996
+ if (!customersOrgId)
5997
+ return null;
5998
+ const zitadel = this.ports.zitadel;
5999
+ const heldKeys = this.ports.heldKeys;
6000
+ if (!zitadel.mintOrgDirectoryReader) {
6001
+ throw new Error(`${at}: this executor's ZITADEL port cannot mint a customers directory credential`);
6002
+ }
6003
+ if (!heldKeys?.put) {
6004
+ throw new Error(`${at}: the customers directory credential needs a held-key store that can put`);
6005
+ }
6006
+ const put = heldKeys.put;
6007
+ const ref = customersDirectoryRef(org.id);
6008
+ const held = await withCallTimeout(BOULE_CREDENTIAL_TIMEOUT_MS, `${at}: read the held directory credential`, () => heldKeys.resolve(ref));
6009
+ if (held)
6010
+ return held;
6011
+ const minted = await withCallTimeout(BOULE_CREDENTIAL_TIMEOUT_MS, `${at}: mint the directory credential`, () => zitadel.mintOrgDirectoryReader(customersOrgId, {
6012
+ userName: CUSTOMERS_DIRECTORY_USER_NAME,
6013
+ name: 'Foundry customers directory (read-only)',
6014
+ }));
6015
+ this.handledSecrets.add(minted.token);
6016
+ context.recordScopeId(CUSTOMERS_DIRECTORY_USER_SCOPE_KEY, minted.userId);
6017
+ await context.persistScopeIds();
6018
+ if (!(await this.holdWithRetry(ref, minted.token, put, `${at}: hold the directory credential`))) {
6019
+ throw new Error(`${at}: the directory credential of ZITADEL user ${minted.userId} could not be held after ${BOULE_CREDENTIAL_ATTEMPTS} attempts; the next apply mints it again and revokes this one`);
6020
+ }
6021
+ return minted.token;
6022
+ }
6023
+ /** `put`, tried up to {@link BOULE_CREDENTIAL_ATTEMPTS} times; true once one attempt succeeds. The cause is dropped. */
6024
+ async holdWithRetry(ref, value, put, label) {
6025
+ for (let attempt = 1; attempt <= BOULE_CREDENTIAL_ATTEMPTS; attempt++) {
6026
+ try {
6027
+ await withCallTimeout(BOULE_CREDENTIAL_TIMEOUT_MS, label, () => put(ref, value));
6028
+ return true;
6029
+ }
6030
+ catch {
6031
+ // Retried; the cause may echo the value.
6032
+ }
6033
+ }
6034
+ return false;
6035
+ }
6036
+ /**
6037
+ * C3: customers switched off. Revokes the directory credential's tokens first, then removes the customers
6038
+ * env from Boule, then forgets the held value and the user id. Each step is repeated safely, so a run that
6039
+ * died halfway is finished by the next. Returns true when Boule's env changed.
6040
+ */
6041
+ async dropCustomersCredential(appId, org, context, liveRows) {
6042
+ if (org.customers?.enabled)
6043
+ return false;
6044
+ const at = `coolify:boule for ${org.slug}`;
6045
+ const recorded = context.recordedIds();
6046
+ const userId = recorded[CUSTOMERS_DIRECTORY_USER_SCOPE_KEY];
6047
+ const customersOrgId = customersOrgIdOf(org, recorded);
6048
+ const zitadel = this.ports.zitadel;
6049
+ if (userId && customersOrgId && zitadel.revokeOrgDirectoryReader) {
6050
+ await withCallTimeout(BOULE_CREDENTIAL_TIMEOUT_MS, `${at}: revoke the directory credential`, () => zitadel.revokeOrgDirectoryReader(customersOrgId, userId));
6051
+ }
6052
+ let changed = false;
6053
+ for (const row of liveRows) {
6054
+ if (row.key !== BOULE_CUSTOMERS_ORG_ENV && row.key !== BOULE_CUSTOMERS_CREDENTIAL_ENV)
6055
+ continue;
6056
+ if (!row.uuid)
6057
+ continue;
6058
+ try {
6059
+ await this.ports.coolify.deleteEnv(appId, row.uuid);
6060
+ changed = true;
6061
+ }
6062
+ catch (error) {
6063
+ if (!isNotFoundError(error))
6064
+ throw error;
6065
+ }
6066
+ }
6067
+ if (userId && (!customersOrgId || zitadel.revokeOrgDirectoryReader)) {
6068
+ const heldKeys = this.ports.heldKeys;
6069
+ if (heldKeys?.put) {
6070
+ const put = heldKeys.put;
6071
+ const ref = customersDirectoryRef(org.id);
6072
+ await withCallTimeout(BOULE_CREDENTIAL_TIMEOUT_MS, `${at}: forget the directory credential`, () => put(ref, ''));
6073
+ }
6074
+ context.recordScopeId(CUSTOMERS_DIRECTORY_USER_SCOPE_KEY, '');
6075
+ }
6076
+ return changed;
6077
+ }
5659
6078
  /**
5660
6079
  * Gives Boule `LETTERMINT_API_TOKEN`: the platform's held key for an org on
5661
6080
  * the platform's own apex, else the org's `<slug>-app` token through the
@@ -6258,6 +6677,42 @@ export class FoundryStepExecutor {
6258
6677
  }
6259
6678
  return { resourceId: registered.id };
6260
6679
  }
6680
+ /** The blue/green database route (`plan/remote-database.ts`). */
6681
+ async remoteDatabaseRoute(action, org, context) {
6682
+ const projectUuid = await this.ensureCoolifyProject(org, context);
6683
+ const desired = action.desired;
6684
+ // The tunnel's TCP rule names the database by the host the apps' own `DATABASE_URL` uses.
6685
+ const db = await this.ports.coolify.getDatabase(desired.database);
6686
+ if (db.internal_db_url)
6687
+ this.handledSecrets.add(db.internal_db_url);
6688
+ let databaseHost = '';
6689
+ try {
6690
+ databaseHost = new URL(db.internal_db_url ?? '').hostname;
6691
+ }
6692
+ catch {
6693
+ // Fixed text below: the URL holds the database password.
6694
+ }
6695
+ if (!databaseHost) {
6696
+ throw new Error(`cloudflare:db-route for ${org.slug}: the database reports no usable address`);
6697
+ }
6698
+ await applyRemoteDatabase({
6699
+ tunnels: this.tunnelPort(),
6700
+ access: this.accessPort(),
6701
+ dns: this.dnsPort(),
6702
+ zones: this.zonePort(),
6703
+ coolify: this.ports.coolify,
6704
+ secret: (value) => void this.handledSecrets.add(value),
6705
+ }, {
6706
+ org,
6707
+ desired,
6708
+ databaseHost,
6709
+ projectUuid,
6710
+ recorded: () => context.recordedIds(),
6711
+ record: (key, value) => context.recordScopeId(key, value),
6712
+ persist: () => context.persistScopeIds(),
6713
+ });
6714
+ return {};
6715
+ }
6261
6716
  /** `deliverSecret`, tried up to {@link JOBS_DELIVERY_ATTEMPTS} times; true once one attempt succeeds. */
6262
6717
  async deliverWithRetry(ref, value) {
6263
6718
  for (let attempt = 1; attempt <= JOBS_DELIVERY_ATTEMPTS; attempt++) {
@@ -6337,17 +6792,19 @@ export class FoundryStepExecutor {
6337
6792
  const loginHost = action.desired.loginHost ?? null;
6338
6793
  // A hosting entry's names (and its wildcard) go straight to its container on the company network:
6339
6794
  // the container carries `traefik.enable=false`, so the proxy has no route for it.
6340
- const entryOrigins = new Map(entryRoutes(org).map((r) => [r.hostname, r.service]));
6795
+ const entryOrigins = new Map(entryRoutes(org, context.recordedIds()).map((r) => [r.hostname, r.service]));
6341
6796
  const ingress = tunnelIngressHostnames(org, loginHost).map((hostname) => ({
6342
6797
  hostname,
6343
6798
  service: hostname === loginHost
6344
6799
  ? `http://${PLATFORM_AUTH_LOGIN_INTERNAL_NAME}:${AUTH_LOGIN_PORT}`
6345
6800
  : (entryOrigins.get(hostname) ?? COOLIFY_PROXY_ORIGIN),
6346
6801
  }));
6802
+ const httpIngress = [...ingress, { service: 'http_status:404' }];
6347
6803
  await this.tunnelPort().putTunnelConfig(tunnel.id, {
6348
- ingress: [...ingress, { service: 'http_status:404' }],
6804
+ ingress: mergeTcpIngress(httpIngress, recordedTcpRoutes(context.recordedIds())),
6349
6805
  });
6350
- context.recordScopeId(CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, entryRoutesMarker(org));
6806
+ context.recordScopeId(TUNNEL_HTTP_INGRESS_SCOPE_KEY, JSON.stringify(httpIngress));
6807
+ context.recordScopeId(CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, entryRoutesMarker(org, context.recordedIds()));
6351
6808
  // #235: a connector for this org's own tunnel, the same call every
6352
6809
  // platform tunnel step already makes (Decided, William, 2026-10-01:
6353
6810
  // one connector per org tunnel, never a shared tunnel with appended
@@ -8091,6 +8548,17 @@ export class FoundryStepExecutor {
8091
8548
  await this.teardownWorker(entryKey, target.id || undefined, recordedIds, workers, `cloudflare:worker '${target.key}' for ${org.slug}`);
8092
8549
  return;
8093
8550
  }
8551
+ case 'cloudflare:worker-app': {
8552
+ // ADR 0002: route, record, containers and script of a Worker app.
8553
+ const workers = this.ports.workerApps;
8554
+ if (!workers) {
8555
+ throw new Error(`cloudflare:worker-app '${target.key}' for ${org.slug}: this executor was built without a WorkerAppPort; cannot delete it`);
8556
+ }
8557
+ const entryKey = target.key.slice(org.id.length + 1);
8558
+ const at = `cloudflare:worker-app '${target.key}' for ${org.slug}`;
8559
+ await teardownWorkerApp(this.workerAppDeps(workers), entryKey, target.id || undefined, recordedIds, (label, task) => withCallTimeout(WORKER_APP_CALL_TIMEOUT_MS, `${at}: ${label}`, task), org.id);
8560
+ return;
8561
+ }
8094
8562
  case 'edge:key': {
8095
8563
  // F7 (#317): an entry's key (`<orgId>:<entry>`); edge has no org-root key.
8096
8564
  // No port means no way to revoke it -- thrown, not silently
@@ -8114,8 +8582,32 @@ export class FoundryStepExecutor {
8114
8582
  return;
8115
8583
  }
8116
8584
  case 'coolify:app':
8585
+ case 'coolify:bg-app':
8117
8586
  await this.ports.coolify.deleteApplication(target.id);
8118
8587
  return;
8588
+ case 'cloudflare:bg-access':
8589
+ await this.accessPort().deleteAccessApp(target.id);
8590
+ return;
8591
+ case 'cloudflare:bg-token':
8592
+ await this.accessPort().removeServiceToken(target.id);
8593
+ return;
8594
+ case 'cloudflare:bg-dns': {
8595
+ // `target.id` is the recorded route ({ hostname, service }); its name lives in the org's zone.
8596
+ const hostname = JSON.parse(target.id).hostname;
8597
+ const zone = org.domain ? await this.zonePort().findZoneByApex(org.domain) : undefined;
8598
+ if (typeof hostname !== 'string' || !zone)
8599
+ return;
8600
+ const dns = this.dnsPort();
8601
+ for (const record of await dns.findDnsRecord(zone.name, hostname, 'CNAME')) {
8602
+ if (record.content.endsWith('.cfargotunnel.com')) {
8603
+ await dns.deleteDnsRecord(zone.name, record.id);
8604
+ }
8605
+ }
8606
+ return;
8607
+ }
8608
+ case 'coolify:bg-database':
8609
+ await this.ports.coolify.deleteDatabase(target.id);
8610
+ return;
8119
8611
  case 'coolify:database':
8120
8612
  await this.ports.coolify.deleteDatabase(target.id);
8121
8613
  return;