@wtfalch/foundry 0.60.0 → 0.62.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 (236) hide show
  1. package/dist/apps/cloudflare.d.ts +173 -0
  2. package/dist/apps/cloudflare.js +17 -0
  3. package/dist/apps/deploy.d.ts +116 -0
  4. package/dist/apps/deploy.js +791 -0
  5. package/dist/apps/github.d.ts +47 -0
  6. package/dist/apps/github.js +201 -0
  7. package/dist/apps/index.d.ts +9 -0
  8. package/dist/apps/index.js +9 -0
  9. package/dist/apps/migrations.d.ts +110 -0
  10. package/dist/apps/migrations.js +458 -0
  11. package/dist/audit/connector.d.ts +43 -0
  12. package/dist/audit/connector.js +22 -0
  13. package/dist/audit/evidence.d.ts +49 -0
  14. package/dist/audit/evidence.js +56 -0
  15. package/dist/audit/index.d.ts +9 -0
  16. package/dist/audit/index.js +9 -0
  17. package/dist/audit/migrations.d.ts +5 -0
  18. package/dist/audit/migrations.js +5 -0
  19. package/dist/audit/registry.d.ts +49 -0
  20. package/dist/audit/registry.js +44 -0
  21. package/dist/audit/seal.d.ts +49 -0
  22. package/dist/audit/seal.js +131 -0
  23. package/dist/audit/sealer-role.d.ts +33 -0
  24. package/dist/audit/sealer-role.js +56 -0
  25. package/dist/audit/signer.d.ts +40 -0
  26. package/dist/audit/signer.js +78 -0
  27. package/dist/audit/tick.d.ts +36 -0
  28. package/dist/audit/tick.js +87 -0
  29. package/dist/catalogue/copy-en.d.ts +1 -0
  30. package/dist/catalogue/copy-en.js +129 -0
  31. package/dist/catalogue/index.d.ts +9 -0
  32. package/dist/catalogue/index.js +21 -0
  33. package/dist/catalogue/integrations.d.ts +4 -0
  34. package/dist/catalogue/integrations.js +104 -0
  35. package/dist/catalogue/systems.d.ts +7 -0
  36. package/dist/catalogue/systems.js +31 -0
  37. package/dist/catalogue/types.d.ts +110 -0
  38. package/dist/catalogue/types.js +1 -0
  39. package/dist/entry-rules.d.ts +53 -0
  40. package/dist/entry-rules.js +71 -0
  41. package/dist/hosted-label.d.ts +13 -0
  42. package/dist/hosted-label.js +81 -0
  43. package/dist/index.d.ts +14 -5
  44. package/dist/index.js +7 -3
  45. package/dist/migrations/0062_hosting_github.sql +6 -0
  46. package/dist/migrations/0063_boule_apps.sql +7 -0
  47. package/dist/migrations/0064_requested_changes_boule_apps.sql +6 -0
  48. package/dist/migrations/0065_native_build_runs.sql +32 -0
  49. package/dist/migrations/0066_org_kosmos.sql +10 -0
  50. package/dist/modules/ai.d.ts +3 -0
  51. package/dist/modules/ai.js +64 -0
  52. package/dist/modules/assistants.d.ts +3 -0
  53. package/dist/modules/assistants.js +72 -0
  54. package/dist/modules/audit-ledger.d.ts +29 -0
  55. package/dist/modules/audit-ledger.js +49 -0
  56. package/dist/modules/audit.d.ts +7 -0
  57. package/dist/modules/audit.js +44 -0
  58. package/dist/modules/database.d.ts +23 -0
  59. package/dist/modules/database.js +189 -0
  60. package/dist/modules/email.d.ts +3 -0
  61. package/dist/modules/email.js +18 -0
  62. package/dist/modules/entry-parts.d.ts +23 -0
  63. package/dist/modules/entry-parts.js +63 -0
  64. package/dist/modules/files.d.ts +3 -0
  65. package/dist/modules/files.js +64 -0
  66. package/dist/modules/index.d.ts +74 -0
  67. package/dist/modules/index.js +774 -0
  68. package/dist/modules/jobs.d.ts +3 -0
  69. package/dist/modules/jobs.js +23 -0
  70. package/dist/modules/organisations.d.ts +3 -0
  71. package/dist/modules/organisations.js +20 -0
  72. package/dist/modules/outputs.d.ts +9 -0
  73. package/dist/modules/outputs.js +38 -0
  74. package/dist/modules/sign-in.d.ts +3 -0
  75. package/dist/modules/sign-in.js +197 -0
  76. package/dist/modules/signed-records.d.ts +3 -0
  77. package/dist/modules/signed-records.js +32 -0
  78. package/dist/modules/types.d.ts +194 -0
  79. package/dist/modules/types.js +1 -0
  80. package/dist/modules/workspaces.d.ts +3 -0
  81. package/dist/modules/workspaces.js +75 -0
  82. package/dist/native-build.d.ts +70 -0
  83. package/dist/native-build.js +92 -0
  84. package/dist/org.d.ts +184 -24
  85. package/dist/org.js +444 -49
  86. package/dist/plan/agora.d.ts +1 -1
  87. package/dist/plan/agora.js +4 -0
  88. package/dist/plan/apps-db-route.d.ts +90 -0
  89. package/dist/plan/apps-db-route.js +168 -0
  90. package/dist/plan/apps-zones.d.ts +56 -0
  91. package/dist/plan/apps-zones.js +118 -0
  92. package/dist/plan/archon-blue-green.js +11 -1
  93. package/dist/plan/blue-green-apply.d.ts +37 -1
  94. package/dist/plan/blue-green-apply.js +55 -15
  95. package/dist/plan/blue-green.d.ts +60 -8
  96. package/dist/plan/blue-green.js +115 -10
  97. package/dist/plan/boule.d.ts +23 -1
  98. package/dist/plan/boule.js +35 -2
  99. package/dist/plan/broker-entries.d.ts +28 -3
  100. package/dist/plan/broker-entries.js +74 -3
  101. package/dist/plan/broker-registrar.d.ts +168 -0
  102. package/dist/plan/broker-registrar.js +440 -0
  103. package/dist/plan/broker-workspaces-database.d.ts +48 -0
  104. package/dist/plan/broker-workspaces-database.js +87 -0
  105. package/dist/plan/deploy-health.js +9 -2
  106. package/dist/plan/desktop-callback.js +15 -7
  107. package/dist/plan/diff.d.ts +8 -2
  108. package/dist/plan/diff.js +552 -104
  109. package/dist/plan/edge-access.d.ts +6 -0
  110. package/dist/plan/edge-access.js +1 -0
  111. package/dist/plan/edge-dns.d.ts +6 -0
  112. package/dist/plan/edge-dns.js +9 -7
  113. package/dist/plan/entry-ai-key.d.ts +1 -1
  114. package/dist/plan/entry-ai-key.js +2 -2
  115. package/dist/plan/entry-audit.d.ts +58 -0
  116. package/dist/plan/entry-audit.js +122 -0
  117. package/dist/plan/entry-customer-grants.d.ts +75 -0
  118. package/dist/plan/entry-customer-grants.js +154 -0
  119. package/dist/plan/entry-edge-key.d.ts +26 -1
  120. package/dist/plan/entry-edge-key.js +42 -3
  121. package/dist/plan/entry-jobs.d.ts +64 -0
  122. package/dist/plan/entry-jobs.js +92 -0
  123. package/dist/plan/entry-repo-key-apply.d.ts +55 -0
  124. package/dist/plan/entry-repo-key-apply.js +185 -0
  125. package/dist/plan/entry-repo-key.d.ts +92 -0
  126. package/dist/plan/entry-repo-key.js +235 -0
  127. package/dist/plan/entry-workspaces.d.ts +55 -0
  128. package/dist/plan/entry-workspaces.js +90 -0
  129. package/dist/plan/executor.d.ts +231 -19
  130. package/dist/plan/executor.js +1648 -129
  131. package/dist/plan/gates.d.ts +1 -1
  132. package/dist/plan/gates.js +62 -9
  133. package/dist/plan/github-deploy-keys.d.ts +78 -0
  134. package/dist/plan/github-deploy-keys.js +305 -0
  135. package/dist/plan/github-source.d.ts +53 -0
  136. package/dist/plan/github-source.js +140 -0
  137. package/dist/plan/held-key-refs.d.ts +12 -0
  138. package/dist/plan/held-key-refs.js +12 -0
  139. package/dist/plan/hostnames.d.ts +9 -0
  140. package/dist/plan/hostnames.js +28 -1
  141. package/dist/plan/index.d.ts +9 -0
  142. package/dist/plan/index.js +6 -0
  143. package/dist/plan/jobs-scheduler-http.js +3 -0
  144. package/dist/plan/keys-registrar-http.d.ts +32 -0
  145. package/dist/plan/keys-registrar-http.js +105 -0
  146. package/dist/plan/keys-registrar.d.ts +67 -0
  147. package/dist/plan/keys-registrar.js +103 -0
  148. package/dist/plan/kosmos.d.ts +56 -0
  149. package/dist/plan/kosmos.js +84 -0
  150. package/dist/plan/live.d.ts +1 -1
  151. package/dist/plan/locked-hostnames.d.ts +2 -2
  152. package/dist/plan/locked-hostnames.js +7 -4
  153. package/dist/plan/login-host.js +11 -5
  154. package/dist/plan/machine-paths.d.ts +36 -0
  155. package/dist/plan/machine-paths.js +137 -0
  156. package/dist/plan/mcp.d.ts +1 -1
  157. package/dist/plan/mcp.js +49 -17
  158. package/dist/plan/org-apps-admin.d.ts +108 -0
  159. package/dist/plan/org-apps-admin.js +434 -0
  160. package/dist/plan/org-names.d.ts +3 -3
  161. package/dist/plan/org-names.js +15 -2
  162. package/dist/plan/org-postgres-migrate.js +1 -0
  163. package/dist/plan/own-diff.d.ts +23 -0
  164. package/dist/plan/own-diff.js +53 -0
  165. package/dist/plan/own-hostnames.d.ts +2 -0
  166. package/dist/plan/own-hostnames.js +1 -0
  167. package/dist/plan/own-jobs-route.d.ts +47 -0
  168. package/dist/plan/own-jobs-route.js +91 -0
  169. package/dist/plan/own-limits.d.ts +6 -0
  170. package/dist/plan/own-limits.js +1 -0
  171. package/dist/plan/own-services.d.ts +4 -0
  172. package/dist/plan/own-services.js +4 -0
  173. package/dist/plan/plan-apply.d.ts +39 -0
  174. package/dist/plan/plan-apply.js +163 -9
  175. package/dist/plan/platform-diff.d.ts +2 -2
  176. package/dist/plan/platform-diff.js +7 -2
  177. package/dist/plan/platform-org.d.ts +30 -0
  178. package/dist/plan/platform-org.js +12 -1
  179. package/dist/plan/platform-providers.d.ts +2 -0
  180. package/dist/plan/platform-providers.js +13 -2
  181. package/dist/plan/post-logout-uris.d.ts +1 -1
  182. package/dist/plan/post-logout-uris.js +12 -3
  183. package/dist/plan/private-pages.d.ts +26 -0
  184. package/dist/plan/private-pages.js +39 -0
  185. package/dist/plan/providers-readers.js +20 -1
  186. package/dist/plan/proxy-labels.d.ts +12 -1
  187. package/dist/plan/proxy-labels.js +32 -2
  188. package/dist/plan/release-host.d.ts +6 -2
  189. package/dist/plan/release-host.js +36 -8
  190. package/dist/plan/stage-env.d.ts +19 -1
  191. package/dist/plan/stage-env.js +25 -1
  192. package/dist/plan/stage-guards.d.ts +20 -0
  193. package/dist/plan/stage-guards.js +24 -0
  194. package/dist/plan/stage-isolation.d.ts +24 -0
  195. package/dist/plan/stage-isolation.js +29 -1
  196. package/dist/plan/worker-app-database.d.ts +46 -42
  197. package/dist/plan/worker-app-database.js +150 -153
  198. package/dist/plan/worker-app.d.ts +5 -65
  199. package/dist/plan/worker-app.js +28 -105
  200. package/dist/plan/worker.d.ts +2 -2
  201. package/dist/plan/worker.js +2 -1
  202. package/dist/plan/workspace-client.d.ts +25 -0
  203. package/dist/plan/workspace-client.js +48 -0
  204. package/dist/plan/workspace-sign-in.d.ts +55 -0
  205. package/dist/plan/workspace-sign-in.js +273 -0
  206. package/dist/plan/workspace-worker.d.ts +4 -0
  207. package/dist/plan/workspace-worker.js +13 -0
  208. package/dist/plan/workspace.d.ts +101 -0
  209. package/dist/plan/workspace.js +305 -0
  210. package/dist/release/decide.d.ts +30 -1
  211. package/dist/release/decide.js +73 -11
  212. package/dist/release/health.d.ts +8 -3
  213. package/dist/release/health.js +58 -5
  214. package/dist/release/migration-compatibility.d.ts +66 -0
  215. package/dist/release/migration-compatibility.js +340 -0
  216. package/dist/release/run.d.ts +2 -5
  217. package/dist/release/run.js +4 -0
  218. package/dist/release/state.d.ts +3 -1
  219. package/dist/release/state.js +6 -0
  220. package/dist/release/types.d.ts +20 -0
  221. package/dist/requested-changes.d.ts +11 -2
  222. package/dist/requested-changes.js +13 -1
  223. package/dist/service-labels.d.ts +4 -0
  224. package/dist/service-labels.js +11 -0
  225. package/dist/slug.d.ts +2 -0
  226. package/dist/slug.js +20 -7
  227. package/dist/store/postgres.js +25 -2
  228. package/dist/store/schema.d.ts +73 -1
  229. package/dist/store/schema.js +10 -0
  230. package/dist/worker-app-config.d.ts +1 -11
  231. package/dist/worker-app-config.js +4 -17
  232. package/dist/worker-app-migrations.d.ts +105 -0
  233. package/dist/worker-app-migrations.js +166 -0
  234. package/dist/worker-app-upload.d.ts +103 -0
  235. package/dist/worker-app-upload.js +128 -0
  236. package/package.json +19 -2
@@ -1,19 +1,25 @@
1
1
  import { AsyncLocalStorage } from 'node:async_hooks';
2
2
  import { randomBytes, randomUUID } from 'node:crypto';
3
3
  import { URL } from 'node:url';
4
+ import { EdgeApiError } from '@wtfalch/edge';
4
5
  import { coolifyImageFor } from '../bootstrap/versions.js';
5
6
  import { redactSecrets } from '../calls-sfu-deploy-support.js';
6
7
  import { resolveCallsSfuUrls } from '../calls-sfu.js';
7
- import { APP_ENTRY_KEY, ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, filesCollectionEnvName, heldEnvRef, isMainEntry, mainEntry, ownerMailboxRecorded, ownerMailboxes, ownsServices, sharedSecretRef, } from '../org.js';
8
+ import { APPS_DOMAIN, APP_ENTRY_KEY, FULL_COMMIT_SHA, ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, appsOn, companyGithubFor, filesCollectionEnvName, heldEnvRef, isLiveEntry, isMainEntry, kosmosOn, mainEntry, ownerMailboxRecorded, ownerMailboxes, ownsServices, sharedSecretRef, } from '../org.js';
8
9
  import { adoptedKeysUnreadable, parseAdoptSnapshot } from './adopt.js';
9
10
  import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, AGORA_CLIENT_ID_SCOPE_KEY, AGORA_DEPLOYED_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_DEPLOYED_SCOPE_KEY, CALLS_SERVICE_NAME_SCOPE_KEY, CALLS_SERVICE_PORT, CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY, CHAT_CALLS_APPLIED_SCOPE_KEY, CHAT_CALLS_TOKEN_SCOPE_KEY, CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, CHAT_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CHAT_SERVICE_URL_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, DESKTOP_AUTH_PORT, ENTRY_CLIENT_ID_SCOPE_PREFIX, ZITADEL_MESSAGING_PROJECT_SCOPE_KEY, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostnameFor, callsIntrospectionAppName, callsServiceHasName, callsServiceInternalName, callsTokenEnvMarker, callsTokenEnvMovedMessage, chatCallsApplied, chatHasUpstreams, chatIntrospectionAppName, desiredAgoraEnv, desktopAuthInternalName, entryClientIdScopeKey, } from './agora.js';
10
- import { applyBlueGreenConnectors, applyBlueGreenServer, applyBlueGreenWeights, } from './blue-green-apply.js';
11
- import { actionsKeyName, bgKey, blueGreenSubjects, colorAppName, healthTokenName, liveAppNameFor, mintActionsKey, splitServers, } from './blue-green.js';
11
+ import { APPS_DATABASE_ADMIN_URL_ENV, applyAppsDbRoute, retireAppsDbRoute, } from './apps-db-route.js';
12
+ import { APPS_ZONES_APPLIED_SCOPE_KEY, applyAppsZones, retireAppsZones } from './apps-zones.js';
13
+ import { applyBlueGreenConnectors, applyBlueGreenServer, applyBlueGreenWeights, applyWorkspaceServer, } from './blue-green-apply.js';
14
+ import { actionsKeyName, bgKey, blueGreenSubjects, colorAppName, healthTokenName, liveAppNameFor, mintActionsKey, mintPinKey, pinKeyName, splitServers, } from './blue-green.js';
12
15
  import { BouleOwnersError, httpBouleOwners } from './boule-owners-http.js';
