@wtfalch/foundry 0.60.0 → 0.61.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 (218) 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 +10 -5
  44. package/dist/index.js +5 -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/modules/ai.d.ts +3 -0
  49. package/dist/modules/ai.js +64 -0
  50. package/dist/modules/assistants.d.ts +3 -0
  51. package/dist/modules/assistants.js +72 -0
  52. package/dist/modules/audit-ledger.d.ts +29 -0
  53. package/dist/modules/audit-ledger.js +49 -0
  54. package/dist/modules/audit.d.ts +7 -0
  55. package/dist/modules/audit.js +44 -0
  56. package/dist/modules/database.d.ts +23 -0
  57. package/dist/modules/database.js +189 -0
  58. package/dist/modules/email.d.ts +3 -0
  59. package/dist/modules/email.js +18 -0
  60. package/dist/modules/entry-parts.d.ts +23 -0
  61. package/dist/modules/entry-parts.js +63 -0
  62. package/dist/modules/files.d.ts +3 -0
  63. package/dist/modules/files.js +64 -0
  64. package/dist/modules/index.d.ts +74 -0
  65. package/dist/modules/index.js +774 -0
  66. package/dist/modules/jobs.d.ts +3 -0
  67. package/dist/modules/jobs.js +23 -0
  68. package/dist/modules/organisations.d.ts +3 -0
  69. package/dist/modules/organisations.js +20 -0
  70. package/dist/modules/outputs.d.ts +9 -0
  71. package/dist/modules/outputs.js +38 -0
  72. package/dist/modules/sign-in.d.ts +3 -0
  73. package/dist/modules/sign-in.js +197 -0
  74. package/dist/modules/signed-records.d.ts +3 -0
  75. package/dist/modules/signed-records.js +32 -0
  76. package/dist/modules/types.d.ts +194 -0
  77. package/dist/modules/types.js +1 -0
  78. package/dist/modules/workspaces.d.ts +3 -0
  79. package/dist/modules/workspaces.js +75 -0
  80. package/dist/org.d.ts +163 -22
  81. package/dist/org.js +419 -47
  82. package/dist/plan/apps-db-route.d.ts +90 -0
  83. package/dist/plan/apps-db-route.js +168 -0
  84. package/dist/plan/apps-zones.d.ts +56 -0
  85. package/dist/plan/apps-zones.js +118 -0
  86. package/dist/plan/archon-blue-green.js +11 -1
  87. package/dist/plan/blue-green-apply.d.ts +37 -1
  88. package/dist/plan/blue-green-apply.js +55 -15
  89. package/dist/plan/blue-green.d.ts +41 -1
  90. package/dist/plan/blue-green.js +80 -2
  91. package/dist/plan/boule.d.ts +23 -1
  92. package/dist/plan/boule.js +35 -2
  93. package/dist/plan/broker-entries.d.ts +28 -3
  94. package/dist/plan/broker-entries.js +74 -3
  95. package/dist/plan/broker-registrar.d.ts +168 -0
  96. package/dist/plan/broker-registrar.js +440 -0
  97. package/dist/plan/broker-workspaces-database.d.ts +48 -0
  98. package/dist/plan/broker-workspaces-database.js +87 -0
  99. package/dist/plan/desktop-callback.js +12 -7
  100. package/dist/plan/diff.d.ts +8 -2
  101. package/dist/plan/diff.js +373 -42
  102. package/dist/plan/edge-access.d.ts +6 -0
  103. package/dist/plan/edge-dns.d.ts +6 -0
  104. package/dist/plan/edge-dns.js +9 -7
  105. package/dist/plan/entry-ai-key.d.ts +1 -1
  106. package/dist/plan/entry-ai-key.js +2 -2
  107. package/dist/plan/entry-audit.d.ts +58 -0
  108. package/dist/plan/entry-audit.js +122 -0
  109. package/dist/plan/entry-customer-grants.d.ts +75 -0
  110. package/dist/plan/entry-customer-grants.js +154 -0
  111. package/dist/plan/entry-edge-key.d.ts +26 -1
  112. package/dist/plan/entry-edge-key.js +42 -3
  113. package/dist/plan/entry-jobs.d.ts +64 -0
  114. package/dist/plan/entry-jobs.js +92 -0
  115. package/dist/plan/entry-repo-key-apply.d.ts +55 -0
  116. package/dist/plan/entry-repo-key-apply.js +185 -0
  117. package/dist/plan/entry-repo-key.d.ts +92 -0
  118. package/dist/plan/entry-repo-key.js +235 -0
  119. package/dist/plan/entry-workspaces.d.ts +55 -0
  120. package/dist/plan/entry-workspaces.js +90 -0
  121. package/dist/plan/executor.d.ts +191 -1
  122. package/dist/plan/executor.js +1505 -90
  123. package/dist/plan/gates.d.ts +1 -1
  124. package/dist/plan/gates.js +62 -9
  125. package/dist/plan/github-deploy-keys.d.ts +78 -0
  126. package/dist/plan/github-deploy-keys.js +305 -0
  127. package/dist/plan/github-source.d.ts +53 -0
  128. package/dist/plan/github-source.js +140 -0
  129. package/dist/plan/held-key-refs.d.ts +12 -0
  130. package/dist/plan/held-key-refs.js +12 -0
  131. package/dist/plan/hostnames.d.ts +9 -0
  132. package/dist/plan/hostnames.js +28 -1
  133. package/dist/plan/index.d.ts +9 -0
  134. package/dist/plan/index.js +6 -0
  135. package/dist/plan/jobs-scheduler-http.js +3 -0
  136. package/dist/plan/keys-registrar-http.d.ts +32 -0
  137. package/dist/plan/keys-registrar-http.js +105 -0
  138. package/dist/plan/keys-registrar.d.ts +67 -0
  139. package/dist/plan/keys-registrar.js +103 -0
  140. package/dist/plan/live.d.ts +1 -1
  141. package/dist/plan/locked-hostnames.d.ts +2 -2
  142. package/dist/plan/locked-hostnames.js +2 -2
  143. package/dist/plan/login-host.js +11 -5
  144. package/dist/plan/machine-paths.d.ts +36 -0
  145. package/dist/plan/machine-paths.js +137 -0
  146. package/dist/plan/mcp.d.ts +1 -1
  147. package/dist/plan/mcp.js +49 -17
  148. package/dist/plan/org-apps-admin.d.ts +108 -0
  149. package/dist/plan/org-apps-admin.js +434 -0
  150. package/dist/plan/org-names.js +12 -1
  151. package/dist/plan/own-diff.d.ts +23 -0
  152. package/dist/plan/own-diff.js +53 -0
  153. package/dist/plan/own-hostnames.d.ts +2 -0
  154. package/dist/plan/own-hostnames.js +1 -0
  155. package/dist/plan/own-jobs-route.d.ts +47 -0
  156. package/dist/plan/own-jobs-route.js +91 -0
  157. package/dist/plan/own-services.d.ts +4 -0
  158. package/dist/plan/own-services.js +4 -0
  159. package/dist/plan/plan-apply.d.ts +39 -0
  160. package/dist/plan/plan-apply.js +163 -9
  161. package/dist/plan/platform-diff.d.ts +2 -2
  162. package/dist/plan/platform-diff.js +7 -2
  163. package/dist/plan/platform-org.d.ts +30 -0
  164. package/dist/plan/platform-org.js +12 -1
  165. package/dist/plan/platform-providers.d.ts +2 -0
  166. package/dist/plan/platform-providers.js +7 -2
  167. package/dist/plan/post-logout-uris.js +7 -1
  168. package/dist/plan/private-pages.d.ts +26 -0
  169. package/dist/plan/private-pages.js +39 -0
  170. package/dist/plan/proxy-labels.d.ts +12 -1
  171. package/dist/plan/proxy-labels.js +32 -2
  172. package/dist/plan/release-host.d.ts +6 -2
  173. package/dist/plan/release-host.js +36 -8
  174. package/dist/plan/stage-env.d.ts +19 -1
  175. package/dist/plan/stage-env.js +25 -1
  176. package/dist/plan/stage-guards.d.ts +20 -0
  177. package/dist/plan/stage-guards.js +24 -0
  178. package/dist/plan/stage-isolation.d.ts +24 -0
  179. package/dist/plan/stage-isolation.js +29 -1
  180. package/dist/plan/worker-app-database.d.ts +46 -42
  181. package/dist/plan/worker-app-database.js +150 -153
  182. package/dist/plan/worker-app.d.ts +5 -65
  183. package/dist/plan/worker-app.js +28 -105
  184. package/dist/plan/worker.d.ts +2 -2
  185. package/dist/plan/worker.js +2 -1
  186. package/dist/plan/workspace-client.d.ts +25 -0
  187. package/dist/plan/workspace-client.js +48 -0
  188. package/dist/plan/workspace-sign-in.d.ts +55 -0
  189. package/dist/plan/workspace-sign-in.js +273 -0
  190. package/dist/plan/workspace-worker.d.ts +4 -0
  191. package/dist/plan/workspace-worker.js +13 -0
  192. package/dist/plan/workspace.d.ts +101 -0
  193. package/dist/plan/workspace.js +305 -0
  194. package/dist/release/decide.d.ts +30 -1
  195. package/dist/release/decide.js +73 -11
  196. package/dist/release/health.d.ts +8 -3
  197. package/dist/release/health.js +58 -5
  198. package/dist/release/run.d.ts +2 -5
  199. package/dist/release/run.js +4 -0
  200. package/dist/release/state.d.ts +3 -1
  201. package/dist/release/state.js +6 -0
  202. package/dist/release/types.d.ts +20 -0
  203. package/dist/requested-changes.d.ts +11 -2
  204. package/dist/requested-changes.js +11 -0
  205. package/dist/service-labels.d.ts +4 -0
  206. package/dist/service-labels.js +11 -0
  207. package/dist/slug.d.ts +2 -0
  208. package/dist/slug.js +10 -6
  209. package/dist/store/postgres.js +12 -2
  210. package/dist/store/schema.d.ts +37 -1
  211. package/dist/store/schema.js +4 -0
  212. package/dist/worker-app-config.d.ts +1 -11
  213. package/dist/worker-app-config.js +4 -17
  214. package/dist/worker-app-migrations.d.ts +105 -0
  215. package/dist/worker-app-migrations.js +166 -0
  216. package/dist/worker-app-upload.d.ts +103 -0
  217. package/dist/worker-app-upload.js +128 -0
  218. package/package.json +18 -1
@@ -1,18 +1,24 @@
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, 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';
21
+ import { CallError, withTimeout as withLabelledTimeout } from './call-timeout.js';
16
22
  import { MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, 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';
@@ -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,39 @@ 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';
40
53
  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';
54
+ import { machineBypassScopeKey } from './machine-paths.js';
41
55
  import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
42
56
  import { OWN_MAIL_SMTP_INTERNAL_SCOPE_KEY, OWN_MAIL_SMTP_PORT_SCOPE_KEY, isOwnMailSmtpPort, normalizeMailTarget, } from './mail-relay.js';
43
57
  import { applyAccessTokenLifetime, applyMcpAccess, applyMcpProject } from './mcp.js';
44
58
  import { NETWORK_APPLIED_SCOPE_KEY } from './network-move.js';
59
+ import { provisionOrgAppsAdmin, retireOrgAppsAdmin, } from './org-apps-admin.js';
45
60
  import { ORG_POSTGRES_MIGRATION_CONFIRMED_SCOPE_KEY, OrgPostgresMigrationNotConfirmedError, migrateHomeOf, migrateOrgPostgresHome, parseMigrationConfirmation, } from './org-postgres-migrate.js';
46
61
  import { orgPostgresSecretRef, provisionOrgPostgresDatabases, provisionOrgPostgresServer, serviceEndpoint, serviceOnServer, serviceUrls, } from './org-postgres.js';
47
62
  import { OWN_AUTH_APPLIED_GIT_REF_SCOPE_KEY, OWN_AUTH_APPLIED_HOSTNAME_SCOPE_KEY, } from './own-diff.js';
48
63
  import { NO_DOMAIN_ZONE, ownHostnames } from './own-hostnames.js';
64
+ import { provisionJobsRoute } from './own-jobs-route.js';
49
65
  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';
66
+ 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
67
  import { ownSfuUrls, provisionSfuApp } from './own-sfu.js';
52
- import { PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, } from './platform-diff.js';
68
+ import { JOBS_INTERNAL_URL_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, } from './platform-diff.js';
53
69
  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';
70
+ 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
71
  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';
72
+ 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
73
  import { createReleaseHost } from './release-host.js';
58
74
  import { TUNNEL_HTTP_INGRESS_SCOPE_KEY, applyRemoteDatabase, mergeTcpIngress, recordedTcpRoutes, } from './remote-database.js';
59
75
  import { editSignInBindings } from './sign-in-bindings.js';
@@ -64,6 +80,9 @@ import { stageGateKey, stageHostname, } from './stage-guards.js';
64
80
  import { WORKER_APP_DB_SCOPE_KEYS, provisionWorkerAppDb, workerAppDbCaPem, workerAppDbOwnerUrl, } from './worker-app-db.js';
65
81
  import { WORKER_APP_CALL_TIMEOUT_MS, applyWorkerApp, heldBrokerKey, teardownWorkerApp, withCallTimeout, } from './worker-app.js';
66
82
  import { entryWorkerAppliedScopeKey, entryWorkerExclusionsScopeKey, entryWorkerRetiredScopeKey, entryWorkerRouteIdScopeKey, entryWorkerRoutePatternScopeKey, entryWorkerRoutePendingScopeKey, entryWorkerRouteZoneScopeKey, entryWorkerUploadedScopeKey, } from './worker.js';
83
+ import { TEST_CUSTOMERS_SIGN_IN_SCOPE_KEY, TEST_CUSTOMERS_SIGN_UP_SCOPE_KEY, testCustomersOrgName, workspaceAuthEntryOf, workspaceClientHosts, workspaceClientKey, } from './workspace-client.js';
84
+ import { liveClientIds, wantedWorkspaceBrokerEntry, workspaceAuthEnv, workspaceSignInMarker, } from './workspace-sign-in.js';
85
+ import { gatePeopleScopeKey, isServerWorkspace, peopleHash, readWorkspaceCommit, withWorkspaceWorkers, workspaceGateKey, workspaceStructureHash, workspaceSubject, wsKey, } from './workspace.js';
67
86
  /**
68
87
  * The real {@link StepExecutor} (#20 "provision Company Factory end to
69
88
  * end"): makes each {@link StepAction} `diff.ts` plans true against a real
@@ -116,9 +135,24 @@ export function adaptZitadelListApps(client) {
116
135
  }
117
136
  /** An outside call for Boule's credentials that has not answered in this long fails the step; the next apply repairs it. */
118
137
  const BOULE_CREDENTIAL_TIMEOUT_MS = 60_000;
138
+ /** One read of the broker's env. */
139
+ const BROKER_ENV_READ_TIMEOUT_MS = 15_000;
140
+ const HELD_KEY_READ_TIMEOUT_MS = 15_000;
119
141
  const BOULE_CREDENTIAL_ATTEMPTS = 3;
120
142
  /** The ZITADEL machine user that reads one company's customers organisation. */
121
143
  const CUSTOMERS_DIRECTORY_USER_NAME = 'foundry-customers-directory';
144
+ const LIVE_CUSTOMERS = {
145
+ step: 'zitadel:customers',
146
+ name: (slug) => `${slug} customers`,
147
+ signUpKey: CUSTOMERS_SIGN_UP_SCOPE_KEY,
148
+ signInKey: CUSTOMERS_SIGN_IN_SCOPE_KEY,
149
+ };
150
+ const TEST_CUSTOMERS = {
151
+ step: 'zitadel:customers-test',
152
+ name: testCustomersOrgName,
153
+ signUpKey: TEST_CUSTOMERS_SIGN_UP_SCOPE_KEY,
154
+ signInKey: TEST_CUSTOMERS_SIGN_IN_SCOPE_KEY,
155
+ };
122
156
  // ---------------------------------------------------------------------------
123
157
  // Errors
124
158
  // ---------------------------------------------------------------------------
@@ -859,6 +893,10 @@ async function withTimeout(work, ms) {
859
893
  clearTimeout(timer);
860
894
  }
861
895
  }
896
+ /** How long one call to the jobs scheduler or the held keys, made for an entry's project, may take. */
897
+ const JOBS_PROJECT_CALL_TIMEOUT_MS = 30_000;
898
+ /** How many times an entry's project credentials are tried against the held keys. */
899
+ const JOBS_PROJECT_HOLD_ATTEMPTS = 3;
862
900
  /** How long one Lettermint call of an own company's sign-in mint may take. */
863
901
  const LETTERMINT_TIMEOUT_MS = 60_000;
864
902
  const OWN_SERVICES_PORT_ABSENT = 'this org runs its own services and the host has no ownServices port for it';
@@ -889,7 +927,11 @@ function limitProfileOf(action) {
889
927
  case 'coolify:app':
890
928
  case 'coolify:database':
891
929
  return LIMITS.companyApp;
930
+ case 'coolify:github-source':
931
+ return undefined;
892
932
  case 'coolify:broker-entry':
933
+ case 'coolify:workspace-broker-entry':
934
+ case 'coolify:workspaces-database':
893
935
  return LIMITS.brokerEntry;
894
936
  case 'cms:site':
895
937
  return LIMITS.cms;
@@ -905,8 +947,14 @@ function limitProfileOf(action) {
905
947
  case 'coolify:boule':
906
948
  case 'coolify:boule-broker-entry':
907
949
  return LIMITS.boule;
950
+ case 'coolify:broker-registrar':
951
+ return LIMITS.brokerEntry;
952
+ case 'coolify:keys-registrar':
953
+ return LIMITS.boule;
908
954
  case 'coolify:bluegreen':
909
955
  case 'coolify:bluegreen-lb':
956
+ case 'coolify:workspace':
957
+ case 'coolify:promote':
910
958
  return LIMITS.companyApp;
911
959
  case 'cloudflare:tunnel':
912
960
  case 'coolify:bluegreen-connector':
@@ -1087,6 +1135,58 @@ export class FoundryStepExecutor {
1087
1135
  throw e;
1088
1136
  }
1089
1137
  }
1138
+ /**
1139
+ * Where the workspaces database of the broker this org's entries are written to already is: in the broker's
1140
+ * `WORKSPACES_DATABASE_URL` (`inEnv`) or as a URL held at the org's ref (`held`). The company's own broker for an
1141
+ * org that owns its services, the shared one otherwise. Throws when the broker is not wired or a read fails,
1142
+ * times out, or finds a held value that is no postgres URL, so a caller never takes "unknown" for "absent".
1143
+ */
1144
+ async workspacesDatabaseSources(org, recordedIds) {
1145
+ const broker = ownsServices(org)
1146
+ ? (await this.ports.ownServices?.portsFor(org, recordedIds))?.broker
1147
+ : this.ports.broker;
1148
+ if (!broker)
1149
+ throw new Error('the broker port is not wired');
1150
+ const envs = await withLabelledTimeout('the broker env read', BROKER_ENV_READ_TIMEOUT_MS, () => broker.listEnvs());
1151
+ if (envs.some((e) => e.key === BROKER_WORKSPACES_DATABASE_ENV && !e.is_preview && e.value)) {
1152
+ return { inEnv: true, held: false };
1153
+ }
1154
+ const held = await withLabelledTimeout('the held key read', HELD_KEY_READ_TIMEOUT_MS, () => heldWorkspacesDatabase(this.ports.heldKeys, workspacesDatabaseRef(org)));
1155
+ return { inEnv: false, held: Boolean(held) };
1156
+ }
1157
+ /**
1158
+ * Whether the broker this org's entries are written to has `WORKSPACES_DATABASE_URL`, or a URL is held to give it.
1159
+ * A read that fails or times out answers `false`, so the plan does not write an entry the broker cannot start
1160
+ * with.
1161
+ */
1162
+ async brokerHasWorkspacesDatabase(org, recordedIds) {
1163
+ try {
1164
+ const { inEnv, held } = await this.workspacesDatabaseSources(org, recordedIds);
1165
+ return inEnv || held;
1166
+ }
1167
+ catch {
1168
+ // Not readable (blocked own services, a timeout, a failed call, a held value that is no URL): the plan reports the entry blocked.
1169
+ return false;
1170
+ }
1171
+ }
1172
+ /**
1173
+ * Whether foundry may make the broker's workspaces database: it can hold the URL, and a read just showed that
1174
+ * neither the broker nor the held keys have one. A read that fails answers `false`, never "make one": a database
1175
+ * made over a broker whose own could not be read would replace it.
1176
+ */
1177
+ async canProvisionWorkspacesDatabase(org, recordedIds) {
1178
+ if (!canHoldWorkspacesDatabase(this.ports.heldKeys))
1179
+ return false;
1180
+ if (ownsServices(org) && !this.ports.ownServices)
1181
+ return false;
1182
+ try {
1183
+ const { inEnv, held } = await this.workspacesDatabaseSources(org, recordedIds);
1184
+ return !inEnv && !held;
1185
+ }
1186
+ catch {
1187
+ return false;
1188
+ }
1189
+ }
1090
1190
  /** #555: the edge-routed ports this executor was built with itself (they win over edge). */
1091
1191
  ownEdgePorts() {
1092
1192
  return {
@@ -1165,6 +1265,8 @@ export class FoundryStepExecutor {
1165
1265
  return this.zitadelDesktopCallback(action, org, context);
1166
1266
  case 'zitadel:customers':
1167
1267
  return this.zitadelCustomers(action, org, context);
1268
+ case 'zitadel:customers-test':
1269
+ return this.zitadelCustomers(action, org, context, TEST_CUSTOMERS);
1168
1270
  case 'zitadel:mcp-project':
1169
1271
  return this.zitadelMcpProject(action, org, context);
1170
1272
  case 'zitadel:mcp-access':
@@ -1173,14 +1275,24 @@ export class FoundryStepExecutor {
1173
1275
  return this.zitadelAccessTokenLifetime(context);
1174
1276
  case 'coolify:app':
1175
1277
  return this.coolifyApp(action, org, context);
1278
+ case 'coolify:github-source':
1279
+ return this.coolifyGithubSource(org, context);
1176
1280
  case 'coolify:network':
1177
1281
  return this.coolifyNetwork(action, org, context);
1178
1282
  case 'coolify:bluegreen':
1179
1283
  case 'coolify:bluegreen-lb':
1180
1284
  case 'coolify:bluegreen-connector':
1181
1285
  return this.coolifyBlueGreen(action, org, context);
1286
+ case 'coolify:workspace':
1287
+ return this.coolifyWorkspace(action, org, context);
1288
+ case 'coolify:promote':
1289
+ return this.coolifyPromote(action, org, context);
1182
1290
  case 'coolify:broker-entry':
1183
1291
  return this.coolifyBrokerEntry(action, org, context);
1292
+ case 'coolify:workspace-broker-entry':
1293
+ return this.coolifyWorkspaceBrokerEntry(action, org, context);
1294
+ case 'coolify:workspaces-database':
1295
+ return this.coolifyWorkspacesDatabase(org, context);
1184
1296
  case 'coolify:database':
1185
1297
  return this.coolifyDatabase(action, org, context);
1186
1298
  case 'coolify:org-postgres':
@@ -1189,6 +1301,12 @@ export class FoundryStepExecutor {
1189
1301
  return this.orgPostgresDatabases(action, org, context);
1190
1302
  case 'coolify:org-postgres-migrate':
1191
1303
  return this.orgPostgresMigrate(action, org, context);
1304
+ case 'coolify:org-apps-admin':
1305
+ return this.orgAppsAdmin(action, org, context);
1306
+ case 'cloudflare:apps-db-route':
1307
+ return this.appsDbRoute(action, org, context);
1308
+ case 'cloudflare:apps-zones':
1309
+ return this.appsZones(action, org, context);
1192
1310
  case 'coolify:backup':
1193
1311
  return this.coolifyBackup(action, org, context);
1194
1312
  case 'coolify:calls':
@@ -1202,7 +1320,7 @@ export class FoundryStepExecutor {
1202
1320
  case 'cloudflare:zone':
1203
1321
  return this.cloudflareZone(action, org, context);
1204
1322
  case 'cloudflare:access':
1205
- return this.cloudflareAccess(action, org);
1323
+ return this.cloudflareAccess(action, org, context);
1206
1324
  case 'cloudflare:gate':
1207
1325
  return this.cloudflareGate(action, org, context);
1208
1326
  case 'cloudflare:ratelimit':
@@ -1214,7 +1332,7 @@ export class FoundryStepExecutor {
1214
1332
  if (!workers) {
1215
1333
  throw new Error(`cloudflare:worker-app for ${org.slug}: this executor was built without a WorkerAppPort`);
1216
1334
  }
1217
- return applyWorkerApp(this.workerAppDeps(workers, org, () => context.recordedIds()), org, action, context);
1335
+ return applyWorkerApp(this.workerAppDeps(workers, org, () => context.recordedIds()), withWorkspaceWorkers(org), action, context);
1218
1336
  }
1219
1337
  case 'files:organisation':
1220
1338
  return this.filesOrganisation(action, org, context);
@@ -1236,6 +1354,14 @@ export class FoundryStepExecutor {
1236
1354
  return this.edgeEntryKey(action, org, context);
1237
1355
  case 'edge:reserved-labels':
1238
1356
  return this.edgeReservedLabels(action, org, context);
1357
+ case 'edge:slug':
1358
+ return this.edgeSlug(action, org, context);
1359
+ case 'jobs:project':
1360
+ return this.jobsProject(action, org, context);
1361
+ case 'github:deploy-key':
1362
+ return this.githubDeployKey(action, org, context);
1363
+ case 'audit:ledger':
1364
+ return this.auditLedger(action, org, context);
1239
1365
  case 'coolify:mail':
1240
1366
  return this.coolifyMail(action, org, context);
1241
1367
  case 'coolify:mail-copy':
@@ -1254,6 +1380,10 @@ export class FoundryStepExecutor {
1254
1380
  return this.bouleOwners(org, context);
1255
1381
  case 'coolify:boule-broker-entry':
1256
1382
  return this.bouleBrokerEntry(org, context);
1383
+ case 'coolify:broker-registrar':
1384
+ return this.brokerRegistrar(org, context);
1385
+ case 'coolify:keys-registrar':
1386
+ return this.keysRegistrar(action, org, context);
1257
1387
  case 'jobs:registration':
1258
1388
  return this.jobsRegistration(action, org, context);
1259
1389
  case 'cloudflare:db-route':
@@ -1266,6 +1396,8 @@ export class FoundryStepExecutor {
1266
1396
  return this.workerAppDb(org, context);
1267
1397
  case 'coolify:own-jobs':
1268
1398
  return this.ownJobs(action, org, context);
1399
+ case 'coolify:own-jobs-route':
1400
+ return this.ownJobsRoute(action, org, context);
1269
1401
  case 'coolify:own-mail':
1270
1402
  return this.ownMail(action, org, context);
1271
1403
  case 'coolify:own-sfu':
@@ -1288,7 +1420,10 @@ export class FoundryStepExecutor {
1288
1420
  * app carries any policy but foundry's, since that policy could let others
1289
1421
  * in.
1290
1422
  */
1291
- async cloudflareAccess(action, org) {
1423
+ async cloudflareAccess(action, org, context) {
1424
+ const machine = action.desired.machineBypass;
1425
+ if (machine)
1426
+ return this.cloudflareMachineBypass(action, org, context);
1292
1427
  const rule = (org.hosting.access ?? []).find((r) => appStepKey(org, r.hostname) === action.key);
1293
1428
  const owned = action.desired.ownedBypass;
1294
1429
  if (!rule && owned && appStepKey(org, owned) === action.key) {
@@ -1310,6 +1445,41 @@ export class FoundryStepExecutor {
1310
1445
  policy: await this.accessPolicyFor(org, rule),
1311
1446
  });
1312
1447
  }
1448
+ /**
1449
+ * F11 (`plan/machine-paths.ts`): one path-scoped Access application (`<host><path>`) with foundry's bypass
1450
+ * policy, so a machine caller is not stopped by the host's gate; or its removal. The stem of the step key
1451
+ * (`mp-<hash>`) names it; the recorded `host/path` is kept only to word the removal.
1452
+ */
1453
+ async cloudflareMachineBypass(action, org, context) {
1454
+ const stem = action.key.slice(org.id.length + 1);
1455
+ const desired = action.desired;
1456
+ if (desired.machineBypass === 'delete') {
1457
+ const id = context.recordedIds()[`cloudflare:access:${action.key}`];
1458
+ if (id) {
1459
+ try {
1460
+ await this.accessPort().deleteAccessApp(id);
1461
+ }
1462
+ catch (error) {
1463
+ if (!isEdgeNotFound(error))
1464
+ throw error;
1465
+ }
1466
+ }
1467
+ forgetScopeId(context, `cloudflare:access:${action.key}`);
1468
+ forgetScopeId(context, machineBypassScopeKey(stem));
1469
+ return {};
1470
+ }
1471
+ if (!desired.domain) {
1472
+ throw new Error(`cloudflare:access for ${org.slug}: machine bypass '${stem}' names no host and path`);
1473
+ }
1474
+ const result = await this.cloudflareAccessApp(org, {
1475
+ appName: `foundry:${org.id}:${desired.domain}`,
1476
+ domain: desired.domain,
1477
+ policyName: ACCESS_RULE_POLICY_NAMES.bypass,
1478
+ policy: await this.accessPolicyFor(org, { kind: 'bypass', hostname: desired.domain }),
1479
+ });
1480
+ context.recordScopeId(machineBypassScopeKey(stem), desired.domain);
1481
+ return result;
1482
+ }
1313
1483
  /**
1314
1484
  * `plan/diff.ts`'s `planGates`: makes, changes or deletes one declared
1315
1485
  * gate through edge. A create needs the platform's recorded Access provider,
@@ -1326,13 +1496,30 @@ export class FoundryStepExecutor {
1326
1496
  const recorded = context.recordedIds();
1327
1497
  const gateId = recorded[gateIdRecordKey(org.id, gateKey)];
1328
1498
  const hostnamesKey = gateHostnamesScopeKey(gateKey);
1499
+ const peopleKey = gatePeopleScopeKey(gateKey);
1329
1500
  if (desired.mode === 'delete') {
1330
1501
  if (gateId)
1331
1502
  await this.accessPort().deleteGate(gateId);
1332
1503
  if (context.forgetId) {
1333
1504
  context.forgetId(gateIdRecordKey(org.id, gateKey));
1334
1505
  context.forgetId(hostnamesKey);
1506
+ context.forgetId(peopleKey);
1507
+ }
1508
+ return {};
1509
+ }
1510
+ if (desired.mode === 'people') {
1511
+ const emails = desired.emails ?? [];
1512
+ if (!gateId)
1513
+ throw new Error(`${at}: no gate is recorded, so there is no one to change`);
1514
+ if (emails.length === 0) {
1515
+ throw new Error(`${at}: a gate nobody can pass is a mistake, and the list is empty`);
1335
1516
  }
1517
+ const access = this.accessPort();
1518
+ if (!access.setGatePeople) {
1519
+ throw new Error(`${at}: this host's Access port cannot change who passes a gate`);
1520
+ }
1521
+ await access.setGatePeople(gateId, emails);
1522
+ context.recordScopeId(peopleKey, peopleHash(emails));
1336
1523
  return {};
1337
1524
  }
1338
1525
  const hostnames = desired.hostnames ?? [];
@@ -1346,6 +1533,7 @@ export class FoundryStepExecutor {
1346
1533
  // The gate is gone at edge: forget it, so the next plan creates it again.
1347
1534
  context.forgetId?.(gateIdRecordKey(org.id, gateKey));
1348
1535
  context.forgetId?.(hostnamesKey);
1536
+ context.forgetId?.(peopleKey);
1349
1537
  return {};
1350
1538
  }
1351
1539
  context.recordScopeId(hostnamesKey, JSON.stringify(hostnames));
@@ -1358,7 +1546,7 @@ export class FoundryStepExecutor {
1358
1546
  if (!claimValue) {
1359
1547
  throw new Error(`${at}: ${org.slug} has no sign-in organisation recorded yet`);
1360
1548
  }
1361
- const emails = org.owners.map((owner) => owner.email);
1549
+ const emails = desired.emails ?? org.owners.map((owner) => owner.email);
1362
1550
  if (emails.length === 0) {
1363
1551
  throw new Error(`${at}: ${org.slug} declares no owner with an email, and a gate nobody can pass is a mistake`);
1364
1552
  }
@@ -1373,6 +1561,8 @@ export class FoundryStepExecutor {
1373
1561
  emails,
1374
1562
  });
1375
1563
  context.recordScopeId(hostnamesKey, JSON.stringify(hostnames));
1564
+ if (desired.emails)
1565
+ context.recordScopeId(peopleKey, peopleHash(desired.emails));
1376
1566
  return { resourceId: created.id };
1377
1567
  }
1378
1568
  /** The Cloudflare Access policy body for one `hosting.access` rule, by kind (#246). */
@@ -1535,6 +1725,7 @@ export class FoundryStepExecutor {
1535
1725
  return db.internal_db_url;
1536
1726
  },
1537
1727
  secret: (value) => void this.handledSecrets.add(value),
1728
+ log: (line) => console.info(redactSecrets(line, [...this.handledSecrets])),
1538
1729
  },
1539
1730
  }
1540
1731
  : {}),
@@ -2488,13 +2679,13 @@ export class FoundryStepExecutor {
2488
2679
  * every issuer org, which is named by a slug and a slug has no spaces. Every call here is idempotent, so
2489
2680
  * a re-run after a crash finishes the job.
2490
2681
  */
2491
- async zitadelCustomers(action, org, context) {
2682
+ async zitadelCustomers(action, org, context, variant = LIVE_CUSTOMERS) {
2492
2683
  const zitadelOrgId = context.recordedIds().zitadel_org;
2493
2684
  if (!zitadelOrgId) {
2494
- throw new Error(`zitadel:customers for ${org.slug}: no ZITADEL org recorded yet -- the issuer must be provisioned first`);
2685
+ throw new Error(`${variant.step} for ${org.slug}: no ZITADEL org recorded yet -- the issuer must be provisioned first`);
2495
2686
  }
2496
2687
  const projectId = await this.ensureZitadelProject(org, zitadelOrgId, context);
2497
- const { org: customers } = await this.ports.zitadel.findOrCreateOrg(`${org.slug} customers`);
2688
+ const { org: customers } = await this.ports.zitadel.findOrCreateOrg(variant.name(org.slug));
2498
2689
  // Self-registration follows `customers.signUp` (off by default). The marker is written last, so a
2499
2690
  // run that failed before here is planned again.
2500
2691
  const signUp = action.desired.signUp === true;
@@ -2504,11 +2695,14 @@ export class FoundryStepExecutor {
2504
2695
  await this.ports.zitadel.closeSelfRegistration(customers.id);
2505
2696
  await this.ports.zitadel.findOrCreateProjectGrant(zitadelOrgId, projectId, customers.id);
2506
2697
  // 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');
2698
+ const signIn = await this.writeCustomersSignInMetadata(org, customers.id, action, context, variant);
2699
+ // The sign-in window text is the live customers organisation's; the test customers planner plans none.
2700
+ const room = variant === LIVE_CUSTOMERS
2701
+ ? await this.writeCustomersRoomMetadata(org, customers.id, action, context)
2702
+ : null;
2703
+ context.recordScopeId(variant.signUpKey, signUp ? '1' : '0');
2510
2704
  if (signIn)
2511
- context.recordScopeId(CUSTOMERS_SIGN_IN_SCOPE_KEY, signIn);
2705
+ context.recordScopeId(variant.signInKey, signIn);
2512
2706
  if (room !== null)
2513
2707
  context.recordScopeId(CUSTOMERS_ROOM_SCOPE_KEY, room);
2514
2708
  return { resourceId: customers.id };
@@ -2550,13 +2744,13 @@ export class FoundryStepExecutor {
2550
2744
  * only when the recorded marker differs, so a step for another change touches
2551
2745
  * nothing here. Returns the marker value to record, or null for no change.
2552
2746
  */
2553
- async writeCustomersSignInMetadata(org, customersOrgId, action, context) {
2747
+ async writeCustomersSignInMetadata(org, customersOrgId, action, context, variant = LIVE_CUSTOMERS) {
2554
2748
  const want = action.desired.signIn;
2555
2749
  if (want === undefined)
2556
2750
  return null;
2557
2751
  const { setOrgMetadata, removeOrgMetadata } = this.ports.zitadel;
2558
2752
  if (!setOrgMetadata || !removeOrgMetadata) {
2559
- throw new Error(`zitadel:customers for ${org.slug}: the ZITADEL client cannot set organisation metadata`);
2753
+ throw new Error(`${variant.step} for ${org.slug}: the ZITADEL client cannot set organisation metadata`);
2560
2754
  }
2561
2755
  try {
2562
2756
  if (want === 'email-code') {
@@ -2564,13 +2758,13 @@ export class FoundryStepExecutor {
2564
2758
  return 'email-code';
2565
2759
  }
2566
2760
  // Nothing to remove when it was never written.
2567
- if (context.recordedIds()[CUSTOMERS_SIGN_IN_SCOPE_KEY] !== 'email-code')
2761
+ if (context.recordedIds()[variant.signInKey] !== 'email-code')
2568
2762
  return null;
2569
2763
  await removeOrgMetadata.call(this.ports.zitadel, customersOrgId, CUSTOMERS_SIGN_IN_METADATA_KEY);
2570
2764
  return 'password';
2571
2765
  }
2572
2766
  catch {
2573
- throw new Error(`zitadel:customers for ${org.slug}: writing the sign-in metadata of organisation ${customersOrgId} failed`);
2767
+ throw new Error(`${variant.step} for ${org.slug}: writing the sign-in metadata of organisation ${customersOrgId} failed`);
2574
2768
  }
2575
2769
  }
2576
2770
  async zitadelApp(action, org, context) {
@@ -2585,13 +2779,15 @@ export class FoundryStepExecutor {
2585
2779
  const isAgoraClient = action.key === 'agora';
2586
2780
  const isBouleClient = action.key === 'boule';
2587
2781
  const authEntry = org.hosting.entries.find((e) => e.key === action.key && e.uses?.auth);
2782
+ // A workspace's own client `<key>-work`, signing in at the workspace host.
2783
+ const workspaceEntry = authEntry ? undefined : workspaceAuthEntryOf(org, action.key);
2588
2784
  const clientIdScopeKey = isCmsClient
2589
2785
  ? CMS_CLIENT_ID_SCOPE_KEY
2590
2786
  : isAgoraClient
2591
2787
  ? AGORA_CLIENT_ID_SCOPE_KEY
2592
2788
  : isBouleClient
2593
2789
  ? BOULE_CLIENT_ID_SCOPE_KEY
2594
- : authEntry
2790
+ : authEntry || workspaceEntry
2595
2791
  ? entryClientIdScopeKey(action.key)
2596
2792
  : APP_CLIENT_ID_SCOPE_KEY;
2597
2793
  const priorAppId = recorded[`zitadel:app:${action.key}`];
@@ -2936,6 +3132,8 @@ export class FoundryStepExecutor {
2936
3132
  coolify: this.ports.coolify,
2937
3133
  healthToken: async () => (await this.ports.heldKeys?.resolve(sharedSecretRef(org.id, healthTokenName(subject.id)))) ?? null,
2938
3134
  secret: (value) => void this.handledSecrets.add(value),
3135
+ pinRef: sharedSecretRef(org.id, pinKeyName(subject.id)),
3136
+ ...(this.ports.heldKeys ? { heldKeys: this.ports.heldKeys } : {}),
2939
3137
  primaryUuid,
2940
3138
  primaryBlueAppId,
2941
3139
  runtime: this.ports.runtime ?? {},
@@ -3035,6 +3233,16 @@ export class FoundryStepExecutor {
3035
3233
  this.handledSecrets.add(token);
3036
3234
  return token;
3037
3235
  },
3236
+ pinKey: () => mintPinKey({
3237
+ id: subject.id,
3238
+ ref: sharedSecretRef(org.id, pinKeyName(subject.id)),
3239
+ recorded,
3240
+ record: (k, v) => context.recordScopeId(k, v),
3241
+ persist: () => context.persistScopeIds(),
3242
+ heldKeys: this.ports.heldKeys ?? { resolve: async () => null },
3243
+ generate: () => randomBytes(32).toString('hex'),
3244
+ secret: (value) => void this.handledSecrets.add(value),
3245
+ }),
3038
3246
  actionsKey: async () => {
3039
3247
  const heldKeys = this.ports.heldKeys;
3040
3248
  if (typeof heldKeys?.put !== 'function') {
@@ -3057,6 +3265,7 @@ export class FoundryStepExecutor {
3057
3265
  return key;
3058
3266
  },
3059
3267
  secret: (value) => void this.handledSecrets.add(value),
3268
+ githubAppUuid: (repo) => this.githubAppUuidFor(org, repo, recorded()),
3060
3269
  runtime,
3061
3270
  sleep: runtime.sleep ?? defaultSleep,
3062
3271
  label: `${action.system}:${action.kind}:${subject.id}`,
@@ -3074,6 +3283,296 @@ export class FoundryStepExecutor {
3074
3283
  }
3075
3284
  return undefined;
3076
3285
  }
3286
+ /**
3287
+ * `coolify:workspace` (`plan/workspace.ts`): a server entry's private workspace on the primary server. It is
3288
+ * the blue/green stage (the stage app, its own empty database, the stage mail sink and the stage guards) at
3289
+ * the workspace host, with the Access gate `planGates` made first. Nothing here makes a color, a balancer or
3290
+ * a public name. The live app, when the entry is live, is only read for the names stage must not share.
3291
+ */
3292
+ async coolifyWorkspace(action, org, context) {
3293
+ const recorded = () => context.recordedIds();
3294
+ const key = String(action.desired.entry ?? '');
3295
+ const entry = org.hosting.entries.find((e) => e.key === key && isServerWorkspace(e) && e.repo !== null);
3296
+ if (!entry) {
3297
+ throw new Error(`coolify:workspace for ${org.slug}: no server workspace '${key}'`);
3298
+ }
3299
+ const serverUuid = String(action.desired.server ?? '');
3300
+ const primaryUuid = String(action.desired.primary ?? '');
3301
+ const servers = await this.resolveServers(org, context);
3302
+ if (!servers.servers.some((s) => s.uuid === serverUuid) ||
3303
+ servers.primary.uuid !== primaryUuid) {
3304
+ throw new Error(`coolify:workspace for ${org.slug}: Coolify server ${serverUuid} is not the usable, recorded primary`);
3305
+ }
3306
+ const projectUuid = await this.ensureCoolifyProject(org, context);
3307
+ const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
3308
+ const subject = workspaceSubject(org, entry);
3309
+ const host = workspaceHost(org.slug, entry.key);
3310
+ const gateId = recorded()[gateIdRecordKey(org.id, workspaceGateKey(entry.key))];
3311
+ const guards = this.ports.stageProbes ? { probes: this.ports.stageProbes } : {};
3312
+ if (gateId)
3313
+ guards.gate = { hostname: host, create: async () => ({ id: gateId }) };
3314
+ const runtime = this.ports.runtime ?? {};
3315
+ const authIssuer = action.desired.authIssuer ?? null;
3316
+ const signIn = await this.workspaceStageSignIn(org, entry, authIssuer, context);
3317
+ await applyWorkspaceServer({
3318
+ coolify: this.ports.coolify,
3319
+ recorded,
3320
+ record: (k, v) => context.recordScopeId(k, v),
3321
+ persist: () => context.persistScopeIds(),
3322
+ projectUuid,
3323
+ environment,
3324
+ secret: (value) => void this.handledSecrets.add(value),
3325
+ githubAppUuid: (repo) => this.githubAppUuidFor(org, repo, recorded()),
3326
+ runtime,
3327
+ sleep: runtime.sleep ?? defaultSleep,
3328
+ label: `${action.system}:${action.kind}:${entry.key}`,
3329
+ // The live app, when there is one: stage's names are checked against its env and nothing is copied from it.
3330
+ primaryBlueAppId: isLiveEntry(entry) ? (recorded()[subject.blueAppKey] ?? null) : null,
3331
+ logDir: LOG_DIR,
3332
+ isRunning: isRunningOrStarting,
3333
+ stageGuards: guards,
3334
+ stageSink: { slug: org.slug },
3335
+ stageAppUrl: `https://${host}`,
3336
+ ...(signIn ? { signIn } : {}),
3337
+ }, subject, serverUuid);
3338
+ // Only a stage that took the values has applied them; otherwise the next plan tries again.
3339
+ if (signIn && recorded()[bgKey.stageState(entry.key)] === 'ok') {
3340
+ const marker = workspaceSignInMarker(org, entry, recorded(), authIssuer);
3341
+ if (marker)
3342
+ context.recordScopeId(wsKey.signIn(entry.key), marker);
3343
+ }
3344
+ context.recordScopeId(wsKey.applied(entry.key), workspaceStructureHash(org, entry));
3345
+ await context.persistScopeIds();
3346
+ return undefined;
3347
+ }
3348
+ /**
3349
+ * The stage's own sign-in, for a workspace of an entry with `uses.auth`: the recorded organisation, client and
3350
+ * issuer, its own workspaces key (made and held first, once) and what it must never hold, which is what the
3351
+ * live app holds. A key is registered for redaction before it goes anywhere. Errors name the entry and the ref,
3352
+ * never a value; every held-key read has a timeout.
3353
+ */
3354
+ async workspaceStageSignIn(org, entry, authIssuer, context) {
3355
+ const auth = entry.uses?.auth;
3356
+ if (!auth)
3357
+ return undefined;
3358
+ const at = `coolify:workspace for ${org.slug}`;
3359
+ const recorded = context.recordedIds();
3360
+ const authEnv = workspaceAuthEnv(org, entry, recorded, authIssuer);
3361
+ if (!authEnv) {
3362
+ throw new Error(`${at}: the sign-in organisation, client or issuer of entry '${entry.key}' is not recorded yet`);
3363
+ }
3364
+ const env = Object.entries(authEnv).map(([key, value]) => ({ key, value }));
3365
+ const held = this.ports.heldKeys;
3366
+ const heldKeys = held && {
3367
+ resolve: (ref) => withLabelledTimeout('held key read', HELD_KEY_READ_TIMEOUT_MS, () => held.resolve(ref)),
3368
+ ...(typeof held.put === 'function'
3369
+ ? {
3370
+ put: (ref, value) => withLabelledTimeout('held key write', HELD_KEY_READ_TIMEOUT_MS, () => held.put(ref, value)),
3371
+ }
3372
+ : {}),
3373
+ };
3374
+ if (auth.workspaces) {
3375
+ const workspacesKey = await ensureWorkspacesKey({
3376
+ orgId: org.id,
3377
+ slug: org.slug,
3378
+ entryKey: workspaceClientKey(entry.key),
3379
+ heldKeys,
3380
+ recorded: () => context.recordedIds(),
3381
+ record: (key, value) => context.recordScopeId(key, value),
3382
+ persist: () => context.persistScopeIds(),
3383
+ secret: (value) => void this.handledSecrets.add(value),
3384
+ });
3385
+ env.push({ key: 'WORKSPACES_API_KEY', value: workspacesKey });
3386
+ }
3387
+ const workspacesKeys = [];
3388
+ for (const live of org.hosting.entries.filter((e) => e.uses?.auth?.workspaces)) {
3389
+ const value = await heldKeys?.resolve(workspacesKeyRef(org.id, live.key));
3390
+ if (value) {
3391
+ this.handledSecrets.add(value);
3392
+ workspacesKeys.push(value);
3393
+ }
3394
+ }
3395
+ const liveMcp = org.hosting.entries.filter((live) => live.uses?.auth?.mcp);
3396
+ return {
3397
+ env,
3398
+ customers: auth.users === 'customers',
3399
+ redirectUri: `https://${workspaceClientHosts(org, entry)[0]}/auth/callback`,
3400
+ live: {
3401
+ ...(recorded[`zitadel:customers:${org.id}`]
3402
+ ? { customersOrgId: recorded[`zitadel:customers:${org.id}`] }
3403
+ : {}),
3404
+ clientIds: liveClientIds(org, recorded),
3405
+ workspacesKeys,
3406
+ mcpAudiences: liveMcp.flatMap((m) => recorded[entryMcpProjectScopeKey(m.key)] ?? []),
3407
+ mcpResources: liveMcp.flatMap((m) => entryMcp(org, m, recorded)?.resource ?? []),
3408
+ },
3409
+ };
3410
+ }
3411
+ /**
3412
+ * `coolify:workspace-broker-entry` (`plan/workspace-sign-in.ts`): the broker entry `<slug>-<key>-work` of a
3413
+ * workspace with sign-in, written with the stage app's own `AUTH_APP_KEY` and the hash of its own workspaces
3414
+ * key. It never reads or writes the live entry. Records what it wrote, never a key.
3415
+ */
3416
+ async coolifyWorkspaceBrokerEntry(action, org, context) {
3417
+ const at = `coolify:workspace-broker-entry for ${org.slug}`;
3418
+ const broker = this.ports.broker;
3419
+ if (!broker)
3420
+ throw new Error(`${at}: the broker port is not wired`);
3421
+ const recorded = context.recordedIds();
3422
+ const entry = org.hosting.entries.find((e) => appStepKey(org, e.key) === action.key && isServerWorkspace(e) && e.uses?.auth);
3423
+ if (!entry?.uses?.auth)
3424
+ throw new Error(`${at}: no workspace with sign-in for ${action.key}`);
3425
+ const wanted = wantedWorkspaceBrokerEntry(org, entry, recorded);
3426
+ if ('missing' in wanted) {
3427
+ throw new Error(`${at}: ${wanted.missing.map((m) => m.what).join(', ')} not recorded yet`);
3428
+ }
3429
+ const stagePrefix = `bg.${entry.key}.app.`;
3430
+ const stageId = Object.entries(recorded).find(([key, id]) => id && key.startsWith(stagePrefix) && key.endsWith('.stage'))?.[1];
3431
+ if (!stageId)
3432
+ throw new Error(`${at}: the workspace's app is not recorded yet`);
3433
+ const appKey = (await this.ports.coolify.listEnvs(stageId)).find((e) => e.key === 'AUTH_APP_KEY' && !e.is_preview)?.value;
3434
+ if (!appKey || appKey.length < 32) {
3435
+ throw new Error(`${at}: the workspace's app ${stageId} has no AUTH_APP_KEY yet`);
3436
+ }
3437
+ this.handledSecrets.add(appKey);
3438
+ const clientKey = workspaceClientKey(entry.key);
3439
+ await this.writeAppBrokerEntry(org, broker, clientKey, wanted.want, appKey, context, wanted.want.workspaces ? await this.workspacesInputs(org, clientKey) : {});
3440
+ return {};
3441
+ }
3442
+ /**
3443
+ * `coolify:workspaces-database` (`plan/broker-workspaces-database.ts`): the broker's workspaces database, made
3444
+ * beside the broker (the platform's project and network, or the company's own for an org that owns its services)
3445
+ * and held at `workspacesDatabaseRef(org)`. Reads the broker and the held keys again first and makes nothing when
3446
+ * either has a URL, so a database is never made over one. Records nothing: the held URL is the record, and a
3447
+ * database made but not held is found by name on the next apply. The database stays when the org is removed, as
3448
+ * the held keys do.
3449
+ */
3450
+ async coolifyWorkspacesDatabase(org, context) {
3451
+ const at = `coolify:workspaces-database for ${org.slug}`;
3452
+ const sources = await this.workspacesDatabaseSources(org, context.recordedIds()).catch(() => {
3453
+ throw new Error(`${at}: the broker's env or the held URL could not be read; nothing was made`);
3454
+ });
3455
+ if (sources.inEnv || sources.held)
3456
+ return undefined;
3457
+ const own = ownsServices(org);
3458
+ const target = own ? orgTarget(org) : platformTarget();
3459
+ let coolify;
3460
+ let projectUuid;
3461
+ if (own) {
3462
+ const ownPorts = this.ports.ownServices;
3463
+ if (!ownPorts)
3464
+ throw new Error(OWN_SERVICES_PORT_ABSENT);
3465
+ coolify = limited(this.placedForCompany(ownPorts.provision.auth.coolify, org, context), LIMITS.ownAuth);
3466
+ projectUuid = () => this.ensureCoolifyProject(org, context);
3467
+ }
3468
+ else {
3469
+ coolify = placedOnPlatform(this.ports.coolify);
3470
+ projectUuid = async () => (await this.ports.coolify.findProjectByName(PLATFORM_AUTH_PROJECT))?.uuid ?? null;
3471
+ }
3472
+ const known = context.recordedIds()[BROKER_WORKSPACES_DATABASE_SCOPE_KEY];
3473
+ await ensureBrokerWorkspacesDatabase({
3474
+ target,
3475
+ coolify,
3476
+ projectUuid,
3477
+ heldKeys: this.ports.heldKeys,
3478
+ ...(known ? { knownUuid: known } : {}),
3479
+ recordUuid: async (uuid) => {
3480
+ context.recordScopeId(BROKER_WORKSPACES_DATABASE_SCOPE_KEY, uuid);
3481
+ await context.persistScopeIds();
3482
+ },
3483
+ secret: (value) => void this.handledSecrets.add(value),
3484
+ });
3485
+ return undefined;
3486
+ }
3487
+ /**
3488
+ * `coolify:promote`: moves a live workspace entry's app to `HostingEntry.liveCommit`. Patches `git_commit_sha`,
3489
+ * deploys and waits for the deployment, then records `ws.<key>.livecommit`. A failure after the patch leaves the
3490
+ * app configured for the new commit with the old container still running and no marker, so the next apply
3491
+ * patches and deploys again.
3492
+ */
3493
+ async coolifyPromote(action, org, context) {
3494
+ const key = String(action.desired.entry ?? '');
3495
+ const entry = org.hosting.entries.find((e) => e.key === key && isServerWorkspace(e));
3496
+ const commit = entry?.liveCommit;
3497
+ if (!entry || commit === undefined || !FULL_COMMIT_SHA.test(commit)) {
3498
+ throw new Error(`coolify:promote for ${org.slug}: entry '${key}' has no valid liveCommit`);
3499
+ }
3500
+ const appId = context.recordedIds()[`coolify:app:${appStepKey(org, key)}`];
3501
+ if (!appId) {
3502
+ throw new Error(`coolify:promote for ${org.slug}: the live app for '${key}' is not recorded`);
3503
+ }
3504
+ await this.ports.coolify.updateApplication(appId, { git_commit_sha: commit });
3505
+ const runtime = this.ports.runtime ?? {};
3506
+ 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`);
3507
+ await recordBuiltCommit(this.ports.coolify, { appId, deploymentUuid: built.deploymentUuid, name: key }, (k, v) => context.recordScopeId(k, v));
3508
+ context.recordScopeId(wsKey.liveCommit(key), commit);
3509
+ await context.persistScopeIds();
3510
+ return undefined;
3511
+ }
3512
+ /**
3513
+ * The commit a workspace entry's live app is made from: the newest finished deployment of its workspace's stage
3514
+ * app, else the commit recorded when the workspace was last applied. Recorded as `ws.<key>.commit`. Throws,
3515
+ * before any app is made, when there is none.
3516
+ */
3517
+ async workspaceCommit(org, entry, serverUuid, context) {
3518
+ const stageId = context.recordedIds()[bgKey.app(entry.key, serverUuid, 'stage')];
3519
+ const commit = (stageId ? await readWorkspaceCommit(this.ports.coolify, stageId) : null) ??
3520
+ context.recordedIds()[wsKey.commit(entry.key)] ??
3521
+ null;
3522
+ if (!commit) {
3523
+ throw new Error(`coolify:app for ${org.slug}: workspace '${entry.key}' has not deployed a commit, so there is nothing to put live`);
3524
+ }
3525
+ context.recordScopeId(wsKey.commit(entry.key), commit);
3526
+ await context.persistScopeIds();
3527
+ return commit;
3528
+ }
3529
+ /**
3530
+ * The Coolify GitHub source an app of `repo` is created through: the company's own (`coolify:github-source`,
3531
+ * recorded by that step) when `repo` is under the company's GitHub account, else the platform's.
3532
+ */
3533
+ async githubAppUuidFor(org, repo, recorded) {
3534
+ if (!companyGithubFor(org.hosting, repo)) {
3535
+ return (await this.ports.coolify.requireGithubApp()).uuid;
3536
+ }
3537
+ const uuid = recorded[githubSourceResourceKey(org.id)];
3538
+ if (!uuid) {
3539
+ 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`);
3540
+ }
3541
+ return uuid;
3542
+ }
3543
+ /** 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. */
3544
+ async coolifyGithubSource(org, context) {
3545
+ const github = org.hosting.github;
3546
+ if (!github)
3547
+ throw new Error(`coolify:github-source for ${org.slug}: no hosting.github declared`);
3548
+ const heldKeys = this.ports.heldKeys;
3549
+ if (!heldKeys) {
3550
+ throw new Error(`coolify:github-source for ${org.slug}: the executor was built without heldKeys`);
3551
+ }
3552
+ const { uuid } = await ensureGithubSource({
3553
+ coolify: this.ports.coolify,
3554
+ heldKeys,
3555
+ secret: (value) => void this.handledSecrets.add(value),
3556
+ label: `coolify:github-source for ${org.slug}`,
3557
+ slug: org.slug,
3558
+ github,
3559
+ });
3560
+ context.recordScopeId(GITHUB_SOURCE_INSTALLATION_SCOPE_KEY, String(github.installationId));
3561
+ await context.persistScopeIds();
3562
+ return { resourceId: uuid };
3563
+ }
3564
+ /** True when no Coolify GitHub source has this uuid. A failed read counts as not gone. */
3565
+ async githubSourceGone(uuid) {
3566
+ try {
3567
+ const sources = await withLabelledTimeout('coolify:github-source: listing sources', GITHUB_SOURCE_TIMEOUT_MS, () => this.ports.coolify.raw('GET', '/github-apps'));
3568
+ return (Array.isArray(sources) && !sources.some((s) => s?.uuid === uuid));
3569
+ }
3570
+ catch (error) {
3571
+ if (error instanceof CallError)
3572
+ return false;
3573
+ throw error;
3574
+ }
3575
+ }
3077
3576
  /**
3078
3577
  * Stage's guards (`plan/stage-guards.ts`): the probe port the host supplied, and the Access gate over the stage
3079
3578
  * host. The gate is offered only when everything it needs is in hand (a domain, the platform's Access provider,
@@ -3201,6 +3700,8 @@ export class FoundryStepExecutor {
3201
3700
  // (the marker is recorded only after the redeploy), e.g. a first deploy
3202
3701
  // that failed: starting it builds nothing. A parked app has its marker.
3203
3702
  let neverDeployed = false;
3703
+ // A workspace entry's live app is made from the commit its workspace runs.
3704
+ let pinnedCommit = null;
3204
3705
  if (existing) {
3205
3706
  appId = existing.uuid;
3206
3707
  // Found by name (rather than just created) is started back up if not
@@ -3219,14 +3720,18 @@ export class FoundryStepExecutor {
3219
3720
  }
3220
3721
  else {
3221
3722
  const server = await this.resolveServer(org, context);
3222
- const githubApp = await this.ports.coolify.requireGithubApp();
3723
+ const githubAppUuid = await this.githubAppUuidFor(org, appRepo, context.recordedIds());
3724
+ if (isServerWorkspace(entry)) {
3725
+ pinnedCommit =
3726
+ entry.liveCommit ?? (await this.workspaceCommit(org, entry, server.uuid, context));
3727
+ }
3223
3728
  const created = await this.ports.coolify.createGithubAppApplication({
3224
3729
  is_preview_deployments_enabled: false,
3225
3730
  project_uuid: projectUuid,
3226
3731
  server_uuid: server.uuid,
3227
3732
  environment_name: environment.name,
3228
3733
  environment_uuid: environment.uuid,
3229
- github_app_uuid: githubApp.uuid,
3734
+ github_app_uuid: githubAppUuid,
3230
3735
  git_repository: appRepo,
3231
3736
  git_branch: entry.branch,
3232
3737
  build_pack: entry.buildPack,
@@ -3261,6 +3766,8 @@ export class FoundryStepExecutor {
3261
3766
  // step for it). A created app has them off from the create.
3262
3767
  if (previewsOn(live))
3263
3768
  patch.is_preview_deployments_enabled = false;
3769
+ if (pinnedCommit)
3770
+ patch.git_commit_sha = pinnedCommit;
3264
3771
  // #543: the health-check quartet too, every run -- the same reason
3265
3772
  // Archon's own doc comment gives (without one, Coolify's rolling update
3266
3773
  // has nothing to wait on and stops the old container immediately). The
@@ -3391,6 +3898,9 @@ export class FoundryStepExecutor {
3391
3898
  context.recordScopeId(settingsKey, JSON.stringify({ ...want, port: want.port ?? have.port }));
3392
3899
  context.recordScopeId(domainsKey, JSON.stringify(wantDomains));
3393
3900
  context.recordScopeId(labelsKey, wantLabelsHash);
3901
+ if (pinnedCommit && pinnedCommit === entry.liveCommit) {
3902
+ context.recordScopeId(wsKey.liveCommit(entry.key), pinnedCommit);
3903
+ }
3394
3904
  context.recordScopeId(appEnvAppliedScopeKey(entry.key), appEnv.applied);
3395
3905
  if (entry.uses?.auth) {
3396
3906
  context.recordScopeId(entryAuthEnvAppliedScopeKey(entry.key), appEnv.authApplied);
@@ -3404,7 +3914,8 @@ export class FoundryStepExecutor {
3404
3914
  // actually resolved a mail domain for this org.
3405
3915
  // One Lettermint project, `<slug>-app`, belongs to the `app` entry; a
3406
3916
  // second entry sharing the name would delete the first's live token.
3407
- if (isMainEntry(entry)) {
3917
+ // An entry that declares `uses.mail` shares that project's token and sender address.
3918
+ if (isMainEntry(entry) || entry.uses?.mail) {
3408
3919
  await this.ensureAppLettermintMail(appId, org, context,
3409
3920
  // Deployed or started above on every path, a created app included
3410
3921
  // (#566), so it has read its env before the mail env is written.
@@ -3416,7 +3927,7 @@ export class FoundryStepExecutor {
3416
3927
  // step planned before this field existed; `ensureAppLettermintMail`
3417
3928
  // refuses rather than silently falling back once it knows there is
3418
3929
  // a recorded mail domain to reason about.
3419
- 'platformApex' in action.desired, action.desired.platformApex ?? null, action.desired.platformLettermintProjectId ?? null);
3930
+ 'platformApex' in action.desired, action.desired.platformApex ?? null, action.desired.platformLettermintProjectId ?? null, isMainEntry(entry) ? 'app' : 'entry');
3420
3931
  }
3421
3932
  return { resourceId: appId };
3422
3933
  }
@@ -3537,6 +4048,150 @@ export class FoundryStepExecutor {
3537
4048
  await provisionOrgPostgresDatabases(scopeFromOrg(context, {}), this.orgPostgresDeps(org, context), org, homes);
3538
4049
  return {};
3539
4050
  }
4051
+ /** F4 (#925): Boule's app and the calls on its env that the apps database steps make. */
4052
+ appsBoule(org, context) {
4053
+ const appId = context.recordedIds()[`coolify:boule:${org.id}`];
4054
+ if (!appId)
4055
+ return null;
4056
+ const coolify = this.ports.coolify;
4057
+ return {
4058
+ upsertEnvs: async (rows) => void (await coolify.bulkUpsertEnvs(appId, rows)),
4059
+ deleteEnvs: async (names) => {
4060
+ let rows;
4061
+ try {
4062
+ rows = await coolify.listEnvs(appId);
4063
+ }
4064
+ catch (error) {
4065
+ if (isNotFoundError(error))
4066
+ return;
4067
+ throw error;
4068
+ }
4069
+ for (const name of names) {
4070
+ const row = rows.find((e) => e.key === name && !e.is_preview);
4071
+ if (!row?.uuid)
4072
+ continue;
4073
+ try {
4074
+ await coolify.deleteEnv(appId, row.uuid);
4075
+ }
4076
+ catch (error) {
4077
+ if (!isNotFoundError(error))
4078
+ throw error;
4079
+ }
4080
+ }
4081
+ },
4082
+ restart: async () => void (await coolify.restart(appId)),
4083
+ };
4084
+ }
4085
+ /**
4086
+ * F4 (#925) `coolify:org-apps-admin`: with `org.boule.apps` on, the `<slug>_apps_admin` role on the
4087
+ * company's Postgres server (`plan/org-apps-admin.ts`); its URL is held and written to Boule's env as
4088
+ * `APPS_DATABASE_ADMIN_URL`, then Boule restarts. Apps off: the retire form (no login, env row gone,
4089
+ * nothing dropped).
4090
+ */
4091
+ async orgAppsAdmin(action, org, context) {
4092
+ const at = `coolify:org-apps-admin for ${org.slug}`;
4093
+ const base = this.orgPostgresDeps(org, context);
4094
+ const boule = this.appsBoule(org, context);
4095
+ const { deliverSecret: _held, ...shared } = base;
4096
+ if (action.desired.mode === 'retire') {
4097
+ await retireOrgAppsAdmin(scopeFromOrg(context, {}), {
4098
+ ...shared,
4099
+ clearHeldUrl: async (ref) => {
4100
+ // The deliver sink has no delete; the held-key store blanks a value (an empty one reads as not held).
4101
+ if (typeof this.ports.heldKeys?.put !== 'function') {
4102
+ console.info(`${at}: the held URL ${ref} was not blanked, the executor's held keys cannot put; blank it by hand`);
4103
+ return;
4104
+ }
4105
+ await this.ports.heldKeys.put(ref, '');
4106
+ },
4107
+ removeBouleEnv: async () => {
4108
+ await boule?.deleteEnvs([APPS_DATABASE_ADMIN_URL_ENV]);
4109
+ if (boule)
4110
+ await boule.restart();
4111
+ },
4112
+ }, org);
4113
+ return {};
4114
+ }
4115
+ if (!appsOn(org))
4116
+ throw new Error(`${at}: the company's Boule runs no apps (org.boule.apps)`);
4117
+ if (!boule)
4118
+ throw new Error(`${at}: Boule is not recorded yet`);
4119
+ const deps = {
4120
+ ...shared,
4121
+ deliverSecret: async (ref, url) => {
4122
+ await this.ports.deliverSecret(ref, url);
4123
+ await boule.upsertEnvs([{ key: APPS_DATABASE_ADMIN_URL_ENV, value: url, literal: true }]);
4124
+ await boule.restart();
4125
+ },
4126
+ };
4127
+ await provisionOrgAppsAdmin(scopeFromOrg(context, {}), deps, org);
4128
+ return {};
4129
+ }
4130
+ /**
4131
+ * F4 (#925) `cloudflare:apps-db-route`: the tunnel route Hyperdrive reaches the company's Postgres server
4132
+ * by, and the `APPS_DATABASE_*` env on Boule (`plan/apps-db-route.ts`). Retire takes both away; the
4133
+ * databases stay.
4134
+ */
4135
+ async appsDbRoute(action, org, context) {
4136
+ const at = `cloudflare:apps-db-route for ${org.slug}`;
4137
+ const heldKeys = this.ports.heldKeys;
4138
+ if (!heldKeys)
4139
+ throw new Error(`${at}: this executor was built without held keys`);
4140
+ const retire = action.desired.mode === 'retire';
4141
+ const boule = this.appsBoule(org, context);
4142
+ if (!boule && !retire)
4143
+ throw new Error(`${at}: Boule is not recorded yet`);
4144
+ const deps = {
4145
+ database: {
4146
+ tunnels: this.tunnelPort(),
4147
+ access: this.accessPort(),
4148
+ dns: this.dnsPort(),
4149
+ zones: this.zonePort(),
4150
+ },
4151
+ heldKeys,
4152
+ secret: (value) => void this.handledSecrets.add(value),
4153
+ upstreamHost: async (serverId) => {
4154
+ const server = await getDatabaseStarted(this.ports.coolify, serverId);
4155
+ if (!server.internal_db_url || !URL.canParse(server.internal_db_url))
4156
+ return '';
4157
+ this.handledSecrets.add(server.internal_db_url);
4158
+ return new URL(server.internal_db_url).hostname;
4159
+ },
4160
+ boule: boule ?? {
4161
+ upsertEnvs: async () => undefined,
4162
+ deleteEnvs: async () => undefined,
4163
+ restart: async () => undefined,
4164
+ },
4165
+ };
4166
+ if (retire)
4167
+ await retireAppsDbRoute(deps, org, context);
4168
+ else
4169
+ await applyAppsDbRoute(deps, org, context);
4170
+ return {};
4171
+ }
4172
+ /**
4173
+ * FZ `cloudflare:apps-zones`: the company slug and the zone ids Boule's managed backend names edge routes
4174
+ * by (`plan/apps-zones.ts`). Retire takes the rows away again.
4175
+ */
4176
+ async appsZones(action, org, context) {
4177
+ const at = `cloudflare:apps-zones for ${org.slug}`;
4178
+ const retire = action.desired.mode === 'retire';
4179
+ const boule = this.appsBoule(org, context);
4180
+ if (retire) {
4181
+ if (boule)
4182
+ await retireAppsZones({ boule }, org, context);
4183
+ else {
4184
+ console.info(`${at}: Boule is not recorded, so no env rows were removed; marker cleared`);
4185
+ context.recordScopeId(APPS_ZONES_APPLIED_SCOPE_KEY, '');
4186
+ await context.persistScopeIds();
4187
+ }
4188
+ return {};
4189
+ }
4190
+ if (!boule)
4191
+ throw new Error(`${at}: Boule is not recorded yet`);
4192
+ await applyAppsZones({ zones: this.zonePort(), boule }, org, context);
4193
+ return {};
4194
+ }
3540
4195
  /**
3541
4196
  * `coolify:org-postgres-migrate` (#444): one home's data from its old Coolify database to the
3542
4197
  * company's server. Refuses before any port call unless the staging confirmation is recorded.
@@ -3845,6 +4500,19 @@ export class FoundryStepExecutor {
3845
4500
  const appKey = await this.ensureAuthAppKey(appId);
3846
4501
  if (appKey)
3847
4502
  envs.push({ key: 'AUTH_APP_KEY', value: appKey });
4503
+ if (entry.uses.auth.workspaces) {
4504
+ const workspacesKey = await ensureWorkspacesKey({
4505
+ orgId: org.id,
4506
+ slug: org.slug,
4507
+ entryKey: entry.key,
4508
+ heldKeys: this.ports.heldKeys,
4509
+ recorded: () => context.recordedIds(),
4510
+ record: (key, value) => context.recordScopeId(key, value),
4511
+ persist: () => context.persistScopeIds(),
4512
+ secret: (value) => void this.handledSecrets.add(value),
4513
+ });
4514
+ envs.push({ key: 'WORKSPACES_API_KEY', value: workspacesKey });
4515
+ }
3848
4516
  }
3849
4517
  else {
3850
4518
  const zitadelOrgId = recorded.zitadel_org;
@@ -3901,6 +4569,8 @@ export class FoundryStepExecutor {
3901
4569
  heldEnvs.push({ key: name, value: await this.readHeldEnv(org, entry.key, name) });
3902
4570
  }
3903
4571
  envs.push(...heldEnvs);
4572
+ // F13: the entry's customer-grants credential, or its removal when the flag went.
4573
+ await this.customerGrantsAppEnv(appId, org, entry, context, envs);
3904
4574
  // A held value has no version the plan can read, so the live env decides whether it changed.
3905
4575
  let heldChanged = false;
3906
4576
  if (heldEnvs.length > 0) {
@@ -4039,9 +4709,24 @@ export class FoundryStepExecutor {
4039
4709
  if (!appKey || appKey.length < 32) {
4040
4710
  throw new Error(`coolify:broker-entry for ${org.slug}: app ${appId} has no AUTH_APP_KEY yet`);
4041
4711
  }
4042
- await this.writeAppBrokerEntry(org, broker, entry.key, wanted.want, appKey, context);
4712
+ await this.writeAppBrokerEntry(org, broker, entry.key, wanted.want, appKey, context, wanted.want.workspaces ? await this.workspacesInputs(org, entry.key) : {});
4043
4713
  return {};
4044
4714
  }
4715
+ /**
4716
+ * What the broker entry needs beyond its want when it declares workspaces: the hash of the held key (the key
4717
+ * itself is registered for redaction and goes no further) and the operator's held database connection string.
4718
+ */
4719
+ async workspacesInputs(org, entryKey) {
4720
+ const key = await heldWorkspacesKey(this.ports.heldKeys, org, entryKey);
4721
+ this.handledSecrets.add(key);
4722
+ const database = await heldWorkspacesDatabase(this.ports.heldKeys, workspacesDatabaseRef(org));
4723
+ if (database)
4724
+ this.handledSecrets.add(database);
4725
+ return {
4726
+ workspacesKeyHash: workspacesKeyHash(key),
4727
+ ...(database ? { workspacesDatabaseUrl: database } : {}),
4728
+ };
4729
+ }
4045
4730
  /**
4046
4731
  * #161: the org's Boule broker entry, `<slug>-boule`, written the way a
4047
4732
  * hosting entry's is: with the `AUTH_APP_KEY` `coolifyBoule` put in the
@@ -4114,12 +4799,13 @@ export class FoundryStepExecutor {
4114
4799
  return {};
4115
4800
  }
4116
4801
  /** 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) {
4802
+ async writeAppBrokerEntry(_org, broker, entryKey, want, appKey, context, workspaces = {}) {
4118
4803
  const previousRaw = context.recordedIds()[brokerEntryAppliedScopeKey(entryKey)];
4119
4804
  const result = await writeBrokerEntry(broker, {
4120
4805
  want,
4121
4806
  appKey,
4122
4807
  previous: previousRaw ? JSON.parse(previousRaw) : null,
4808
+ ...workspaces,
4123
4809
  });
4124
4810
  context.recordScopeId(brokerEntryWarningsScopeKey(entryKey), JSON.stringify(result.invalidForeign));
4125
4811
  // Last: a run that failed above plans the step again.
@@ -4220,7 +4906,7 @@ export class FoundryStepExecutor {
4220
4906
  * mail domain was never even recorded for this org, the same as before
4221
4907
  * this field existed (review round 2, finding 3).
4222
4908
  */
4223
- async ensureAppLettermintMail(appId, org, context, running, hasPlatformApexField, platformApex, platformLettermintProjectId) {
4909
+ async ensureAppLettermintMail(appId, org, context, running, hasPlatformApexField, platformApex, platformLettermintProjectId, role = 'app') {
4224
4910
  const mailDomain = context.recordedIds()[MAIL_DOMAIN_SCOPE_KEY];
4225
4911
  if (!mailDomain)
4226
4912
  return;
@@ -4273,7 +4959,7 @@ export class FoundryStepExecutor {
4273
4959
  org,
4274
4960
  context,
4275
4961
  mailDomain,
4276
- self: { role: 'app', id: appId, restart: running },
4962
+ self: { role, id: appId, restart: running },
4277
4963
  mailFrom: `${org.mail.noReplyLocalPart ?? 'no-reply'}@${mailDomain}`,
4278
4964
  platformApex,
4279
4965
  platformLettermintProjectId,
@@ -4307,12 +4993,26 @@ export class FoundryStepExecutor {
4307
4993
  const { org, context, mailDomain, self, mailFrom } = args;
4308
4994
  const appName = `${org.slug}-app`;
4309
4995
  const recorded = context.recordedIds();
4310
- const appId = self.role === 'app'
4996
+ const mainId = self.role === 'app'
4311
4997
  ? self.id
4312
4998
  : recorded[`coolify:app:${appStepKey(org, mainEntry(org.hosting)?.key)}`];
4313
4999
  const bouleId = self.role === 'boule' ? self.id : recorded[`coolify:boule:${org.id}`];
4314
- const peerListEnvs = (id) => !id || id === self.id
4315
- ? Promise.resolve(null)
5000
+ // The hosted app first, Boule second, then the other entries that send mail (`uses.mail`): an earlier token wins a disagreement.
5001
+ const ids = [];
5002
+ const add = (id) => {
5003
+ if (id && !ids.includes(id))
5004
+ ids.push(id);
5005
+ };
5006
+ add(mainId);
5007
+ add(bouleId);
5008
+ for (const entry of org.hosting.entries) {
5009
+ if (entry.uses?.mail && !isMainEntry(entry)) {
5010
+ add(recorded[`coolify:app:${appStepKey(org, entry.key)}`]);
5011
+ }
5012
+ }
5013
+ add(self.id);
5014
+ const envsOf = (id) => id === self.id
5015
+ ? this.ports.coolify.listEnvs(id)
4316
5016
  : // A recorded peer that Coolify no longer has is not a consumer: not
4317
5017
  // read, written or restarted (same 404 rule as the retire step).
4318
5018
  this.ports.coolify
@@ -4322,21 +5022,17 @@ export class FoundryStepExecutor {
4322
5022
  throw error;
4323
5023
  return null;
4324
5024
  });
4325
- const [projects, selfEnvs, appEnvs, bouleEnvs] = await Promise.all([
5025
+ const [projects, envLists] = await Promise.all([
4326
5026
  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),
5027
+ Promise.all(ids.map(envsOf)),
4330
5028
  ]);
4331
5029
  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
5030
  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) });
5031
+ ids.forEach((id, at) => {
5032
+ const envs = envLists[at];
5033
+ if (envs)
5034
+ consumers.push({ id, token: tokenOf(envs) });
5035
+ });
4340
5036
  const existingToken = consumers.find((c) => c.token)?.token;
4341
5037
  const ours = projects.filter((p) => p.name === appName);
4342
5038
  const held = ours.length === 1 ? ours[0] : undefined;
@@ -6558,6 +7254,7 @@ export class FoundryStepExecutor {
6558
7254
  const credentialWants = {
6559
7255
  archonCredential: action.desired.archonCredential === true,
6560
7256
  customersDirectory: action.desired.customersDirectory === true,
7257
+ registrar: action.desired.registrar === true,
6561
7258
  };
6562
7259
  const { env, missing } = desiredBouleEnv(org, context.recordedIds(), issuer, archonUrl, mailWanted, credentialWants);
6563
7260
  if (missing.length > 0) {
@@ -6646,11 +7343,34 @@ export class FoundryStepExecutor {
6646
7343
  archonPushedAt = archon.pushedAt;
6647
7344
  credentialEnvs.push({ key: BOULE_ARCHON_CREDENTIAL_ENV, value: archon.value });
6648
7345
  }
7346
+ let directoryCredential = null;
6649
7347
  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
- }
7348
+ directoryCredential = await this.ensureCustomersDirectoryCredential(org, context);
7349
+ if (directoryCredential) {
7350
+ credentialEnvs.push({ key: BOULE_CUSTOMERS_CREDENTIAL_ENV, value: directoryCredential });
7351
+ }
7352
+ }
7353
+ // F13: Boule's customer-grants entry, written with the rest and compared with the live value, so Boule
7354
+ // restarts only when the JSON itself changes.
7355
+ const grants = await this.customerGrantsForBoule(app.appId, org, context, action.desired.customerGrants === true, directoryCredential, liveRows);
7356
+ // Pushed with the credentials: the loop below registers the JSON for redaction and compares it with the live value.
7357
+ if (grants.value !== null)
7358
+ credentialEnvs.push({ key: CUSTOMER_GRANTS_ENV, value: grants.value });
7359
+ if (grants.removed)
7360
+ envChanged = true;
7361
+ // T3: the company's registrar key at the broker, the same held value the registrar step writes there.
7362
+ let registrarMint = null;
7363
+ // Only with the broker's address beside it: a key Boule cannot use is a key held for nothing.
7364
+ if (credentialWants.registrar && issuer) {
7365
+ const registrar = await this.registrarKey(org, context);
7366
+ registrarMint = registrar.mint;
7367
+ credentialEnvs.push({ key: AUTH_REGISTRAR_KEY_ENV, value: registrar.key });
7368
+ }
7369
+ if (await this.dropRegistrarEnv(app.appId, org, context, liveRows))
7370
+ envChanged = true;
7371
+ // A key Boule was given but has not run with yet (a restart that failed, or a key made again).
7372
+ const registrarUnstarted = registrarMint !== null &&
7373
+ context.recordedIds()[REGISTRAR_DELIVERED_SCOPE_KEY] !== registrarMint;
6654
7374
  for (const credential of credentialEnvs) {
6655
7375
  this.handledSecrets.add(credential.value);
6656
7376
  envs.push(credential);
@@ -6664,6 +7384,11 @@ export class FoundryStepExecutor {
6664
7384
  context.recordScopeId(BOULE_ARCHON_PUSHED_AT_SCOPE_KEY, archonPushedAt);
6665
7385
  await context.persistScopeIds();
6666
7386
  }
7387
+ if (grants.value !== null) {
7388
+ context.recordScopeId(CUSTOMER_GRANTS_WRITTEN_SCOPE_KEY, '1');
7389
+ await context.persistScopeIds();
7390
+ }
7391
+ await this.forgetDroppedCustomerGrants(org, context);
6667
7392
  }
6668
7393
  catch (error) {
6669
7394
  const minted = [
@@ -6671,6 +7396,7 @@ export class FoundryStepExecutor {
6671
7396
  bridgeSecret,
6672
7397
  ownerSeedSecret,
6673
7398
  ...credentialEnvs.map((credential) => credential.value),
7399
+ ...grants.secrets,
6674
7400
  ].filter((v) => v !== null);
6675
7401
  if (minted.length === 0)
6676
7402
  throw error;
@@ -6709,6 +7435,7 @@ export class FoundryStepExecutor {
6709
7435
  bridgeSecret !== null ||
6710
7436
  ownerSeedSecret !== null ||
6711
7437
  secretsNotRestarted ||
7438
+ registrarUnstarted ||
6712
7439
  dbPush.changed ||
6713
7440
  mail.changed)) {
6714
7441
  // A restart inside the mail rule already came after every env write above.
@@ -6718,6 +7445,9 @@ export class FoundryStepExecutor {
6718
7445
  if (action.desired.platformOwner === true) {
6719
7446
  context.recordScopeId(BOULE_SECRETS_RESTARTED_SCOPE_KEY, '1');
6720
7447
  }
7448
+ // After the restart or deploy, so a key Boule never ran with is planned (and restarted) again.
7449
+ if (registrarMint !== null)
7450
+ context.recordScopeId(REGISTRAR_DELIVERED_SCOPE_KEY, registrarMint);
6721
7451
  // Last: the login-host step points the sign-in at this app's own address only once the env is running.
6722
7452
  this.recordSignInEnvLive(context, 'boule', bouleHostnameFor(org), env);
6723
7453
  return { resourceId: app.appId };
@@ -6786,23 +7516,278 @@ export class FoundryStepExecutor {
6786
7516
  }
6787
7517
  return minted.token;
6788
7518
  }
6789
- /** `put`, tried up to {@link BOULE_CREDENTIAL_ATTEMPTS} times; true once one attempt succeeds. The cause is dropped. */
6790
- async holdWithRetry(ref, value, put, label) {
6791
- for (let attempt = 1; attempt <= BOULE_CREDENTIAL_ATTEMPTS; attempt++) {
6792
- try {
6793
- await withCallTimeout(BOULE_CREDENTIAL_TIMEOUT_MS, label, () => put(ref, value));
6794
- return true;
6795
- }
6796
- catch {
6797
- // Retried; the cause may echo the value.
6798
- }
6799
- }
6800
- return false;
7519
+ /** The held keys with a timeout on every call: a hung store stops the step with a plain line. */
7520
+ timedHeldKeys() {
7521
+ const held = this.ports.heldKeys;
7522
+ if (!held)
7523
+ return undefined;
7524
+ return {
7525
+ resolve: (ref) => withLabelledTimeout('held key read', HELD_KEY_READ_TIMEOUT_MS, () => held.resolve(ref)),
7526
+ ...(typeof held.put === 'function'
7527
+ ? {
7528
+ put: (ref, value) => withLabelledTimeout('held key write', HELD_KEY_READ_TIMEOUT_MS, () => held.put(ref, value)),
7529
+ }
7530
+ : {}),
7531
+ };
6801
7532
  }
6802
- /**
6803
- * C3: customers switched off. Revokes the directory credential's tokens first, then removes the customers
6804
- * env from Boule, then forgets the held value and the user id. Each step is repeated safely, so a run that
6805
- * died halfway is finished by the next. Returns true when Boule's env changed.
7533
+ /** T3: the company's registrar key, held at `foundry:registrar:<org id>`; either step may make it. */
7534
+ registrarKey(org, context) {
7535
+ return ensureRegistrarKey({
7536
+ orgId: org.id,
7537
+ slug: org.slug,
7538
+ heldKeys: this.timedHeldKeys(),
7539
+ recorded: () => context.recordedIds(),
7540
+ record: (key, value) => context.recordScopeId(key, value),
7541
+ persist: () => context.persistScopeIds(),
7542
+ secret: (value) => void this.handledSecrets.add(value),
7543
+ log: (line) => console.info(`coolify: ${line}`),
7544
+ });
7545
+ }
7546
+ /**
7547
+ * T3, customers off: takes away the registrar env foundry gave Boule. The registrar step removes the record
7548
+ * at the broker and forgets the key. Returns true when Boule's env changed.
7549
+ */
7550
+ async dropRegistrarEnv(appId, org, context, liveRows) {
7551
+ if (registrarWanted(org) || !context.recordedIds()[REGISTRAR_DELIVERED_SCOPE_KEY])
7552
+ return false;
7553
+ // In step with the record: while the broker may still take the key, Boule keeps it.
7554
+ const kept = !this.ports.broker
7555
+ ? 'the host has not wired the broker port, so the record cannot be taken away'
7556
+ : context.recordedIds()[REGISTRAR_REMOVAL_HELD_SCOPE_KEY]
7557
+ ? 'the last removal at the broker wrote nothing'
7558
+ : null;
7559
+ if (kept) {
7560
+ console.info(`coolify:boule for ${org.slug}: Boule keeps its registrar env: ${kept}`);
7561
+ return false;
7562
+ }
7563
+ let changed = false;
7564
+ for (const row of liveRows) {
7565
+ if (row.key !== AUTH_REGISTRAR_KEY_ENV && row.key !== AUTH_REGISTRAR_URL_ENV)
7566
+ continue;
7567
+ if (!row.uuid)
7568
+ continue;
7569
+ try {
7570
+ await this.ports.coolify.deleteEnv(appId, row.uuid);
7571
+ changed = true;
7572
+ }
7573
+ catch (error) {
7574
+ if (!isNotFoundError(error))
7575
+ throw error;
7576
+ }
7577
+ }
7578
+ context.recordScopeId(REGISTRAR_DELIVERED_SCOPE_KEY, '');
7579
+ return changed;
7580
+ }
7581
+ /**
7582
+ * T3: the company's record in the broker's `AUTH_REGISTRARS` (`plan/broker-registrar.ts`): its key (made
7583
+ * and held first if the Boule step has not), its sign-in organisation, customers organisations and login
7584
+ * host. With customers off it takes the record away, then forgets the key. The applied marker is recorded
7585
+ * last and never holds the key; every error is redacted of it.
7586
+ */
7587
+ async brokerRegistrar(org, context) {
7588
+ const at = `coolify:broker-registrar for ${org.slug}`;
7589
+ const broker = this.ports.broker;
7590
+ if (!broker)
7591
+ throw new Error(`${at}: the broker port is not wired`);
7592
+ const recorded = context.recordedIds();
7593
+ const previousOrganizationId = appliedRegistrarOrganization(recorded);
7594
+ if (!registrarWanted(org)) {
7595
+ const keys = [];
7596
+ const heldKeys = this.timedHeldKeys();
7597
+ let removal;
7598
+ try {
7599
+ // The held key finds the record whatever organisation it names, also after a crash before the
7600
+ // applied marker; the recorded sign-in organisation does too.
7601
+ const ownKey = recorded[REGISTRAR_MINTED_SCOPE_KEY]
7602
+ ? ((await heldKeys?.resolve(registrarRef(org.id))) ?? null)
7603
+ : null;
7604
+ if (ownKey) {
7605
+ keys.push(ownKey);
7606
+ this.handledSecrets.add(ownKey);
7607
+ }
7608
+ removal = await writeRegistrar(broker, {
7609
+ slug: org.slug,
7610
+ record: null,
7611
+ previousOrganizationId,
7612
+ ownOrganizationIds: [recorded[ZITADEL_ORG_SCOPE_KEY] ?? ''],
7613
+ ownKey,
7614
+ secret: (value) => {
7615
+ keys.push(value);
7616
+ this.handledSecrets.add(value);
7617
+ },
7618
+ log: (line) => console.info(redactSecrets(line, keys)),
7619
+ });
7620
+ }
7621
+ catch (error) {
7622
+ throw new Error(redactSecrets(error instanceof Error ? error.message : String(error), keys));
7623
+ }
7624
+ if (removal.removalHeld) {
7625
+ // The record stays at the broker, so the key stays held; the plan says why.
7626
+ console.info(`${at}: the record is not taken away and the key is kept: ${removal.removalHeld}`);
7627
+ context.recordScopeId(REGISTRAR_REMOVAL_HELD_SCOPE_KEY, removal.removalHeld);
7628
+ return {};
7629
+ }
7630
+ context.recordScopeId(REGISTRAR_REMOVAL_HELD_SCOPE_KEY, '');
7631
+ context.recordScopeId(REGISTRAR_APPLIED_SCOPE_KEY, '');
7632
+ context.recordScopeId(REGISTRAR_DEPLOY_WAITS_SCOPE_KEY, '');
7633
+ await context.persistScopeIds();
7634
+ // Last: the broker no longer takes the key, so forgetting it leaves nothing that needs it.
7635
+ if (recorded[REGISTRAR_MINTED_SCOPE_KEY]) {
7636
+ if (!heldKeys?.put) {
7637
+ throw new Error(`${at}: the held keys cannot forget the registrar key (ref ${registrarRef(org.id)})`);
7638
+ }
7639
+ await heldKeys.put(registrarRef(org.id), '');
7640
+ context.recordScopeId(REGISTRAR_MINTED_SCOPE_KEY, '');
7641
+ }
7642
+ return {};
7643
+ }
7644
+ const wanted = wantedRegistrar(org, recorded);
7645
+ if ('missing' in wanted) {
7646
+ throw new Error(`${at}: ${wanted.missing.map((m) => m.what).join(', ')} not recorded yet`);
7647
+ }
7648
+ const { key, mint } = await this.registrarKey(org, context);
7649
+ // Every company's key the broker's env holds, so none can leave in an error.
7650
+ const keys = [key];
7651
+ let result;
7652
+ try {
7653
+ result = await writeRegistrar(broker, {
7654
+ slug: org.slug,
7655
+ record: { ...wanted.want, key },
7656
+ previousOrganizationId,
7657
+ secret: (value) => {
7658
+ keys.push(value);
7659
+ this.handledSecrets.add(value);
7660
+ },
7661
+ log: (line) => console.info(redactSecrets(line, keys)),
7662
+ });
7663
+ }
7664
+ catch (error) {
7665
+ throw new Error(redactSecrets(error instanceof Error ? error.message : String(error), keys));
7666
+ }
7667
+ context.recordScopeId(REGISTRAR_DEPLOY_WAITS_SCOPE_KEY, result.waits.join(', '));
7668
+ // Last: a run that failed above plans the step again.
7669
+ context.recordScopeId(REGISTRAR_APPLIED_SCOPE_KEY, registrarAppliedValue(wanted.want, mint));
7670
+ return {};
7671
+ }
7672
+ customerGrantsCredential(org, entryKey, context) {
7673
+ return ensureCustomerGrantsCredential({
7674
+ orgId: org.id,
7675
+ slug: org.slug,
7676
+ entryKey,
7677
+ heldKeys: this.timedHeldKeys(),
7678
+ recorded: () => context.recordedIds(),
7679
+ record: (key, value) => context.recordScopeId(key, value),
7680
+ persist: () => context.persistScopeIds(),
7681
+ secret: (value) => void this.handledSecrets.add(value),
7682
+ });
7683
+ }
7684
+ /**
7685
+ * F13, app side: an entry with `uses.customerGrants` gets `CUSTOMER_GRANTS_CREDENTIAL` (made and held first,
7686
+ * once). An entry that had one and no longer declares the flag loses the env and the held value; each part is
7687
+ * repeated safely, so a run that died halfway is finished by the next.
7688
+ */
7689
+ async customerGrantsAppEnv(appId, org, entry, context, envs) {
7690
+ if (entry.uses?.customerGrants) {
7691
+ envs.push({
7692
+ key: CUSTOMER_GRANTS_CREDENTIAL_ENV,
7693
+ value: await this.customerGrantsCredential(org, entry.key, context),
7694
+ });
7695
+ return;
7696
+ }
7697
+ // Either marker says the app may still hold the env: the held value may already be forgotten by Boule's step.
7698
+ const recorded = context.recordedIds();
7699
+ const minted = Boolean(recorded[customerGrantsMintedScopeKey(entry.key)]);
7700
+ const applied = (recorded[appEnvAppliedScopeKey(entry.key)] ?? '').includes(`"${CUSTOMER_GRANTS_CREDENTIAL_ENV}"`);
7701
+ if (!minted && !applied)
7702
+ return;
7703
+ const at = `coolify:app for ${org.slug}`;
7704
+ for (const row of await this.ports.coolify.listEnvs(appId)) {
7705
+ if (row.key !== CUSTOMER_GRANTS_CREDENTIAL_ENV || row.is_preview || !row.uuid)
7706
+ continue;
7707
+ try {
7708
+ await this.ports.coolify.deleteEnv(appId, row.uuid);
7709
+ }
7710
+ catch (error) {
7711
+ if (!isNotFoundError(error))
7712
+ throw error;
7713
+ }
7714
+ }
7715
+ if (minted)
7716
+ await this.forgetCustomerGrantsCredential(org, entry.key, context, at);
7717
+ }
7718
+ /** Forgets one entry's held credential and clears its marker. Held first, marker last, so a failed forget is repeated. */
7719
+ async forgetCustomerGrantsCredential(org, entryKey, context, at) {
7720
+ const heldKeys = this.timedHeldKeys();
7721
+ if (typeof heldKeys?.put !== 'function') {
7722
+ throw new Error(`${at}: the held keys cannot forget the customer grants credential of entry '${entryKey}' (ref ${customerGrantsRef(org.id, entryKey)})`);
7723
+ }
7724
+ await heldKeys.put(customerGrantsRef(org.id, entryKey), '');
7725
+ context.recordScopeId(customerGrantsMintedScopeKey(entryKey), '');
7726
+ await context.persistScopeIds();
7727
+ }
7728
+ /** F13: the held credentials of entries that no longer declare the flag, forgotten after Boule stopped listing them. */
7729
+ async forgetDroppedCustomerGrants(org, context) {
7730
+ for (const key of customerGrantsToDrop(org, context.recordedIds())) {
7731
+ await this.forgetCustomerGrantsCredential(org, key, context, `coolify:boule for ${org.slug}`);
7732
+ }
7733
+ }
7734
+ /**
7735
+ * F13, Boule side: the `CUSTOMER_GRANTS` value for the flagged entries (each credential made and held if it
7736
+ * is not yet), or `value: null`. With no flagged entry left and a value foundry wrote, the env row is
7737
+ * removed (`removed`). A flagged entry that cannot be listed yet changes nothing. Never a hand-set value
7738
+ * it did not write.
7739
+ */
7740
+ async customerGrantsForBoule(appId, org, context, planned, directoryCredential, liveRows) {
7741
+ const none = { value: null, removed: false, secrets: [] };
7742
+ const at = `coolify:boule for ${org.slug}`;
7743
+ if (!planned) {
7744
+ if (customerGrantsEntries(org).length > 0)
7745
+ return none;
7746
+ if (!context.recordedIds()[CUSTOMER_GRANTS_WRITTEN_SCOPE_KEY])
7747
+ return none;
7748
+ for (const row of liveRows) {
7749
+ if (row.key !== CUSTOMER_GRANTS_ENV || !row.uuid)
7750
+ continue;
7751
+ try {
7752
+ await this.ports.coolify.deleteEnv(appId, row.uuid);
7753
+ }
7754
+ catch (error) {
7755
+ if (!isNotFoundError(error))
7756
+ throw error;
7757
+ }
7758
+ }
7759
+ context.recordScopeId(CUSTOMER_GRANTS_WRITTEN_SCOPE_KEY, '');
7760
+ await context.persistScopeIds();
7761
+ return { ...none, removed: liveRows.some((row) => row.key === CUSTOMER_GRANTS_ENV) };
7762
+ }
7763
+ const wanted = wantedCustomerGrants(org, context.recordedIds(), { live: true });
7764
+ if (!wanted.shape || !directoryCredential) {
7765
+ 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`);
7766
+ }
7767
+ const credentials = {};
7768
+ for (const key of Object.keys(wanted.shape.apps)) {
7769
+ credentials[key] = await this.customerGrantsCredential(org, key, context);
7770
+ }
7771
+ const value = customerGrantsJson(wanted.shape, directoryCredential, credentials);
7772
+ return { value, removed: false, secrets: [...Object.values(credentials), value] };
7773
+ }
7774
+ /** `put`, tried up to {@link BOULE_CREDENTIAL_ATTEMPTS} times; true once one attempt succeeds. The cause is dropped. */
7775
+ async holdWithRetry(ref, value, put, label) {
7776
+ for (let attempt = 1; attempt <= BOULE_CREDENTIAL_ATTEMPTS; attempt++) {
7777
+ try {
7778
+ await withCallTimeout(BOULE_CREDENTIAL_TIMEOUT_MS, label, () => put(ref, value));
7779
+ return true;
7780
+ }
7781
+ catch {
7782
+ // Retried; the cause may echo the value.
7783
+ }
7784
+ }
7785
+ return false;
7786
+ }
7787
+ /**
7788
+ * C3: customers switched off. Revokes the directory credential's tokens first, then removes the customers
7789
+ * env from Boule, then forgets the held value and the user id. Each step is repeated safely, so a run that
7790
+ * died halfway is finished by the next. Returns true when Boule's env changed.
6806
7791
  */
6807
7792
  async dropCustomersCredential(appId, org, context, liveRows) {
6808
7793
  if (org.customers?.enabled)
@@ -6817,7 +7802,9 @@ export class FoundryStepExecutor {
6817
7802
  }
6818
7803
  let changed = false;
6819
7804
  for (const row of liveRows) {
6820
- if (row.key !== BOULE_CUSTOMERS_ORG_ENV && row.key !== BOULE_CUSTOMERS_CREDENTIAL_ENV)
7805
+ if (row.key !== BOULE_CUSTOMERS_ORG_ENV &&
7806
+ row.key !== BOULE_TEST_CUSTOMERS_ORG_ENV &&
7807
+ row.key !== BOULE_CUSTOMERS_CREDENTIAL_ENV)
6821
7808
  continue;
6822
7809
  if (!row.uuid)
6823
7810
  continue;
@@ -7020,6 +8007,30 @@ export class FoundryStepExecutor {
7020
8007
  project: { ensure: () => this.ensureCoolifyProject(org, context) },
7021
8008
  });
7022
8009
  }
8010
+ /**
8011
+ * `plan/own-diff.ts`'s `planOwnJobsRoute`: the org's scheduler on `jobs.<domain>`, through the company's own
8012
+ * edge-routed DNS and tunnel ports, dialling the address `coolify:own-jobs` recorded.
8013
+ */
8014
+ async ownJobsRoute(action, org, context) {
8015
+ const own = this.ports.ownServices;
8016
+ if (!own)
8017
+ throw new Error(OWN_SERVICES_PORT_ABSENT);
8018
+ const internalUrl = context.recordedIds()[JOBS_INTERNAL_URL_SCOPE_KEY];
8019
+ if (!internalUrl) {
8020
+ 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`);
8021
+ }
8022
+ const scope = scopeFromOrg(context, {});
8023
+ const projectUuid = await this.ensureCoolifyProject(org, context);
8024
+ return provisionJobsRoute(scope, {
8025
+ coolify: limited(this.placedForCompany(own.provision.sfu.coolify, org, context), LIMITS.connector),
8026
+ network: orgNetwork({ dns: this.dnsPort(), tunnels: this.tunnelPort(), zones: this.zonePort() }, scope, `${org.slug}:jobs-route`),
8027
+ }, orgTarget(org), {
8028
+ hostname: action.desired.hostname,
8029
+ apex: ownHostnames(org).zone,
8030
+ internalUrl,
8031
+ projectUuid,
8032
+ });
8033
+ }
7023
8034
  /**
7024
8035
  * `plan/own-diff.ts`'s `planOwnFiles`: the org's own files host in the
7025
8036
  * org's own project, trusting the company's issuer `https://auth.<domain>`.
@@ -7426,6 +8437,168 @@ export class FoundryStepExecutor {
7426
8437
  ...(context.serverIpv4 ? { serverIpv4: context.serverIpv4 } : {}),
7427
8438
  });
7428
8439
  }
8440
+ /**
8441
+ * `plan/entry-jobs.ts`'s `planEntryJobs`: registers one entry as its own project in the platform's jobs
8442
+ * scheduler, holds the credentials the scheduler shows once, then writes `JOBS_SCHEDULER_URL`,
8443
+ * `JOBS_SIGNING_SECRET` and `JOBS_SYNC_KEY` to the entry's env and restarts the app.
8444
+ *
8445
+ * The project's id is recorded as undelivered and persisted before the credentials are held, and cleared
8446
+ * only once both are, so a failure in between never registers a second project: `planEntryJobs` blocks
8447
+ * on it. Credentials already held are never registered again, which is also how a failed env write is
8448
+ * retried. Errors name the project and the refs, never a credential.
8449
+ */
8450
+ async jobsProject(action, org, context) {
8451
+ const entryKey = action.key.slice(org.id.length + 1);
8452
+ const at = `jobs:project for ${org.slug} entry '${entryKey}'`;
8453
+ const want = action.desired;
8454
+ if (!org.hosting.entries.some((e) => e.key === entryKey && e.uses?.jobs)) {
8455
+ throw new Error(`${at}: the entry no longer declares uses.jobs`);
8456
+ }
8457
+ const appId = context.recordedIds()[`coolify:app:${appStepKey(org, entryKey)}`];
8458
+ if (!appId)
8459
+ throw new Error(`${at}: the entry's app is not recorded yet`);
8460
+ const held = this.ports.heldKeys;
8461
+ const put = held?.put?.bind(held);
8462
+ if (!held || !put)
8463
+ throw new Error(`${at}: the held keys cannot hold the project's credentials`);
8464
+ const refs = entryJobsRefs(org.id, entryKey);
8465
+ const read = async (ref) => {
8466
+ try {
8467
+ const value = await withTimeout(held.resolve(ref), JOBS_PROJECT_CALL_TIMEOUT_MS);
8468
+ return value || null;
8469
+ }
8470
+ catch {
8471
+ throw new Error(`${at}: the held key ${ref} could not be read`);
8472
+ }
8473
+ };
8474
+ let signingSecret = await read(refs.signingSecret);
8475
+ let syncKey = await read(refs.syncKey);
8476
+ if (Boolean(signingSecret) !== Boolean(syncKey)) {
8477
+ 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`);
8478
+ }
8479
+ if (signingSecret)
8480
+ this.handledSecrets.add(signingSecret);
8481
+ if (syncKey)
8482
+ this.handledSecrets.add(syncKey);
8483
+ if (!signingSecret || !syncKey) {
8484
+ const scheduler = await this.jobsPort(org);
8485
+ let registered;
8486
+ try {
8487
+ registered = await withTimeout(scheduler.registerProject({ name: want.name, baseUrl: want.baseUrl }), JOBS_PROJECT_CALL_TIMEOUT_MS);
8488
+ }
8489
+ catch (error) {
8490
+ const detail = error instanceof Error ? error.message : 'no detail';
8491
+ throw new Error(`${at}: the scheduler refused or did not answer the registration (${detail})`);
8492
+ }
8493
+ if (registered.secret)
8494
+ this.handledSecrets.add(registered.secret);
8495
+ if (registered.syncKey)
8496
+ this.handledSecrets.add(registered.syncKey);
8497
+ context.recordScopeId(entryJobsUndeliveredScopeKey(entryKey), registered.id);
8498
+ await context.persistScopeIds();
8499
+ if (!registered.secret || !registered.syncKey) {
8500
+ 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)}`);
8501
+ }
8502
+ for (const [ref, value] of [
8503
+ [refs.signingSecret, registered.secret],
8504
+ [refs.syncKey, registered.syncKey],
8505
+ ]) {
8506
+ let stored = false;
8507
+ for (let attempt = 1; attempt <= JOBS_PROJECT_HOLD_ATTEMPTS && !stored; attempt++) {
8508
+ try {
8509
+ await withTimeout(put(ref, value), JOBS_PROJECT_CALL_TIMEOUT_MS);
8510
+ stored = true;
8511
+ }
8512
+ catch {
8513
+ // Retried; the error is dropped in case it echoes the credential.
8514
+ }
8515
+ }
8516
+ if (!stored) {
8517
+ 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)}`);
8518
+ }
8519
+ }
8520
+ signingSecret = registered.secret;
8521
+ syncKey = registered.syncKey;
8522
+ forgetScopeId(context, entryJobsUndeliveredScopeKey(entryKey));
8523
+ await context.persistScopeIds();
8524
+ }
8525
+ try {
8526
+ await withTimeout(this.ports.coolify.bulkUpsertEnvs(appId, [
8527
+ { key: 'JOBS_SCHEDULER_URL', value: want.schedulerUrl, literal: true },
8528
+ { key: 'JOBS_SIGNING_SECRET', value: signingSecret, literal: true },
8529
+ { key: 'JOBS_SYNC_KEY', value: syncKey, literal: true },
8530
+ ]), JOBS_PROJECT_CALL_TIMEOUT_MS);
8531
+ await withTimeout(this.ports.coolify.restart(appId), JOBS_PROJECT_CALL_TIMEOUT_MS);
8532
+ }
8533
+ catch {
8534
+ // The error is dropped in case Coolify echoes the request body.
8535
+ 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`);
8536
+ }
8537
+ context.recordScopeId(entryJobsAppliedScopeKey(entryKey), entryJobsWantMarker(want));
8538
+ return { resourceId: want.name };
8539
+ }
8540
+ /** `plan/entry-audit.ts`: makes the platform signer if none exists and registers one entry's ledger for sealing. */
8541
+ async auditLedger(action, org, context) {
8542
+ const entryKey = action.key.slice(org.id.length + 1);
8543
+ const at = `audit:ledger for ${org.slug} entry '${entryKey}'`;
8544
+ if (!org.hosting.entries.some((e) => e.key === entryKey && e.uses?.audit === true)) {
8545
+ throw new Error(`${at}: the entry no longer declares uses.audit`);
8546
+ }
8547
+ const held = this.timedHeldKeys();
8548
+ if (typeof held?.put !== 'function') {
8549
+ throw new Error(`${at}: the held keys cannot hold the platform signer's key`);
8550
+ }
8551
+ return applyEntryAudit({
8552
+ heldKeys: { resolve: held.resolve, put: held.put },
8553
+ org: { id: org.id, slug: org.slug },
8554
+ entryKey,
8555
+ recorded: () => context.recordedIds(),
8556
+ record: (key, value) => context.recordScopeId(key, value),
8557
+ persist: () => context.persistScopeIds(),
8558
+ secret: (value) => void this.handledSecrets.add(value),
8559
+ });
8560
+ }
8561
+ /** F12, `plan/entry-repo-key.ts`: makes or removes one entry's deploy key. See `entry-repo-key-apply.ts`. */
8562
+ async githubDeployKey(action, org, context) {
8563
+ const entryKey = action.key.slice(org.id.length + 1);
8564
+ const at = `github:deploy-key for ${org.slug} entry '${entryKey}'`;
8565
+ const desired = action.desired;
8566
+ const github = this.ports.githubDeployKeys;
8567
+ if (!github)
8568
+ throw new Error(`${at}: the host gave no GitHub deploy-key client`);
8569
+ const held = this.timedHeldKeys();
8570
+ if (typeof held?.put !== 'function') {
8571
+ throw new Error(`${at}: the held keys cannot hold the key's private half`);
8572
+ }
8573
+ const deps = {
8574
+ github,
8575
+ heldKeys: { resolve: held.resolve, put: held.put },
8576
+ coolify: this.ports.coolify,
8577
+ org: { id: org.id, slug: org.slug },
8578
+ entryKey,
8579
+ appId: context.recordedIds()[`coolify:app:${appStepKey(org, entryKey)}`],
8580
+ recorded: () => context.recordedIds(),
8581
+ record: (key, value) => context.recordScopeId(key, value),
8582
+ persist: () => context.persistScopeIds(),
8583
+ secret: (value) => void this.handledSecrets.add(value),
8584
+ installationFor: (repo) => companyGithubFor(org.hosting, repo)?.installationId,
8585
+ };
8586
+ if (desired.remove) {
8587
+ await removeRepoKey(deps, desired.repo);
8588
+ return {};
8589
+ }
8590
+ if (!org.hosting.entries.some((e) => e.key === entryKey && e.uses?.repo)) {
8591
+ throw new Error(`${at}: the entry no longer declares uses.repo`);
8592
+ }
8593
+ await ensureRepoKey(deps, {
8594
+ repo: desired.repo,
8595
+ access: desired.access === 'write' ? 'write' : 'read',
8596
+ ...(companyGithubFor(org.hosting, desired.repo)
8597
+ ? { installationId: companyGithubFor(org.hosting, desired.repo)?.installationId }
8598
+ : {}),
8599
+ });
8600
+ return {};
8601
+ }
7429
8602
  async jobsRegistration(action, org, context) {
7430
8603
  const scheduler = await this.jobsPort(org);
7431
8604
  const name = action.desired.name;
@@ -7695,6 +8868,7 @@ export class FoundryStepExecutor {
7695
8868
  }
7696
8869
  }
7697
8870
  await this.publishOikosEntryRecords(org, tunnel.id, previousIds, context);
8871
+ await this.publishAppsEntryRecords(org, tunnel.id, previousIds, context);
7698
8872
  return { resourceId: tunnel.id };
7699
8873
  }
7700
8874
  /**
@@ -7708,19 +8882,41 @@ export class FoundryStepExecutor {
7708
8882
  * halfway is planned again.
7709
8883
  */
7710
8884
  async publishOikosEntryRecords(org, tunnelId, previousIds, context) {
7711
- const wanted = entryOikosHostnames(org);
7712
- const recorded = context.recordedIds()[CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY];
8885
+ await this.publishPlatformZoneEntryRecords(org, tunnelId, previousIds, context, {
8886
+ zoneName: NO_DOMAIN_ZONE,
8887
+ wanted: entryOikosHostnames(org, context.recordedIds()),
8888
+ scopeKey: CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY,
8889
+ owns: (name) => ownsOikosName(org, name),
8890
+ });
8891
+ }
8892
+ /**
8893
+ * The entry names a company has in the platform's `companyincluded.app` zone: its apps' `hostedName`
8894
+ * addresses. Written the same way as {@link publishOikosEntryRecords}. Which company may hold a name is
8895
+ * the host's check (Archon), so the step owns any single-label name under the zone.
8896
+ */
8897
+ async publishAppsEntryRecords(org, tunnelId, previousIds, context) {
8898
+ await this.publishPlatformZoneEntryRecords(org, tunnelId, previousIds, context, {
8899
+ zoneName: APPS_DOMAIN,
8900
+ wanted: entryAppsHostnames(org, context.recordedIds()),
8901
+ scopeKey: CLOUDFLARE_TUNNEL_APPS_HOSTNAMES_SCOPE_KEY,
8902
+ owns: (name) => ownsAppsZoneName(org, name),
8903
+ organisationId: org.id,
8904
+ });
8905
+ }
8906
+ async publishPlatformZoneEntryRecords(org, tunnelId, previousIds, context, zoneSpec) {
8907
+ const { zoneName, wanted, scopeKey, owns, organisationId } = zoneSpec;
8908
+ const recorded = context.recordedIds()[scopeKey];
7713
8909
  const previous = recorded ? recorded.split(',') : [];
7714
8910
  if (wanted.length === 0 && previous.length === 0)
7715
8911
  return;
7716
8912
  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`);
8913
+ if (!owns(name)) {
8914
+ throw new Error(`cloudflare:tunnel for ${org.slug}: ${name} is not a name this company may write in the ${zoneName} zone`);
7719
8915
  }
7720
8916
  }
7721
- const zone = await this.zonePort().findZoneByApex(NO_DOMAIN_ZONE);
8917
+ const zone = await this.zonePort().findZoneByApex(zoneName);
7722
8918
  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`);
8919
+ throw new Error(`cloudflare:tunnel for ${org.slug}: no Cloudflare zone found for ${zoneName} to point its entry names at`);
7724
8920
  }
7725
8921
  const dns = this.dnsPort();
7726
8922
  const dnsTarget = `${tunnelId}.cfargotunnel.com`;
@@ -7728,7 +8924,7 @@ export class FoundryStepExecutor {
7728
8924
  dnsTarget,
7729
8925
  ...[...previousIds].map((id) => `${id}.cfargotunnel.com`),
7730
8926
  ]);
7731
- const [shared] = await dns.findDnsRecord(zone.name, `*.${NO_DOMAIN_ZONE}`, 'CNAME');
8927
+ const [shared] = await dns.findDnsRecord(zone.name, `*.${zoneName}`, 'CNAME');
7732
8928
  const writes = [];
7733
8929
  const conflicts = [];
7734
8930
  for (const name of wanted) {
@@ -7739,7 +8935,13 @@ export class FoundryStepExecutor {
7739
8935
  continue;
7740
8936
  }
7741
8937
  const [cname] = await dns.findDnsRecord(zone.name, name, 'CNAME');
7742
- const record = { type: 'CNAME', name, content: dnsTarget, proxied: true };
8938
+ const record = {
8939
+ type: 'CNAME',
8940
+ name,
8941
+ content: dnsTarget,
8942
+ proxied: true,
8943
+ ...(organisationId === undefined ? {} : { organisationId }),
8944
+ };
7743
8945
  if (!cname)
7744
8946
  writes.push(() => dns.createDnsRecord(zone.name, record));
7745
8947
  else if (cname.content === dnsTarget)
@@ -7756,14 +8958,14 @@ export class FoundryStepExecutor {
7756
8958
  for (const write of writes)
7757
8959
  await write();
7758
8960
  for (const name of previous.filter((n) => !wanted.includes(n))) {
7759
- if (!ownsOikosName(org, name))
8961
+ if (!owns(name))
7760
8962
  continue;
7761
8963
  for (const record of await dns.findDnsRecord(zone.name, name, 'CNAME')) {
7762
8964
  if (ownTargets.has(record.content))
7763
8965
  await dns.deleteDnsRecord(zone.name, record.id);
7764
8966
  }
7765
8967
  }
7766
- context.recordScopeId(CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY, wanted.join(','));
8968
+ context.recordScopeId(scopeKey, wanted.join(','));
7767
8969
  }
7768
8970
  /**
7769
8971
  * #236: when this tunnel's DNS was last published under a domain that
@@ -8814,8 +10016,9 @@ export class FoundryStepExecutor {
8814
10016
  return {};
8815
10017
  }
8816
10018
  if (entryKey === BOULE_EDGE_ENTRY_KEY) {
8817
- if (!org.domain)
8818
- throw new Error(`${at}: Boule's edge key needs org.domain`);
10019
+ if (!bouleEdgeKeyWanted(org)) {
10020
+ throw new Error(`${at}: Boule's edge key needs org.domain or org.boule.apps`);
10021
+ }
8819
10022
  }
8820
10023
  else if (!org.hosting.entries.some((e) => e.key === entryKey && e.uses?.edge)) {
8821
10024
  throw new Error(`${at}: the entry no longer declares uses.edge`);
@@ -8823,7 +10026,7 @@ export class FoundryStepExecutor {
8823
10026
  const appId = context.recordedIds()[edgeKeyAppRecordKey(org, entryKey)];
8824
10027
  if (!appId)
8825
10028
  throw new Error(`${at}: the entry's app is not recorded yet`);
8826
- if (!org.domain) {
10029
+ if (entryKey !== BOULE_EDGE_ENTRY_KEY && !org.domain) {
8827
10030
  throw new Error(`${at}: edge needs org.domain, and validateEntryEdge already refuses it without one`);
8828
10031
  }
8829
10032
  // A key left over from a previous mint whose delivery never succeeded
@@ -8846,7 +10049,7 @@ export class FoundryStepExecutor {
8846
10049
  }
8847
10050
  const minted = await edge.mintKey({
8848
10051
  organisationId: org.id,
8849
- domain: org.domain,
10052
+ domain: edgeKeyDomain(org),
8850
10053
  name: desired.name,
8851
10054
  expiresDays: EDGE_ENTRY_KEY_DAYS,
8852
10055
  });
@@ -8874,6 +10077,150 @@ export class FoundryStepExecutor {
8874
10077
  // Names the key id only; the underlying error is dropped in case it echoes the secret.
8875
10078
  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
10079
  }
10080
+ /**
10081
+ * F7 (#928): the company's signed-records registrar key (`plan/keys-registrar.ts`). Mint: a leftover
10082
+ * undelivered key is revoked first; the new key's id is recorded and persisted before it is written to
10083
+ * Boule's env (3 tries, then the error names the id only and the next apply revokes and mints again).
10084
+ * `url` rewrites the address only. Retire revokes and takes both env rows away. Never holds the key.
10085
+ */
10086
+ async keysRegistrar(action, org, context) {
10087
+ const at = `coolify:keys-registrar for ${org.slug}`;
10088
+ const keys = this.ports.keysRegistrar;
10089
+ if (!keys)
10090
+ throw new Error(`${at}: this executor was built without a KeysRegistrarPort`);
10091
+ const desired = action.desired;
10092
+ const recorded = context.recordedIds();
10093
+ const appId = recorded[`coolify:boule:${org.id}`];
10094
+ const revoke = async (id) => {
10095
+ try {
10096
+ await keys.revokeCompanyKey(org.id, id);
10097
+ }
10098
+ catch (error) {
10099
+ if (!isNotFoundError(error))
10100
+ throw error;
10101
+ }
10102
+ };
10103
+ const tolerate = async (call, gone) => {
10104
+ try {
10105
+ return await call();
10106
+ }
10107
+ catch (error) {
10108
+ if (!isNotFoundError(error))
10109
+ throw error;
10110
+ return gone;
10111
+ }
10112
+ };
10113
+ if (desired.mode === 'retire') {
10114
+ for (const id of new Set([recorded[KEYS_REGISTRAR_SCOPE_KEY], recorded[KEYS_REGISTRAR_UNDELIVERED_SCOPE_KEY]].filter((id) => Boolean(id)))) {
10115
+ await revoke(id);
10116
+ }
10117
+ if (recorded[KEYS_REGISTRAR_MINT_STARTED_SCOPE_KEY]) {
10118
+ // A mint whose answer was lost may have left a key no id points at.
10119
+ await keys.revokeAllCompanyKeys(org.id);
10120
+ }
10121
+ if (appId) {
10122
+ const rows = await tolerate(() => this.ports.coolify.listEnvs(appId), []);
10123
+ for (const name of [KEYS_REGISTRAR_URL_ENV, KEYS_REGISTRAR_KEY_ENV]) {
10124
+ const row = rows.find((e) => e.key === name && !e.is_preview);
10125
+ if (row?.uuid) {
10126
+ const uuid = row.uuid;
10127
+ await tolerate(() => this.ports.coolify.deleteEnv(appId, uuid), undefined);
10128
+ }
10129
+ }
10130
+ }
10131
+ for (const scope of [
10132
+ KEYS_REGISTRAR_SCOPE_KEY,
10133
+ KEYS_REGISTRAR_UNDELIVERED_SCOPE_KEY,
10134
+ KEYS_REGISTRAR_MINTED_AT_SCOPE_KEY,
10135
+ KEYS_REGISTRAR_MINT_STARTED_SCOPE_KEY,
10136
+ KEYS_REGISTRAR_URL_APPLIED_SCOPE_KEY,
10137
+ ]) {
10138
+ context.recordScopeId(scope, '');
10139
+ }
10140
+ return {};
10141
+ }
10142
+ if (!appsOn(org))
10143
+ throw new Error(`${at}: the company's Boule runs no apps (org.boule.apps)`);
10144
+ if (!appId)
10145
+ throw new Error(`${at}: Boule is not recorded yet`);
10146
+ if (!desired.keysUrl)
10147
+ throw new Error(`${at}: the plan gave no keys service address`);
10148
+ if (desired.mode === 'url') {
10149
+ await this.ports.coolify.bulkUpsertEnvs(appId, [
10150
+ { key: KEYS_REGISTRAR_URL_ENV, value: desired.keysUrl, literal: true },
10151
+ ]);
10152
+ await this.ports.coolify.restart(appId);
10153
+ context.recordScopeId(KEYS_REGISTRAR_URL_APPLIED_SCOPE_KEY, desired.keysUrl);
10154
+ return {};
10155
+ }
10156
+ if (recorded[KEYS_REGISTRAR_SCOPE_KEY])
10157
+ return {};
10158
+ const stale = recorded[KEYS_REGISTRAR_UNDELIVERED_SCOPE_KEY];
10159
+ if (stale) {
10160
+ await revoke(stale);
10161
+ context.recordScopeId(KEYS_REGISTRAR_UNDELIVERED_SCOPE_KEY, '');
10162
+ await context.persistScopeIds();
10163
+ }
10164
+ if (recorded[KEYS_REGISTRAR_MINT_STARTED_SCOPE_KEY]) {
10165
+ // The last mint's answer never reached us, so a key may exist whose id was never recorded. Revoke all
10166
+ // of the company's registrar keys first (none is delivered: the delivered id is not recorded here).
10167
+ const revoked = await keys.revokeAllCompanyKeys(org.id);
10168
+ console.info(`${at}: revoked ${revoked} registrar key(s) left by a mint whose answer was lost, before minting again`);
10169
+ }
10170
+ context.recordScopeId(KEYS_REGISTRAR_MINT_STARTED_SCOPE_KEY, new Date(context.now?.() ?? Date.now()).toISOString());
10171
+ await context.persistScopeIds();
10172
+ const minted = await keys.mintCompanyKey({
10173
+ organisationId: org.id,
10174
+ name: desired.name ?? `${org.slug}-registrar`,
10175
+ expiresDays: KEYS_REGISTRAR_KEY_DAYS,
10176
+ });
10177
+ this.handledSecrets.add(minted.secret);
10178
+ context.recordScopeId(KEYS_REGISTRAR_UNDELIVERED_SCOPE_KEY, minted.id);
10179
+ context.recordScopeId(KEYS_REGISTRAR_MINT_STARTED_SCOPE_KEY, '');
10180
+ await context.persistScopeIds();
10181
+ const rows = [
10182
+ { key: KEYS_REGISTRAR_URL_ENV, value: desired.keysUrl, literal: true },
10183
+ { key: KEYS_REGISTRAR_KEY_ENV, value: minted.secret, literal: true },
10184
+ ];
10185
+ let lastError;
10186
+ for (let attempt = 1; attempt <= KEYS_REGISTRAR_DELIVERY_ATTEMPTS; attempt++) {
10187
+ try {
10188
+ await this.ports.coolify.bulkUpsertEnvs(appId, rows);
10189
+ await this.ports.coolify.restart(appId);
10190
+ context.recordScopeId(KEYS_REGISTRAR_SCOPE_KEY, minted.id);
10191
+ context.recordScopeId(KEYS_REGISTRAR_UNDELIVERED_SCOPE_KEY, '');
10192
+ context.recordScopeId(KEYS_REGISTRAR_MINTED_AT_SCOPE_KEY, new Date(context.now?.() ?? Date.now()).toISOString());
10193
+ context.recordScopeId(KEYS_REGISTRAR_URL_APPLIED_SCOPE_KEY, desired.keysUrl);
10194
+ return {};
10195
+ }
10196
+ catch (e) {
10197
+ lastError = e;
10198
+ }
10199
+ }
10200
+ 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`);
10201
+ }
10202
+ /**
10203
+ * F3: `plan/entry-edge-key.ts`'s `planEdgeSlug` -- records the org's slug in edge. A slug another company
10204
+ * holds (edge's `409`) is a plain line naming the clash, not the raw provider error.
10205
+ */
10206
+ async edgeSlug(action, org, context) {
10207
+ const edge = this.ports.edge;
10208
+ if (!edge) {
10209
+ throw new Error(`edge:slug for ${org.slug}: this executor was built without an EdgePort`);
10210
+ }
10211
+ const slug = action.desired.slug;
10212
+ try {
10213
+ await edge.setOrganisationSlug(org.id, slug);
10214
+ }
10215
+ catch (e) {
10216
+ if (e instanceof EdgeApiError && e.code === 'conflict') {
10217
+ 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`);
10218
+ }
10219
+ throw e;
10220
+ }
10221
+ context.recordScopeId(EDGE_SLUG_APPLIED_SCOPE_KEY, slug);
10222
+ return {};
10223
+ }
8877
10224
  /**
8878
10225
  * F7 (#317): `plan/entry-edge-key.ts`'s `planEdgeReservedLabels` --
8879
10226
  * tells the edge service every label this org's own entries already
@@ -9299,6 +10646,28 @@ export class FoundryStepExecutor {
9299
10646
  throw error;
9300
10647
  }
9301
10648
  }
10649
+ const appsNames = (recordedIds[CLOUDFLARE_TUNNEL_APPS_HOSTNAMES_SCOPE_KEY] ?? '')
10650
+ .split(',')
10651
+ .filter((name) => name !== '' && name.endsWith(`.${APPS_DOMAIN}`));
10652
+ if (appsNames.length > 0) {
10653
+ try {
10654
+ const zone = await this.zonePort().findZoneByApex(APPS_DOMAIN);
10655
+ if (zone) {
10656
+ const dns = this.dnsPort();
10657
+ for (const name of appsNames) {
10658
+ for (const record of await dns.findDnsRecord(zone.name, name, 'CNAME')) {
10659
+ if (dnsTargets.has(record.content)) {
10660
+ await dns.deleteDnsRecord(zone.name, record.id);
10661
+ }
10662
+ }
10663
+ }
10664
+ }
10665
+ }
10666
+ catch (error) {
10667
+ if (!isNotFoundError(error))
10668
+ throw error;
10669
+ }
10670
+ }
9302
10671
  await this.tunnelPort().deleteTunnel(target.id);
9303
10672
  return;
9304
10673
  }
@@ -9377,6 +10746,16 @@ export class FoundryStepExecutor {
9377
10746
  case 'coolify:bg-app':
9378
10747
  await this.ports.coolify.deleteApplication(target.id);
9379
10748
  return;
10749
+ case 'coolify:github-source':
10750
+ try {
10751
+ await withLabelledTimeout(`coolify:github-source for ${org.slug}: deleting the source`, GITHUB_SOURCE_TIMEOUT_MS, () => this.ports.coolify.raw('DELETE', `/github-apps/${target.id}`));
10752
+ }
10753
+ catch (error) {
10754
+ // withTimeout hides the cause; a source already gone counts as deleted.
10755
+ if (!(await this.githubSourceGone(target.id)))
10756
+ throw error;
10757
+ }
10758
+ return;
9380
10759
  case 'cloudflare:bg-access':
9381
10760
  await this.accessPort().deleteAccessApp(target.id);
9382
10761
  return;
@@ -9384,16 +10763,31 @@ export class FoundryStepExecutor {
9384
10763
  await this.accessPort().removeServiceToken(target.id);
9385
10764
  return;
9386
10765
  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)
10766
+ // `target.id` is the recorded route ({ hostname, service }). The zone comes from the hostname
10767
+ // (`<label>-hd.<zone apex>`, one label deep), as the apps-off retire finds it: a company on
10768
+ // oikos.host has no `org.domain`, and the apps database's record lives in the platform zone.
10769
+ let hostname;
10770
+ try {
10771
+ hostname = JSON.parse(target.id).hostname;
10772
+ }
10773
+ catch {
10774
+ hostname = undefined;
10775
+ }
10776
+ if (typeof hostname !== 'string' || !hostname.includes('.'))
10777
+ return;
10778
+ const zone = await this.zonePort().findZoneByApex(hostname.slice(hostname.indexOf('.') + 1));
10779
+ if (!zone)
9391
10780
  return;
9392
10781
  const dns = this.dnsPort();
10782
+ // Only this company's own tunnel's record is deleted; a record that points anywhere else is not ours.
10783
+ const ownTunnel = recordedIds[`cloudflare:tunnel:${org.id}`];
9393
10784
  for (const record of await dns.findDnsRecord(zone.name, hostname, 'CNAME')) {
9394
- if (record.content.endsWith('.cfargotunnel.com')) {
10785
+ if (ownTunnel && record.content === `${ownTunnel}.cfargotunnel.com`) {
9395
10786
  await dns.deleteDnsRecord(zone.name, record.id);
9396
10787
  }
10788
+ else {
10789
+ console.info(`cloudflare:bg-dns for ${org.slug}: left the DNS record ${hostname} alone, it does not point at this company's tunnel`);
10790
+ }
9397
10791
  }
9398
10792
  return;
9399
10793
  }
@@ -9415,6 +10809,26 @@ export class FoundryStepExecutor {
9415
10809
  case 'coolify:database':
9416
10810
  await this.ports.coolify.deleteDatabase(target.id);
9417
10811
  return;
10812
+ case 'coolify:keys-registrar': {
10813
+ // F7: the company's registrar key at the keys service (live or never delivered).
10814
+ const keys = this.ports.keysRegistrar;
10815
+ if (!keys) {
10816
+ throw new Error(`coolify:keys-registrar for ${org.slug}: this executor was built without a KeysRegistrarPort; cannot revoke key ${target.id}`);
10817
+ }
10818
+ if (target.key === 'all') {
10819
+ // Every registrar key of the company, including one no recorded id points at.
10820
+ await keys.revokeAllCompanyKeys(org.id);
10821
+ return;
10822
+ }
10823
+ try {
10824
+ await keys.revokeCompanyKey(org.id, target.id);
10825
+ }
10826
+ catch (error) {
10827
+ if (!isNotFoundError(error))
10828
+ throw error;
10829
+ }
10830
+ return;
10831
+ }
9418
10832
  case 'coolify:own-files-r2': {
9419
10833
  // An own files bucket's storage key; a 404 is caught below as already gone.
9420
10834
  const r2Keys = this.ports.ownServices?.provision.files.r2Keys;
@@ -9450,6 +10864,7 @@ export class FoundryStepExecutor {
9450
10864
  return;
9451
10865
  }
9452
10866
  case 'zitadel:customers':
10867
+ case 'zitadel:customers-test':
9453
10868
  case 'zitadel:org':
9454
10869
  await this.ports.zitadel.deleteOrg(target.id);
9455
10870
  return;