13
- import { BOULE_ARCHON_CREDENTIAL_ENV, BOULE_ARCHON_PUSHED_AT_SCOPE_KEY, BOULE_BRIDGE_SECRET_ENV, BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_CUSTOMERS_CREDENTIAL_ENV, BOULE_CUSTOMERS_ORG_ENV, BOULE_DATABASE_SCOPE_KEY, BOULE_DEPLOYED_SCOPE_KEY, BOULE_OWNER_SEED_SECRET_ENV, BOULE_PEER_PASSWORD_ENV, BOULE_SECRETS_RESTARTED_SCOPE_KEY, CUSTOMERS_DIRECTORY_USER_SCOPE_KEY, CUSTOMERS_ROOM_METADATA_KEY, CUSTOMERS_ROOM_SCOPE_KEY, CUSTOMERS_SIGN_IN_METADATA_KEY, CUSTOMERS_SIGN_IN_SCOPE_KEY, CUSTOMERS_SIGN_UP_SCOPE_KEY, archonCredentialRenewDue, bouleArchonCredentialRef, bouleEnvDrift, bouleHostnameFor, bouleMailWanted, bouleOwnerRefusedScopeKey, bouleOwnerSeededScopeKey, bouleOwnerSeeds, customersDirectoryRef, customersOrgIdOf, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
14
- import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, mcpCallbackUri, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
16
+ import { BOULE_ARCHON_CREDENTIAL_ENV, BOULE_ARCHON_PUSHED_AT_SCOPE_KEY, BOULE_BRIDGE_SECRET_ENV, BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_CUSTOMERS_CREDENTIAL_ENV, BOULE_CUSTOMERS_ORG_ENV, BOULE_DATABASE_SCOPE_KEY, BOULE_DEPLOYED_SCOPE_KEY, BOULE_OWNER_SEED_SECRET_ENV, BOULE_PEER_PASSWORD_ENV, BOULE_SECRETS_RESTARTED_SCOPE_KEY, BOULE_TEST_CUSTOMERS_ORG_ENV, CUSTOMERS_DIRECTORY_USER_SCOPE_KEY, CUSTOMERS_ROOM_METADATA_KEY, CUSTOMERS_ROOM_SCOPE_KEY, CUSTOMERS_SIGN_IN_METADATA_KEY, CUSTOMERS_SIGN_IN_SCOPE_KEY, CUSTOMERS_SIGN_UP_SCOPE_KEY, archonCredentialRenewDue, bouleArchonCredentialRef, bouleEnvDrift, bouleHostnameFor, bouleMailWanted, bouleOwnerRefusedScopeKey, bouleOwnerSeededScopeKey, bouleOwnerSeeds, customersDirectoryRef, customersOrgIdOf, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
17
+ import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, entryMcp, entryMcpProjectScopeKey, mcpCallbackUri, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
18
+ import { AUTH_REGISTRAR_KEY_ENV, AUTH_REGISTRAR_URL_ENV, REGISTRAR_APPLIED_SCOPE_KEY, REGISTRAR_DELIVERED_SCOPE_KEY, REGISTRAR_DEPLOY_WAITS_SCOPE_KEY, REGISTRAR_MINTED_SCOPE_KEY, REGISTRAR_REMOVAL_HELD_SCOPE_KEY, appliedRegistrarOrganization, ensureRegistrarKey, registrarAppliedValue, registrarRef, registrarWanted, wantedRegistrar, writeRegistrar, } from './broker-registrar.js';
19
+ import { BROKER_WORKSPACES_DATABASE_SCOPE_KEY, canHoldWorkspacesDatabase, ensureBrokerWorkspacesDatabase, } from './broker-workspaces-database.js';
15
20
  import { recordBuiltCommit } from './build-record.js';
16
- import { MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, masDockerfile, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
21
+ import { CallError, withTimeout as withLabelledTimeout } from './call-timeout.js';
22
+ import { MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsSynapseHomeserverYaml, masDockerfile, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
17
23
  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';
18
24
  import { ensureCloudflaredConnector } from './cloudflared-connector.js';
19
25
  import { BACKUP_POSTGRES_DATABASES_SCOPE_KEY, BACKUP_SCOPE_PREFIX, COMPANY_BACKUP_DATABASE_NAMES, backupScopeKey, } from './company-backups.js';
@@ -23,7 +29,7 @@ import { CORE_FILES_COLLECTION_NAME, FILES_CORE_COLLECTION_SCOPE_KEY, ZITADEL_FI
23
29
  import { waitForHealthyVersion, waitForNewDeployment, } from './deploy-health.js';
24
30
  import { DESKTOP_AUTH_APP_SCOPE_KEY, DESKTOP_AUTH_DEPLOYED_SCOPE_KEY, DESKTOP_AUTH_DOCKERFILE, DESKTOP_AUTH_HOME, DESKTOP_AUTH_ORIGIN_SCOPE_KEY, DESKTOP_AUTH_READY_PATH, DESKTOP_AUTH_READY_SCOPE_KEY, DESKTOP_AUTH_REDIRECT_SCOPE_KEY, DESKTOP_AUTH_REVISION_SCOPE_KEY, desktopAuthCallbackUri, desktopAuthEnv, desktopAuthInputs, desktopAuthRedirectUnsupported, desktopAuthRevision, } from './desktop-auth.js';
25
31
  import { signInCallbackAppliedValue, signInRedirectUris, unionRedirectUris, zitadelAppDesktopCallbackAppliedScopeKey, } from './desktop-callback.js';
26
- import { PLATFORM_NETWORK, companyNetworkName, ensureDestination, findDestination, onPlatformNetwork, placed, placedByScope, } from './destinations.js';
32
+ import { PLATFORM_NETWORK, companyNetworkName, ensureDestination, findDestination, onPlatformNetwork, placed, placedByScope, placedOnPlatform, } from './destinations.js';
27
33
  import { DEFAULT_AI_LIMIT_MICROS } from './diff.js';
28
34
  import { createEdgeAccess, isNotFound as isEdgeNotFound, } from './edge-access.js';
29
35
  import { EDGE_DNS_WRITE_CONCURRENCY, createEdgeDns, limiter, } from './edge-dns.js';
@@ -31,29 +37,40 @@ import { createEdgeRateLimits, } from './edge-ratelimits.js';
31
37
  import { createEdgeTunnels } from './edge-tunnels.js';
32
38
  import { createEdgeZones } from './edge-zones.js';
33
39
  import { AI_ENTRY_RENEW_WINDOW_DAYS, entryAiAppliedScopeKey, entryAiClampedScopeKey, entryAiExpiresScopeKey, entryAiPendingExpiresScopeKey, entryAiRetiredScopeKey, entryAiUndeliveredScopeKey, entryAiUnrevokedScopeKey, entryAiWebhookPendingScopeKey, entryAiWrittenScopeKey, parseIdList, } from './entry-ai-key.js';
34
- import { BOULE_EDGE_ENTRY_KEY, EDGE_ENTRY_KEY_DAYS, EDGE_RESERVED_LABELS_APPLIED_SCOPE_KEY, ENTRY_EDGE_KEY_DELIVERY_ATTEMPTS, edgeKeyAppRecordKey, entryEdgeMintedAtScopeKey, entryEdgeRetiredScopeKey, entryEdgeUndeliveredScopeKey, } from './entry-edge-key.js';
40
+ import { applyEntryAudit } from './entry-audit.js';
41
+ import { CUSTOMER_GRANTS_CREDENTIAL_ENV, CUSTOMER_GRANTS_ENV, CUSTOMER_GRANTS_WRITTEN_SCOPE_KEY, customerGrantsEntries, customerGrantsJson, customerGrantsMintedScopeKey, customerGrantsRef, customerGrantsToDrop, ensureCustomerGrantsCredential, wantedCustomerGrants, } from './entry-customer-grants.js';
42
+ import { BOULE_EDGE_ENTRY_KEY, EDGE_ENTRY_KEY_DAYS, EDGE_RESERVED_LABELS_APPLIED_SCOPE_KEY, EDGE_SLUG_APPLIED_SCOPE_KEY, ENTRY_EDGE_KEY_DELIVERY_ATTEMPTS, bouleEdgeKeyWanted, edgeKeyAppRecordKey, edgeKeyDomain, entryEdgeMintedAtScopeKey, entryEdgeRetiredScopeKey, entryEdgeUndeliveredScopeKey, } from './entry-edge-key.js';
35
43
  import { FILES_AGORA_KEY_SCOPE_KEY, FILES_AGORA_UNDELIVERED_SCOPE_KEY, FILES_KEY_DAYS, FILES_KEY_RENEW_WINDOW_DAYS, FILES_ORG_OWNER_SCOPE_KEY, FILES_ROOT_EXPIRES_SCOPE_KEY, FILES_ROOT_PREVIOUS_SCOPE_KEY, FILES_ROOT_UNDELIVERED_SCOPE_KEY, FILES_ROOT_UNREVOKED_SCOPE_KEY, entryFilesAppliedScopeKey, entryFilesExpiresScopeKey, entryFilesMinterScopeKey, entryFilesPendingExpiresScopeKey, entryFilesRetiredScopeKey, entryFilesUndeliveredScopeKey, entryFilesUnrevokedScopeKey, entryFilesWrittenScopeKey, filesOwnerUserId, parseIdList as parseFilesIdList, } from './entry-files-key.js';
44
+ import { entryJobsAppliedScopeKey, entryJobsRefs, entryJobsUndeliveredScopeKey, entryJobsWantMarker, } from './entry-jobs.js';
45
+ import { ensureRepoKey, removeRepoKey } from './entry-repo-key-apply.js';
46
+ import { BROKER_WORKSPACES_DATABASE_ENV, ensureWorkspacesKey, heldWorkspacesDatabase, heldWorkspacesKey, workspacesDatabaseRef, workspacesKeyHash, workspacesKeyRef, } from './entry-workspaces.js';
36
47
  import { GATE_CLAIM_NAME, gateHostnamesScopeKey, gateIdRecordKey, isBehindGate } from './gates.js';
48
+ import { GITHUB_SOURCE_INSTALLATION_SCOPE_KEY, GITHUB_SOURCE_TIMEOUT_MS, ensureGithubSource, githubSourceResourceKey, } from './github-source.js';
37
49
  import { IMAGE_HEALTHCHECK_SETTINGS, commandHealthCheckSettings, healthCheckSettings, wgetProbe, } from './health-check-settings.js';
38
50
  import { PROXY_LABEL_TOKEN_REF } from './held-key-refs.js';
39
- import { appHostname, cmsStudioHostnameFor, coolifyTunnelDomains, entryHostnames, siteHostnameFor, tunnelDnsHostnames, tunnelIngressHostnames, } from './hostnames.js';
51
+ import { appHostname, cmsStudioHostnameFor, coolifyTunnelDomains, entryHostnames, siteHostnameFor, tunnelDnsHostnames, tunnelIngressHostnames, workspaceHost, } from './hostnames.js';
52
+ import { KEYS_REGISTRAR_DELIVERY_ATTEMPTS, KEYS_REGISTRAR_KEY_DAYS, KEYS_REGISTRAR_KEY_ENV, KEYS_REGISTRAR_MINTED_AT_SCOPE_KEY, KEYS_REGISTRAR_MINT_STARTED_SCOPE_KEY, KEYS_REGISTRAR_SCOPE_KEY, KEYS_REGISTRAR_UNDELIVERED_SCOPE_KEY, KEYS_REGISTRAR_URL_APPLIED_SCOPE_KEY, KEYS_REGISTRAR_URL_ENV, } from './keys-registrar.js';
53
+ import { KOSMOS_CLIENT_ID_SCOPE_KEY, KOSMOS_DATABASE_SCOPE_KEY, KOSMOS_DEPLOYED_SCOPE_KEY, desiredKosmosEnv, kosmosEnvDrift, kosmosHostnameFor, } from './kosmos.js';
40
54
  import { LOGIN_BINDING_SCOPE_KEY, LOGIN_CLIENT_ORGS_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, LOGIN_HOST_SHARED_SCOPE_KEY, OWN_CUSTOMERS_BASE_URI_SCOPE_KEY, clientOrganizationIds, clientOrganizationsState, customersClientOrganizations, customersEntryKeys, loginBindingState, loginHostClientIds, ownAuthApplied, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
55
+ import { machineBypassScopeKey } from './machine-paths.js';
41
56
  import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
42
57
  import { OWN_MAIL_SMTP_INTERNAL_SCOPE_KEY, OWN_MAIL_SMTP_PORT_SCOPE_KEY, isOwnMailSmtpPort, normalizeMailTarget, } from './mail-relay.js';
43
58
  import { applyAccessTokenLifetime, applyMcpAccess, applyMcpProject } from './mcp.js';
44
59
  import { NETWORK_APPLIED_SCOPE_KEY } from './network-move.js';
60
+ import { provisionOrgAppsAdmin, retireOrgAppsAdmin, } from './org-apps-admin.js';
45
61
  import { ORG_POSTGRES_MIGRATION_CONFIRMED_SCOPE_KEY, OrgPostgresMigrationNotConfirmedError, migrateHomeOf, migrateOrgPostgresHome, parseMigrationConfirmation, } from './org-postgres-migrate.js';
46
62
  import { orgPostgresSecretRef, provisionOrgPostgresDatabases, provisionOrgPostgresServer, serviceEndpoint, serviceOnServer, serviceUrls, } from './org-postgres.js';
47
63
  import { OWN_AUTH_APPLIED_GIT_REF_SCOPE_KEY, OWN_AUTH_APPLIED_HOSTNAME_SCOPE_KEY, } from './own-diff.js';
48
64
  import { NO_DOMAIN_ZONE, ownHostnames } from './own-hostnames.js';
65
+ import { provisionJobsRoute } from './own-jobs-route.js';
49
66
  import { LIMITS, limited } from './own-limits.js';
50
- import { OWN_FILES_R2_SCOPE_KEY, OWN_FILES_R2_UNDELIVERED_SCOPE_KEY, OWN_SERVICES_APPLIED_SCOPE_KEY, OwnServicesBlockedError, orgNetwork, orgTarget, scopeFromOrg, } from './own-services.js';
67
+ import { OWN_FILES_R2_SCOPE_KEY, OWN_FILES_R2_UNDELIVERED_SCOPE_KEY, OWN_SERVICES_APPLIED_SCOPE_KEY, OwnServicesBlockedError, orgNetwork, orgTarget, platformTarget, scopeFromOrg, } from './own-services.js';
51
68
  import { ownSfuUrls, provisionSfuApp } from './own-sfu.js';
52
- import { PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, } from './platform-diff.js';
69
+ import { JOBS_INTERNAL_URL_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, } from './platform-diff.js';
53
70
  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, orgDeclaresMcp, resolveAppEnv, serviceDbEnvAppliedScopeKey, zitadelAppIdTokenUserinfoAppliedScopeKey, } from './platform-diff.js';
54
- import { AUTH_LOGIN_PORT, PLATFORM_AUTH_BROKER_MAIL_KEY_REF, PLATFORM_AUTH_LOGIN_INTERNAL_NAME, PLATFORM_AUTH_LOGIN_SCOPE_KEY, PLATFORM_MAIL_SERVER_SCOPE_KEY, defaultStalwartForInstance, filesDepsInvalid, filesShareDomains, provisionAuthStack, provisionDatabaseBackup, provisionFilesHost, provisionJobsHost, provisionMailServer, resolveOwnFilesKeys, } from './platform-providers.js';
71
+ import { AUTH_LOGIN_PORT, PLATFORM_AUTH_BROKER_MAIL_KEY_REF, PLATFORM_AUTH_LOGIN_INTERNAL_NAME, PLATFORM_AUTH_LOGIN_SCOPE_KEY, PLATFORM_AUTH_PROJECT, PLATFORM_MAIL_SERVER_SCOPE_KEY, defaultStalwartForInstance, filesDepsInvalid, filesShareDomains, provisionAuthStack, provisionDatabaseBackup, provisionFilesHost, provisionJobsHost, provisionMailServer, resolveOwnFilesKeys, } from './platform-providers.js';
55
72
  import { companyAppPostLogoutUris, postLogoutAppliedValue, zitadelAppPostLogoutAppliedScopeKey, } from './post-logout-uris.js';
56
- import { CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY, ENTRY_CONTAINER_LABELS, appLabelsAppliedScopeKey, builtinRoutes, entryLabelHash, entryLabelSpec, entryOikosHostnames, entryProxyLabels, entryRoutes, entryRoutesMarker, ownsOikosName, routesWithoutToken, sameLabelSets, } from './proxy-labels.js';
73
+ import { CLOUDFLARE_TUNNEL_APPS_HOSTNAMES_SCOPE_KEY, CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY, ENTRY_CONTAINER_LABELS, appLabelsAppliedScopeKey, builtinRoutes, entryAppsHostnames, entryLabelHash, entryLabelSpec, entryOikosHostnames, entryProxyLabels, entryRoutes, entryRoutesMarker, ownsAppsZoneName, ownsOikosName, routesWithoutToken, sameLabelSets, } from './proxy-labels.js';
57
74
  import { createReleaseHost } from './release-host.js';
58
75
  import { TUNNEL_HTTP_INGRESS_SCOPE_KEY, applyRemoteDatabase, mergeTcpIngress, recordedTcpRoutes, } from './remote-database.js';
59
76
  import { editSignInBindings } from './sign-in-bindings.js';
@@ -64,6 +81,9 @@ import { stageGateKey, stageHostname, } from './stage-guards.js';
64
81
  import { WORKER_APP_DB_SCOPE_KEYS, provisionWorkerAppDb, workerAppDbCaPem, workerAppDbOwnerUrl, } from './worker-app-db.js';
65
82
  import { WORKER_APP_CALL_TIMEOUT_MS, applyWorkerApp, heldBrokerKey, teardownWorkerApp, withCallTimeout, } from './worker-app.js';
66
83
  import { entryWorkerAppliedScopeKey, entryWorkerExclusionsScopeKey, entryWorkerRetiredScopeKey, entryWorkerRouteIdScopeKey, entryWorkerRoutePatternScopeKey, entryWorkerRoutePendingScopeKey, entryWorkerRouteZoneScopeKey, entryWorkerUploadedScopeKey, } from './worker.js';
84
+ import { TEST_CUSTOMERS_SIGN_IN_SCOPE_KEY, TEST_CUSTOMERS_SIGN_UP_SCOPE_KEY, testCustomersOrgName, workspaceAuthEntryOf, workspaceClientHosts, workspaceClientKey, } from './workspace-client.js';
85
+ import { liveClientIds, wantedWorkspaceBrokerEntry, workspaceAuthEnv, workspaceSignInMarker, } from './workspace-sign-in.js';
86
+ import { gatePeopleScopeKey, isServerWorkspace, peopleHash, readWorkspaceCommit, withWorkspaceWorkers, workspaceGateKey, workspaceStructureHash, workspaceSubject, wsKey, } from './workspace.js';
67
87
  /**
68
88
  * The real {@link StepExecutor} (#20 "provision Company Factory end to
69
89
  * end"): makes each {@link StepAction} `diff.ts` plans true against a real
@@ -116,9 +136,24 @@ export function adaptZitadelListApps(client) {
116
136
  }
117
137
  /** An outside call for Boule's credentials that has not answered in this long fails the step; the next apply repairs it. */
118
138
  const BOULE_CREDENTIAL_TIMEOUT_MS = 60_000;
139
+ /** One read of the broker's env. */
140
+ const BROKER_ENV_READ_TIMEOUT_MS = 15_000;
141
+ const HELD_KEY_READ_TIMEOUT_MS = 15_000;
119
142
  const BOULE_CREDENTIAL_ATTEMPTS = 3;
120
143
  /** The ZITADEL machine user that reads one company's customers organisation. */
121
144
  const CUSTOMERS_DIRECTORY_USER_NAME = 'foundry-customers-directory';
145
+ const LIVE_CUSTOMERS = {
146
+ step: 'zitadel:customers',
147
+ name: (slug) => `${slug} customers`,
148
+ signUpKey: CUSTOMERS_SIGN_UP_SCOPE_KEY,
149
+ signInKey: CUSTOMERS_SIGN_IN_SCOPE_KEY,
150
+ };
151
+ const TEST_CUSTOMERS = {
152
+ step: 'zitadel:customers-test',
153
+ name: testCustomersOrgName,
154
+ signUpKey: TEST_CUSTOMERS_SIGN_UP_SCOPE_KEY,
155
+ signInKey: TEST_CUSTOMERS_SIGN_IN_SCOPE_KEY,
156
+ };
122
157
  // ---------------------------------------------------------------------------
123
158
  // Errors
124
159
  // ---------------------------------------------------------------------------
@@ -358,6 +393,15 @@ export const BOULE_REPO = 'wtfalch/boule';
358
393
  export const BOULE_APP_DOCKERFILE = '/Dockerfile';
359
394
  /** Boule's own `EXPOSE 3000` (its doc comment just above) -- the port its health check (#543) probes. */
360
395
  export const BOULE_PORT = 3000;
396
+ /**
397
+ * foundry#949: kosmos (https://github.com/wtfalch/kosmos), bootstrapped from the same `wtfalch/package-template`
398
+ * service shape Boule is, with its own Dockerfile at the repository root, serving on 3000 (`EXPOSE 3000`) and
399
+ * running its migrations through `apps/host/scripts/migrate.mjs` before it serves, the same shape Boule's
400
+ * `migrate-and-start.sh` follows.
401
+ */
402
+ export const KOSMOS_REPO = 'wtfalch/kosmos';
403
+ export const KOSMOS_APP_DOCKERFILE = '/Dockerfile';
404
+ export const KOSMOS_PORT = 3000;
361
405
  /**
362
406
  * Every app foundry deploys keeps its own logs across a redeploy, so a
363
407
  * database outage does not also erase the only other copy of the evidence
@@ -623,6 +667,14 @@ const CALLS_SYNAPSE_SCOPE_KEY = 'coolify_calls_synapse';
623
667
  const CALLS_MAS_SCOPE_KEY = 'coolify_calls_mas';
624
668
  const CALLS_SYNAPSE_DB_SCOPE_KEY = 'coolify_calls_synapse_database';
625
669
  const CALLS_MAS_DB_SCOPE_KEY = 'coolify_calls_mas_database';
670
+ /** #430: the calls service's own Matrix/MAS env vars, from before calls#93 (transit-only). Removed from every org's calls service, never written again. */
671
+ const CALLS_MATRIX_ENV_KEYS = [
672
+ 'MAS_URL',
673
+ 'MAS_HOMESERVER',
674
+ 'MAS_CLIENT_ID',
675
+ 'MAS_CLIENT_SECRET',
676
+ 'SYNAPSE_URL',
677
+ ];
626
678
  /**
627
679
  * Synapse refuses to start against a database whose `LC_COLLATE`/`LC_CTYPE`
628
680
  * is not `C` (https://element-hq.github.io/synapse/latest/postgres.html);
@@ -859,6 +911,10 @@ async function withTimeout(work, ms) {
859
911
  clearTimeout(timer);
860
912
  }
861
913
  }
914
+ /** How long one call to the jobs scheduler or the held keys, made for an entry's project, may take. */
915
+ const JOBS_PROJECT_CALL_TIMEOUT_MS = 30_000;
916
+ /** How many times an entry's project credentials are tried against the held keys. */
917
+ const JOBS_PROJECT_HOLD_ATTEMPTS = 3;
862
918
  /** How long one Lettermint call of an own company's sign-in mint may take. */
863
919
  const LETTERMINT_TIMEOUT_MS = 60_000;
864
920
  const OWN_SERVICES_PORT_ABSENT = 'this org runs its own services and the host has no ownServices port for it';
@@ -889,7 +945,11 @@ function limitProfileOf(action) {
889
945
  case 'coolify:app':
890
946
  case 'coolify:database':
891
947
  return LIMITS.companyApp;
948
+ case 'coolify:github-source':
949
+ return undefined;
892
950
  case 'coolify:broker-entry':
951
+ case 'coolify:workspace-broker-entry':
952
+ case 'coolify:workspaces-database':
893
953
  return LIMITS.brokerEntry;
894
954
  case 'cms:site':
895
955
  return LIMITS.cms;
@@ -905,8 +965,16 @@ function limitProfileOf(action) {
905
965
  case 'coolify:boule':
906
966
  case 'coolify:boule-broker-entry':
907
967
  return LIMITS.boule;
968
+ case 'coolify:kosmos':
969
+ return LIMITS.kosmos;
970
+ case 'coolify:broker-registrar':
971
+ return LIMITS.brokerEntry;
972
+ case 'coolify:keys-registrar':
973
+ return LIMITS.boule;
908
974
  case 'coolify:bluegreen':
909
975
  case 'coolify:bluegreen-lb':
976
+ case 'coolify:workspace':
977
+ case 'coolify:promote':
910
978
  return LIMITS.companyApp;
911
979
  case 'cloudflare:tunnel':
912
980
  case 'coolify:bluegreen-connector':
@@ -1087,6 +1155,58 @@ export class FoundryStepExecutor {
1087
1155
  throw e;
1088
1156
  }
1089
1157
  }
1158
+ /**
1159
+ * Where the workspaces database of the broker this org's entries are written to already is: in the broker's
1160
+ * `WORKSPACES_DATABASE_URL` (`inEnv`) or as a URL held at the org's ref (`held`). The company's own broker for an
1161
+ * org that owns its services, the shared one otherwise. Throws when the broker is not wired or a read fails,
1162
+ * times out, or finds a held value that is no postgres URL, so a caller never takes "unknown" for "absent".
1163
+ */
1164
+ async workspacesDatabaseSources(org, recordedIds) {
1165
+ const broker = ownsServices(org)
1166
+ ? (await this.ports.ownServices?.portsFor(org, recordedIds))?.broker
1167
+ : this.ports.broker;
1168
+ if (!broker)
1169
+ throw new Error('the broker port is not wired');
1170
+ const envs = await withLabelledTimeout('the broker env read', BROKER_ENV_READ_TIMEOUT_MS, () => broker.listEnvs());
1171
+ if (envs.some((e) => e.key === BROKER_WORKSPACES_DATABASE_ENV && !e.is_preview && e.value)) {
1172
+ return { inEnv: true, held: false };
1173
+ }
1174
+ const held = await withLabelledTimeout('the held key read', HELD_KEY_READ_TIMEOUT_MS, () => heldWorkspacesDatabase(this.ports.heldKeys, workspacesDatabaseRef(org)));
1175
+ return { inEnv: false, held: Boolean(held) };
1176
+ }
1177
+ /**
1178
+ * Whether the broker this org's entries are written to has `WORKSPACES_DATABASE_URL`, or a URL is held to give it.
1179
+ * A read that fails or times out answers `false`, so the plan does not write an entry the broker cannot start
1180
+ * with.
1181
+ */
1182
+ async brokerHasWorkspacesDatabase(org, recordedIds) {
1183
+ try {
1184
+ const { inEnv, held } = await this.workspacesDatabaseSources(org, recordedIds);
1185
+ return inEnv || held;
1186
+ }
1187
+ catch {
1188
+ // Not readable (blocked own services, a timeout, a failed call, a held value that is no URL): the plan reports the entry blocked.
1189
+ return false;
1190
+ }
1191
+ }
1192
+ /**
1193
+ * Whether foundry may make the broker's workspaces database: it can hold the URL, and a read just showed that
1194
+ * neither the broker nor the held keys have one. A read that fails answers `false`, never "make one": a database
1195
+ * made over a broker whose own could not be read would replace it.
1196
+ */
1197
+ async canProvisionWorkspacesDatabase(org, recordedIds) {
1198
+ if (!canHoldWorkspacesDatabase(this.ports.heldKeys))
1199
+ return false;
1200
+ if (ownsServices(org) && !this.ports.ownServices)
1201
+ return false;
1202
+ try {
1203
+ const { inEnv, held } = await this.workspacesDatabaseSources(org, recordedIds);
1204
+ return !inEnv && !held;
1205
+ }
1206
+ catch {
1207
+ return false;
1208
+ }
1209
+ }
1090
1210
  /** #555: the edge-routed ports this executor was built with itself (they win over edge). */
1091
1211
  ownEdgePorts() {
1092
1212
  return {
@@ -1165,6 +1285,8 @@ export class FoundryStepExecutor {
1165
1285
  return this.zitadelDesktopCallback(action, org, context);
1166
1286
  case 'zitadel:customers':
1167
1287
  return this.zitadelCustomers(action, org, context);
1288
+ case 'zitadel:customers-test':
1289
+ return this.zitadelCustomers(action, org, context, TEST_CUSTOMERS);
1168
1290
  case 'zitadel:mcp-project':
1169
1291
  return this.zitadelMcpProject(action, org, context);
1170
1292
  case 'zitadel:mcp-access':
@@ -1173,14 +1295,24 @@ export class FoundryStepExecutor {
1173
1295
  return this.zitadelAccessTokenLifetime(context);
1174
1296
  case 'coolify:app':
1175
1297
  return this.coolifyApp(action, org, context);
1298
+ case 'coolify:github-source':
1299
+ return this.coolifyGithubSource(org, context);
1176
1300
  case 'coolify:network':
1177
1301
  return this.coolifyNetwork(action, org, context);
1178
1302
  case 'coolify:bluegreen':
1179
1303
  case 'coolify:bluegreen-lb':
1180
1304
  case 'coolify:bluegreen-connector':
1181
1305
  return this.coolifyBlueGreen(action, org, context);
1306
+ case 'coolify:workspace':
1307
+ return this.coolifyWorkspace(action, org, context);
1308
+ case 'coolify:promote':
1309
+ return this.coolifyPromote(action, org, context);
1182
1310
  case 'coolify:broker-entry':
1183
1311
  return this.coolifyBrokerEntry(action, org, context);
1312
+ case 'coolify:workspace-broker-entry':
1313
+ return this.coolifyWorkspaceBrokerEntry(action, org, context);
1314
+ case 'coolify:workspaces-database':
1315
+ return this.coolifyWorkspacesDatabase(org, context);
1184
1316
  case 'coolify:database':
1185
1317
  return this.coolifyDatabase(action, org, context);
1186
1318
  case 'coolify:org-postgres':
@@ -1189,6 +1321,12 @@ export class FoundryStepExecutor {
1189
1321
  return this.orgPostgresDatabases(action, org, context);
1190
1322
  case 'coolify:org-postgres-migrate':
1191
1323
  return this.orgPostgresMigrate(action, org, context);
1324
+ case 'coolify:org-apps-admin':
1325
+ return this.orgAppsAdmin(action, org, context);
1326
+ case 'cloudflare:apps-db-route':
1327
+ return this.appsDbRoute(action, org, context);
1328
+ case 'cloudflare:apps-zones':
1329
+ return this.appsZones(action, org, context);
1192
1330
  case 'coolify:backup':
1193
1331
  return this.coolifyBackup(action, org, context);
1194
1332
  case 'coolify:calls':
@@ -1202,7 +1340,7 @@ export class FoundryStepExecutor {
1202
1340
  case 'cloudflare:zone':
1203
1341
  return this.cloudflareZone(action, org, context);
1204
1342
  case 'cloudflare:access':
1205
- return this.cloudflareAccess(action, org);
1343
+ return this.cloudflareAccess(action, org, context);
1206
1344
  case 'cloudflare:gate':
1207
1345
  return this.cloudflareGate(action, org, context);
1208
1346
  case 'cloudflare:ratelimit':
@@ -1214,7 +1352,7 @@ export class FoundryStepExecutor {
1214
1352
  if (!workers) {
1215
1353
  throw new Error(`cloudflare:worker-app for ${org.slug}: this executor was built without a WorkerAppPort`);
1216
1354
  }
1217
- return applyWorkerApp(this.workerAppDeps(workers, org, () => context.recordedIds()), org, action, context);
1355
+ return applyWorkerApp(this.workerAppDeps(workers, org, () => context.recordedIds()), withWorkspaceWorkers(org), action, context);
1218
1356
  }
1219
1357
  case 'files:organisation':
1220
1358
  return this.filesOrganisation(action, org, context);
@@ -1236,6 +1374,14 @@ export class FoundryStepExecutor {
1236
1374
  return this.edgeEntryKey(action, org, context);
1237
1375
  case 'edge:reserved-labels':
1238
1376
  return this.edgeReservedLabels(action, org, context);
1377
+ case 'edge:slug':
1378
+ return this.edgeSlug(action, org, context);
1379
+ case 'jobs:project':
1380
+ return this.jobsProject(action, org, context);
1381
+ case 'github:deploy-key':
1382
+ return this.githubDeployKey(action, org, context);
1383
+ case 'audit:ledger':
1384
+ return this.auditLedger(action, org, context);
1239
1385
  case 'coolify:mail':
1240
1386
  return this.coolifyMail(action, org, context);
1241
1387
  case 'coolify:mail-copy':
@@ -1254,6 +1400,12 @@ export class FoundryStepExecutor {
1254
1400
  return this.bouleOwners(org, context);
1255
1401
  case 'coolify:boule-broker-entry':
1256
1402
  return this.bouleBrokerEntry(org, context);
1403
+ case 'coolify:kosmos':
1404
+ return this.coolifyKosmos(action, org, context);
1405
+ case 'coolify:broker-registrar':
1406
+ return this.brokerRegistrar(org, context);
1407
+ case 'coolify:keys-registrar':
1408
+ return this.keysRegistrar(action, org, context);
1257
1409
  case 'jobs:registration':
1258
1410
  return this.jobsRegistration(action, org, context);
1259
1411
  case 'cloudflare:db-route':
@@ -1266,6 +1418,8 @@ export class FoundryStepExecutor {
1266
1418
  return this.workerAppDb(org, context);
1267
1419
  case 'coolify:own-jobs':
1268
1420
  return this.ownJobs(action, org, context);
1421
+ case 'coolify:own-jobs-route':
1422
+ return this.ownJobsRoute(action, org, context);
1269
1423
  case 'coolify:own-mail':
1270
1424
  return this.ownMail(action, org, context);
1271
1425
  case 'coolify:own-sfu':
@@ -1288,7 +1442,10 @@ export class FoundryStepExecutor {
1288
1442
  * app carries any policy but foundry's, since that policy could let others
1289
1443
  * in.
1290
1444
  */
1291
- async cloudflareAccess(action, org) {
1445
+ async cloudflareAccess(action, org, context) {
1446
+ const machine = action.desired.machineBypass;
1447
+ if (machine)
1448
+ return this.cloudflareMachineBypass(action, org, context);
1292
1449
  const rule = (org.hosting.access ?? []).find((r) => appStepKey(org, r.hostname) === action.key);
1293
1450
  const owned = action.desired.ownedBypass;
1294
1451
  if (!rule && owned && appStepKey(org, owned) === action.key) {
@@ -1310,6 +1467,41 @@ export class FoundryStepExecutor {
1310
1467
  policy: await this.accessPolicyFor(org, rule),
1311
1468
  });
1312
1469
  }
1470
+ /**
1471
+ * F11 (`plan/machine-paths.ts`): one path-scoped Access application (`<host><path>`) with foundry's bypass
1472
+ * policy, so a machine caller is not stopped by the host's gate; or its removal. The stem of the step key
1473
+ * (`mp-<hash>`) names it; the recorded `host/path` is kept only to word the removal.
1474
+ */
1475
+ async cloudflareMachineBypass(action, org, context) {
1476
+ const stem = action.key.slice(org.id.length + 1);
1477
+ const desired = action.desired;
1478
+ if (desired.machineBypass === 'delete') {
1479
+ const id = context.recordedIds()[`cloudflare:access:${action.key}`];
1480
+ if (id) {
1481
+ try {
1482
+ await this.accessPort().deleteAccessApp(id);
1483
+ }
1484
+ catch (error) {
1485
+ if (!isEdgeNotFound(error))
1486
+ throw error;
1487
+ }
1488
+ }
1489
+ forgetScopeId(context, `cloudflare:access:${action.key}`);
1490
+ forgetScopeId(context, machineBypassScopeKey(stem));
1491
+ return {};
1492
+ }
1493
+ if (!desired.domain) {
1494
+ throw new Error(`cloudflare:access for ${org.slug}: machine bypass '${stem}' names no host and path`);
1495
+ }
1496
+ const result = await this.cloudflareAccessApp(org, {
1497
+ appName: `foundry:${org.id}:${desired.domain}`,
1498
+ domain: desired.domain,
1499
+ policyName: ACCESS_RULE_POLICY_NAMES.bypass,
1500
+ policy: await this.accessPolicyFor(org, { kind: 'bypass', hostname: desired.domain }),
1501
+ });
1502
+ context.recordScopeId(machineBypassScopeKey(stem), desired.domain);
1503
+ return result;
1504
+ }
1313
1505
  /**
1314
1506
  * `plan/diff.ts`'s `planGates`: makes, changes or deletes one declared
1315
1507
  * gate through edge. A create needs the platform's recorded Access provider,
@@ -1326,13 +1518,30 @@ export class FoundryStepExecutor {
1326
1518
  const recorded = context.recordedIds();
1327
1519
  const gateId = recorded[gateIdRecordKey(org.id, gateKey)];
1328
1520
  const hostnamesKey = gateHostnamesScopeKey(gateKey);
1521
+ const peopleKey = gatePeopleScopeKey(gateKey);
1329
1522
  if (desired.mode === 'delete') {
1330
1523
  if (gateId)
1331
1524
  await this.accessPort().deleteGate(gateId);
1332
1525
  if (context.forgetId) {
1333
1526
  context.forgetId(gateIdRecordKey(org.id, gateKey));
1334
1527
  context.forgetId(hostnamesKey);
1528
+ context.forgetId(peopleKey);
1529
+ }
1530
+ return {};
1531
+ }
1532
+ if (desired.mode === 'people') {
1533
+ const emails = desired.emails ?? [];
1534
+ if (!gateId)
1535
+ throw new Error(`${at}: no gate is recorded, so there is no one to change`);
1536
+ if (emails.length === 0) {
1537
+ throw new Error(`${at}: a gate nobody can pass is a mistake, and the list is empty`);
1335
1538
  }
1539
+ const access = this.accessPort();
1540
+ if (!access.setGatePeople) {
1541
+ throw new Error(`${at}: this host's Access port cannot change who passes a gate`);
1542
+ }
1543
+ await access.setGatePeople(gateId, emails);
1544
+ context.recordScopeId(peopleKey, peopleHash(emails));
1336
1545
  return {};
1337
1546
  }
1338
1547
  const hostnames = desired.hostnames ?? [];
@@ -1346,6 +1555,7 @@ export class FoundryStepExecutor {
1346
1555
  // The gate is gone at edge: forget it, so the next plan creates it again.
1347
1556
  context.forgetId?.(gateIdRecordKey(org.id, gateKey));
1348
1557
  context.forgetId?.(hostnamesKey);
1558
+ context.forgetId?.(peopleKey);
1349
1559
  return {};
1350
1560
  }
1351
1561
  context.recordScopeId(hostnamesKey, JSON.stringify(hostnames));
@@ -1358,7 +1568,7 @@ export class FoundryStepExecutor {
1358
1568
  if (!claimValue) {
1359
1569
  throw new Error(`${at}: ${org.slug} has no sign-in organisation recorded yet`);
1360
1570
  }
1361
- const emails = org.owners.map((owner) => owner.email);
1571
+ const emails = desired.emails ?? org.owners.map((owner) => owner.email);
1362
1572
  if (emails.length === 0) {
1363
1573
  throw new Error(`${at}: ${org.slug} declares no owner with an email, and a gate nobody can pass is a mistake`);
1364
1574
  }
@@ -1373,6 +1583,8 @@ export class FoundryStepExecutor {
1373
1583
  emails,
1374
1584
  });
1375
1585
  context.recordScopeId(hostnamesKey, JSON.stringify(hostnames));
1586
+ if (desired.emails)
1587
+ context.recordScopeId(peopleKey, peopleHash(desired.emails));
1376
1588
  return { resourceId: created.id };
1377
1589
  }
1378
1590
  /** The Cloudflare Access policy body for one `hosting.access` rule, by kind (#246). */
@@ -1535,6 +1747,7 @@ export class FoundryStepExecutor {
1535
1747
  return db.internal_db_url;
1536
1748
  },
1537
1749
  secret: (value) => void this.handledSecrets.add(value),
1750
+ log: (line) => console.info(redactSecrets(line, [...this.handledSecrets])),
1538
1751
  },
1539
1752
  }
1540
1753
  : {}),
@@ -2488,13 +2701,13 @@ export class FoundryStepExecutor {
2488
2701
  * every issuer org, which is named by a slug and a slug has no spaces. Every call here is idempotent, so
2489
2702
  * a re-run after a crash finishes the job.
2490
2703
  */
2491
- async zitadelCustomers(action, org, context) {
2704
+ async zitadelCustomers(action, org, context, variant = LIVE_CUSTOMERS) {
2492
2705
  const zitadelOrgId = context.recordedIds().zitadel_org;
2493
2706
  if (!zitadelOrgId) {
2494
- throw new Error(`zitadel:customers for ${org.slug}: no ZITADEL org recorded yet -- the issuer must be provisioned first`);
2707
+ throw new Error(`${variant.step} for ${org.slug}: no ZITADEL org recorded yet -- the issuer must be provisioned first`);
2495
2708
  }
2496
2709
  const projectId = await this.ensureZitadelProject(org, zitadelOrgId, context);
2497
- const { org: customers } = await this.ports.zitadel.findOrCreateOrg(`${org.slug} customers`);
2710
+ const { org: customers } = await this.ports.zitadel.findOrCreateOrg(variant.name(org.slug));
2498
2711
  // Self-registration follows `customers.signUp` (off by default). The marker is written last, so a
2499
2712
  // run that failed before here is planned again.
2500
2713
  const signUp = action.desired.signUp === true;
@@ -2504,11 +2717,14 @@ export class FoundryStepExecutor {
2504
2717
  await this.ports.zitadel.closeSelfRegistration(customers.id);
2505
2718
  await this.ports.zitadel.findOrCreateProjectGrant(zitadelOrgId, projectId, customers.id);
2506
2719
  // The sign-in and room metadata come before every marker: a failed write leaves both markers behind, so the next plan repeats the step.
2507
- const signIn = await this.writeCustomersSignInMetadata(org, customers.id, action, context);
2508
- const room = await this.writeCustomersRoomMetadata(org, customers.id, action, context);
2509
- context.recordScopeId(CUSTOMERS_SIGN_UP_SCOPE_KEY, signUp ? '1' : '0');
2720
+ const signIn = await this.writeCustomersSignInMetadata(org, customers.id, action, context, variant);
2721
+ // The sign-in window text is the live customers organisation's; the test customers planner plans none.
2722
+ const room = variant === LIVE_CUSTOMERS
2723
+ ? await this.writeCustomersRoomMetadata(org, customers.id, action, context)
2724
+ : null;
2725
+ context.recordScopeId(variant.signUpKey, signUp ? '1' : '0');
2510
2726
  if (signIn)
2511
- context.recordScopeId(CUSTOMERS_SIGN_IN_SCOPE_KEY, signIn);
2727
+ context.recordScopeId(variant.signInKey, signIn);
2512
2728
  if (room !== null)
2513
2729
  context.recordScopeId(CUSTOMERS_ROOM_SCOPE_KEY, room);
2514
2730
  return { resourceId: customers.id };
@@ -2550,13 +2766,13 @@ export class FoundryStepExecutor {
2550
2766
  * only when the recorded marker differs, so a step for another change touches
2551
2767
  * nothing here. Returns the marker value to record, or null for no change.
2552
2768
  */
2553
- async writeCustomersSignInMetadata(org, customersOrgId, action, context) {
2769
+ async writeCustomersSignInMetadata(org, customersOrgId, action, context, variant = LIVE_CUSTOMERS) {
2554
2770
  const want = action.desired.signIn;
2555
2771
  if (want === undefined)
2556
2772
  return null;
2557
2773
  const { setOrgMetadata, removeOrgMetadata } = this.ports.zitadel;
2558
2774
  if (!setOrgMetadata || !removeOrgMetadata) {
2559
- throw new Error(`zitadel:customers for ${org.slug}: the ZITADEL client cannot set organisation metadata`);
2775
+ throw new Error(`${variant.step} for ${org.slug}: the ZITADEL client cannot set organisation metadata`);
2560
2776
  }
2561
2777
  try {
2562
2778
  if (want === 'email-code') {
@@ -2564,13 +2780,13 @@ export class FoundryStepExecutor {
2564
2780
  return 'email-code';
2565
2781
  }
2566
2782
  // Nothing to remove when it was never written.
2567
- if (context.recordedIds()[CUSTOMERS_SIGN_IN_SCOPE_KEY] !== 'email-code')
2783
+ if (context.recordedIds()[variant.signInKey] !== 'email-code')
2568
2784
  return null;
2569
2785
  await removeOrgMetadata.call(this.ports.zitadel, customersOrgId, CUSTOMERS_SIGN_IN_METADATA_KEY);
2570
2786
  return 'password';
2571
2787
  }
2572
2788
  catch {
2573
- throw new Error(`zitadel:customers for ${org.slug}: writing the sign-in metadata of organisation ${customersOrgId} failed`);
2789
+ throw new Error(`${variant.step} for ${org.slug}: writing the sign-in metadata of organisation ${customersOrgId} failed`);
2574
2790
  }
2575
2791
  }
2576
2792
  async zitadelApp(action, org, context) {
@@ -2584,16 +2800,21 @@ export class FoundryStepExecutor {
2584
2800
  const isCmsClient = action.key === 'cms' && org.site.enabled;
2585
2801
  const isAgoraClient = action.key === 'agora';
2586
2802
  const isBouleClient = action.key === 'boule';
2803
+ const isKosmosClient = action.key === 'kosmos' && kosmosOn(org);
2587
2804
  const authEntry = org.hosting.entries.find((e) => e.key === action.key && e.uses?.auth);
2805
+ // A workspace's own client `<key>-work`, signing in at the workspace host.
2806
+ const workspaceEntry = authEntry ? undefined : workspaceAuthEntryOf(org, action.key);
2588
2807
  const clientIdScopeKey = isCmsClient
2589
2808
  ? CMS_CLIENT_ID_SCOPE_KEY
2590
2809
  : isAgoraClient
2591
2810
  ? AGORA_CLIENT_ID_SCOPE_KEY
2592
2811
  : isBouleClient
2593
2812
  ? BOULE_CLIENT_ID_SCOPE_KEY
2594
- : authEntry
2595
- ? entryClientIdScopeKey(action.key)
2596
- : APP_CLIENT_ID_SCOPE_KEY;
2813
+ : isKosmosClient
2814
+ ? KOSMOS_CLIENT_ID_SCOPE_KEY
2815
+ : authEntry || workspaceEntry
2816
+ ? entryClientIdScopeKey(action.key)
2817
+ : APP_CLIENT_ID_SCOPE_KEY;
2597
2818
  const priorAppId = recorded[`zitadel:app:${action.key}`];
2598
2819
  // Only a step the planner flagged as destroying (`recreate`, set with
2599
2820
  // `destroys: true` so plan-apply asks for the typed confirmation) may
@@ -2936,6 +3157,8 @@ export class FoundryStepExecutor {
2936
3157
  coolify: this.ports.coolify,
2937
3158
  healthToken: async () => (await this.ports.heldKeys?.resolve(sharedSecretRef(org.id, healthTokenName(subject.id)))) ?? null,
2938
3159
  secret: (value) => void this.handledSecrets.add(value),
3160
+ pinRef: sharedSecretRef(org.id, pinKeyName(subject.id)),
3161
+ ...(this.ports.heldKeys ? { heldKeys: this.ports.heldKeys } : {}),
2939
3162
  primaryUuid,
2940
3163
  primaryBlueAppId,
2941
3164
  runtime: this.ports.runtime ?? {},
@@ -3035,6 +3258,16 @@ export class FoundryStepExecutor {
3035
3258
  this.handledSecrets.add(token);
3036
3259
  return token;
3037
3260
  },
3261
+ pinKey: () => mintPinKey({
3262
+ id: subject.id,
3263
+ ref: sharedSecretRef(org.id, pinKeyName(subject.id)),
3264
+ recorded,
3265
+ record: (k, v) => context.recordScopeId(k, v),
3266
+ persist: () => context.persistScopeIds(),
3267
+ heldKeys: this.ports.heldKeys ?? { resolve: async () => null },
3268
+ generate: () => randomBytes(32).toString('hex'),
3269
+ secret: (value) => void this.handledSecrets.add(value),
3270
+ }),
3038
3271
  actionsKey: async () => {
3039
3272
  const heldKeys = this.ports.heldKeys;
3040
3273
  if (typeof heldKeys?.put !== 'function') {
@@ -3057,6 +3290,7 @@ export class FoundryStepExecutor {
3057
3290
  return key;
3058
3291
  },
3059
3292
  secret: (value) => void this.handledSecrets.add(value),
3293
+ githubAppUuid: (repo) => this.githubAppUuidFor(org, repo, recorded()),
3060
3294
  runtime,
3061
3295
  sleep: runtime.sleep ?? defaultSleep,
3062
3296
  label: `${action.system}:${action.kind}:${subject.id}`,
@@ -3074,6 +3308,296 @@ export class FoundryStepExecutor {
3074
3308
  }
3075
3309
  return undefined;
3076
3310
  }
3311
+ /**
3312
+ * `coolify:workspace` (`plan/workspace.ts`): a server entry's private workspace on the primary server. It is
3313
+ * the blue/green stage (the stage app, its own empty database, the stage mail sink and the stage guards) at
3314
+ * the workspace host, with the Access gate `planGates` made first. Nothing here makes a color, a balancer or
3315
+ * a public name. The live app, when the entry is live, is only read for the names stage must not share.
3316
+ */
3317
+ async coolifyWorkspace(action, org, context) {
3318
+ const recorded = () => context.recordedIds();
3319
+ const key = String(action.desired.entry ?? '');
3320
+ const entry = org.hosting.entries.find((e) => e.key === key && isServerWorkspace(e) && e.repo !== null);
3321
+ if (!entry) {
3322
+ throw new Error(`coolify:workspace for ${org.slug}: no server workspace '${key}'`);
3323
+ }
3324
+ const serverUuid = String(action.desired.server ?? '');
3325
+ const primaryUuid = String(action.desired.primary ?? '');
3326
+ const servers = await this.resolveServers(org, context);
3327
+ if (!servers.servers.some((s) => s.uuid === serverUuid) ||
3328
+ servers.primary.uuid !== primaryUuid) {
3329
+ throw new Error(`coolify:workspace for ${org.slug}: Coolify server ${serverUuid} is not the usable, recorded primary`);
3330
+ }
3331
+ const projectUuid = await this.ensureCoolifyProject(org, context);
3332
+ const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
3333
+ const subject = workspaceSubject(org, entry);
3334
+ const host = workspaceHost(org.slug, entry.key);
3335
+ const gateId = recorded()[gateIdRecordKey(org.id, workspaceGateKey(entry.key))];
3336
+ const guards = this.ports.stageProbes ? { probes: this.ports.stageProbes } : {};
3337
+ if (gateId)
3338
+ guards.gate = { hostname: host, create: async () => ({ id: gateId }) };
3339
+ const runtime = this.ports.runtime ?? {};
3340
+ const authIssuer = action.desired.authIssuer ?? null;
3341
+ const signIn = await this.workspaceStageSignIn(org, entry, authIssuer, context);
3342
+ await applyWorkspaceServer({
3343
+ coolify: this.ports.coolify,
3344
+ recorded,
3345
+ record: (k, v) => context.recordScopeId(k, v),
3346
+ persist: () => context.persistScopeIds(),
3347
+ projectUuid,
3348
+ environment,
3349
+ secret: (value) => void this.handledSecrets.add(value),
3350
+ githubAppUuid: (repo) => this.githubAppUuidFor(org, repo, recorded()),
3351
+ runtime,
3352
+ sleep: runtime.sleep ?? defaultSleep,
3353
+ label: `${action.system}:${action.kind}:${entry.key}`,
3354
+ // The live app, when there is one: stage's names are checked against its env and nothing is copied from it.
3355
+ primaryBlueAppId: isLiveEntry(entry) ? (recorded()[subject.blueAppKey] ?? null) : null,
3356
+ logDir: LOG_DIR,
3357
+ isRunning: isRunningOrStarting,
3358
+ stageGuards: guards,
3359
+ stageSink: { slug: org.slug },
3360
+ stageAppUrl: `https://${host}`,
3361
+ ...(signIn ? { signIn } : {}),
3362
+ }, subject, serverUuid);
3363
+ // Only a stage that took the values has applied them; otherwise the next plan tries again.
3364
+ if (signIn && recorded()[bgKey.stageState(entry.key)] === 'ok') {
3365
+ const marker = workspaceSignInMarker(org, entry, recorded(), authIssuer);
3366
+ if (marker)
3367
+ context.recordScopeId(wsKey.signIn(entry.key), marker);
3368
+ }
3369
+ context.recordScopeId(wsKey.applied(entry.key), workspaceStructureHash(org, entry));
3370
+ await context.persistScopeIds();
3371
+ return undefined;
3372
+ }
3373
+ /**
3374
+ * The stage's own sign-in, for a workspace of an entry with `uses.auth`: the recorded organisation, client and
3375
+ * issuer, its own workspaces key (made and held first, once) and what it must never hold, which is what the
3376
+ * live app holds. A key is registered for redaction before it goes anywhere. Errors name the entry and the ref,
3377
+ * never a value; every held-key read has a timeout.
3378
+ */
3379
+ async workspaceStageSignIn(org, entry, authIssuer, context) {
3380
+ const auth = entry.uses?.auth;
3381
+ if (!auth)
3382
+ return undefined;
3383
+ const at = `coolify:workspace for ${org.slug}`;
3384
+ const recorded = context.recordedIds();
3385
+ const authEnv = workspaceAuthEnv(org, entry, recorded, authIssuer);
3386
+ if (!authEnv) {
3387
+ throw new Error(`${at}: the sign-in organisation, client or issuer of entry '${entry.key}' is not recorded yet`);
3388
+ }
3389
+ const env = Object.entries(authEnv).map(([key, value]) => ({ key, value }));
3390
+ const held = this.ports.heldKeys;
3391
+ const heldKeys = held && {
3392
+ resolve: (ref) => withLabelledTimeout('held key read', HELD_KEY_READ_TIMEOUT_MS, () => held.resolve(ref)),
3393
+ ...(typeof held.put === 'function'
3394
+ ? {
3395
+ put: (ref, value) => withLabelledTimeout('held key write', HELD_KEY_READ_TIMEOUT_MS, () => held.put(ref, value)),
3396
+ }
3397
+ : {}),
3398
+ };
3399
+ if (auth.workspaces) {
3400
+ const workspacesKey = await ensureWorkspacesKey({
3401
+ orgId: org.id,
3402
+ slug: org.slug,
3403
+ entryKey: workspaceClientKey(entry.key),
3404
+ heldKeys,
3405
+ recorded: () => context.recordedIds(),
3406
+ record: (key, value) => context.recordScopeId(key, value),
3407
+ persist: () => context.persistScopeIds(),
3408
+ secret: (value) => void this.handledSecrets.add(value),
3409
+ });
3410
+ env.push({ key: 'WORKSPACES_API_KEY', value: workspacesKey });
3411
+ }
3412
+ const workspacesKeys = [];
3413
+ for (const live of org.hosting.entries.filter((e) => e.uses?.auth?.workspaces)) {
3414
+ const value = await heldKeys?.resolve(workspacesKeyRef(org.id, live.key));
3415
+ if (value) {
3416
+ this.handledSecrets.add(value);
3417
+ workspacesKeys.push(value);
3418
+ }
3419
+ }
3420
+ const liveMcp = org.hosting.entries.filter((live) => live.uses?.auth?.mcp);
3421
+ return {
3422
+ env,
3423
+ customers: auth.users === 'customers',
3424
+ redirectUri: `https://${workspaceClientHosts(org, entry)[0]}/auth/callback`,
3425
+ live: {
3426
+ ...(recorded[`zitadel:customers:${org.id}`]
3427
+ ? { customersOrgId: recorded[`zitadel:customers:${org.id}`] }
3428
+ : {}),
3429
+ clientIds: liveClientIds(org, recorded),
3430
+ workspacesKeys,
3431
+ mcpAudiences: liveMcp.flatMap((m) => recorded[entryMcpProjectScopeKey(m.key)] ?? []),
3432
+ mcpResources: liveMcp.flatMap((m) => entryMcp(org, m, recorded)?.resource ?? []),
3433
+ },
3434
+ };
3435
+ }
3436
+ /**
3437
+ * `coolify:workspace-broker-entry` (`plan/workspace-sign-in.ts`): the broker entry `<slug>-<key>-work` of a
3438
+ * workspace with sign-in, written with the stage app's own `AUTH_APP_KEY` and the hash of its own workspaces
3439
+ * key. It never reads or writes the live entry. Records what it wrote, never a key.
3440
+ */
3441
+ async coolifyWorkspaceBrokerEntry(action, org, context) {
3442
+ const at = `coolify:workspace-broker-entry for ${org.slug}`;
3443
+ const broker = this.ports.broker;
3444
+ if (!broker)
3445
+ throw new Error(`${at}: the broker port is not wired`);
3446
+ const recorded = context.recordedIds();
3447
+ const entry = org.hosting.entries.find((e) => appStepKey(org, e.key) === action.key && isServerWorkspace(e) && e.uses?.auth);
3448
+ if (!entry?.uses?.auth)
3449
+ throw new Error(`${at}: no workspace with sign-in for ${action.key}`);
3450
+ const wanted = wantedWorkspaceBrokerEntry(org, entry, recorded);
3451
+ if ('missing' in wanted) {
3452
+ throw new Error(`${at}: ${wanted.missing.map((m) => m.what).join(', ')} not recorded yet`);
3453
+ }
3454
+ const stagePrefix = `bg.${entry.key}.app.`;
3455
+ const stageId = Object.entries(recorded).find(([key, id]) => id && key.startsWith(stagePrefix) && key.endsWith('.stage'))?.[1];
3456
+ if (!stageId)
3457
+ throw new Error(`${at}: the workspace's app is not recorded yet`);
3458
+ const appKey = (await this.ports.coolify.listEnvs(stageId)).find((e) => e.key === 'AUTH_APP_KEY' && !e.is_preview)?.value;
3459
+ if (!appKey || appKey.length < 32) {
3460
+ throw new Error(`${at}: the workspace's app ${stageId} has no AUTH_APP_KEY yet`);
3461
+ }
3462
+ this.handledSecrets.add(appKey);
3463
+ const clientKey = workspaceClientKey(entry.key);
3464
+ await this.writeAppBrokerEntry(org, broker, clientKey, wanted.want, appKey, context, wanted.want.workspaces ? await this.workspacesInputs(org, clientKey) : {});
3465
+ return {};
3466
+ }
3467
+ /**
3468
+ * `coolify:workspaces-database` (`plan/broker-workspaces-database.ts`): the broker's workspaces database, made
3469
+ * beside the broker (the platform's project and network, or the company's own for an org that owns its services)
3470
+ * and held at `workspacesDatabaseRef(org)`. Reads the broker and the held keys again first and makes nothing when
3471
+ * either has a URL, so a database is never made over one. Records nothing: the held URL is the record, and a
3472
+ * database made but not held is found by name on the next apply. The database stays when the org is removed, as
3473
+ * the held keys do.
3474
+ */
3475
+ async coolifyWorkspacesDatabase(org, context) {
3476
+ const at = `coolify:workspaces-database for ${org.slug}`;
3477
+ const sources = await this.workspacesDatabaseSources(org, context.recordedIds()).catch(() => {
3478
+ throw new Error(`${at}: the broker's env or the held URL could not be read; nothing was made`);
3479
+ });
3480
+ if (sources.inEnv || sources.held)
3481
+ return undefined;
3482
+ const own = ownsServices(org);
3483
+ const target = own ? orgTarget(org) : platformTarget();
3484
+ let coolify;
3485
+ let projectUuid;
3486
+ if (own) {
3487
+ const ownPorts = this.ports.ownServices;
3488
+ if (!ownPorts)
3489
+ throw new Error(OWN_SERVICES_PORT_ABSENT);
3490
+ coolify = limited(this.placedForCompany(ownPorts.provision.auth.coolify, org, context), LIMITS.ownAuth);
3491
+ projectUuid = () => this.ensureCoolifyProject(org, context);
3492
+ }
3493
+ else {
3494
+ coolify = placedOnPlatform(this.ports.coolify);
3495
+ projectUuid = async () => (await this.ports.coolify.findProjectByName(PLATFORM_AUTH_PROJECT))?.uuid ?? null;
3496
+ }
3497
+ const known = context.recordedIds()[BROKER_WORKSPACES_DATABASE_SCOPE_KEY];
3498
+ await ensureBrokerWorkspacesDatabase({
3499
+ target,
3500
+ coolify,
3501
+ projectUuid,
3502
+ heldKeys: this.ports.heldKeys,
3503
+ ...(known ? { knownUuid: known } : {}),
3504
+ recordUuid: async (uuid) => {
3505
+ context.recordScopeId(BROKER_WORKSPACES_DATABASE_SCOPE_KEY, uuid);
3506
+ await context.persistScopeIds();
3507
+ },
3508
+ secret: (value) => void this.handledSecrets.add(value),
3509
+ });
3510
+ return undefined;
3511
+ }
3512
+ /**
3513
+ * `coolify:promote`: moves a live workspace entry's app to `HostingEntry.liveCommit`. Patches `git_commit_sha`,
3514
+ * deploys and waits for the deployment, then records `ws.<key>.livecommit`. A failure after the patch leaves the
3515
+ * app configured for the new commit with the old container still running and no marker, so the next apply
3516
+ * patches and deploys again.
3517
+ */
3518
+ async coolifyPromote(action, org, context) {
3519
+ const key = String(action.desired.entry ?? '');
3520
+ const entry = org.hosting.entries.find((e) => e.key === key && isServerWorkspace(e));
3521
+ const commit = entry?.liveCommit;
3522
+ if (!entry || commit === undefined || !FULL_COMMIT_SHA.test(commit)) {
3523
+ throw new Error(`coolify:promote for ${org.slug}: entry '${key}' has no valid liveCommit`);
3524
+ }
3525
+ const appId = context.recordedIds()[`coolify:app:${appStepKey(org, key)}`];
3526
+ if (!appId) {
3527
+ throw new Error(`coolify:promote for ${org.slug}: the live app for '${key}' is not recorded`);
3528
+ }
3529
+ await this.ports.coolify.updateApplication(appId, { git_commit_sha: commit });
3530
+ const runtime = this.ports.runtime ?? {};
3531
+ const built = await waitForNewDeployment(this.ports.coolify, appId, { kind: 'deployment-only' }, runtime, runtime.sleep ?? defaultSleep, `coolify:promote:${key}`, `${org.slug}'s ${key} app stays at its previous deploy`);
3532
+ await recordBuiltCommit(this.ports.coolify, { appId, deploymentUuid: built.deploymentUuid, name: key }, (k, v) => context.recordScopeId(k, v));
3533
+ context.recordScopeId(wsKey.liveCommit(key), commit);
3534
+ await context.persistScopeIds();
3535
+ return undefined;
3536
+ }
3537
+ /**
3538
+ * The commit a workspace entry's live app is made from: the newest finished deployment of its workspace's stage
3539
+ * app, else the commit recorded when the workspace was last applied. Recorded as `ws.<key>.commit`. Throws,
3540
+ * before any app is made, when there is none.
3541
+ */
3542
+ async workspaceCommit(org, entry, serverUuid, context) {
3543
+ const stageId = context.recordedIds()[bgKey.app(entry.key, serverUuid, 'stage')];
3544
+ const commit = (stageId ? await readWorkspaceCommit(this.ports.coolify, stageId) : null) ??
3545
+ context.recordedIds()[wsKey.commit(entry.key)] ??
3546
+ null;
3547
+ if (!commit) {
3548
+ throw new Error(`coolify:app for ${org.slug}: workspace '${entry.key}' has not deployed a commit, so there is nothing to put live`);
3549
+ }
3550
+ context.recordScopeId(wsKey.commit(entry.key), commit);
3551
+ await context.persistScopeIds();
3552
+ return commit;
3553
+ }
3554
+ /**
3555
+ * The Coolify GitHub source an app of `repo` is created through: the company's own (`coolify:github-source`,
3556
+ * recorded by that step) when `repo` is under the company's GitHub account, else the platform's.
3557
+ */
3558
+ async githubAppUuidFor(org, repo, recorded) {
3559
+ if (!companyGithubFor(org.hosting, repo)) {
3560
+ return (await this.ports.coolify.requireGithubApp()).uuid;
3561
+ }
3562
+ const uuid = recorded[githubSourceResourceKey(org.id)];
3563
+ if (!uuid) {
3564
+ throw new Error(`coolify:app for ${org.slug}: repo ${repo} is under the company's GitHub account but its GitHub source is not recorded yet`);
3565
+ }
3566
+ return uuid;
3567
+ }
3568
+ /** The company's Coolify GitHub source (`plan/github-source.ts`): made from the platform GitHub App's held keys and the company's installation id. */
3569
+ async coolifyGithubSource(org, context) {
3570
+ const github = org.hosting.github;
3571
+ if (!github)
3572
+ throw new Error(`coolify:github-source for ${org.slug}: no hosting.github declared`);
3573
+ const heldKeys = this.ports.heldKeys;
3574
+ if (!heldKeys) {
3575
+ throw new Error(`coolify:github-source for ${org.slug}: the executor was built without heldKeys`);
3576
+ }
3577
+ const { uuid } = await ensureGithubSource({
3578
+ coolify: this.ports.coolify,
3579
+ heldKeys,
3580
+ secret: (value) => void this.handledSecrets.add(value),
3581
+ label: `coolify:github-source for ${org.slug}`,
3582
+ slug: org.slug,
3583
+ github,
3584
+ });
3585
+ context.recordScopeId(GITHUB_SOURCE_INSTALLATION_SCOPE_KEY, String(github.installationId));
3586
+ await context.persistScopeIds();
3587
+ return { resourceId: uuid };
3588
+ }
3589
+ /** True when no Coolify GitHub source has this uuid. A failed read counts as not gone. */
3590
+ async githubSourceGone(uuid) {
3591
+ try {
3592
+ const sources = await withLabelledTimeout('coolify:github-source: listing sources', GITHUB_SOURCE_TIMEOUT_MS, () => this.ports.coolify.raw('GET', '/github-apps'));
3593
+ return (Array.isArray(sources) && !sources.some((s) => s?.uuid === uuid));
3594
+ }
3595
+ catch (error) {
3596
+ if (error instanceof CallError)
3597
+ return false;
3598
+ throw error;
3599
+ }
3600
+ }
3077
3601
  /**
3078
3602
  * Stage's guards (`plan/stage-guards.ts`): the probe port the host supplied, and the Access gate over the stage
3079
3603
  * host. The gate is offered only when everything it needs is in hand (a domain, the platform's Access provider,
@@ -3201,6 +3725,8 @@ export class FoundryStepExecutor {
3201
3725
  // (the marker is recorded only after the redeploy), e.g. a first deploy
3202
3726
  // that failed: starting it builds nothing. A parked app has its marker.
3203
3727
  let neverDeployed = false;
3728
+ // A workspace entry's live app is made from the commit its workspace runs.
3729
+ let pinnedCommit = null;
3204
3730
  if (existing) {
3205
3731
  appId = existing.uuid;
3206
3732
  // Found by name (rather than just created) is started back up if not
@@ -3219,14 +3745,18 @@ export class FoundryStepExecutor {
3219
3745
  }
3220
3746
  else {
3221
3747
  const server = await this.resolveServer(org, context);
3222
- const githubApp = await this.ports.coolify.requireGithubApp();
3748
+ const githubAppUuid = await this.githubAppUuidFor(org, appRepo, context.recordedIds());
3749
+ if (isServerWorkspace(entry)) {
3750
+ pinnedCommit =
3751
+ entry.liveCommit ?? (await this.workspaceCommit(org, entry, server.uuid, context));
3752
+ }
3223
3753
  const created = await this.ports.coolify.createGithubAppApplication({
3224
3754
  is_preview_deployments_enabled: false,
3225
3755
  project_uuid: projectUuid,
3226
3756
  server_uuid: server.uuid,
3227
3757
  environment_name: environment.name,
3228
3758
  environment_uuid: environment.uuid,
3229
- github_app_uuid: githubApp.uuid,
3759
+ github_app_uuid: githubAppUuid,
3230
3760
  git_repository: appRepo,
3231
3761
  git_branch: entry.branch,
3232
3762
  build_pack: entry.buildPack,
@@ -3261,6 +3791,8 @@ export class FoundryStepExecutor {
3261
3791
  // step for it). A created app has them off from the create.
3262
3792
  if (previewsOn(live))
3263
3793
  patch.is_preview_deployments_enabled = false;
3794
+ if (pinnedCommit)
3795
+ patch.git_commit_sha = pinnedCommit;
3264
3796
  // #543: the health-check quartet too, every run -- the same reason
3265
3797
  // Archon's own doc comment gives (without one, Coolify's rolling update
3266
3798
  // has nothing to wait on and stops the old container immediately). The
@@ -3391,6 +3923,9 @@ export class FoundryStepExecutor {
3391
3923
  context.recordScopeId(settingsKey, JSON.stringify({ ...want, port: want.port ?? have.port }));
3392
3924
  context.recordScopeId(domainsKey, JSON.stringify(wantDomains));
3393
3925
  context.recordScopeId(labelsKey, wantLabelsHash);
3926
+ if (pinnedCommit && pinnedCommit === entry.liveCommit) {
3927
+ context.recordScopeId(wsKey.liveCommit(entry.key), pinnedCommit);
3928
+ }
3394
3929
  context.recordScopeId(appEnvAppliedScopeKey(entry.key), appEnv.applied);
3395
3930
  if (entry.uses?.auth) {
3396
3931
  context.recordScopeId(entryAuthEnvAppliedScopeKey(entry.key), appEnv.authApplied);
@@ -3404,7 +3939,8 @@ export class FoundryStepExecutor {
3404
3939
  // actually resolved a mail domain for this org.
3405
3940
  // One Lettermint project, `<slug>-app`, belongs to the `app` entry; a
3406
3941
  // second entry sharing the name would delete the first's live token.
3407
- if (isMainEntry(entry)) {
3942
+ // An entry that declares `uses.mail` shares that project's token and sender address.
3943
+ if (isMainEntry(entry) || entry.uses?.mail) {
3408
3944
  await this.ensureAppLettermintMail(appId, org, context,
3409
3945
  // Deployed or started above on every path, a created app included
3410
3946
  // (#566), so it has read its env before the mail env is written.
@@ -3416,7 +3952,7 @@ export class FoundryStepExecutor {
3416
3952
  // step planned before this field existed; `ensureAppLettermintMail`
3417
3953
  // refuses rather than silently falling back once it knows there is
3418
3954
  // a recorded mail domain to reason about.
3419
- 'platformApex' in action.desired, action.desired.platformApex ?? null, action.desired.platformLettermintProjectId ?? null);
3955
+ 'platformApex' in action.desired, action.desired.platformApex ?? null, action.desired.platformLettermintProjectId ?? null, isMainEntry(entry) ? 'app' : 'entry');
3420
3956
  }
3421
3957
  return { resourceId: appId };
3422
3958
  }
@@ -3537,6 +4073,150 @@ export class FoundryStepExecutor {
3537
4073
  await provisionOrgPostgresDatabases(scopeFromOrg(context, {}), this.orgPostgresDeps(org, context), org, homes);
3538
4074
  return {};
3539
4075
  }
4076
+ /** F4 (#925): Boule's app and the calls on its env that the apps database steps make. */
4077
+ appsBoule(org, context) {
4078
+ const appId = context.recordedIds()[`coolify:boule:${org.id}`];
4079
+ if (!appId)
4080
+ return null;
4081
+ const coolify = this.ports.coolify;
4082
+ return {
4083
+ upsertEnvs: async (rows) => void (await coolify.bulkUpsertEnvs(appId, rows)),
4084
+ deleteEnvs: async (names) => {
4085
+ let rows;
4086
+ try {
4087
+ rows = await coolify.listEnvs(appId);
4088
+ }
4089
+ catch (error) {
4090
+ if (isNotFoundError(error))
4091
+ return;
4092
+ throw error;
4093
+ }
4094
+ for (const name of names) {
4095
+ const row = rows.find((e) => e.key === name && !e.is_preview);
4096
+ if (!row?.uuid)
4097
+ continue;
4098
+ try {
4099
+ await coolify.deleteEnv(appId, row.uuid);
4100
+ }
4101
+ catch (error) {
4102
+ if (!isNotFoundError(error))
4103
+ throw error;
4104
+ }
4105
+ }
4106
+ },
4107
+ restart: async () => void (await coolify.restart(appId)),
4108
+ };
4109
+ }
4110
+ /**
4111
+ * F4 (#925) `coolify:org-apps-admin`: with `org.boule.apps` on, the `<slug>_apps_admin` role on the
4112
+ * company's Postgres server (`plan/org-apps-admin.ts`); its URL is held and written to Boule's env as
4113
+ * `APPS_DATABASE_ADMIN_URL`, then Boule restarts. Apps off: the retire form (no login, env row gone,
4114
+ * nothing dropped).
4115
+ */
4116
+ async orgAppsAdmin(action, org, context) {
4117
+ const at = `coolify:org-apps-admin for ${org.slug}`;
4118
+ const base = this.orgPostgresDeps(org, context);
4119
+ const boule = this.appsBoule(org, context);
4120
+ const { deliverSecret: _held, ...shared } = base;
4121
+ if (action.desired.mode === 'retire') {
4122
+ await retireOrgAppsAdmin(scopeFromOrg(context, {}), {
4123
+ ...shared,
4124
+ clearHeldUrl: async (ref) => {
4125
+ // The deliver sink has no delete; the held-key store blanks a value (an empty one reads as not held).
4126
+ if (typeof this.ports.heldKeys?.put !== 'function') {
4127
+ console.info(`${at}: the held URL ${ref} was not blanked, the executor's held keys cannot put; blank it by hand`);
4128
+ return;
4129
+ }
4130
+ await this.ports.heldKeys.put(ref, '');
4131
+ },
4132
+ removeBouleEnv: async () => {
4133
+ await boule?.deleteEnvs([APPS_DATABASE_ADMIN_URL_ENV]);
4134
+ if (boule)
4135
+ await boule.restart();
4136
+ },
4137
+ }, org);
4138
+ return {};
4139
+ }
4140
+ if (!appsOn(org))
4141
+ throw new Error(`${at}: the company's Boule runs no apps (org.boule.apps)`);
4142
+ if (!boule)
4143
+ throw new Error(`${at}: Boule is not recorded yet`);
4144
+ const deps = {
4145
+ ...shared,
4146
+ deliverSecret: async (ref, url) => {
4147
+ await this.ports.deliverSecret(ref, url);
4148
+ await boule.upsertEnvs([{ key: APPS_DATABASE_ADMIN_URL_ENV, value: url, literal: true }]);
4149
+ await boule.restart();
4150
+ },
4151
+ };
4152
+ await provisionOrgAppsAdmin(scopeFromOrg(context, {}), deps, org);
4153
+ return {};
4154
+ }
4155
+ /**
4156
+ * F4 (#925) `cloudflare:apps-db-route`: the tunnel route Hyperdrive reaches the company's Postgres server
4157
+ * by, and the `APPS_DATABASE_*` env on Boule (`plan/apps-db-route.ts`). Retire takes both away; the
4158
+ * databases stay.
4159
+ */
4160
+ async appsDbRoute(action, org, context) {
4161
+ const at = `cloudflare:apps-db-route for ${org.slug}`;
4162
+ const heldKeys = this.ports.heldKeys;
4163
+ if (!heldKeys)
4164
+ throw new Error(`${at}: this executor was built without held keys`);
4165
+ const retire = action.desired.mode === 'retire';
4166
+ const boule = this.appsBoule(org, context);
4167
+ if (!boule && !retire)
4168
+ throw new Error(`${at}: Boule is not recorded yet`);
4169
+ const deps = {
4170
+ database: {
4171
+ tunnels: this.tunnelPort(),
4172
+ access: this.accessPort(),
4173
+ dns: this.dnsPort(),
4174
+ zones: this.zonePort(),
4175
+ },
4176
+ heldKeys,
4177
+ secret: (value) => void this.handledSecrets.add(value),
4178
+ upstreamHost: async (serverId) => {
4179
+ const server = await getDatabaseStarted(this.ports.coolify, serverId);
4180
+ if (!server.internal_db_url || !URL.canParse(server.internal_db_url))
4181
+ return '';
4182
+ this.handledSecrets.add(server.internal_db_url);
4183
+ return new URL(server.internal_db_url).hostname;
4184
+ },
4185
+ boule: boule ?? {
4186
+ upsertEnvs: async () => undefined,
4187
+ deleteEnvs: async () => undefined,
4188
+ restart: async () => undefined,
4189
+ },
4190
+ };
4191
+ if (retire)
4192
+ await retireAppsDbRoute(deps, org, context);
4193
+ else
4194
+ await applyAppsDbRoute(deps, org, context);
4195
+ return {};
4196
+ }
4197
+ /**
4198
+ * FZ `cloudflare:apps-zones`: the company slug and the zone ids Boule's managed backend names edge routes
4199
+ * by (`plan/apps-zones.ts`). Retire takes the rows away again.
4200
+ */
4201
+ async appsZones(action, org, context) {
4202
+ const at = `cloudflare:apps-zones for ${org.slug}`;
4203
+ const retire = action.desired.mode === 'retire';
4204
+ const boule = this.appsBoule(org, context);
4205
+ if (retire) {
4206
+ if (boule)
4207
+ await retireAppsZones({ boule }, org, context);
4208
+ else {
4209
+ console.info(`${at}: Boule is not recorded, so no env rows were removed; marker cleared`);
4210
+ context.recordScopeId(APPS_ZONES_APPLIED_SCOPE_KEY, '');
4211
+ await context.persistScopeIds();
4212
+ }
4213
+ return {};
4214
+ }
4215
+ if (!boule)
4216
+ throw new Error(`${at}: Boule is not recorded yet`);
4217
+ await applyAppsZones({ zones: this.zonePort(), boule }, org, context);
4218
+ return {};
4219
+ }
3540
4220
  /**
3541
4221
  * `coolify:org-postgres-migrate` (#444): one home's data from its old Coolify database to the
3542
4222
  * company's server. Refuses before any port call unless the staging confirmation is recorded.
@@ -3592,6 +4272,8 @@ export class FoundryStepExecutor {
3592
4272
  }
3593
4273
  case 'boule':
3594
4274
  return { appIds: await byName(`${org.slug}-boule`), template: true };
4275
+ case 'kosmos':
4276
+ return { appIds: await byName(`${org.slug}-kosmos`), template: true };
3595
4277
  case 'chat_service':
3596
4278
  return { appIds: await byName(chat.service), template: true };
3597
4279
  case 'calls_service':
@@ -3845,6 +4527,19 @@ export class FoundryStepExecutor {
3845
4527
  const appKey = await this.ensureAuthAppKey(appId);
3846
4528
  if (appKey)
3847
4529
  envs.push({ key: 'AUTH_APP_KEY', value: appKey });
4530
+ if (entry.uses.auth.workspaces) {
4531
+ const workspacesKey = await ensureWorkspacesKey({
4532
+ orgId: org.id,
4533
+ slug: org.slug,
4534
+ entryKey: entry.key,
4535
+ heldKeys: this.ports.heldKeys,
4536
+ recorded: () => context.recordedIds(),
4537
+ record: (key, value) => context.recordScopeId(key, value),
4538
+ persist: () => context.persistScopeIds(),
4539
+ secret: (value) => void this.handledSecrets.add(value),
4540
+ });
4541
+ envs.push({ key: 'WORKSPACES_API_KEY', value: workspacesKey });
4542
+ }
3848
4543
  }
3849
4544
  else {
3850
4545
  const zitadelOrgId = recorded.zitadel_org;
@@ -3901,6 +4596,8 @@ export class FoundryStepExecutor {
3901
4596
  heldEnvs.push({ key: name, value: await this.readHeldEnv(org, entry.key, name) });
3902
4597
  }
3903
4598
  envs.push(...heldEnvs);
4599
+ // F13: the entry's customer-grants credential, or its removal when the flag went.
4600
+ await this.customerGrantsAppEnv(appId, org, entry, context, envs);
3904
4601
  // A held value has no version the plan can read, so the live env decides whether it changed.
3905
4602
  let heldChanged = false;
3906
4603
  if (heldEnvs.length > 0) {
@@ -4039,9 +4736,24 @@ export class FoundryStepExecutor {
4039
4736
  if (!appKey || appKey.length < 32) {
4040
4737
  throw new Error(`coolify:broker-entry for ${org.slug}: app ${appId} has no AUTH_APP_KEY yet`);
4041
4738
  }
4042
- await this.writeAppBrokerEntry(org, broker, entry.key, wanted.want, appKey, context);
4739
+ await this.writeAppBrokerEntry(org, broker, entry.key, wanted.want, appKey, context, wanted.want.workspaces ? await this.workspacesInputs(org, entry.key) : {});
4043
4740
  return {};
4044
4741
  }
4742
+ /**
4743
+ * What the broker entry needs beyond its want when it declares workspaces: the hash of the held key (the key
4744
+ * itself is registered for redaction and goes no further) and the operator's held database connection string.
4745
+ */
4746
+ async workspacesInputs(org, entryKey) {
4747
+ const key = await heldWorkspacesKey(this.ports.heldKeys, org, entryKey);
4748
+ this.handledSecrets.add(key);
4749
+ const database = await heldWorkspacesDatabase(this.ports.heldKeys, workspacesDatabaseRef(org));
4750
+ if (database)
4751
+ this.handledSecrets.add(database);
4752
+ return {
4753
+ workspacesKeyHash: workspacesKeyHash(key),
4754
+ ...(database ? { workspacesDatabaseUrl: database } : {}),
4755
+ };
4756
+ }
4045
4757
  /**
4046
4758
  * #161: the org's Boule broker entry, `<slug>-boule`, written the way a
4047
4759
  * hosting entry's is: with the `AUTH_APP_KEY` `coolifyBoule` put in the
@@ -4114,12 +4826,13 @@ export class FoundryStepExecutor {
4114
4826
  return {};
4115
4827
  }
4116
4828
  /** The shared tail of both broker-entry steps: read the app's key, write the entry, record what was written. */
4117
- async writeAppBrokerEntry(_org, broker, entryKey, want, appKey, context) {
4829
+ async writeAppBrokerEntry(_org, broker, entryKey, want, appKey, context, workspaces = {}) {
4118
4830
  const previousRaw = context.recordedIds()[brokerEntryAppliedScopeKey(entryKey)];
4119
4831
  const result = await writeBrokerEntry(broker, {
4120
4832
  want,
4121
4833
  appKey,
4122
4834
  previous: previousRaw ? JSON.parse(previousRaw) : null,
4835
+ ...workspaces,
4123
4836
  });
4124
4837
  context.recordScopeId(brokerEntryWarningsScopeKey(entryKey), JSON.stringify(result.invalidForeign));
4125
4838
  // Last: a run that failed above plans the step again.
@@ -4220,7 +4933,7 @@ export class FoundryStepExecutor {
4220
4933
  * mail domain was never even recorded for this org, the same as before
4221
4934
  * this field existed (review round 2, finding 3).
4222
4935
  */
4223
- async ensureAppLettermintMail(appId, org, context, running, hasPlatformApexField, platformApex, platformLettermintProjectId) {
4936
+ async ensureAppLettermintMail(appId, org, context, running, hasPlatformApexField, platformApex, platformLettermintProjectId, role = 'app') {
4224
4937
  const mailDomain = context.recordedIds()[MAIL_DOMAIN_SCOPE_KEY];
4225
4938
  if (!mailDomain)
4226
4939
  return;
@@ -4273,7 +4986,7 @@ export class FoundryStepExecutor {
4273
4986
  org,
4274
4987
  context,
4275
4988
  mailDomain,
4276
- self: { role: 'app', id: appId, restart: running },
4989
+ self: { role, id: appId, restart: running },
4277
4990
  mailFrom: `${org.mail.noReplyLocalPart ?? 'no-reply'}@${mailDomain}`,
4278
4991
  platformApex,
4279
4992
  platformLettermintProjectId,
@@ -4307,12 +5020,26 @@ export class FoundryStepExecutor {
4307
5020
  const { org, context, mailDomain, self, mailFrom } = args;
4308
5021
  const appName = `${org.slug}-app`;
4309
5022
  const recorded = context.recordedIds();
4310
- const appId = self.role === 'app'
5023
+ const mainId = self.role === 'app'
4311
5024
  ? self.id
4312
5025
  : recorded[`coolify:app:${appStepKey(org, mainEntry(org.hosting)?.key)}`];
4313
5026
  const bouleId = self.role === 'boule' ? self.id : recorded[`coolify:boule:${org.id}`];
4314
- const peerListEnvs = (id) => !id || id === self.id
4315
- ? Promise.resolve(null)
5027
+ // The hosted app first, Boule second, then the other entries that send mail (`uses.mail`): an earlier token wins a disagreement.
5028
+ const ids = [];
5029
+ const add = (id) => {
5030
+ if (id && !ids.includes(id))
5031
+ ids.push(id);
5032
+ };
5033
+ add(mainId);
5034
+ add(bouleId);
5035
+ for (const entry of org.hosting.entries) {
5036
+ if (entry.uses?.mail && !isMainEntry(entry)) {
5037
+ add(recorded[`coolify:app:${appStepKey(org, entry.key)}`]);
5038
+ }
5039
+ }
5040
+ add(self.id);
5041
+ const envsOf = (id) => id === self.id
5042
+ ? this.ports.coolify.listEnvs(id)
4316
5043
  : // A recorded peer that Coolify no longer has is not a consumer: not
4317
5044
  // read, written or restarted (same 404 rule as the retire step).
4318
5045
  this.ports.coolify
@@ -4322,21 +5049,17 @@ export class FoundryStepExecutor {
4322
5049
  throw error;
4323
5050
  return null;
4324
5051
  });
4325
- const [projects, selfEnvs, appEnvs, bouleEnvs] = await Promise.all([
5052
+ const [projects, envLists] = await Promise.all([
4326
5053
  this.ports.lettermint.listProjects(),
4327
- this.ports.coolify.listEnvs(self.id),
4328
- self.role === 'app' ? Promise.resolve(null) : peerListEnvs(appId),
4329
- self.role === 'boule' ? Promise.resolve(null) : peerListEnvs(bouleId),
5054
+ Promise.all(ids.map(envsOf)),
4330
5055
  ]);
4331
5056
  const tokenOf = (envs) => envs.find((e) => e.key === 'LETTERMINT_API_TOKEN' && !e.is_preview)?.value || undefined;
4332
- // The hosted app first, Boule second: the app's token wins a disagreement.
4333
5057
  const consumers = [];
4334
- const appSlot = self.role === 'app' ? selfEnvs : appEnvs;
4335
- const bouleSlot = self.role === 'boule' ? selfEnvs : bouleEnvs;
4336
- if (appSlot && appId)
4337
- consumers.push({ id: appId, token: tokenOf(appSlot) });
4338
- if (bouleSlot && bouleId)
4339
- consumers.push({ id: bouleId, token: tokenOf(bouleSlot) });
5058
+ ids.forEach((id, at) => {
5059
+ const envs = envLists[at];
5060
+ if (envs)
5061
+ consumers.push({ id, token: tokenOf(envs) });
5062
+ });
4340
5063
  const existingToken = consumers.find((c) => c.token)?.token;
4341
5064
  const ours = projects.filter((p) => p.name === appName);
4342
5065
  const held = ours.length === 1 ? ours[0] : undefined;
@@ -5834,16 +6557,18 @@ export class FoundryStepExecutor {
5834
6557
  // ---- calls service (#145, part 2 of #128) -------------------------------
5835
6558
  /**
5836
6559
  * `plan/diff.ts`'s `planCallsService`: the org's own calls service --
5837
- * `wtfalch/calls`'s `packages/service`, wired to the calls Matrix stack
5838
- * {@link coolifyCalls} provisions and the shared LiveKit SFU (#122/#127).
5839
- * Static config checks run first, before any Coolify call (#128's own
5840
- * carried-over PR 344 review fix, restated in #145's own issue body): the
5841
- * calls Matrix stack must already be recorded, its MAS app must already
5842
- * carry its client id/secret, and the shared SFU's LiveKit credentials must
5843
- * already be resolvable -- none of the three is minted or guessed here.
5844
- * Park stops the app only, the same park-not-delete rule
5845
- * {@link coolifyCalls}/{@link coolifyApp} already follow (#7 corrections
5846
- * 1); its own database is never touched from that branch.
6560
+ * `wtfalch/calls`'s `packages/service`, wired to the shared LiveKit SFU
6561
+ * (#122/#127). #430: calls run transit-only (calls#93's
6562
+ * `CALLS_ENCRYPTION=transit`), so this no longer waits on or wires a
6563
+ * per-org Matrix stack at all -- an org whose stack is already live from
6564
+ * before this keeps running it, unused, until a human removes it
6565
+ * separately. Static config checks run first, before any Coolify call
6566
+ * (#128's own carried-over PR 344 review fix, restated in #145's own
6567
+ * issue body): the shared SFU's LiveKit credentials must already be
6568
+ * resolvable -- not minted or guessed here. Park stops the app only, the
6569
+ * same park-not-delete rule {@link coolifyCalls}/{@link coolifyApp}
6570
+ * already follow (#7 corrections 1); its own database is never touched
6571
+ * from that branch.
5847
6572
  */
5848
6573
  async coolifyCallsService(action, org, context) {
5849
6574
  if (action.desired.enabled === false) {
@@ -5854,18 +6579,8 @@ export class FoundryStepExecutor {
5854
6579
  await this.ports.coolify.raw('POST', `/applications/${appId}/stop`);
5855
6580
  return { resourceId: appId };
5856
6581
  }
5857
- // Refused before anything is created, like the missing-stack refusal below.
6582
+ // Refused before anything is created, like the missing-SFU refusal below.
5858
6583
  const sfuUrls = resolveCallsSfuUrls(this.ports.callsSfuUrls);
5859
- const recorded = context.recordedIds();
5860
- const synapseId = recorded[CALLS_SYNAPSE_SCOPE_KEY];
5861
- const masId = recorded[CALLS_MAS_SCOPE_KEY];
5862
- if (!synapseId || !masId) {
5863
- throw new Error(`coolify:calls-service for ${org.slug}: the calls Matrix stack has not been provisioned yet -- coolify:calls must run first`);
5864
- }
5865
- const masCredentials = await this.readCallsMasClientCredentials(masId);
5866
- if (!masCredentials) {
5867
- throw new Error(`coolify:calls-service for ${org.slug}: the calls Matrix stack's MAS client credentials are not on the MAS app yet -- retry this apply once coolify:calls has minted them`);
5868
- }
5869
6584
  const sfu = await this.ports.callsSfu.resolveCredentials(org.slug);
5870
6585
  if (!sfu) {
5871
6586
  throw new Error(`coolify:calls-service for ${org.slug}: the shared calls SFU has no LiveKit credentials for this org yet -- see https://github.com/wtfalch/foundry/issues/152`);
@@ -5873,8 +6588,6 @@ export class FoundryStepExecutor {
5873
6588
  const projectUuid = await this.ensureCoolifyProject(org, context);
5874
6589
  const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
5875
6590
  const name = callsServiceInternalName(org);
5876
- const synapseHost = `${org.slug}-calls-synapse`;
5877
- const masHost = `${org.slug}-calls-mas`;
5878
6591
  const dbName = `${name}-db`;
5879
6592
  const serviceHome = await this.ensureHomeDatabase(org, context, 'calls_service', CALLS_SERVICE_DB_SCOPE_KEY, () => this.ensureCallsDatabase(org, context, projectUuid, environment, dbName, dbName.replace(/-/g, '_'), CALLS_SERVICE_DB_SCOPE_KEY));
5880
6593
  if (!(await this.homeOwnerUrl(serviceHome))) {
@@ -5916,17 +6629,25 @@ export class FoundryStepExecutor {
5916
6629
  });
5917
6630
  // Every app foundry deploys keeps its own logs (#75), reused as-is here.
5918
6631
  await this.ensureLogVolume(app.appId, org, app.existed);
6632
+ // #430: an org whose Matrix stack is still live from before this keeps
6633
+ // the Synapse/MAS apps running, unused, until a human removes them
6634
+ // separately -- but its calls service's own env drops their vars on its
6635
+ // next apply, the same as a brand new org that never got them.
6636
+ for (const key of CALLS_MATRIX_ENV_KEYS) {
6637
+ const env = (await this.ports.coolify.listEnvs(app.appId)).find((e) => e.key === key && !e.is_preview);
6638
+ if (env?.uuid)
6639
+ await this.ports.coolify.deleteEnv(app.appId, env.uuid);
6640
+ }
5919
6641
  const bindingSecret = await this.ensureCallsBindingSecret(app.appId);
5920
6642
  const envs = [
5921
6643
  { key: 'LIVEKIT_URL', value: sfuUrls.privateUrl },
5922
6644
  { key: 'LIVEKIT_PUBLIC_URL', value: sfuUrls.publicUrl },
5923
6645
  { key: 'LIVEKIT_API_KEY', value: sfu.apiKey },
5924
6646
  { key: 'LIVEKIT_API_SECRET', value: sfu.apiSecret },
5925
- { key: 'MAS_URL', value: `http://${masHost}:8080/` },
5926
- { key: 'MAS_HOMESERVER', value: callsServerName(org.slug) },
5927
- { key: 'MAS_CLIENT_ID', value: masCredentials.clientId },
5928
- { key: 'MAS_CLIENT_SECRET', value: masCredentials.clientSecret },
5929
- { key: 'SYNAPSE_URL', value: `http://${synapseHost}:8008` },
6647
+ // #430 (calls#93): calls are transit-only, so the service never needs
6648
+ // a Matrix/MAS provider -- MAS_URL, MAS_HOMESERVER, MAS_CLIENT_ID,
6649
+ // MAS_CLIENT_SECRET and SYNAPSE_URL are never written.
6650
+ { key: 'CALLS_ENCRYPTION', value: 'transit' },
5930
6651
  { key: 'CALLS_ORIGIN', value: `https://${agoraHostnameFor(org)}` },
5931
6652
  { key: 'CALLS_HOST_ADAPTER', value: 'production' },
5932
6653
  // Deliberately kept as it was, not widened: the calls service refuses to
@@ -6052,6 +6773,9 @@ export class FoundryStepExecutor {
6052
6773
  throw new Error(`${at}: token ${tokenId} is recorded but chat-service no longer holds a usable one; remove the recorded keys ${CHAT_CALLS_TOKEN_SCOPE_KEY} and ${CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY} to mint a new one`);
6053
6774
  }
6054
6775
  const token = usable ?? randomBytes(32).toString('hex');
6776
+ // Registered before either write, so a Coolify error body echoing the upsert is scrubbed
6777
+ // from the run record the same way the write loop's own dropped-error fallback intends.
6778
+ this.handledSecrets.add(token);
6055
6779
  if (!tokenId) {
6056
6780
  tokenId = randomBytes(8).toString('hex');
6057
6781
  context.recordScopeId(CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY, tokenId);
@@ -6156,22 +6880,6 @@ export class FoundryStepExecutor {
6156
6880
  appId: client.appId,
6157
6881
  };
6158
6882
  }
6159
- /**
6160
- * Reads back the calls Matrix stack's MAS client id/secret from the MAS
6161
- * app's own envs ({@link CALLS_SECRET_ENV_KEYS}) -- never mints: minting
6162
- * either is {@link ensureCallsMatrixSecrets}'s own job, and a missing value
6163
- * here means that stack has not finished provisioning yet, not something
6164
- * this step can fix on its own.
6165
- */
6166
- async readCallsMasClientCredentials(masAppId) {
6167
- const envs = await this.ports.coolify.listEnvs(masAppId);
6168
- const read = (key) => envs.find((e) => e.key === key && !e.is_preview)?.value;
6169
- const clientId = read(CALLS_SECRET_ENV_KEYS.masClientId);
6170
- const clientSecret = read(CALLS_SECRET_ENV_KEYS.masClientSecret);
6171
- if (!clientId || !clientSecret)
6172
- return null;
6173
- return { clientId, clientSecret };
6174
- }
6175
6883
  /**
6176
6884
  * `null` once the calls service app already has a `CALLS_BINDING_SECRET` --
6177
6885
  * never rotated, the same rule {@link ensureAuthCookieSecret} follows for
@@ -6558,6 +7266,7 @@ export class FoundryStepExecutor {
6558
7266
  const credentialWants = {
6559
7267
  archonCredential: action.desired.archonCredential === true,
6560
7268
  customersDirectory: action.desired.customersDirectory === true,
7269
+ registrar: action.desired.registrar === true,
6561
7270
  };
6562
7271
  const { env, missing } = desiredBouleEnv(org, context.recordedIds(), issuer, archonUrl, mailWanted, credentialWants);
6563
7272
  if (missing.length > 0) {
@@ -6646,11 +7355,34 @@ export class FoundryStepExecutor {
6646
7355
  archonPushedAt = archon.pushedAt;
6647
7356
  credentialEnvs.push({ key: BOULE_ARCHON_CREDENTIAL_ENV, value: archon.value });
6648
7357
  }
7358
+ let directoryCredential = null;
6649
7359
  if (credentialWants.customersDirectory) {
6650
- const directory = await this.ensureCustomersDirectoryCredential(org, context);
6651
- if (directory)
6652
- credentialEnvs.push({ key: BOULE_CUSTOMERS_CREDENTIAL_ENV, value: directory });
6653
- }
7360
+ directoryCredential = await this.ensureCustomersDirectoryCredential(org, context);
7361
+ if (directoryCredential) {
7362
+ credentialEnvs.push({ key: BOULE_CUSTOMERS_CREDENTIAL_ENV, value: directoryCredential });
7363
+ }
7364
+ }
7365
+ // F13: Boule's customer-grants entry, written with the rest and compared with the live value, so Boule
7366
+ // restarts only when the JSON itself changes.
7367
+ const grants = await this.customerGrantsForBoule(app.appId, org, context, action.desired.customerGrants === true, directoryCredential, liveRows);
7368
+ // Pushed with the credentials: the loop below registers the JSON for redaction and compares it with the live value.
7369
+ if (grants.value !== null)
7370
+ credentialEnvs.push({ key: CUSTOMER_GRANTS_ENV, value: grants.value });
7371
+ if (grants.removed)
7372
+ envChanged = true;
7373
+ // T3: the company's registrar key at the broker, the same held value the registrar step writes there.
7374
+ let registrarMint = null;
7375
+ // Only with the broker's address beside it: a key Boule cannot use is a key held for nothing.
7376
+ if (credentialWants.registrar && issuer) {
7377
+ const registrar = await this.registrarKey(org, context);
7378
+ registrarMint = registrar.mint;
7379
+ credentialEnvs.push({ key: AUTH_REGISTRAR_KEY_ENV, value: registrar.key });
7380
+ }
7381
+ if (await this.dropRegistrarEnv(app.appId, org, context, liveRows))
7382
+ envChanged = true;
7383
+ // A key Boule was given but has not run with yet (a restart that failed, or a key made again).
7384
+ const registrarUnstarted = registrarMint !== null &&
7385
+ context.recordedIds()[REGISTRAR_DELIVERED_SCOPE_KEY] !== registrarMint;
6654
7386
  for (const credential of credentialEnvs) {
6655
7387
  this.handledSecrets.add(credential.value);
6656
7388
  envs.push(credential);
@@ -6664,6 +7396,11 @@ export class FoundryStepExecutor {
6664
7396
  context.recordScopeId(BOULE_ARCHON_PUSHED_AT_SCOPE_KEY, archonPushedAt);
6665
7397
  await context.persistScopeIds();
6666
7398
  }
7399
+ if (grants.value !== null) {
7400
+ context.recordScopeId(CUSTOMER_GRANTS_WRITTEN_SCOPE_KEY, '1');
7401
+ await context.persistScopeIds();
7402
+ }
7403
+ await this.forgetDroppedCustomerGrants(org, context);
6667
7404
  }
6668
7405
  catch (error) {
6669
7406
  const minted = [
@@ -6671,6 +7408,7 @@ export class FoundryStepExecutor {
6671
7408
  bridgeSecret,
6672
7409
  ownerSeedSecret,
6673
7410
  ...credentialEnvs.map((credential) => credential.value),
7411
+ ...grants.secrets,
6674
7412
  ].filter((v) => v !== null);
6675
7413
  if (minted.length === 0)
6676
7414
  throw error;
@@ -6709,6 +7447,7 @@ export class FoundryStepExecutor {
6709
7447
  bridgeSecret !== null ||
6710
7448
  ownerSeedSecret !== null ||
6711
7449
  secretsNotRestarted ||
7450
+ registrarUnstarted ||
6712
7451
  dbPush.changed ||
6713
7452
  mail.changed)) {
6714
7453
  // A restart inside the mail rule already came after every env write above.
@@ -6718,20 +7457,115 @@ export class FoundryStepExecutor {
6718
7457
  if (action.desired.platformOwner === true) {
6719
7458
  context.recordScopeId(BOULE_SECRETS_RESTARTED_SCOPE_KEY, '1');
6720
7459
  }
7460
+ // After the restart or deploy, so a key Boule never ran with is planned (and restarted) again.
7461
+ if (registrarMint !== null)
7462
+ context.recordScopeId(REGISTRAR_DELIVERED_SCOPE_KEY, registrarMint);
6721
7463
  // Last: the login-host step points the sign-in at this app's own address only once the env is running.
6722
7464
  this.recordSignInEnvLive(context, 'boule', bouleHostnameFor(org), env);
6723
7465
  return { resourceId: app.appId };
6724
7466
  }
7467
+ // ---- kosmos (foundry#949) --------------------------------------------------
6725
7468
  /**
6726
- * C3: the Archon credential of an org's Boule. Archon's mint rotates on every call, so it is called only
6727
- * when none was pushed or the recorded push time (no secret) is 335 days old. Otherwise the held value is
6728
- * written again unchanged. The secret is held first; the caller records `pushedAt` only after the env write
6729
- * succeeded, so a failed hold or a failed push is mended by the next apply minting again. Errors name no secret.
7469
+ * `plan/diff.ts`'s `planKosmos`: provisions the org's own kosmos
7470
+ * (https://github.com/wtfalch/kosmos) as one more Coolify application, from
7471
+ * that repo's root Dockerfile ({@link KOSMOS_REPO}), with a database of its
7472
+ * own, and pushes its sign-in env. The same shape as {@link coolifyBoule},
7473
+ * trimmed: kosmos is opt-in (`kosmosOn(org)` guards every caller, so this is
7474
+ * never reached while it is off), has no mail, no owner seeding and no
7475
+ * Archon credential.
7476
+ *
7477
+ * The sign-in env comes from `plan/kosmos.ts`'s `desiredKosmosEnv`, the same
7478
+ * function the plan compares the live app against; it never writes a
7479
+ * partial set. The database is `<slug>-kosmos-db`, split the same way
7480
+ * Boule's is: the superuser URL as `DATABASE_URL_OWNER` and a `<dbName>_rt`
7481
+ * role's URL as `DATABASE_URL`, both through `pushDatabaseUrl`.
7482
+ *
7483
+ * #504 (same reason {@link coolifyBoule}'s own comment gives): a brand new
7484
+ * app is never restarted, but it is deployed once ({@link KOSMOS_DEPLOYED_SCOPE_KEY}).
6730
7485
  */
6731
- async ensureBouleArchonCredential(org, context) {
6732
- const at = `coolify:boule for ${org.slug}`;
6733
- const mint = this.ports.mintBouleArchonCredential;
6734
- const heldKeys = this.ports.heldKeys;
7486
+ async coolifyKosmos(action, org, context) {
7487
+ // Park stops the app only; its database is never touched (#7 corrections 1).
7488
+ if (action.desired.enabled === false) {
7489
+ const appId = context.recordedIds()[`coolify:kosmos:${action.key}`];
7490
+ if (!appId) {
7491
+ throw new Error(`coolify:kosmos for ${org.slug}: no live app recorded, nothing to park`);
7492
+ }
7493
+ await this.ports.coolify.raw('POST', `/applications/${appId}/stop`);
7494
+ return { resourceId: appId };
7495
+ }
7496
+ const issuer = action.desired.issuer ?? null;
7497
+ const { env, missing } = desiredKosmosEnv(org, context.recordedIds(), issuer);
7498
+ if (missing.length > 0) {
7499
+ throw new Error(`kosmos for ${org.slug} cannot be configured: missing ${missing.join(', ')}. Nothing was written.`);
7500
+ }
7501
+ const projectUuid = await this.ensureCoolifyProject(org, context);
7502
+ const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
7503
+ const name = `${org.slug}-kosmos`;
7504
+ const dbName = `${org.slug}-kosmos-db`.replace(/-/g, '_');
7505
+ // #444: on the company's server there is no Coolify database to make, like Boule's.
7506
+ const kosmosServer = this.homeOnServer(org, context, 'kosmos')
7507
+ ? await this.serverHome(org, context, 'kosmos')
7508
+ : null;
7509
+ const dbId = kosmosServer
7510
+ ? ''
7511
+ : await this.ensureCallsDatabase(org, context, projectUuid, environment, `${org.slug}-kosmos-db`, dbName, KOSMOS_DATABASE_SCOPE_KEY);
7512
+ if (!kosmosServer)
7513
+ context.recordScopeId(KOSMOS_DATABASE_SCOPE_KEY, dbId);
7514
+ const server = await this.resolveServer(org, context);
7515
+ const githubApp = await this.ports.coolify.requireGithubApp();
7516
+ const app = await this.findOrCreateCallsApp(name, environment.id, () => this.ports.coolify.createGithubAppApplication({
7517
+ is_preview_deployments_enabled: false,
7518
+ project_uuid: projectUuid,
7519
+ server_uuid: server.uuid,
7520
+ environment_name: environment.name,
7521
+ environment_uuid: environment.uuid,
7522
+ github_app_uuid: githubApp.uuid,
7523
+ git_repository: KOSMOS_REPO,
7524
+ git_branch: 'main',
7525
+ build_pack: 'dockerfile',
7526
+ name,
7527
+ domains: coolifyTunnelDomains([kosmosHostnameFor(org)]),
7528
+ dockerfile_location: KOSMOS_APP_DOCKERFILE,
7529
+ }));
7530
+ // Every app foundry deploys keeps its own logs (#75), reused as-is here.
7531
+ await this.ensureLogVolume(app.appId, org, app.existed);
7532
+ await this.ports.coolify.updateApplication(app.appId, {
7533
+ ...healthCheckSettings('/api/health', KOSMOS_PORT),
7534
+ ...(await this.routedLabels(name, kosmosHostnameFor(org), KOSMOS_PORT)),
7535
+ });
7536
+ const liveRows = (await this.ports.coolify.listEnvs(app.appId)).filter((e) => !e.is_preview);
7537
+ const liveEnv = Object.fromEntries(liveRows.map((e) => [e.key, e.value]));
7538
+ const envChanged = kosmosEnvDrift(env, liveEnv).length > 0;
7539
+ const envs = Object.entries(env).map(([key, value]) => ({ key, value }));
7540
+ const cookieSecret = await this.ensureAuthCookieSecret(app.appId);
7541
+ if (cookieSecret)
7542
+ envs.push({ key: 'AUTH_COOKIE_SECRET', value: cookieSecret });
7543
+ await this.ports.coolify.bulkUpsertEnvs(app.appId, envs);
7544
+ const dbPush = await this.pushDatabaseUrl(app.appId, dbId, dbName, kosmosServer ?? undefined);
7545
+ if (!dbPush.pushed) {
7546
+ throw new Error(`kosmos for ${org.slug}: its database has no internal_db_url yet -- retry this apply once Coolify reports one`);
7547
+ }
7548
+ if (!context.recordedIds()[KOSMOS_DEPLOYED_SCOPE_KEY]) {
7549
+ const runtime = this.ports.runtime ?? {};
7550
+ const built = await waitForNewDeployment(this.ports.coolify, app.appId, publicHealthCheck(org, context.recordedIds(), kosmosHostnameFor(org), '/api/health'), runtime, runtime.sleep ?? defaultSleep, 'coolify:kosmos', `${org.slug}'s kosmos stays at its previous deploy`);
7551
+ await recordBuiltCommit(this.ports.coolify, { appId: app.appId, deploymentUuid: built.deploymentUuid, name: 'kosmos' }, (key, value) => context.recordScopeId(key, value));
7552
+ context.recordScopeId(KOSMOS_DEPLOYED_SCOPE_KEY, '1');
7553
+ }
7554
+ else if (app.existed && (envChanged || cookieSecret !== null || dbPush.changed)) {
7555
+ await this.ports.coolify.restart(app.appId);
7556
+ }
7557
+ return { resourceId: app.appId };
7558
+ }
7559
+ /**
7560
+ * C3: the Archon credential of an org's Boule. Archon's mint rotates on every call, so it is called only
7561
+ * when none was pushed or the recorded push time (no secret) is 335 days old. Otherwise the held value is
7562
+ * written again unchanged. The secret is held first; the caller records `pushedAt` only after the env write
7563
+ * succeeded, so a failed hold or a failed push is mended by the next apply minting again. Errors name no secret.
7564
+ */
7565
+ async ensureBouleArchonCredential(org, context) {
7566
+ const at = `coolify:boule for ${org.slug}`;
7567
+ const mint = this.ports.mintBouleArchonCredential;
7568
+ const heldKeys = this.ports.heldKeys;
6735
7569
  if (!mint)
6736
7570
  throw new Error(`${at}: this executor was built without mintBouleArchonCredential`);
6737
7571
  if (!heldKeys?.put) {
@@ -6786,6 +7620,261 @@ export class FoundryStepExecutor {
6786
7620
  }
6787
7621
  return minted.token;
6788
7622
  }
7623
+ /** The held keys with a timeout on every call: a hung store stops the step with a plain line. */
7624
+ timedHeldKeys() {
7625
+ const held = this.ports.heldKeys;
7626
+ if (!held)
7627
+ return undefined;
7628
+ return {
7629
+ resolve: (ref) => withLabelledTimeout('held key read', HELD_KEY_READ_TIMEOUT_MS, () => held.resolve(ref)),
7630
+ ...(typeof held.put === 'function'
7631
+ ? {
7632
+ put: (ref, value) => withLabelledTimeout('held key write', HELD_KEY_READ_TIMEOUT_MS, () => held.put(ref, value)),
7633
+ }
7634
+ : {}),
7635
+ };
7636
+ }
7637
+ /** T3: the company's registrar key, held at `foundry:registrar:<org id>`; either step may make it. */
7638
+ registrarKey(org, context) {
7639
+ return ensureRegistrarKey({
7640
+ orgId: org.id,
7641
+ slug: org.slug,
7642
+ heldKeys: this.timedHeldKeys(),
7643
+ recorded: () => context.recordedIds(),
7644
+ record: (key, value) => context.recordScopeId(key, value),
7645
+ persist: () => context.persistScopeIds(),
7646
+ secret: (value) => void this.handledSecrets.add(value),
7647
+ log: (line) => console.info(`coolify: ${line}`),
7648
+ });
7649
+ }
7650
+ /**
7651
+ * T3, customers off: takes away the registrar env foundry gave Boule. The registrar step removes the record
7652
+ * at the broker and forgets the key. Returns true when Boule's env changed.
7653
+ */
7654
+ async dropRegistrarEnv(appId, org, context, liveRows) {
7655
+ if (registrarWanted(org) || !context.recordedIds()[REGISTRAR_DELIVERED_SCOPE_KEY])
7656
+ return false;
7657
+ // In step with the record: while the broker may still take the key, Boule keeps it.
7658
+ const kept = !this.ports.broker
7659
+ ? 'the host has not wired the broker port, so the record cannot be taken away'
7660
+ : context.recordedIds()[REGISTRAR_REMOVAL_HELD_SCOPE_KEY]
7661
+ ? 'the last removal at the broker wrote nothing'
7662
+ : null;
7663
+ if (kept) {
7664
+ console.info(`coolify:boule for ${org.slug}: Boule keeps its registrar env: ${kept}`);
7665
+ return false;
7666
+ }
7667
+ let changed = false;
7668
+ for (const row of liveRows) {
7669
+ if (row.key !== AUTH_REGISTRAR_KEY_ENV && row.key !== AUTH_REGISTRAR_URL_ENV)
7670
+ continue;
7671
+ if (!row.uuid)
7672
+ continue;
7673
+ try {
7674
+ await this.ports.coolify.deleteEnv(appId, row.uuid);
7675
+ changed = true;
7676
+ }
7677
+ catch (error) {
7678
+ if (!isNotFoundError(error))
7679
+ throw error;
7680
+ }
7681
+ }
7682
+ context.recordScopeId(REGISTRAR_DELIVERED_SCOPE_KEY, '');
7683
+ return changed;
7684
+ }
7685
+ /**
7686
+ * T3: the company's record in the broker's `AUTH_REGISTRARS` (`plan/broker-registrar.ts`): its key (made
7687
+ * and held first if the Boule step has not), its sign-in organisation, customers organisations and login
7688
+ * host. With customers off it takes the record away, then forgets the key. The applied marker is recorded
7689
+ * last and never holds the key; every error is redacted of it.
7690
+ */
7691
+ async brokerRegistrar(org, context) {
7692
+ const at = `coolify:broker-registrar for ${org.slug}`;
7693
+ const broker = this.ports.broker;
7694
+ if (!broker)
7695
+ throw new Error(`${at}: the broker port is not wired`);
7696
+ const recorded = context.recordedIds();
7697
+ const previousOrganizationId = appliedRegistrarOrganization(recorded);
7698
+ if (!registrarWanted(org)) {
7699
+ const keys = [];
7700
+ const heldKeys = this.timedHeldKeys();
7701
+ let removal;
7702
+ try {
7703
+ // The held key finds the record whatever organisation it names, also after a crash before the
7704
+ // applied marker; the recorded sign-in organisation does too.
7705
+ const ownKey = recorded[REGISTRAR_MINTED_SCOPE_KEY]
7706
+ ? ((await heldKeys?.resolve(registrarRef(org.id))) ?? null)
7707
+ : null;
7708
+ if (ownKey) {
7709
+ keys.push(ownKey);
7710
+ this.handledSecrets.add(ownKey);
7711
+ }
7712
+ removal = await writeRegistrar(broker, {
7713
+ slug: org.slug,
7714
+ record: null,
7715
+ previousOrganizationId,
7716
+ ownOrganizationIds: [recorded[ZITADEL_ORG_SCOPE_KEY] ?? ''],
7717
+ ownKey,
7718
+ secret: (value) => {
7719
+ keys.push(value);
7720
+ this.handledSecrets.add(value);
7721
+ },
7722
+ log: (line) => console.info(redactSecrets(line, keys)),
7723
+ });
7724
+ }
7725
+ catch (error) {
7726
+ throw new Error(redactSecrets(error instanceof Error ? error.message : String(error), keys));
7727
+ }
7728
+ if (removal.removalHeld) {
7729
+ // The record stays at the broker, so the key stays held; the plan says why.
7730
+ console.info(`${at}: the record is not taken away and the key is kept: ${removal.removalHeld}`);
7731
+ context.recordScopeId(REGISTRAR_REMOVAL_HELD_SCOPE_KEY, removal.removalHeld);
7732
+ return {};
7733
+ }
7734
+ context.recordScopeId(REGISTRAR_REMOVAL_HELD_SCOPE_KEY, '');
7735
+ context.recordScopeId(REGISTRAR_APPLIED_SCOPE_KEY, '');
7736
+ context.recordScopeId(REGISTRAR_DEPLOY_WAITS_SCOPE_KEY, '');
7737
+ await context.persistScopeIds();
7738
+ // Last: the broker no longer takes the key, so forgetting it leaves nothing that needs it.
7739
+ if (recorded[REGISTRAR_MINTED_SCOPE_KEY]) {
7740
+ if (!heldKeys?.put) {
7741
+ throw new Error(`${at}: the held keys cannot forget the registrar key (ref ${registrarRef(org.id)})`);
7742
+ }
7743
+ await heldKeys.put(registrarRef(org.id), '');
7744
+ context.recordScopeId(REGISTRAR_MINTED_SCOPE_KEY, '');
7745
+ }
7746
+ return {};
7747
+ }
7748
+ const wanted = wantedRegistrar(org, recorded);
7749
+ if ('missing' in wanted) {
7750
+ throw new Error(`${at}: ${wanted.missing.map((m) => m.what).join(', ')} not recorded yet`);
7751
+ }
7752
+ const { key, mint } = await this.registrarKey(org, context);
7753
+ // Every company's key the broker's env holds, so none can leave in an error.
7754
+ const keys = [key];
7755
+ let result;
7756
+ try {
7757
+ result = await writeRegistrar(broker, {
7758
+ slug: org.slug,
7759
+ record: { ...wanted.want, key },
7760
+ previousOrganizationId,
7761
+ secret: (value) => {
7762
+ keys.push(value);
7763
+ this.handledSecrets.add(value);
7764
+ },
7765
+ log: (line) => console.info(redactSecrets(line, keys)),
7766
+ });
7767
+ }
7768
+ catch (error) {
7769
+ throw new Error(redactSecrets(error instanceof Error ? error.message : String(error), keys));
7770
+ }
7771
+ context.recordScopeId(REGISTRAR_DEPLOY_WAITS_SCOPE_KEY, result.waits.join(', '));
7772
+ // Last: a run that failed above plans the step again.
7773
+ context.recordScopeId(REGISTRAR_APPLIED_SCOPE_KEY, registrarAppliedValue(wanted.want, mint));
7774
+ return {};
7775
+ }
7776
+ customerGrantsCredential(org, entryKey, context) {
7777
+ return ensureCustomerGrantsCredential({
7778
+ orgId: org.id,
7779
+ slug: org.slug,
7780
+ entryKey,
7781
+ heldKeys: this.timedHeldKeys(),
7782
+ recorded: () => context.recordedIds(),
7783
+ record: (key, value) => context.recordScopeId(key, value),
7784
+ persist: () => context.persistScopeIds(),
7785
+ secret: (value) => void this.handledSecrets.add(value),
7786
+ });
7787
+ }
7788
+ /**
7789
+ * F13, app side: an entry with `uses.customerGrants` gets `CUSTOMER_GRANTS_CREDENTIAL` (made and held first,
7790
+ * once). An entry that had one and no longer declares the flag loses the env and the held value; each part is
7791
+ * repeated safely, so a run that died halfway is finished by the next.
7792
+ */
7793
+ async customerGrantsAppEnv(appId, org, entry, context, envs) {
7794
+ if (entry.uses?.customerGrants) {
7795
+ envs.push({
7796
+ key: CUSTOMER_GRANTS_CREDENTIAL_ENV,
7797
+ value: await this.customerGrantsCredential(org, entry.key, context),
7798
+ });
7799
+ return;
7800
+ }
7801
+ // Either marker says the app may still hold the env: the held value may already be forgotten by Boule's step.
7802
+ const recorded = context.recordedIds();
7803
+ const minted = Boolean(recorded[customerGrantsMintedScopeKey(entry.key)]);
7804
+ const applied = (recorded[appEnvAppliedScopeKey(entry.key)] ?? '').includes(`"${CUSTOMER_GRANTS_CREDENTIAL_ENV}"`);
7805
+ if (!minted && !applied)
7806
+ return;
7807
+ const at = `coolify:app for ${org.slug}`;
7808
+ for (const row of await this.ports.coolify.listEnvs(appId)) {
7809
+ if (row.key !== CUSTOMER_GRANTS_CREDENTIAL_ENV || row.is_preview || !row.uuid)
7810
+ continue;
7811
+ try {
7812
+ await this.ports.coolify.deleteEnv(appId, row.uuid);
7813
+ }
7814
+ catch (error) {
7815
+ if (!isNotFoundError(error))
7816
+ throw error;
7817
+ }
7818
+ }
7819
+ if (minted)
7820
+ await this.forgetCustomerGrantsCredential(org, entry.key, context, at);
7821
+ }
7822
+ /** Forgets one entry's held credential and clears its marker. Held first, marker last, so a failed forget is repeated. */
7823
+ async forgetCustomerGrantsCredential(org, entryKey, context, at) {
7824
+ const heldKeys = this.timedHeldKeys();
7825
+ if (typeof heldKeys?.put !== 'function') {
7826
+ throw new Error(`${at}: the held keys cannot forget the customer grants credential of entry '${entryKey}' (ref ${customerGrantsRef(org.id, entryKey)})`);
7827
+ }
7828
+ await heldKeys.put(customerGrantsRef(org.id, entryKey), '');
7829
+ context.recordScopeId(customerGrantsMintedScopeKey(entryKey), '');
7830
+ await context.persistScopeIds();
7831
+ }
7832
+ /** F13: the held credentials of entries that no longer declare the flag, forgotten after Boule stopped listing them. */
7833
+ async forgetDroppedCustomerGrants(org, context) {
7834
+ for (const key of customerGrantsToDrop(org, context.recordedIds())) {
7835
+ await this.forgetCustomerGrantsCredential(org, key, context, `coolify:boule for ${org.slug}`);
7836
+ }
7837
+ }
7838
+ /**
7839
+ * F13, Boule side: the `CUSTOMER_GRANTS` value for the flagged entries (each credential made and held if it
7840
+ * is not yet), or `value: null`. With no flagged entry left and a value foundry wrote, the env row is
7841
+ * removed (`removed`). A flagged entry that cannot be listed yet changes nothing. Never a hand-set value
7842
+ * it did not write.
7843
+ */
7844
+ async customerGrantsForBoule(appId, org, context, planned, directoryCredential, liveRows) {
7845
+ const none = { value: null, removed: false, secrets: [] };
7846
+ const at = `coolify:boule for ${org.slug}`;
7847
+ if (!planned) {
7848
+ if (customerGrantsEntries(org).length > 0)
7849
+ return none;
7850
+ if (!context.recordedIds()[CUSTOMER_GRANTS_WRITTEN_SCOPE_KEY])
7851
+ return none;
7852
+ for (const row of liveRows) {
7853
+ if (row.key !== CUSTOMER_GRANTS_ENV || !row.uuid)
7854
+ continue;
7855
+ try {
7856
+ await this.ports.coolify.deleteEnv(appId, row.uuid);
7857
+ }
7858
+ catch (error) {
7859
+ if (!isNotFoundError(error))
7860
+ throw error;
7861
+ }
7862
+ }
7863
+ context.recordScopeId(CUSTOMER_GRANTS_WRITTEN_SCOPE_KEY, '');
7864
+ await context.persistScopeIds();
7865
+ return { ...none, removed: liveRows.some((row) => row.key === CUSTOMER_GRANTS_ENV) };
7866
+ }
7867
+ const wanted = wantedCustomerGrants(org, context.recordedIds(), { live: true });
7868
+ if (!wanted.shape || !directoryCredential) {
7869
+ throw new Error(`${at}: the plan carries customer grants this run cannot build (${wanted.notes.join('; ') || 'no directory credential'}); plan this org's Boule again`);
7870
+ }
7871
+ const credentials = {};
7872
+ for (const key of Object.keys(wanted.shape.apps)) {
7873
+ credentials[key] = await this.customerGrantsCredential(org, key, context);
7874
+ }
7875
+ const value = customerGrantsJson(wanted.shape, directoryCredential, credentials);
7876
+ return { value, removed: false, secrets: [...Object.values(credentials), value] };
7877
+ }
6789
7878
  /** `put`, tried up to {@link BOULE_CREDENTIAL_ATTEMPTS} times; true once one attempt succeeds. The cause is dropped. */
6790
7879
  async holdWithRetry(ref, value, put, label) {
6791
7880
  for (let attempt = 1; attempt <= BOULE_CREDENTIAL_ATTEMPTS; attempt++) {
@@ -6817,7 +7906,9 @@ export class FoundryStepExecutor {
6817
7906
  }
6818
7907
  let changed = false;
6819
7908
  for (const row of liveRows) {
6820
- if (row.key !== BOULE_CUSTOMERS_ORG_ENV && row.key !== BOULE_CUSTOMERS_CREDENTIAL_ENV)
7909
+ if (row.key !== BOULE_CUSTOMERS_ORG_ENV &&
7910
+ row.key !== BOULE_TEST_CUSTOMERS_ORG_ENV &&
7911
+ row.key !== BOULE_CUSTOMERS_CREDENTIAL_ENV)
6821
7912
  continue;
6822
7913
  if (!row.uuid)
6823
7914
  continue;
@@ -7020,6 +8111,30 @@ export class FoundryStepExecutor {
7020
8111
  project: { ensure: () => this.ensureCoolifyProject(org, context) },
7021
8112
  });
7022
8113
  }
8114
+ /**
8115
+ * `plan/own-diff.ts`'s `planOwnJobsRoute`: the org's scheduler on `jobs.<domain>`, through the company's own
8116
+ * edge-routed DNS and tunnel ports, dialling the address `coolify:own-jobs` recorded.
8117
+ */
8118
+ async ownJobsRoute(action, org, context) {
8119
+ const own = this.ports.ownServices;
8120
+ if (!own)
8121
+ throw new Error(OWN_SERVICES_PORT_ABSENT);
8122
+ const internalUrl = context.recordedIds()[JOBS_INTERNAL_URL_SCOPE_KEY];
8123
+ if (!internalUrl) {
8124
+ throw new Error(`own step coolify:own-jobs-route for ${org.slug}: the jobs scheduler has not recorded its address yet; apply its step first`);
8125
+ }
8126
+ const scope = scopeFromOrg(context, {});
8127
+ const projectUuid = await this.ensureCoolifyProject(org, context);
8128
+ return provisionJobsRoute(scope, {
8129
+ coolify: limited(this.placedForCompany(own.provision.sfu.coolify, org, context), LIMITS.connector),
8130
+ network: orgNetwork({ dns: this.dnsPort(), tunnels: this.tunnelPort(), zones: this.zonePort() }, scope, `${org.slug}:jobs-route`),
8131
+ }, orgTarget(org), {
8132
+ hostname: action.desired.hostname,
8133
+ apex: ownHostnames(org).zone,
8134
+ internalUrl,
8135
+ projectUuid,
8136
+ });
8137
+ }
7023
8138
  /**
7024
8139
  * `plan/own-diff.ts`'s `planOwnFiles`: the org's own files host in the
7025
8140
  * org's own project, trusting the company's issuer `https://auth.<domain>`.
@@ -7426,6 +8541,168 @@ export class FoundryStepExecutor {
7426
8541
  ...(context.serverIpv4 ? { serverIpv4: context.serverIpv4 } : {}),
7427
8542
  });
7428
8543
  }
8544
+ /**
8545
+ * `plan/entry-jobs.ts`'s `planEntryJobs`: registers one entry as its own project in the platform's jobs
8546
+ * scheduler, holds the credentials the scheduler shows once, then writes `JOBS_SCHEDULER_URL`,
8547
+ * `JOBS_SIGNING_SECRET` and `JOBS_SYNC_KEY` to the entry's env and restarts the app.
8548
+ *
8549
+ * The project's id is recorded as undelivered and persisted before the credentials are held, and cleared
8550
+ * only once both are, so a failure in between never registers a second project: `planEntryJobs` blocks
8551
+ * on it. Credentials already held are never registered again, which is also how a failed env write is
8552
+ * retried. Errors name the project and the refs, never a credential.
8553
+ */
8554
+ async jobsProject(action, org, context) {
8555
+ const entryKey = action.key.slice(org.id.length + 1);
8556
+ const at = `jobs:project for ${org.slug} entry '${entryKey}'`;
8557
+ const want = action.desired;
8558
+ if (!org.hosting.entries.some((e) => e.key === entryKey && e.uses?.jobs)) {
8559
+ throw new Error(`${at}: the entry no longer declares uses.jobs`);
8560
+ }
8561
+ const appId = context.recordedIds()[`coolify:app:${appStepKey(org, entryKey)}`];
8562
+ if (!appId)
8563
+ throw new Error(`${at}: the entry's app is not recorded yet`);
8564
+ const held = this.ports.heldKeys;
8565
+ const put = held?.put?.bind(held);
8566
+ if (!held || !put)
8567
+ throw new Error(`${at}: the held keys cannot hold the project's credentials`);
8568
+ const refs = entryJobsRefs(org.id, entryKey);
8569
+ const read = async (ref) => {
8570
+ try {
8571
+ const value = await withTimeout(held.resolve(ref), JOBS_PROJECT_CALL_TIMEOUT_MS);
8572
+ return value || null;
8573
+ }
8574
+ catch {
8575
+ throw new Error(`${at}: the held key ${ref} could not be read`);
8576
+ }
8577
+ };
8578
+ let signingSecret = await read(refs.signingSecret);
8579
+ let syncKey = await read(refs.syncKey);
8580
+ if (Boolean(signingSecret) !== Boolean(syncKey)) {
8581
+ throw new Error(`${at}: only one of the project's credentials is held (${refs.signingSecret}, ${refs.syncKey}); the scheduler shows them once, so deregister project ${want.name} and hold neither`);
8582
+ }
8583
+ if (signingSecret)
8584
+ this.handledSecrets.add(signingSecret);
8585
+ if (syncKey)
8586
+ this.handledSecrets.add(syncKey);
8587
+ if (!signingSecret || !syncKey) {
8588
+ const scheduler = await this.jobsPort(org);
8589
+ let registered;
8590
+ try {
8591
+ registered = await withTimeout(scheduler.registerProject({ name: want.name, baseUrl: want.baseUrl }), JOBS_PROJECT_CALL_TIMEOUT_MS);
8592
+ }
8593
+ catch (error) {
8594
+ const detail = error instanceof Error ? error.message : 'no detail';
8595
+ throw new Error(`${at}: the scheduler refused or did not answer the registration (${detail})`);
8596
+ }
8597
+ if (registered.secret)
8598
+ this.handledSecrets.add(registered.secret);
8599
+ if (registered.syncKey)
8600
+ this.handledSecrets.add(registered.syncKey);
8601
+ context.recordScopeId(entryJobsUndeliveredScopeKey(entryKey), registered.id);
8602
+ await context.persistScopeIds();
8603
+ if (!registered.secret || !registered.syncKey) {
8604
+ throw new Error(`${at}: project ${registered.id} is registered but the scheduler returned ${registered.secret || registered.syncKey ? 'only one of' : 'neither of'} its credentials, which it shows once; deregister it (DELETE /admin/projects/${want.name}) and remove the recorded key ${entryJobsUndeliveredScopeKey(entryKey)}`);
8605
+ }
8606
+ for (const [ref, value] of [
8607
+ [refs.signingSecret, registered.secret],
8608
+ [refs.syncKey, registered.syncKey],
8609
+ ]) {
8610
+ let stored = false;
8611
+ for (let attempt = 1; attempt <= JOBS_PROJECT_HOLD_ATTEMPTS && !stored; attempt++) {
8612
+ try {
8613
+ await withTimeout(put(ref, value), JOBS_PROJECT_CALL_TIMEOUT_MS);
8614
+ stored = true;
8615
+ }
8616
+ catch {
8617
+ // Retried; the error is dropped in case it echoes the credential.
8618
+ }
8619
+ }
8620
+ if (!stored) {
8621
+ throw new Error(`${at}: project ${registered.id} is registered but the credential for ${ref} could not be held after ${JOBS_PROJECT_HOLD_ATTEMPTS} attempts; deregister it (DELETE /admin/projects/${want.name}) and remove the recorded key ${entryJobsUndeliveredScopeKey(entryKey)}`);
8622
+ }
8623
+ }
8624
+ signingSecret = registered.secret;
8625
+ syncKey = registered.syncKey;
8626
+ forgetScopeId(context, entryJobsUndeliveredScopeKey(entryKey));
8627
+ await context.persistScopeIds();
8628
+ }
8629
+ try {
8630
+ await withTimeout(this.ports.coolify.bulkUpsertEnvs(appId, [
8631
+ { key: 'JOBS_SCHEDULER_URL', value: want.schedulerUrl, literal: true },
8632
+ { key: 'JOBS_SIGNING_SECRET', value: signingSecret, literal: true },
8633
+ { key: 'JOBS_SYNC_KEY', value: syncKey, literal: true },
8634
+ ]), JOBS_PROJECT_CALL_TIMEOUT_MS);
8635
+ await withTimeout(this.ports.coolify.restart(appId), JOBS_PROJECT_CALL_TIMEOUT_MS);
8636
+ }
8637
+ catch {
8638
+ // The error is dropped in case Coolify echoes the request body.
8639
+ throw new Error(`${at}: the project's credentials are held but could not be written to the app's env; the next apply writes them again`);
8640
+ }
8641
+ context.recordScopeId(entryJobsAppliedScopeKey(entryKey), entryJobsWantMarker(want));
8642
+ return { resourceId: want.name };
8643
+ }
8644
+ /** `plan/entry-audit.ts`: makes the platform signer if none exists and registers one entry's ledger for sealing. */
8645
+ async auditLedger(action, org, context) {
8646
+ const entryKey = action.key.slice(org.id.length + 1);
8647
+ const at = `audit:ledger for ${org.slug} entry '${entryKey}'`;
8648
+ if (!org.hosting.entries.some((e) => e.key === entryKey && e.uses?.audit === true)) {
8649
+ throw new Error(`${at}: the entry no longer declares uses.audit`);
8650
+ }
8651
+ const held = this.timedHeldKeys();
8652
+ if (typeof held?.put !== 'function') {
8653
+ throw new Error(`${at}: the held keys cannot hold the platform signer's key`);
8654
+ }
8655
+ return applyEntryAudit({
8656
+ heldKeys: { resolve: held.resolve, put: held.put },
8657
+ org: { id: org.id, slug: org.slug },
8658
+ entryKey,
8659
+ recorded: () => context.recordedIds(),
8660
+ record: (key, value) => context.recordScopeId(key, value),
8661
+ persist: () => context.persistScopeIds(),
8662
+ secret: (value) => void this.handledSecrets.add(value),
8663
+ });
8664
+ }
8665
+ /** F12, `plan/entry-repo-key.ts`: makes or removes one entry's deploy key. See `entry-repo-key-apply.ts`. */
8666
+ async githubDeployKey(action, org, context) {
8667
+ const entryKey = action.key.slice(org.id.length + 1);
8668
+ const at = `github:deploy-key for ${org.slug} entry '${entryKey}'`;
8669
+ const desired = action.desired;
8670
+ const github = this.ports.githubDeployKeys;
8671
+ if (!github)
8672
+ throw new Error(`${at}: the host gave no GitHub deploy-key client`);
8673
+ const held = this.timedHeldKeys();
8674
+ if (typeof held?.put !== 'function') {
8675
+ throw new Error(`${at}: the held keys cannot hold the key's private half`);
8676
+ }
8677
+ const deps = {
8678
+ github,
8679
+ heldKeys: { resolve: held.resolve, put: held.put },
8680
+ coolify: this.ports.coolify,
8681
+ org: { id: org.id, slug: org.slug },
8682
+ entryKey,
8683
+ appId: context.recordedIds()[`coolify:app:${appStepKey(org, entryKey)}`],
8684
+ recorded: () => context.recordedIds(),
8685
+ record: (key, value) => context.recordScopeId(key, value),
8686
+ persist: () => context.persistScopeIds(),
8687
+ secret: (value) => void this.handledSecrets.add(value),
8688
+ installationFor: (repo) => companyGithubFor(org.hosting, repo)?.installationId,
8689
+ };
8690
+ if (desired.remove) {
8691
+ await removeRepoKey(deps, desired.repo);
8692
+ return {};
8693
+ }
8694
+ if (!org.hosting.entries.some((e) => e.key === entryKey && e.uses?.repo)) {
8695
+ throw new Error(`${at}: the entry no longer declares uses.repo`);
8696
+ }
8697
+ await ensureRepoKey(deps, {
8698
+ repo: desired.repo,
8699
+ access: desired.access === 'write' ? 'write' : 'read',
8700
+ ...(companyGithubFor(org.hosting, desired.repo)
8701
+ ? { installationId: companyGithubFor(org.hosting, desired.repo)?.installationId }
8702
+ : {}),
8703
+ });
8704
+ return {};
8705
+ }
7429
8706
  async jobsRegistration(action, org, context) {
7430
8707
  const scheduler = await this.jobsPort(org);
7431
8708
  const name = action.desired.name;
@@ -7695,6 +8972,7 @@ export class FoundryStepExecutor {
7695
8972
  }
7696
8973
  }
7697
8974
  await this.publishOikosEntryRecords(org, tunnel.id, previousIds, context);
8975
+ await this.publishAppsEntryRecords(org, tunnel.id, previousIds, context);
7698
8976
  return { resourceId: tunnel.id };
7699
8977
  }
7700
8978
  /**
@@ -7708,19 +8986,41 @@ export class FoundryStepExecutor {
7708
8986
  * halfway is planned again.
7709
8987
  */
7710
8988
  async publishOikosEntryRecords(org, tunnelId, previousIds, context) {
7711
- const wanted = entryOikosHostnames(org);
7712
- const recorded = context.recordedIds()[CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY];
8989
+ await this.publishPlatformZoneEntryRecords(org, tunnelId, previousIds, context, {
8990
+ zoneName: NO_DOMAIN_ZONE,
8991
+ wanted: entryOikosHostnames(org, context.recordedIds()),
8992
+ scopeKey: CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY,
8993
+ owns: (name) => ownsOikosName(org, name),
8994
+ });
8995
+ }
8996
+ /**
8997
+ * The entry names a company has in the platform's `companyincluded.app` zone: its apps' `hostedName`
8998
+ * addresses. Written the same way as {@link publishOikosEntryRecords}. Which company may hold a name is
8999
+ * the host's check (Archon), so the step owns any single-label name under the zone.
9000
+ */
9001
+ async publishAppsEntryRecords(org, tunnelId, previousIds, context) {
9002
+ await this.publishPlatformZoneEntryRecords(org, tunnelId, previousIds, context, {
9003
+ zoneName: APPS_DOMAIN,
9004
+ wanted: entryAppsHostnames(org, context.recordedIds()),
9005
+ scopeKey: CLOUDFLARE_TUNNEL_APPS_HOSTNAMES_SCOPE_KEY,
9006
+ owns: (name) => ownsAppsZoneName(org, name),
9007
+ organisationId: org.id,
9008
+ });
9009
+ }
9010
+ async publishPlatformZoneEntryRecords(org, tunnelId, previousIds, context, zoneSpec) {
9011
+ const { zoneName, wanted, scopeKey, owns, organisationId } = zoneSpec;
9012
+ const recorded = context.recordedIds()[scopeKey];
7713
9013
  const previous = recorded ? recorded.split(',') : [];
7714
9014
  if (wanted.length === 0 && previous.length === 0)
7715
9015
  return;
7716
9016
  for (const name of wanted) {
7717
- if (!ownsOikosName(org, name)) {
7718
- throw new Error(`cloudflare:tunnel for ${org.slug}: ${name} is not a name this company may write in the ${NO_DOMAIN_ZONE} zone`);
9017
+ if (!owns(name)) {
9018
+ throw new Error(`cloudflare:tunnel for ${org.slug}: ${name} is not a name this company may write in the ${zoneName} zone`);
7719
9019
  }
7720
9020
  }
7721
- const zone = await this.zonePort().findZoneByApex(NO_DOMAIN_ZONE);
9021
+ const zone = await this.zonePort().findZoneByApex(zoneName);
7722
9022
  if (!zone) {
7723
- throw new Error(`cloudflare:tunnel for ${org.slug}: no Cloudflare zone found for ${NO_DOMAIN_ZONE} to point its entry names at`);
9023
+ throw new Error(`cloudflare:tunnel for ${org.slug}: no Cloudflare zone found for ${zoneName} to point its entry names at`);
7724
9024
  }
7725
9025
  const dns = this.dnsPort();
7726
9026
  const dnsTarget = `${tunnelId}.cfargotunnel.com`;
@@ -7728,7 +9028,7 @@ export class FoundryStepExecutor {
7728
9028
  dnsTarget,
7729
9029
  ...[...previousIds].map((id) => `${id}.cfargotunnel.com`),
7730
9030
  ]);
7731
- const [shared] = await dns.findDnsRecord(zone.name, `*.${NO_DOMAIN_ZONE}`, 'CNAME');
9031
+ const [shared] = await dns.findDnsRecord(zone.name, `*.${zoneName}`, 'CNAME');
7732
9032
  const writes = [];
7733
9033
  const conflicts = [];
7734
9034
  for (const name of wanted) {
@@ -7739,7 +9039,13 @@ export class FoundryStepExecutor {
7739
9039
  continue;
7740
9040
  }
7741
9041
  const [cname] = await dns.findDnsRecord(zone.name, name, 'CNAME');
7742
- const record = { type: 'CNAME', name, content: dnsTarget, proxied: true };
9042
+ const record = {
9043
+ type: 'CNAME',
9044
+ name,
9045
+ content: dnsTarget,
9046
+ proxied: true,
9047
+ ...(organisationId === undefined ? {} : { organisationId }),
9048
+ };
7743
9049
  if (!cname)
7744
9050
  writes.push(() => dns.createDnsRecord(zone.name, record));
7745
9051
  else if (cname.content === dnsTarget)
@@ -7756,14 +9062,14 @@ export class FoundryStepExecutor {
7756
9062
  for (const write of writes)
7757
9063
  await write();
7758
9064
  for (const name of previous.filter((n) => !wanted.includes(n))) {
7759
- if (!ownsOikosName(org, name))
9065
+ if (!owns(name))
7760
9066
  continue;
7761
9067
  for (const record of await dns.findDnsRecord(zone.name, name, 'CNAME')) {
7762
9068
  if (ownTargets.has(record.content))
7763
9069
  await dns.deleteDnsRecord(zone.name, record.id);
7764
9070
  }
7765
9071
  }
7766
- context.recordScopeId(CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY, wanted.join(','));
9072
+ context.recordScopeId(scopeKey, wanted.join(','));
7767
9073
  }
7768
9074
  /**
7769
9075
  * #236: when this tunnel's DNS was last published under a domain that
@@ -8814,8 +10120,9 @@ export class FoundryStepExecutor {
8814
10120
  return {};
8815
10121
  }
8816
10122
  if (entryKey === BOULE_EDGE_ENTRY_KEY) {
8817
- if (!org.domain)
8818
- throw new Error(`${at}: Boule's edge key needs org.domain`);
10123
+ if (!bouleEdgeKeyWanted(org)) {
10124
+ throw new Error(`${at}: Boule's edge key needs org.domain or org.boule.apps`);
10125
+ }
8819
10126
  }
8820
10127
  else if (!org.hosting.entries.some((e) => e.key === entryKey && e.uses?.edge)) {
8821
10128
  throw new Error(`${at}: the entry no longer declares uses.edge`);
@@ -8823,7 +10130,7 @@ export class FoundryStepExecutor {
8823
10130
  const appId = context.recordedIds()[edgeKeyAppRecordKey(org, entryKey)];
8824
10131
  if (!appId)
8825
10132
  throw new Error(`${at}: the entry's app is not recorded yet`);
8826
- if (!org.domain) {
10133
+ if (entryKey !== BOULE_EDGE_ENTRY_KEY && !org.domain) {
8827
10134
  throw new Error(`${at}: edge needs org.domain, and validateEntryEdge already refuses it without one`);
8828
10135
  }
8829
10136
  // A key left over from a previous mint whose delivery never succeeded
@@ -8846,7 +10153,7 @@ export class FoundryStepExecutor {
8846
10153
  }
8847
10154
  const minted = await edge.mintKey({
8848
10155
  organisationId: org.id,
8849
- domain: org.domain,
10156
+ domain: edgeKeyDomain(org),
8850
10157
  name: desired.name,
8851
10158
  expiresDays: EDGE_ENTRY_KEY_DAYS,
8852
10159
  });
@@ -8874,6 +10181,150 @@ export class FoundryStepExecutor {
8874
10181
  // Names the key id only; the underlying error is dropped in case it echoes the secret.
8875
10182
  throw new Error(`${at}: key ${minted.id} was minted but could not be written to the app's env after ${ENTRY_EDGE_KEY_DELIVERY_ATTEMPTS} attempts (${lastError instanceof Error ? lastError.name : 'error'}); the next apply revokes it and mints a fresh one automatically`);
8876
10183
  }
10184
+ /**
10185
+ * F7 (#928): the company's signed-records registrar key (`plan/keys-registrar.ts`). Mint: a leftover
10186
+ * undelivered key is revoked first; the new key's id is recorded and persisted before it is written to
10187
+ * Boule's env (3 tries, then the error names the id only and the next apply revokes and mints again).
10188
+ * `url` rewrites the address only. Retire revokes and takes both env rows away. Never holds the key.
10189
+ */
10190
+ async keysRegistrar(action, org, context) {
10191
+ const at = `coolify:keys-registrar for ${org.slug}`;
10192
+ const keys = this.ports.keysRegistrar;
10193
+ if (!keys)
10194
+ throw new Error(`${at}: this executor was built without a KeysRegistrarPort`);
10195
+ const desired = action.desired;
10196
+ const recorded = context.recordedIds();
10197
+ const appId = recorded[`coolify:boule:${org.id}`];
10198
+ const revoke = async (id) => {
10199
+ try {
10200
+ await keys.revokeCompanyKey(org.id, id);
10201
+ }
10202
+ catch (error) {
10203
+ if (!isNotFoundError(error))
10204
+ throw error;
10205
+ }
10206
+ };
10207
+ const tolerate = async (call, gone) => {
10208
+ try {
10209
+ return await call();
10210
+ }
10211
+ catch (error) {
10212
+ if (!isNotFoundError(error))
10213
+ throw error;
10214
+ return gone;
10215
+ }
10216
+ };
10217
+ if (desired.mode === 'retire') {
10218
+ for (const id of new Set([recorded[KEYS_REGISTRAR_SCOPE_KEY], recorded[KEYS_REGISTRAR_UNDELIVERED_SCOPE_KEY]].filter((id) => Boolean(id)))) {
10219
+ await revoke(id);
10220
+ }
10221
+ if (recorded[KEYS_REGISTRAR_MINT_STARTED_SCOPE_KEY]) {
10222
+ // A mint whose answer was lost may have left a key no id points at.
10223
+ await keys.revokeAllCompanyKeys(org.id);
10224
+ }
10225
+ if (appId) {
10226
+ const rows = await tolerate(() => this.ports.coolify.listEnvs(appId), []);
10227
+ for (const name of [KEYS_REGISTRAR_URL_ENV, KEYS_REGISTRAR_KEY_ENV]) {
10228
+ const row = rows.find((e) => e.key === name && !e.is_preview);
10229
+ if (row?.uuid) {
10230
+ const uuid = row.uuid;
10231
+ await tolerate(() => this.ports.coolify.deleteEnv(appId, uuid), undefined);
10232
+ }
10233
+ }
10234
+ }
10235
+ for (const scope of [
10236
+ KEYS_REGISTRAR_SCOPE_KEY,
10237
+ KEYS_REGISTRAR_UNDELIVERED_SCOPE_KEY,
10238
+ KEYS_REGISTRAR_MINTED_AT_SCOPE_KEY,
10239
+ KEYS_REGISTRAR_MINT_STARTED_SCOPE_KEY,
10240
+ KEYS_REGISTRAR_URL_APPLIED_SCOPE_KEY,
10241
+ ]) {
10242
+ context.recordScopeId(scope, '');
10243
+ }
10244
+ return {};
10245
+ }
10246
+ if (!appsOn(org))
10247
+ throw new Error(`${at}: the company's Boule runs no apps (org.boule.apps)`);
10248
+ if (!appId)
10249
+ throw new Error(`${at}: Boule is not recorded yet`);
10250
+ if (!desired.keysUrl)
10251
+ throw new Error(`${at}: the plan gave no keys service address`);
10252
+ if (desired.mode === 'url') {
10253
+ await this.ports.coolify.bulkUpsertEnvs(appId, [
10254
+ { key: KEYS_REGISTRAR_URL_ENV, value: desired.keysUrl, literal: true },
10255
+ ]);
10256
+ await this.ports.coolify.restart(appId);
10257
+ context.recordScopeId(KEYS_REGISTRAR_URL_APPLIED_SCOPE_KEY, desired.keysUrl);
10258
+ return {};
10259
+ }
10260
+ if (recorded[KEYS_REGISTRAR_SCOPE_KEY])
10261
+ return {};
10262
+ const stale = recorded[KEYS_REGISTRAR_UNDELIVERED_SCOPE_KEY];
10263
+ if (stale) {
10264
+ await revoke(stale);
10265
+ context.recordScopeId(KEYS_REGISTRAR_UNDELIVERED_SCOPE_KEY, '');
10266
+ await context.persistScopeIds();
10267
+ }
10268
+ if (recorded[KEYS_REGISTRAR_MINT_STARTED_SCOPE_KEY]) {
10269
+ // The last mint's answer never reached us, so a key may exist whose id was never recorded. Revoke all
10270
+ // of the company's registrar keys first (none is delivered: the delivered id is not recorded here).
10271
+ const revoked = await keys.revokeAllCompanyKeys(org.id);
10272
+ console.info(`${at}: revoked ${revoked} registrar key(s) left by a mint whose answer was lost, before minting again`);
10273
+ }
10274
+ context.recordScopeId(KEYS_REGISTRAR_MINT_STARTED_SCOPE_KEY, new Date(context.now?.() ?? Date.now()).toISOString());
10275
+ await context.persistScopeIds();
10276
+ const minted = await keys.mintCompanyKey({
10277
+ organisationId: org.id,
10278
+ name: desired.name ?? `${org.slug}-registrar`,
10279
+ expiresDays: KEYS_REGISTRAR_KEY_DAYS,
10280
+ });
10281
+ this.handledSecrets.add(minted.secret);
10282
+ context.recordScopeId(KEYS_REGISTRAR_UNDELIVERED_SCOPE_KEY, minted.id);
10283
+ context.recordScopeId(KEYS_REGISTRAR_MINT_STARTED_SCOPE_KEY, '');
10284
+ await context.persistScopeIds();
10285
+ const rows = [
10286
+ { key: KEYS_REGISTRAR_URL_ENV, value: desired.keysUrl, literal: true },
10287
+ { key: KEYS_REGISTRAR_KEY_ENV, value: minted.secret, literal: true },
10288
+ ];
10289
+ let lastError;
10290
+ for (let attempt = 1; attempt <= KEYS_REGISTRAR_DELIVERY_ATTEMPTS; attempt++) {
10291
+ try {
10292
+ await this.ports.coolify.bulkUpsertEnvs(appId, rows);
10293
+ await this.ports.coolify.restart(appId);
10294
+ context.recordScopeId(KEYS_REGISTRAR_SCOPE_KEY, minted.id);
10295
+ context.recordScopeId(KEYS_REGISTRAR_UNDELIVERED_SCOPE_KEY, '');
10296
+ context.recordScopeId(KEYS_REGISTRAR_MINTED_AT_SCOPE_KEY, new Date(context.now?.() ?? Date.now()).toISOString());
10297
+ context.recordScopeId(KEYS_REGISTRAR_URL_APPLIED_SCOPE_KEY, desired.keysUrl);
10298
+ return {};
10299
+ }
10300
+ catch (e) {
10301
+ lastError = e;
10302
+ }
10303
+ }
10304
+ throw new Error(`${at}: key ${minted.id} was minted but could not be written to Boule's env after ${KEYS_REGISTRAR_DELIVERY_ATTEMPTS} attempts (${lastError instanceof Error ? lastError.name : 'error'}); the next apply revokes it and mints a new one`);
10305
+ }
10306
+ /**
10307
+ * F3: `plan/entry-edge-key.ts`'s `planEdgeSlug` -- records the org's slug in edge. A slug another company
10308
+ * holds (edge's `409`) is a plain line naming the clash, not the raw provider error.
10309
+ */
10310
+ async edgeSlug(action, org, context) {
10311
+ const edge = this.ports.edge;
10312
+ if (!edge) {
10313
+ throw new Error(`edge:slug for ${org.slug}: this executor was built without an EdgePort`);
10314
+ }
10315
+ const slug = action.desired.slug;
10316
+ try {
10317
+ await edge.setOrganisationSlug(org.id, slug);
10318
+ }
10319
+ catch (e) {
10320
+ if (e instanceof EdgeApiError && e.code === 'conflict') {
10321
+ throw new Error(`edge:slug for ${org.slug} is blocked: edge says another company already holds the slug '${slug}', so its platform hostnames cannot be accepted; change the slug`);
10322
+ }
10323
+ throw e;
10324
+ }
10325
+ context.recordScopeId(EDGE_SLUG_APPLIED_SCOPE_KEY, slug);
10326
+ return {};
10327
+ }
8877
10328
  /**
8878
10329
  * F7 (#317): `plan/entry-edge-key.ts`'s `planEdgeReservedLabels` --
8879
10330
  * tells the edge service every label this org's own entries already
@@ -9299,6 +10750,28 @@ export class FoundryStepExecutor {
9299
10750
  throw error;
9300
10751
  }
9301
10752
  }
10753
+ const appsNames = (recordedIds[CLOUDFLARE_TUNNEL_APPS_HOSTNAMES_SCOPE_KEY] ?? '')
10754
+ .split(',')
10755
+ .filter((name) => name !== '' && name.endsWith(`.${APPS_DOMAIN}`));
10756
+ if (appsNames.length > 0) {
10757
+ try {
10758
+ const zone = await this.zonePort().findZoneByApex(APPS_DOMAIN);
10759
+ if (zone) {
10760
+ const dns = this.dnsPort();
10761
+ for (const name of appsNames) {
10762
+ for (const record of await dns.findDnsRecord(zone.name, name, 'CNAME')) {
10763
+ if (dnsTargets.has(record.content)) {
10764
+ await dns.deleteDnsRecord(zone.name, record.id);
10765
+ }
10766
+ }
10767
+ }
10768
+ }
10769
+ }
10770
+ catch (error) {
10771
+ if (!isNotFoundError(error))
10772
+ throw error;
10773
+ }
10774
+ }
9302
10775
  await this.tunnelPort().deleteTunnel(target.id);
9303
10776
  return;
9304
10777
  }
@@ -9377,6 +10850,16 @@ export class FoundryStepExecutor {
9377
10850
  case 'coolify:bg-app':
9378
10851
  await this.ports.coolify.deleteApplication(target.id);
9379
10852
  return;
10853
+ case 'coolify:github-source':
10854
+ try {
10855
+ await withLabelledTimeout(`coolify:github-source for ${org.slug}: deleting the source`, GITHUB_SOURCE_TIMEOUT_MS, () => this.ports.coolify.raw('DELETE', `/github-apps/${target.id}`));
10856
+ }
10857
+ catch (error) {
10858
+ // withTimeout hides the cause; a source already gone counts as deleted.
10859
+ if (!(await this.githubSourceGone(target.id)))
10860
+ throw error;
10861
+ }
10862
+ return;
9380
10863
  case 'cloudflare:bg-access':
9381
10864
  await this.accessPort().deleteAccessApp(target.id);
9382
10865
  return;
@@ -9384,16 +10867,31 @@ export class FoundryStepExecutor {
9384
10867
  await this.accessPort().removeServiceToken(target.id);
9385
10868
  return;
9386
10869
  case 'cloudflare:bg-dns': {
9387
- // `target.id` is the recorded route ({ hostname, service }); its name lives in the org's zone.
9388
- const hostname = JSON.parse(target.id).hostname;
9389
- const zone = org.domain ? await this.zonePort().findZoneByApex(org.domain) : undefined;
9390
- if (typeof hostname !== 'string' || !zone)
10870
+ // `target.id` is the recorded route ({ hostname, service }). The zone comes from the hostname
10871
+ // (`<label>-hd.<zone apex>`, one label deep), as the apps-off retire finds it: a company on
10872
+ // oikos.host has no `org.domain`, and the apps database's record lives in the platform zone.
10873
+ let hostname;
10874
+ try {
10875
+ hostname = JSON.parse(target.id).hostname;
10876
+ }
10877
+ catch {
10878
+ hostname = undefined;
10879
+ }
10880
+ if (typeof hostname !== 'string' || !hostname.includes('.'))
10881
+ return;
10882
+ const zone = await this.zonePort().findZoneByApex(hostname.slice(hostname.indexOf('.') + 1));
10883
+ if (!zone)
9391
10884
  return;
9392
10885
  const dns = this.dnsPort();
10886
+ // Only this company's own tunnel's record is deleted; a record that points anywhere else is not ours.
10887
+ const ownTunnel = recordedIds[`cloudflare:tunnel:${org.id}`];
9393
10888
  for (const record of await dns.findDnsRecord(zone.name, hostname, 'CNAME')) {
9394
- if (record.content.endsWith('.cfargotunnel.com')) {
10889
+ if (ownTunnel && record.content === `${ownTunnel}.cfargotunnel.com`) {
9395
10890
  await dns.deleteDnsRecord(zone.name, record.id);
9396
10891
  }
10892
+ else {
10893
+ console.info(`cloudflare:bg-dns for ${org.slug}: left the DNS record ${hostname} alone, it does not point at this company's tunnel`);
10894
+ }
9397
10895
  }
9398
10896
  return;
9399
10897
  }
@@ -9415,6 +10913,26 @@ export class FoundryStepExecutor {
9415
10913
  case 'coolify:database':
9416
10914
  await this.ports.coolify.deleteDatabase(target.id);
9417
10915
  return;
10916
+ case 'coolify:keys-registrar': {
10917
+ // F7: the company's registrar key at the keys service (live or never delivered).
10918
+ const keys = this.ports.keysRegistrar;
10919
+ if (!keys) {
10920
+ throw new Error(`coolify:keys-registrar for ${org.slug}: this executor was built without a KeysRegistrarPort; cannot revoke key ${target.id}`);
10921
+ }
10922
+ if (target.key === 'all') {
10923
+ // Every registrar key of the company, including one no recorded id points at.
10924
+ await keys.revokeAllCompanyKeys(org.id);
10925
+ return;
10926
+ }
10927
+ try {
10928
+ await keys.revokeCompanyKey(org.id, target.id);
10929
+ }
10930
+ catch (error) {
10931
+ if (!isNotFoundError(error))
10932
+ throw error;
10933
+ }
10934
+ return;
10935
+ }
9418
10936
  case 'coolify:own-files-r2': {
9419
10937
  // An own files bucket's storage key; a 404 is caught below as already gone.
9420
10938
  const r2Keys = this.ports.ownServices?.provision.files.r2Keys;
@@ -9450,6 +10968,7 @@ export class FoundryStepExecutor {
9450
10968
  return;
9451
10969
  }
9452
10970
  case 'zitadel:customers':
10971
+ case 'zitadel:customers-test':
9453
10972
  case 'zitadel:org':
9454
10973
  await this.ports.zitadel.deleteOrg(target.id);
9455
10974
  return;