@wtfalch/foundry 0.60.0 → 0.62.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (236) hide show
  1. package/dist/apps/cloudflare.d.ts +173 -0
  2. package/dist/apps/cloudflare.js +17 -0
  3. package/dist/apps/deploy.d.ts +116 -0
  4. package/dist/apps/deploy.js +791 -0
  5. package/dist/apps/github.d.ts +47 -0
  6. package/dist/apps/github.js +201 -0
  7. package/dist/apps/index.d.ts +9 -0
  8. package/dist/apps/index.js +9 -0
  9. package/dist/apps/migrations.d.ts +110 -0
  10. package/dist/apps/migrations.js +458 -0
  11. package/dist/audit/connector.d.ts +43 -0
  12. package/dist/audit/connector.js +22 -0
  13. package/dist/audit/evidence.d.ts +49 -0
  14. package/dist/audit/evidence.js +56 -0
  15. package/dist/audit/index.d.ts +9 -0
  16. package/dist/audit/index.js +9 -0
  17. package/dist/audit/migrations.d.ts +5 -0
  18. package/dist/audit/migrations.js +5 -0
  19. package/dist/audit/registry.d.ts +49 -0
  20. package/dist/audit/registry.js +44 -0
  21. package/dist/audit/seal.d.ts +49 -0
  22. package/dist/audit/seal.js +131 -0
  23. package/dist/audit/sealer-role.d.ts +33 -0
  24. package/dist/audit/sealer-role.js +56 -0
  25. package/dist/audit/signer.d.ts +40 -0
  26. package/dist/audit/signer.js +78 -0
  27. package/dist/audit/tick.d.ts +36 -0
  28. package/dist/audit/tick.js +87 -0
  29. package/dist/catalogue/copy-en.d.ts +1 -0
  30. package/dist/catalogue/copy-en.js +129 -0
  31. package/dist/catalogue/index.d.ts +9 -0
  32. package/dist/catalogue/index.js +21 -0
  33. package/dist/catalogue/integrations.d.ts +4 -0
  34. package/dist/catalogue/integrations.js +104 -0
  35. package/dist/catalogue/systems.d.ts +7 -0
  36. package/dist/catalogue/systems.js +31 -0
  37. package/dist/catalogue/types.d.ts +110 -0
  38. package/dist/catalogue/types.js +1 -0
  39. package/dist/entry-rules.d.ts +53 -0
  40. package/dist/entry-rules.js +71 -0
  41. package/dist/hosted-label.d.ts +13 -0
  42. package/dist/hosted-label.js +81 -0
  43. package/dist/index.d.ts +14 -5
  44. package/dist/index.js +7 -3
  45. package/dist/migrations/0062_hosting_github.sql +6 -0
  46. package/dist/migrations/0063_boule_apps.sql +7 -0
  47. package/dist/migrations/0064_requested_changes_boule_apps.sql +6 -0
  48. package/dist/migrations/0065_native_build_runs.sql +32 -0
  49. package/dist/migrations/0066_org_kosmos.sql +10 -0
  50. package/dist/modules/ai.d.ts +3 -0
  51. package/dist/modules/ai.js +64 -0
  52. package/dist/modules/assistants.d.ts +3 -0
  53. package/dist/modules/assistants.js +72 -0
  54. package/dist/modules/audit-ledger.d.ts +29 -0
  55. package/dist/modules/audit-ledger.js +49 -0
  56. package/dist/modules/audit.d.ts +7 -0
  57. package/dist/modules/audit.js +44 -0
  58. package/dist/modules/database.d.ts +23 -0
  59. package/dist/modules/database.js +189 -0
  60. package/dist/modules/email.d.ts +3 -0
  61. package/dist/modules/email.js +18 -0
  62. package/dist/modules/entry-parts.d.ts +23 -0
  63. package/dist/modules/entry-parts.js +63 -0
  64. package/dist/modules/files.d.ts +3 -0
  65. package/dist/modules/files.js +64 -0
  66. package/dist/modules/index.d.ts +74 -0
  67. package/dist/modules/index.js +774 -0
  68. package/dist/modules/jobs.d.ts +3 -0
  69. package/dist/modules/jobs.js +23 -0
  70. package/dist/modules/organisations.d.ts +3 -0
  71. package/dist/modules/organisations.js +20 -0
  72. package/dist/modules/outputs.d.ts +9 -0
  73. package/dist/modules/outputs.js +38 -0
  74. package/dist/modules/sign-in.d.ts +3 -0
  75. package/dist/modules/sign-in.js +197 -0
  76. package/dist/modules/signed-records.d.ts +3 -0
  77. package/dist/modules/signed-records.js +32 -0
  78. package/dist/modules/types.d.ts +194 -0
  79. package/dist/modules/types.js +1 -0
  80. package/dist/modules/workspaces.d.ts +3 -0
  81. package/dist/modules/workspaces.js +75 -0
  82. package/dist/native-build.d.ts +70 -0
  83. package/dist/native-build.js +92 -0
  84. package/dist/org.d.ts +184 -24
  85. package/dist/org.js +444 -49
  86. package/dist/plan/agora.d.ts +1 -1
  87. package/dist/plan/agora.js +4 -0
  88. package/dist/plan/apps-db-route.d.ts +90 -0
  89. package/dist/plan/apps-db-route.js +168 -0
  90. package/dist/plan/apps-zones.d.ts +56 -0
  91. package/dist/plan/apps-zones.js +118 -0
  92. package/dist/plan/archon-blue-green.js +11 -1
  93. package/dist/plan/blue-green-apply.d.ts +37 -1
  94. package/dist/plan/blue-green-apply.js +55 -15
  95. package/dist/plan/blue-green.d.ts +60 -8
  96. package/dist/plan/blue-green.js +115 -10
  97. package/dist/plan/boule.d.ts +23 -1
  98. package/dist/plan/boule.js +35 -2
  99. package/dist/plan/broker-entries.d.ts +28 -3
  100. package/dist/plan/broker-entries.js +74 -3
  101. package/dist/plan/broker-registrar.d.ts +168 -0
  102. package/dist/plan/broker-registrar.js +440 -0
  103. package/dist/plan/broker-workspaces-database.d.ts +48 -0
  104. package/dist/plan/broker-workspaces-database.js +87 -0
  105. package/dist/plan/deploy-health.js +9 -2
  106. package/dist/plan/desktop-callback.js +15 -7
  107. package/dist/plan/diff.d.ts +8 -2
  108. package/dist/plan/diff.js +552 -104
  109. package/dist/plan/edge-access.d.ts +6 -0
  110. package/dist/plan/edge-access.js +1 -0
  111. package/dist/plan/edge-dns.d.ts +6 -0
  112. package/dist/plan/edge-dns.js +9 -7
  113. package/dist/plan/entry-ai-key.d.ts +1 -1
  114. package/dist/plan/entry-ai-key.js +2 -2
  115. package/dist/plan/entry-audit.d.ts +58 -0
  116. package/dist/plan/entry-audit.js +122 -0
  117. package/dist/plan/entry-customer-grants.d.ts +75 -0
  118. package/dist/plan/entry-customer-grants.js +154 -0
  119. package/dist/plan/entry-edge-key.d.ts +26 -1
  120. package/dist/plan/entry-edge-key.js +42 -3
  121. package/dist/plan/entry-jobs.d.ts +64 -0
  122. package/dist/plan/entry-jobs.js +92 -0
  123. package/dist/plan/entry-repo-key-apply.d.ts +55 -0
  124. package/dist/plan/entry-repo-key-apply.js +185 -0
  125. package/dist/plan/entry-repo-key.d.ts +92 -0
  126. package/dist/plan/entry-repo-key.js +235 -0
  127. package/dist/plan/entry-workspaces.d.ts +55 -0
  128. package/dist/plan/entry-workspaces.js +90 -0
  129. package/dist/plan/executor.d.ts +231 -19
  130. package/dist/plan/executor.js +1648 -129
  131. package/dist/plan/gates.d.ts +1 -1
  132. package/dist/plan/gates.js +62 -9
  133. package/dist/plan/github-deploy-keys.d.ts +78 -0
  134. package/dist/plan/github-deploy-keys.js +305 -0
  135. package/dist/plan/github-source.d.ts +53 -0
  136. package/dist/plan/github-source.js +140 -0
  137. package/dist/plan/held-key-refs.d.ts +12 -0
  138. package/dist/plan/held-key-refs.js +12 -0
  139. package/dist/plan/hostnames.d.ts +9 -0
  140. package/dist/plan/hostnames.js +28 -1
  141. package/dist/plan/index.d.ts +9 -0
  142. package/dist/plan/index.js +6 -0
  143. package/dist/plan/jobs-scheduler-http.js +3 -0
  144. package/dist/plan/keys-registrar-http.d.ts +32 -0
  145. package/dist/plan/keys-registrar-http.js +105 -0
  146. package/dist/plan/keys-registrar.d.ts +67 -0
  147. package/dist/plan/keys-registrar.js +103 -0
  148. package/dist/plan/kosmos.d.ts +56 -0
  149. package/dist/plan/kosmos.js +84 -0
  150. package/dist/plan/live.d.ts +1 -1
  151. package/dist/plan/locked-hostnames.d.ts +2 -2
  152. package/dist/plan/locked-hostnames.js +7 -4
  153. package/dist/plan/login-host.js +11 -5
  154. package/dist/plan/machine-paths.d.ts +36 -0
  155. package/dist/plan/machine-paths.js +137 -0
  156. package/dist/plan/mcp.d.ts +1 -1
  157. package/dist/plan/mcp.js +49 -17
  158. package/dist/plan/org-apps-admin.d.ts +108 -0
  159. package/dist/plan/org-apps-admin.js +434 -0
  160. package/dist/plan/org-names.d.ts +3 -3
  161. package/dist/plan/org-names.js +15 -2
  162. package/dist/plan/org-postgres-migrate.js +1 -0
  163. package/dist/plan/own-diff.d.ts +23 -0
  164. package/dist/plan/own-diff.js +53 -0
  165. package/dist/plan/own-hostnames.d.ts +2 -0
  166. package/dist/plan/own-hostnames.js +1 -0
  167. package/dist/plan/own-jobs-route.d.ts +47 -0
  168. package/dist/plan/own-jobs-route.js +91 -0
  169. package/dist/plan/own-limits.d.ts +6 -0
  170. package/dist/plan/own-limits.js +1 -0
  171. package/dist/plan/own-services.d.ts +4 -0
  172. package/dist/plan/own-services.js +4 -0
  173. package/dist/plan/plan-apply.d.ts +39 -0
  174. package/dist/plan/plan-apply.js +163 -9
  175. package/dist/plan/platform-diff.d.ts +2 -2
  176. package/dist/plan/platform-diff.js +7 -2
  177. package/dist/plan/platform-org.d.ts +30 -0
  178. package/dist/plan/platform-org.js +12 -1
  179. package/dist/plan/platform-providers.d.ts +2 -0
  180. package/dist/plan/platform-providers.js +13 -2
  181. package/dist/plan/post-logout-uris.d.ts +1 -1
  182. package/dist/plan/post-logout-uris.js +12 -3
  183. package/dist/plan/private-pages.d.ts +26 -0
  184. package/dist/plan/private-pages.js +39 -0
  185. package/dist/plan/providers-readers.js +20 -1
  186. package/dist/plan/proxy-labels.d.ts +12 -1
  187. package/dist/plan/proxy-labels.js +32 -2
  188. package/dist/plan/release-host.d.ts +6 -2
  189. package/dist/plan/release-host.js +36 -8
  190. package/dist/plan/stage-env.d.ts +19 -1
  191. package/dist/plan/stage-env.js +25 -1
  192. package/dist/plan/stage-guards.d.ts +20 -0
  193. package/dist/plan/stage-guards.js +24 -0
  194. package/dist/plan/stage-isolation.d.ts +24 -0
  195. package/dist/plan/stage-isolation.js +29 -1
  196. package/dist/plan/worker-app-database.d.ts +46 -42
  197. package/dist/plan/worker-app-database.js +150 -153
  198. package/dist/plan/worker-app.d.ts +5 -65
  199. package/dist/plan/worker-app.js +28 -105
  200. package/dist/plan/worker.d.ts +2 -2
  201. package/dist/plan/worker.js +2 -1
  202. package/dist/plan/workspace-client.d.ts +25 -0
  203. package/dist/plan/workspace-client.js +48 -0
  204. package/dist/plan/workspace-sign-in.d.ts +55 -0
  205. package/dist/plan/workspace-sign-in.js +273 -0
  206. package/dist/plan/workspace-worker.d.ts +4 -0
  207. package/dist/plan/workspace-worker.js +13 -0
  208. package/dist/plan/workspace.d.ts +101 -0
  209. package/dist/plan/workspace.js +305 -0
  210. package/dist/release/decide.d.ts +30 -1
  211. package/dist/release/decide.js +73 -11
  212. package/dist/release/health.d.ts +8 -3
  213. package/dist/release/health.js +58 -5
  214. package/dist/release/migration-compatibility.d.ts +66 -0
  215. package/dist/release/migration-compatibility.js +340 -0
  216. package/dist/release/run.d.ts +2 -5
  217. package/dist/release/run.js +4 -0
  218. package/dist/release/state.d.ts +3 -1
  219. package/dist/release/state.js +6 -0
  220. package/dist/release/types.d.ts +20 -0
  221. package/dist/requested-changes.d.ts +11 -2
  222. package/dist/requested-changes.js +13 -1
  223. package/dist/service-labels.d.ts +4 -0
  224. package/dist/service-labels.js +11 -0
  225. package/dist/slug.d.ts +2 -0
  226. package/dist/slug.js +20 -7
  227. package/dist/store/postgres.js +25 -2
  228. package/dist/store/schema.d.ts +73 -1
  229. package/dist/store/schema.js +10 -0
  230. package/dist/worker-app-config.d.ts +1 -11
  231. package/dist/worker-app-config.js +4 -17
  232. package/dist/worker-app-migrations.d.ts +105 -0
  233. package/dist/worker-app-migrations.js +166 -0
  234. package/dist/worker-app-upload.d.ts +103 -0
  235. package/dist/worker-app-upload.js +128 -0
  236. package/package.json +19 -2
package/dist/plan/diff.js CHANGED
@@ -1,10 +1,13 @@
1
- import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
2
- import { APP_ENTRY_KEY, ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, heldEnvRef, mainEntry, ownerMailboxRecorded, ownerMailboxes, ownerNameProblem, ownsServices, } from '../org.js';
1
+ import { VERSIONS } from '../bootstrap/versions.js';
2
+ import { APP_ENTRY_KEY, ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, appsOn, companyGithubFor, heldEnvRef, kosmosOn, mainEntry, ownerMailboxRecorded, ownerMailboxes, ownerNameProblem, ownsServices, } from '../org.js';
3
3
  import { entryHasDatabase } from '../worker-app-config.js';
4
- import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_DEPLOYED_SCOPE_KEY, CHAT_CALLS_APPLIED_SCOPE_KEY, CHAT_CALLS_TOKEN_SCOPE_KEY, CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostMissing, agoraHostnameFor, callsServiceHasName, callsServiceHasTokenEnv, callsTokenEnvMovedMessage, callsTokenHostFrom, chatCallsApplied, chatHasUpstreams, desiredAgoraEnv, withRecordedMessagingProject, } from './agora.js';
5
- import { planBlueGreen } from './blue-green.js';
4
+ import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_DEPLOYED_SCOPE_KEY, CHAT_CALLS_APPLIED_SCOPE_KEY, CHAT_CALLS_TOKEN_SCOPE_KEY, CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostMissing, agoraHostnameFor, callsServiceHasName, callsServiceHasTokenEnv, callsTokenEnvMovedMessage, callsTokenHostFrom, chatCallsApplied, chatHasUpstreams, desiredAgoraEnv, entryClientIdScopeKey, withRecordedMessagingProject, } from './agora.js';
5
+ import { planAppsDbRoute } from './apps-db-route.js';
6
+ import { planAppsZones } from './apps-zones.js';
7
+ import { bgKey, planBlueGreen, } from './blue-green.js';
6
8
  import { BOULE_ARCHON_CREDENTIAL_ENV, BOULE_BRIDGE_SECRET_ENV, BOULE_BROKER_ENTRY_KEY, BOULE_CUSTOMERS_CREDENTIAL_ENV, BOULE_MAIL_TOKEN_ENV, BOULE_OWNER_SEED_SECRET_ENV, BOULE_PEER_PASSWORD_ENV, BOULE_SECRETS_RESTARTED_SCOPE_KEY, CUSTOMERS_ROOM_SCOPE_KEY, CUSTOMERS_SIGN_IN_SCOPE_KEY, CUSTOMERS_SIGN_UP_SCOPE_KEY, archonCredentialRenewDue, bouleEnvDrift, bouleMailTokenDrift, bouleMailWanted, customersOrgIdOf, customersRoomValue, desiredBouleEnv, planBouleOwners, staleCustomersEnv, wantedBouleBrokerEntry, } from './boule.js';
7
- import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, } from './broker-entries.js';
9
+ import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, entryMcpProjectScopeKey, wantedBrokerEntry, } from './broker-entries.js';
10
+ import { bouleRegistrarDrift, planBrokerRegistrar } from './broker-registrar.js';
8
11
  import { chatInternalNames, chatUpstreams } from './chat-matrix.js';
9
12
  import { planCompanyBackups } from './company-backups.js';
10
13
  import { COOKIE_DOMAIN_WAITING_REASONS, LOGIN_COOKIE_DOMAIN_SCOPE_KEY, MIN_AUTH_FOR_CLIENT_ORGS, authAtLeast, cookieDomainAllowed, cookieDomainWaitingLine, } from './cookie-domain.js';
@@ -12,34 +15,49 @@ import { DESKTOP_AUTH_HOME, planDesktopAuth } from './desktop-auth.js';
12
15
  import { planDesktopCallbacks } from './desktop-callback.js';
13
16
  import { onPlatformNetwork } from './destinations.js';
14
17
  import { planEntryAiKeys } from './entry-ai-key.js';
15
- import { planEdgeReservedLabels, planEntryEdgeKeys } from './entry-edge-key.js';
18
+ import { planEntryAudit } from './entry-audit.js';
19
+ import { CUSTOMER_GRANTS_ENV, CUSTOMER_GRANTS_WRITTEN_SCOPE_KEY, customerGrantsEntries, customerGrantsShapeMatches, customerGrantsToDrop, wantedCustomerGrants, } from './entry-customer-grants.js';
20
+ import { planEdgeReservedLabels, planEdgeSlug, planEntryEdgeKeys } from './entry-edge-key.js';
16
21
  import { planFilesKeys } from './entry-files-key.js';
17
- import { hasWildcardGate, planGates } from './gates.js';
22
+ import { planEntryJobs } from './entry-jobs.js';
23
+ import { planEntryRepoKeys } from './entry-repo-key.js';
24
+ import { BROKER_WORKSPACES_DATABASE_ENV, workspacesDatabaseRef } from './entry-workspaces.js';
25
+ import { gateIdRecordKey, hasWildcardGate, planGates } from './gates.js';
26
+ import { githubSourceResourceKey, planGithubSource } from './github-source.js';
18
27
  import { appHostname, tunnelDnsHostnames } from './hostnames.js';
28
+ import { planKeysRegistrar } from './keys-registrar.js';
29
+ import { desiredKosmosEnv, kosmosEnvDrift } from './kosmos.js';
19
30
  import { APP_GONE_KIND } from './live.js';
20
31
  import { lockedHostnames } from './locked-hostnames.js';
21
32
  import { LOGIN_BINDING_SCOPE_KEY, LOGIN_CLIENT_ORGS_SCOPE_KEY, OWN_CUSTOMERS_BASE_URI_SCOPE_KEY, agoraIssuerHostFor, clientOrganizationsState, companyLoginTunnelHost, customersClientOrganizations, inAppApps, loginBindingState, loginHostClientIds, loginHostServesAgora, loginHostnameFor, ownAuthApplied, ownCustomersAppState, pendingRemovalHosts, } from './login-host.js';
33
+ import { planMachineBypasses } from './machine-paths.js';
22
34
  import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, } from './mail-copy.js';
23
35
  import { mailStoreSwitchChange } from './mail-store.js';
24
36
  import { planMcp } from './mcp.js';
25
37
  import { hasAdoptedResources, networkMoveNeeded, planNetworkMove } from './network-move.js';
38
+ import { planAppsAdmin } from './org-apps-admin.js';
26
39
  import { planOrgPostgresMigration } from './org-postgres-migrate.js';
27
40
  import { legacyUnmigrated, orgPostgresUndeliveredScopeKey, planOrgPostgresSteps, serviceOnServer, } from './org-postgres.js';
28
- import { OWN_AUTH_BLOCKED_KEY, OWN_FILES_BLOCKED_KEY, OWN_FILES_R2_BLOCKED_KEY, OWN_JOBS_BLOCKED_KEY, OWN_MAIL_BLOCKED_KEY, OWN_SFU_BLOCKED_KEY, OWN_SFU_FIREWALL_KEY, OWN_SIGNIN_DB_MOVE_BLOCKED_KEY, planOwnAuth, planOwnFiles, planOwnFilesR2, planOwnJobs, planOwnMail, planOwnSfu, planSigninDbMove, } from './own-diff.js';
41
+ import { OWN_AUTH_BLOCKED_KEY, OWN_FILES_BLOCKED_KEY, OWN_FILES_R2_BLOCKED_KEY, OWN_JOBS_BLOCKED_KEY, OWN_JOBS_ROUTE_BLOCKED_KEY, OWN_MAIL_BLOCKED_KEY, OWN_SFU_BLOCKED_KEY, OWN_SFU_FIREWALL_KEY, OWN_SIGNIN_DB_MOVE_BLOCKED_KEY, planOwnAuth, planOwnFiles, planOwnFilesR2, planOwnJobs, planOwnJobsRoute, planOwnMail, planOwnSfu, planSigninDbMove, } from './own-diff.js';
29
42
  import { ownDnsHostnames } from './own-dns.js';
30
43
  import { ownHostnames } from './own-hostnames.js';
44
+ import { platformMailMxHost } from './platform-diff.js';
31
45
  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, PLATFORM_AUTH_MCP_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, appEnvAppliedScopeKey, appFolderDiffers, appSecretAvailability, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, versionNotAppliedLine, zitadelAppIdTokenUserinfoAppliedScopeKey, } from './platform-diff.js';
32
46
  import { DEFAULT_ORG_PLATFORM_READINESS, } from './platform-org.js';
33
47
  import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
34
48
  import { companyAppPostLogoutUris, postLogoutUpdateOwed, zitadelAppPostLogoutAppliedScopeKey, } from './post-logout-uris.js';
35
- import { CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY, appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec, entryOikosHostnames, entryRoutes, entryRoutesMarker, } from './proxy-labels.js';
49
+ import { CLOUDFLARE_TUNNEL_APPS_HOSTNAMES_SCOPE_KEY, CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY, appLabelsAppliedScopeKey, entryAppsHostnames, entryLabelHash, entryLabelSpec, entryOikosHostnames, entryRoutes, entryRoutesMarker, } from './proxy-labels.js';
36
50
  import { planRemoteDatabase } from './remote-database.js';
37
51
  import { ZITADEL_ORG_DOMAIN_BLOCKED_SCOPE_KEY, ZITADEL_ORG_DOMAIN_SCOPE_KEY, ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY, signInDomainBlockedText, signInOrgDomain, } from './sign-in-org.js';
38
- import { SIGN_IN_BASE_PATH, SIGN_IN_IMAGE_TAG_SUFFIX, SIGN_IN_IN_APP_SCOPE_KEY, SIGN_IN_LOGIN_APP_SCOPE_KEY, SIGN_IN_LOGIN_INTERNAL_NAME, SIGN_IN_RATE_LIMIT_SCOPE_KEY, SIGN_IN_TRUSTED_SCOPE_KEY, inAppSignIn, inAppStatePairs, inAppTargets, } from './sign-in.js';
52
+ import { SIGN_IN_BASE_PATH, SIGN_IN_IMAGE_TAG_SUFFIX, SIGN_IN_IN_APP_SCOPE_KEY, SIGN_IN_LOGIN_APP_SCOPE_KEY, SIGN_IN_LOGIN_INTERNAL_NAME, SIGN_IN_RATE_LIMIT_SCOPE_KEY, SIGN_IN_TRUSTED_SCOPE_KEY, inAppSignIn, inAppStatePairs, inAppTargets, signInLoginNames, } from './sign-in.js';
39
53
  import { withStepSystem } from './step-system.js';
40
54
  import { WORKER_APP_DB_BLOCKED_KEY, WORKER_APP_DB_LEGACY_NOTE_KEY, WORKER_APP_DB_SCOPE_KEYS, planWorkerAppDb, } from './worker-app-db.js';
41
55
  import { brokerKeyDelivered, planWorkerApps } from './worker-app.js';
42
56
  import { planWorkers } from './worker.js';
57
+ import { testCustomersOrgKey, withDarkWorkspaceSignIn, workspaceClientKey, } from './workspace-client.js';
58
+ import { planTestCustomers, planWorkspaceBrokerEntries, planWorkspaceClients, workspaceSignInMarker, } from './workspace-sign-in.js';
59
+ import { workspaceWorkerParent } from './workspace-worker.js';
60
+ import { planWorkspaces, withWorkspaceWorkers, withoutDarkEntries, workspaceGateKey, wsKey, } from './workspace.js';
43
61
  /** The `Plan` (#19's public contract) a `PlanDetail` carries. */
44
62
  export function toPlan(detail) {
45
63
  return {
@@ -84,7 +102,17 @@ function madeByFoundry(org, r, recordedIds, recordedValues, platformNames) {
84
102
  if (r.system === 'coolify') {
85
103
  if (r.kind === 'environment')
86
104
  return true;
87
- return r.kind === 'application' && r.config.name === chatInternalNames(org.slug).mas;
105
+ if (r.kind !== 'application')
106
+ return false;
107
+ // The in-app login app of the org's own sign-in stack; its id is not recorded, so its name says it.
108
+ const login = `${org.slug}-auth-login`;
109
+ const inAppLogin = signInLoginNames({
110
+ names: { authLogin: login },
111
+ internal: { authLogin: login },
112
+ });
113
+ if (r.config.name === inAppLogin.name)
114
+ return Boolean(recordedIds[`coolify:own-auth:${org.id}`]);
115
+ return r.config.name === chatInternalNames(org.slug).mas;
88
116
  }
89
117
  if (r.system !== 'cloudflare' || r.kind !== 'dns_record')
90
118
  return false;
@@ -122,6 +150,7 @@ const OWN_MARKER_KINDS = new Set([
122
150
  'own-files',
123
151
  'own-files-r2',
124
152
  'own-jobs',
153
+ 'own-jobs-route',
125
154
  'own-sfu',
126
155
  ]);
127
156
  /** The kinds a provider lists a resource under before a recorded `system:kind:key` maps it to foundry's own. */
@@ -197,7 +226,10 @@ function liveResourceName(r) {
197
226
  * Company Factory end to end", which wires real readers and executors
198
227
  * against this seam.
199
228
  */
200
- export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AGORA_HOST_VALUES, jobsAvailable = false, platformReadiness = DEFAULT_ORG_PLATFORM_READINESS, options = {}) {
229
+ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost = NO_AGORA_HOST_VALUES, jobsAvailable = false, platformReadiness = DEFAULT_ORG_PLATFORM_READINESS, options = {}) {
230
+ // An entry that is not live has no app, address or Worker of its own: only its workspace exists (the
231
+ // workspace steps, the gate and the tunnel read `declaredOrg`).
232
+ const org = withoutDarkEntries(declaredOrg);
201
233
  const byKey = new Map(live.map((r) => [resourceKey(r.system, r.kind, r.key), r]));
202
234
  const claimed = new Set();
203
235
  const plannedSteps = [];
@@ -243,7 +275,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
243
275
  // #267: after the ZITADEL org step, before any app's own issuer
244
276
  // application. Apps created later in this run are not in the binding yet;
245
277
  // the next run replans this step and repoints them.
246
- const loginHost = planLoginHost(org, recordedIds, {
278
+ const loginHost = planLoginHost(withDarkWorkspaceSignIn(org, declaredOrg), recordedIds, {
247
279
  authReady: platformReadiness.authReady,
248
280
  issuerPlanned: issuer.step !== null,
249
281
  bindingsWired: options.loginHostBindings ?? false,
@@ -320,7 +352,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
320
352
  const chat = planChat(org, byKey, claimed, recordedIds, agoraHost, issuer.step !== null);
321
353
  if (chat.step)
322
354
  plannedSteps.push(chat.step);
323
- const calls = planCalls(org, byKey, claimed, platformReadiness.callsReady, versionPin?.calls);
355
+ const calls = planCalls(org, byKey, claimed);
324
356
  if (calls.step)
325
357
  plannedSteps.push(calls.step);
326
358
  // An own org's media server, before the calls service that reads its addresses.
@@ -423,9 +455,14 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
423
455
  const customers = planCustomers(org, live, claimed, recordedIds, options.zitadelOrgMetadata ?? false);
424
456
  if (customers.step)
425
457
  plannedSteps.push(customers.step);
458
+ // The workspaces' own customers, before the clients and the workspace steps that read the organisation's id.
459
+ const testCustomers = planTestCustomers(declaredOrg, live, claimed, recordedIds, options.zitadelOrgMetadata ?? false);
460
+ if (testCustomers.step)
461
+ plannedSteps.push(testCustomers.step);
426
462
  for (const step of [
427
463
  ...planIssuerApps(org, byKey, claimed),
428
464
  ...planEntryClients(org, byKey, claimed),
465
+ ...planWorkspaceClients(declaredOrg, byKey, claimed),
429
466
  ]) {
430
467
  const guarded = guardApp(step);
431
468
  if (guarded)
@@ -433,14 +470,16 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
433
470
  }
434
471
  // Auth ADR 0002: the MCP project and the JWT app, after the app they change
435
472
  // and before the broker entry and the Worker that read the project id.
436
- const mcp = planMcp(org, recordedIds, plannedSteps, options.zitadelMcp ?? false);
473
+ const mcp = planMcp(org, recordedIds, plannedSteps, options.zitadelMcp ?? false, declaredOrg);
437
474
  plannedSteps.push(...mcp.steps);
438
475
  // The Access gate before the database, app, tunnel and zone (#84): on a
439
476
  // first apply the hostname is gated before anything answers on it.
440
477
  // An Access gate that cannot be planned must not leave the hostname it
441
478
  // guards routed: the tunnel (and its DNS) that exposes it waits too.
442
479
  let accessGateBlocked = false;
443
- const access = planAccess(org, recordedIds, platformReadiness.accessProviderId ?? null, options.platformHostnames ?? []);
480
+ const access = planAccess(org, recordedIds, platformReadiness.accessProviderId ?? null, options.platformHostnames ?? [], declaredOrg);
481
+ for (const note of access.notes)
482
+ dnsBlocked.push({ description: note, key: 'cloudflare:access' });
444
483
  if (access.blocked) {
445
484
  accessGateBlocked = true;
446
485
  dnsBlocked.push({
@@ -477,11 +516,32 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
477
516
  });
478
517
  if (workerAppDb.step)
479
518
  plannedSteps.push(workerAppDb.step);
519
+ // Before the apps: an app built from the company's own account is created through this source.
520
+ const githubSource = planGithubSource(org, recordedIds, platformReadiness.githubAppKeys);
521
+ if (githubSource.step)
522
+ plannedSteps.push(githubSource.step);
480
523
  const authIssuer = 'error' in agoraHost ? null : agoraHost.issuer;
481
524
  const app = planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer, platformReadiness);
482
525
  plannedSteps.push(...app.steps);
526
+ // The broker's workspaces database, before any broker entry that needs it: made once, when none is held or set.
527
+ const workspacesDatabasePlanned = declaredOrg.hosting.enabled &&
528
+ declaredOrg.hosting.entries.some((e) => e.uses?.auth?.workspaces) &&
529
+ (options.broker ?? false) &&
530
+ !(options.brokerWorkspacesDatabase ?? false) &&
531
+ (options.provisionWorkspacesDatabase ?? false);
532
+ if (workspacesDatabasePlanned) {
533
+ plannedSteps.push({
534
+ action: { system: 'coolify', kind: 'workspaces-database', key: org.id, desired: {} },
535
+ step: {
536
+ description: `make the database of ${org.slug}'s broker for customer workspaces and hold its URL at ${workspacesDatabaseRef(org)}`,
537
+ destroys: false,
538
+ migrates: false,
539
+ },
540
+ });
541
+ }
542
+ const brokerWorkspacesDatabase = (options.brokerWorkspacesDatabase ?? false) || workspacesDatabasePlanned;
483
543
  // After the apps: the broker entry's key is minted into each app's env.
484
- const brokerEntries = planBrokerEntries(org, recordedIds, plannedSteps, options.broker ?? false);
544
+ const brokerEntries = planBrokerEntries(org, recordedIds, plannedSteps, options.broker ?? false, brokerWorkspacesDatabase);
485
545
  plannedSteps.push(...brokerEntries.steps);
486
546
  // F6a: after the apps and the org's own ai key, which it mints below.
487
547
  const entryAiKeys = planEntryAiKeys(org, recordedIds, plannedSteps, platformReadiness, (options.now ?? Date.now)());
@@ -495,6 +555,10 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
495
555
  const edgeReservedLabels = planEdgeReservedLabels(org, recordedIds, platformReadiness, options.platformHostnames ?? []);
496
556
  if (edgeReservedLabels.step)
497
557
  plannedSteps.push(edgeReservedLabels.step);
558
+ // F3: the slug edge keys a company's platform hostnames on, next to the company's own edge key.
559
+ const edgeSlugStep = planEdgeSlug(org, recordedIds, platformReadiness);
560
+ if (edgeSlugStep)
561
+ plannedSteps.push(edgeSlugStep);
498
562
  // Blue/green (`blue-green.ts`): the apps and balancers before the tunnel routes to them, a second
499
563
  // server's connector after the tunnel exists.
500
564
  const blueGreen = planBlueGreen({
@@ -509,7 +573,49 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
509
573
  p.action.key === appStepKey(org, entryKey)),
510
574
  });
511
575
  plannedSteps.push(...blueGreen.steps.filter((x) => x.phase === 'before').map(blueGreenDraft()));
512
- const tunnel = planTunnel(org, byKey, claimed, recordedIds, platformReadiness.platformApex);
576
+ // A workspace is made only behind its gate: recorded already, or planned earlier in this run.
577
+ const workspaceGateReady = (gateKey) => Boolean(recordedIds[gateIdRecordKey(org.id, gateKey)]) ||
578
+ plannedSteps.some((p) => p.action.system === 'cloudflare' &&
579
+ p.action.kind === 'gate' &&
580
+ p.action.key === appStepKey(org, gateKey));
581
+ const stepPlanned = (system, kind, key) => plannedSteps.some((p) => p.action.system === system &&
582
+ p.action.kind === kind &&
583
+ (key === undefined || p.action.key === key));
584
+ // A workspace's sign-in is ready when its values are recorded, or the steps that record them are planned.
585
+ const workspaceSignInWanted = (entry) => {
586
+ const marker = workspaceSignInMarker(declaredOrg, entry, recordedIds, authIssuer);
587
+ if (marker)
588
+ return marker;
589
+ const customers = entry.uses?.auth?.users === 'customers';
590
+ const orgKnown = customers
591
+ ? Boolean(recordedIds[testCustomersOrgKey(org.id)]) ||
592
+ stepPlanned('zitadel', 'customers-test')
593
+ : Boolean(recordedIds.zitadel_org);
594
+ const clientKnown = Boolean(recordedIds[entryClientIdScopeKey(workspaceClientKey(entry.key))]) ||
595
+ stepPlanned('zitadel', 'app', workspaceClientKey(entry.key));
596
+ const mcpKnown = !entry.uses?.auth?.mcp ||
597
+ Boolean(recordedIds[entryMcpProjectScopeKey(workspaceClientKey(entry.key))]) ||
598
+ stepPlanned('zitadel', 'mcp-project', `${org.id}:${workspaceClientKey(entry.key)}`);
599
+ return orgKnown && clientKnown && mcpKnown && authIssuer ? 'pending' : null;
600
+ };
601
+ // Blue/green's cousin: each server workspace is a stage with a host of its own, behind its gate.
602
+ const workspaces = planWorkspaces({
603
+ org: declaredOrg,
604
+ recordedIds,
605
+ servers: options.blueGreenServers,
606
+ gateReady: workspaceGateReady,
607
+ signInWanted: workspaceSignInWanted,
608
+ authIssuer,
609
+ ...(options.stageSinkDown ? { stageSinkDown: true } : {}),
610
+ });
611
+ plannedSteps.push(...workspaces.steps);
612
+ // After the workspace step has put the stage's own key in its env.
613
+ const workspaceBrokerEntries = planWorkspaceBrokerEntries(declaredOrg, recordedIds, plannedSteps, options.broker ?? false, brokerWorkspacesDatabase, (entry) => recordedIds[bgKey.stageState(entry.key)] === 'ok' ||
614
+ plannedSteps.some((p) => p.action.system === 'coolify' &&
615
+ p.action.kind === 'workspace' &&
616
+ p.action.desired.entry === entry.key));
617
+ plannedSteps.push(...workspaceBrokerEntries.steps);
618
+ const tunnel = planTunnel(declaredOrg, byKey, claimed, recordedIds, platformReadiness.platformApex);
513
619
  // #238: a tunnel that is being provisioned waits for an active zone; parking
514
620
  // one never does.
515
621
  // #555: provisioning writes the tunnel config, the zone lookup and DNS; a
@@ -577,10 +683,45 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
577
683
  archon: options.bouleArchonCredential === true,
578
684
  directory: options.customersDirectory === true,
579
685
  }, platformReadiness.canHoldSecrets === true, (options.now ?? Date.now)());
580
- const boule = planBoule(org, byKey, claimed, recordedIds, authIssuer, options.otherSlugs ?? [], options.archonUrl ?? null, platformReadiness, bouleCredentials.wants);
686
+ // F13: Boule's `CUSTOMER_GRANTS`, from the entries that declare `uses.customerGrants`. Every reason it
687
+ // says nothing yet is a plan line, never silence.
688
+ const customerGrants = wantedCustomerGrants(org, recordedIds, {
689
+ live: byKey.has(resourceKey('coolify', 'boule', org.id)),
690
+ });
691
+ const customerGrantsNotes = [...customerGrants.notes];
692
+ let customerGrantsShape = null;
693
+ if (customerGrants.shape) {
694
+ if (bouleCredentials.wants.customersDirectory) {
695
+ customerGrantsShape = customerGrants.shape;
696
+ }
697
+ else {
698
+ customerGrantsNotes.push(`${org.slug}'s customer grants are not planned: they need the customers directory credential, which is blocked above`);
699
+ }
700
+ }
701
+ // T3: a company with customers gets a registrar key at the sign-in broker; Boule is given it.
702
+ const registrar = planBrokerRegistrar(org, recordedIds, plannedSteps, {
703
+ brokerWired: options.broker ?? false,
704
+ canHoldSecrets: platformReadiness.canHoldSecrets === true,
705
+ issuerKnown: Boolean(authIssuer),
706
+ });
707
+ const boule = planBoule(org, byKey, claimed, recordedIds, authIssuer, options.otherSlugs ?? [], options.archonUrl ?? null, platformReadiness, {
708
+ ...bouleCredentials.wants,
709
+ ...(customerGrantsShape ? { customerGrants: true } : {}),
710
+ ...(registrar.deliver ? { registrar: true } : {}),
711
+ ...(registrar.keepBouleEnv ? { registrarKept: true } : {}),
712
+ }, customerGrantsShape);
581
713
  if (boule.step)
582
714
  plannedSteps.push(boule.step);
583
- // Boule's and Agora's blue/green: after their own steps, which make the app it copies.
715
+ // foundry#949: the company's kosmos, only once it turns it on. Its fixed ZITADEL client first, the same
716
+ // reason Boule's and Agora's do: `coolifyKosmos` reads this step's recorded client id.
717
+ const kosmosClient = planKosmosClient(org, byKey, claimed);
718
+ const kosmosGuarded = guardApp(kosmosClient);
719
+ if (kosmosGuarded)
720
+ plannedSteps.push(kosmosGuarded);
721
+ const kosmos = planKosmos(org, byKey, claimed, recordedIds, authIssuer, options.otherSlugs ?? []);
722
+ if (kosmos.step)
723
+ plannedSteps.push(kosmos.step);
724
+ // Boule's, Agora's and kosmos's blue/green: after their own steps, which make the app it copies.
584
725
  plannedSteps.push(...blueGreen.steps.filter((x) => x.phase === 'last').map(blueGreenDraft()));
585
726
  // Boule owners, after the Boule step: needs the app deployed and the seeding secret in its env.
586
727
  const bouleOwners = planBouleOwners(org, recordedIds, (byKey.get(resourceKey('coolify', 'boule', org.id))?.config.env ?? null));
@@ -598,11 +739,26 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
598
739
  // the same dependency `planEntryAiKeys`/`planFilesKeys` have.
599
740
  const entryEdgeKeys = planEntryEdgeKeys(org, recordedIds, plannedSteps, platformReadiness, (options.now ?? Date.now)());
600
741
  plannedSteps.push(...entryEdgeKeys.steps);
742
+ // F7: the signed-records registrar key, after Boule like the edge key.
743
+ const keysRegistrar = planKeysRegistrar(org, recordedIds, plannedSteps, { ...(options.keysRegistrarUrl !== undefined ? { url: options.keysRegistrarUrl } : {}) }, (options.now ?? Date.now)());
744
+ plannedSteps.push(...keysRegistrar.steps);
745
+ // A company on shared services registers on the scheduler the host supplies (the owner company's); a company
746
+ // that runs its own services never does, only on its own.
747
+ const schedulerUsable = (jobsAvailable && !ownsServices(org)) || platformReadiness.jobsReady;
748
+ // A `uses.jobs` entry registers with the scheduler after its app, whose env the step writes.
749
+ const entryJobs = planEntryJobs(org, recordedIds, plannedSteps, platformReadiness, schedulerUsable);
750
+ plannedSteps.push(...entryJobs.steps);
751
+ // F12: a deploy key for an entry's own repo, after its app (whose env the step writes).
752
+ const entryRepoKeys = planEntryRepoKeys(org, recordedIds, plannedSteps, platformReadiness);
753
+ plannedSteps.push(...entryRepoKeys.steps);
601
754
  // Boule's own broker entry, after its app: the entry needs the AUTH_APP_KEY
602
755
  // that step puts in the app's env.
603
756
  const bouleBroker = planBouleBrokerEntry(org, recordedIds, plannedSteps, options.broker ?? false);
604
757
  if (bouleBroker.step)
605
758
  plannedSteps.push(bouleBroker.step);
759
+ // The company's registrar record in the broker, after Boule's own broker entry.
760
+ if (registrar.step)
761
+ plannedSteps.push(registrar.step);
606
762
  // #440: Agora's desktop sign-in backend, after Agora and Boule: its readiness probe goes through Agora's proxy, and
607
763
  // Agora writes the upstream only once the service ran healthy (so a first apply needs a second to turn it on).
608
764
  const desktopAuth = planDesktopAuth(org, recordedIds, {
@@ -622,12 +778,20 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
622
778
  });
623
779
  if (ownJobs.step)
624
780
  plannedSteps.push(ownJobs.step);
781
+ const ownJobsRoute = planOwnJobsRoute({
782
+ org,
783
+ recordedIds,
784
+ readiness: platformReadiness,
785
+ ownServices: options.ownServices ?? false,
786
+ zoneActive: ownZoneActive,
787
+ schedulerPlanned: ownJobs.step !== null,
788
+ });
789
+ if (ownJobsRoute.step)
790
+ plannedSteps.push(ownJobsRoute.step);
625
791
  // #173: register the org's own app with the platform's jobs scheduler.
626
792
  // After Agora, so a failing registration can never stop Agora from
627
793
  // provisioning (`runSteps` stops at the first failing step).
628
- const jobsRegistration = planJobsRegistration(org, recordedIds,
629
- // An own org never registers with the shared scheduler the host supplies.
630
- (jobsAvailable && !ownsServices(org)) || platformReadiness.jobsReady, platformReadiness.missingKeys.jobs, platformReadiness.noKeyReader, platformReadiness.jobsUrlMissing ?? false);
794
+ const jobsRegistration = planJobsRegistration(org, recordedIds, schedulerUsable, platformReadiness.missingKeys.jobs, platformReadiness.noKeyReader, platformReadiness.jobsUrlMissing ?? false);
631
795
  if (jobsRegistration.step)
632
796
  plannedSteps.push(jobsRegistration.step);
633
797
  const remoteDatabase = planRemoteDatabase(org, options.remoteDatabase, recordedIds);
@@ -660,7 +824,8 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
660
824
  }
661
825
  // ADR 0002: Worker apps, after the Workers for the same reason: a route, record
662
826
  // or script name that is not ours throws at apply and `runSteps` stops there.
663
- const workerApps = planWorkerApps(org, recordedIds, {
827
+ const workerOrg = withWorkspaceWorkers(org, declaredOrg);
828
+ const workerApps = planWorkerApps(workerOrg, recordedIds, {
664
829
  available: options.workerApps ?? false,
665
830
  ...(options.workerAppKeysUrl !== undefined ? { keysUrl: options.workerAppKeysUrl } : {}),
666
831
  ...(options.workerAppRegistryAccountId !== undefined
@@ -683,7 +848,10 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
683
848
  });
684
849
  for (const step of workerApps.steps) {
685
850
  const stepEntry = step.action.key.slice(org.id.length + 1);
686
- const ownZone = org.hosting.usesDomain && Boolean(org.domain);
851
+ // A workspace Worker lives in the platform's apps zone, not the org's own.
852
+ const wsEntry = workerOrg.hosting.entries.find((e) => e.key === stepEntry);
853
+ const wsParent = wsEntry ? workspaceWorkerParent(wsEntry) : null;
854
+ const ownZone = wsParent === null && org.hosting.usesDomain && Boolean(org.domain);
687
855
  const apply = step.action.desired.mode === 'apply';
688
856
  // Retire never looks a zone up; a deploy needs the org's zone, or the shared oikos.host zone.
689
857
  const hasDatabase = Boolean(org.hosting.entries.find((e) => e.key === stepEntry)?.workerApp?.database);
@@ -691,10 +859,16 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
691
859
  ? null
692
860
  : hasDatabase
693
861
  ? edgeNeeded(platformReadiness, ['zones', 'dns', 'access', 'tunnels'])
694
- : ownZone
862
+ : ownZone || wsParent !== null
695
863
  ? edgeNeeded(platformReadiness, ['zones', 'dns'])
696
864
  : edgeNeeded(platformReadiness, ['zones']);
697
- if (reason) {
865
+ if (apply && wsParent !== null && !workspaceGateReady(workspaceGateKey(wsParent))) {
866
+ appBlocked.push({
867
+ description: `${org.slug}'s ${wsParent} workspace Worker is blocked, not planned: its Access gate is not recorded or planned yet`,
868
+ key: step.action.key,
869
+ });
870
+ }
871
+ else if (reason) {
698
872
  dnsBlocked.push(edgeBlockedItem(org, `${stepEntry} Worker app`, step.action.key, reason));
699
873
  }
700
874
  else if (apply && ownZone && zoneWaiting) {
@@ -703,7 +877,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
703
877
  key: step.action.key,
704
878
  });
705
879
  }
706
- else if (apply && !ownZone && !(options.oikosZoneReady ?? false)) {
880
+ else if (apply && wsParent === null && !ownZone && !(options.oikosZoneReady ?? false)) {
707
881
  zoneBlocked.push({
708
882
  description: `${org.slug}'s ${stepEntry} Worker app is blocked, not planned: the shared oikos.host zone is not ready yet`,
709
883
  key: step.action.key,
@@ -713,11 +887,14 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
713
887
  plannedSteps.push(step);
714
888
  }
715
889
  }
890
+ // `uses.audit`: registers the entry's ledger for platform sealing, after its Worker app.
891
+ const entryAudit = planEntryAudit(org, recordedIds, plannedSteps, platformReadiness);
892
+ plannedSteps.push(...entryAudit.steps);
716
893
  // Last: a repair that fails must not stop any step after it (`runSteps` stops at the first failure).
717
894
  const idTokenUserinfo = planIdTokenUserinfo(org, live, recordedIds, plannedSteps, platformReadiness.authReady, options.zitadelIdTokenUserinfo ?? false);
718
895
  if (idTokenUserinfo)
719
896
  plannedSteps.push(idTokenUserinfo);
720
- const postLogoutUris = planPostLogoutUris(org, live, recordedIds, plannedSteps, platformReadiness.authReady, options.zitadelPostLogoutUris ?? false);
897
+ const postLogoutUris = planPostLogoutUris(declaredOrg, live, recordedIds, plannedSteps, platformReadiness.authReady, options.zitadelPostLogoutUris ?? false);
721
898
  if (postLogoutUris)
722
899
  plannedSteps.push(postLogoutUris);
723
900
  const desktopCallbacks = planDesktopCallbacks(org, live, recordedIds, plannedSteps, platformReadiness.authReady, options.zitadelRedirectUris ?? false);
@@ -725,6 +902,24 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
725
902
  plannedSteps.push(desktopCallbacks);
726
903
  // #444, last: a Postgres step that fails must not stop the zone, mail, app, Agora and Boule steps of a live company.
727
904
  plannedSteps.push(...orgPostgresSteps);
905
+ // F4 (#925): with apps on, the apps admin role on that server and the route Hyperdrive reaches it by.
906
+ const appsAdmin = planAppsAdmin(org, recordedIds, plannedSteps, {
907
+ canHoldSecrets: platformReadiness.canHoldSecrets === true,
908
+ postgresPlanned: orgPostgresSteps.some((p) => p.action.system === 'coolify' && p.action.kind === 'org-postgres'),
909
+ });
910
+ plannedSteps.push(...appsAdmin.steps);
911
+ const appsDbRoute = planAppsDbRoute(org, recordedIds, plannedSteps, {
912
+ accessReady: edgeNeeded(platformReadiness, ['access', 'tunnels', 'dns', 'zones']) === null,
913
+ canHoldSecrets: platformReadiness.canHoldSecrets === true,
914
+ tunnelReady: Boolean(recordedIds[`cloudflare:tunnel:${org.id}`]) ||
915
+ plannedSteps.some((s) => s.action.system === 'cloudflare' && s.action.kind === 'tunnel'),
916
+ });
917
+ plannedSteps.push(...appsDbRoute.steps);
918
+ // FZ: the company slug and the zone ids, in the same Boule env.
919
+ const appsZones = planAppsZones(org, recordedIds, plannedSteps, {
920
+ zonesReady: edgeNeeded(platformReadiness, ['zones']) === null,
921
+ });
922
+ plannedSteps.push(...appsZones.steps);
728
923
  // After the org-postgres steps: the desktop sign-in service lives on a home they may create in this same plan.
729
924
  if (desktopAuth.step)
730
925
  plannedSteps.push(desktopAuth.step);
@@ -742,7 +937,12 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
742
937
  for (const id of platformReadiness.platformZitadelIds ?? [])
743
938
  recordedValues.add(id);
744
939
  }
745
- const platformNames = new Set([...(options.platformHostnames ?? []), platformReadiness.mailHost?.mailHost ?? ''].map((h) => h.toLowerCase()));
940
+ const platformNames = new Set([
941
+ ...(options.platformHostnames ?? []),
942
+ platformReadiness.mailHost?.mailHost ?? '',
943
+ // The shared mail relay's MX host, which the platform's relay step writes.
944
+ platformReadiness.platformApex ? platformMailMxHost(platformReadiness.platformApex) : '',
945
+ ].map((h) => h.toLowerCase()));
746
946
  platformNames.delete('');
747
947
  const unmanaged = live
748
948
  .filter((r) => !claimed.has(resourceKey(r.system, r.kind, r.key)))
@@ -778,14 +978,23 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
778
978
  for (const description of bouleCredentials.blocked) {
779
979
  unmanaged.push({ description, key: 'coolify:boule:credentials' });
780
980
  }
981
+ customerGrantsNotes.forEach((description, index) => {
982
+ unmanaged.push({ description, key: `coolify:boule:customer-grants:${index}` });
983
+ });
984
+ registrar.blocked.forEach((description, index) => {
985
+ unmanaged.push({ description, key: `coolify:broker-registrar:${index}` });
986
+ });
781
987
  if (boule.mailBlocked) {
782
988
  unmanaged.push({ description: boule.mailBlocked, key: 'coolify:boule:mail' });
783
989
  }
990
+ if (kosmos.blocked)
991
+ unmanaged.push({ description: kosmos.blocked, key: 'coolify:kosmos' });
784
992
  // Same for an ai key minted but never delivered: planning a new mint would
785
993
  // orphan a second live key.
786
994
  if (aiKey.blocked)
787
995
  unmanaged.push({ description: aiKey.blocked, key: 'ai:key' });
788
996
  unmanaged.push(...blueGreen.unmanaged);
997
+ unmanaged.push(...workspaces.unmanaged);
789
998
  unmanaged.push(...filesKeys.blocked);
790
999
  unmanaged.push(...entryAiKeys.blocked);
791
1000
  unmanaged.push(...edgeWorkers.blocked);
@@ -798,7 +1007,15 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
798
1007
  }
799
1008
  unmanaged.push(...mcp.blocked);
800
1009
  unmanaged.push(...customers.blocked);
1010
+ unmanaged.push(...testCustomers.blocked);
801
1011
  unmanaged.push(...entryEdgeKeys.blocked);
1012
+ unmanaged.push(...keysRegistrar.blocked);
1013
+ unmanaged.push(...appsAdmin.blocked);
1014
+ unmanaged.push(...appsDbRoute.blocked);
1015
+ unmanaged.push(...appsZones.blocked);
1016
+ unmanaged.push(...entryJobs.blocked);
1017
+ unmanaged.push(...entryAudit.blocked);
1018
+ unmanaged.push(...entryRepoKeys.blocked);
802
1019
  unmanaged.push(...edgeReservedLabels.blocked);
803
1020
  if (ownAuth.blocked)
804
1021
  unmanaged.push({ description: ownAuth.blocked, key: OWN_AUTH_BLOCKED_KEY });
@@ -807,6 +1024,9 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
807
1024
  }
808
1025
  if (ownJobs.blocked)
809
1026
  unmanaged.push({ description: ownJobs.blocked, key: OWN_JOBS_BLOCKED_KEY });
1027
+ if (ownJobsRoute.blocked) {
1028
+ unmanaged.push({ description: ownJobsRoute.blocked, key: OWN_JOBS_ROUTE_BLOCKED_KEY });
1029
+ }
810
1030
  if (ownMail.blocked)
811
1031
  unmanaged.push({ description: ownMail.blocked, key: OWN_MAIL_BLOCKED_KEY });
812
1032
  if (ownSfu.blocked)
@@ -895,8 +1115,10 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
895
1115
  });
896
1116
  }
897
1117
  // #243: an appEnv source nothing will record is reported, not silently skipped.
1118
+ unmanaged.push(...githubSource.blocked);
898
1119
  unmanaged.push(...app.blocked);
899
1120
  unmanaged.push(...brokerEntries.blocked);
1121
+ unmanaged.push(...workspaceBrokerEntries.blocked);
900
1122
  unmanaged.push(...bouleBroker.blocked);
901
1123
  // F6: last, so every database step above runs before the backup of its database.
902
1124
  const backups = planCompanyBackups(org, recordedIds, options.backupStorageUuid);
@@ -940,6 +1162,7 @@ const NEEDS_OWN_ZITADEL = new Set([
940
1162
  'zitadel:owner',
941
1163
  'zitadel:app',
942
1164
  'zitadel:customers',
1165
+ 'zitadel:customers-test',
943
1166
  'zitadel:id-token-userinfo',
944
1167
  'zitadel:desktop-callback',
945
1168
  'zitadel:login-host',
@@ -947,7 +1170,10 @@ const NEEDS_OWN_ZITADEL = new Set([
947
1170
  'coolify:calls-service',
948
1171
  'coolify:calls-chat',
949
1172
  'coolify:broker-entry',
1173
+ 'coolify:workspace-broker-entry',
1174
+ 'coolify:workspaces-database',
950
1175
  'coolify:boule-broker-entry',
1176
+ 'coolify:broker-registrar',
951
1177
  'files:organisation',
952
1178
  ]);
953
1179
  /**
@@ -1555,8 +1781,8 @@ function loginHostWaiting(org, host, recordedIds, bindingsWired, issuerPlanned)
1555
1781
  * actually need it -- CMS's, chat's and calls' entries follow their own
1556
1782
  * on/off switch, the same ones this file already gates their own
1557
1783
  * `coolify:*` steps on (`org.ts`'s `SiteConfig.enabled`, `ChatConfig.enabled`,
1558
- * `CallsConfig.enabled`); Boule's is unconditional, matching `org.ts`'s own
1559
- * "every org gets one" rule; the app schema follows whether any
1784
+ * `CallsConfig.enabled`); kosmos follows its own switch too (`KosmosApp.enabled`, foundry#949); Boule's is
1785
+ * unconditional, matching `org.ts`'s own "every org gets one" rule; the app schema follows whether any
1560
1786
  * `hosting.entries` entry declares `database: true` (`coolifyDatabase`'s own
1561
1787
  * gate) rather than `hosting.enabled` alone, since a hosting org with no
1562
1788
  * database-bearing entry has nothing for this schema to hold.
@@ -1564,6 +1790,8 @@ function loginHostWaiting(org, host, recordedIds, bindingsWired, issuerPlanned)
1564
1790
  function orgServiceSchemaEnabled(org, schema) {
1565
1791
  if (schema.schema === 'cms')
1566
1792
  return org.site.enabled;
1793
+ if (schema.schema === 'kosmos')
1794
+ return kosmosOn(org);
1567
1795
  if (schema.schema.startsWith('chat_'))
1568
1796
  return org.chat.enabled;
1569
1797
  if (schema.schema.startsWith('calls_'))
@@ -1983,75 +2211,47 @@ function planChat(org, byKey, claimed, recordedIds, reading, issuerPlanned) {
1983
2211
  * matrix-authentication-service, entirely separate from chat's
1984
2212
  * (`plan/calls-matrix.ts`'s own top doc comment: a call guest must never gain
1985
2213
  * chat membership). Unlike chat, off by default: `org.ts`'s
1986
- * `CallsConfig.enabled` doc comment. Same on/off, park-not-delete shape as
1987
- * {@link planApp}/{@link planDatabase}/{@link planTunnel} below
1988
- * (`planHostingToggle`, #7 corrections 1) -- turning it off parks Synapse and
1989
- * MAS, never either database (`executor.ts`'s `coolifyCalls`).
2214
+ * `CallsConfig.enabled` doc comment.
1990
2215
  *
1991
- * #177: a first-ever provision (no live resource yet) is blocked, not
1992
- * planned, while `platformReadiness.callsReady` is false -- an org's own
1993
- * Matrix stack is wired to the shared calls SFU (#122/#127), so there is
1994
- * nothing for it to authenticate calls against until the platform's own
1995
- * `sfu:livekit` step is declared. Parking, and an already-provisioned stack,
1996
- * are unaffected.
2216
+ * #430: calls run transit-only now (calls#93's `CALLS_ENCRYPTION=transit`),
2217
+ * so the calls service no longer talks to a per-org Matrix stack and this
2218
+ * never provisions a new one, nor re-provisions a parked one -- the live
2219
+ * resource is still claimed, so it is never reported unmanaged. An org
2220
+ * whose stack is already live and enabled is left exactly as it is;
2221
+ * removing it is a separate, human-confirmed step. Turning `calls.enabled`
2222
+ * off still parks an already-live stack unchanged (`executor.ts`'s
2223
+ * `coolifyCalls`, #7 corrections 1): data is retained, not deleted.
1997
2224
  */
1998
- function planCalls(org, byKey, claimed, callsReady, synapseTag) {
2225
+ function planCalls(org, byKey, claimed) {
1999
2226
  const key = resourceKey('coolify', 'calls', org.id);
2000
- if (org.calls.enabled && !byKey.get(key) && !callsReady) {
2001
- return {
2002
- step: null,
2003
- blocked: ownsServices(org)
2004
- ? `${org.slug}'s calls Matrix stack is blocked, not planned: its own media server (coolify:own-sfu) is not applied yet`
2005
- : `${org.slug}'s calls Matrix stack is blocked, not planned: the platform owner's calls SFU (the platform's) is off`,
2006
- };
2007
- }
2008
- // #248: the two images this stack's own apply would run, checked the same
2009
- // way the platform's SFU checks LiveKit/cloudflared and ZITADEL checks its
2010
- // own tag -- a re-apply of the same declaration must not silently run a
2011
- // different version with no diff shown anywhere.
2012
- if (org.calls.enabled && !byKey.get(key)) {
2013
- const synapseBlocked = floatingTagBlocked('Synapse', synapseTag ?? VERSIONS.synapse, 'v1.161.0');
2014
- if (synapseBlocked) {
2015
- return {
2016
- step: null,
2017
- blocked: `${org.slug}'s calls Matrix stack is blocked, not planned: ${synapseBlocked}`,
2018
- };
2019
- }
2020
- const masBlocked = floatingTagBlocked('matrix-authentication-service', VERSIONS.mas, '1.24.0');
2021
- if (masBlocked) {
2022
- return {
2023
- step: null,
2024
- blocked: `${org.slug}'s calls Matrix stack is blocked, not planned: ${masBlocked}`,
2025
- };
2026
- }
2027
- }
2227
+ const live = byKey.get(key);
2228
+ if (live)
2229
+ claimed.add(key);
2230
+ const liveEnabled = live !== undefined && live.config.enabled !== false;
2231
+ if (org.calls.enabled && !liveEnabled)
2232
+ return { step: null, blocked: null };
2028
2233
  const toggle = planHostingToggle('coolify', 'calls', org.calls.enabled, org, byKey, claimed, () => `provision ${org.slug}'s calls Matrix stack (Synapse + matrix-authentication-service)`, () => `park ${org.slug}'s calls Matrix stack (stops serving; data is retained, not deleted -- #7 corrections 1)`);
2029
- // A held Synapse tag rides in the provisioning step; a parking step carries none.
2030
- if (toggle && synapseTag && toggle.action.desired.enabled === true) {
2031
- toggle.action.desired = { ...toggle.action.desired, synapseTag };
2032
- }
2033
2234
  return { step: toggle, blocked: null };
2034
2235
  }
2035
2236
  /**
2036
2237
  * The org's own calls service (#145, part 2 of #128) -- `wtfalch/calls`'s
2037
- * `packages/service`, deployed alongside the calls Matrix stack {@link planCalls}
2038
- * provisions and wired to it and to the shared LiveKit SFU (foundry#122/#127).
2039
- * Gated on the same `org.calls.enabled` flag as the Matrix stack, not a flag
2040
- * of its own: a calls service with no Matrix stack behind it has nothing to
2041
- * authenticate a guest against, and a Matrix stack alone answers no browser,
2042
- * so the two are declared as one on/off switch even though they are two
2043
- * planner steps. Same on/off, park-not-delete shape as {@link planCalls}
2044
- * (`planHostingToggle`, #7 corrections 1) -- turning it off parks the app,
2045
- * never its database (`executor.ts`'s `coolifyCallsService`).
2238
+ * `packages/service`, wired to the shared LiveKit SFU (foundry#122/#127).
2239
+ * Same on/off shape as {@link planCalls} (`planHostingToggle`, #7
2240
+ * corrections 1) -- turning it off parks the app, never its database
2241
+ * (`executor.ts`'s `coolifyCallsService`).
2046
2242
  *
2047
- * #177: silently plans nothing (no second blocked message) while the Matrix
2048
- * stack it rides beside is itself blocked on `callsReady` -- {@link planCalls}
2049
- * already reports the platform's shared SFU as missing once.
2243
+ * #430: calls run transit-only (calls#93), so this no longer rides behind
2244
+ * the Matrix stack {@link planCalls} used to provision -- there is none to
2245
+ * ride behind any more -- and reports the shared SFU as missing itself
2246
+ * (#177) rather than relying on `planCalls`'s now-removed report.
2050
2247
  */
2051
2248
  function planCallsService(org, byKey, claimed, callsReady, recordedIds, callsBlocked, tokenHost) {
2052
2249
  const key = resourceKey('coolify', 'calls-service', org.id);
2053
- if (org.calls.enabled && !byKey.get(key) && !callsReady) {
2054
- return { step: null, blocked: null };
2250
+ if (org.calls.enabled && !byKey.get(key) && !callsReady && !ownsServices(org)) {
2251
+ return {
2252
+ step: null,
2253
+ blocked: `${org.slug}'s calls service is blocked, not planned: the platform owner's calls SFU (the platform's) is off`,
2254
+ };
2055
2255
  }
2056
2256
  // An own org's service names its own media server, so it waits for it even when already provisioned.
2057
2257
  if (org.calls.enabled && ownsServices(org) && !callsReady) {
@@ -2867,6 +3067,137 @@ function planBouleClient(org, byKey, claimed) {
2867
3067
  },
2868
3068
  };
2869
3069
  }
3070
+ /**
3071
+ * foundry#949: the fixed ZITADEL web client `kosmos`, once the company turns kosmos on. The same shape as
3072
+ * {@link planBouleClient} and {@link planAgoraClient}; `kosmosOn(org)` is false before any client is planned.
3073
+ */
3074
+ function planKosmosClient(org, byKey, claimed) {
3075
+ if (!kosmosOn(org))
3076
+ return null;
3077
+ const key = resourceKey('zitadel', 'app', 'kosmos');
3078
+ const live = byKey.get(key);
3079
+ if (live)
3080
+ claimed.add(key);
3081
+ const desired = { appType: 'web' };
3082
+ if (!live) {
3083
+ return {
3084
+ action: { system: 'zitadel', kind: 'app', key: 'kosmos', desired },
3085
+ step: {
3086
+ description: `provision the web ZITADEL app 'kosmos' for ${org.slug}'s kosmos sign-in`,
3087
+ destroys: false,
3088
+ migrates: false,
3089
+ },
3090
+ };
3091
+ }
3092
+ if (live.config.appType === 'web')
3093
+ return null;
3094
+ return {
3095
+ action: {
3096
+ system: 'zitadel',
3097
+ kind: 'app',
3098
+ key: 'kosmos',
3099
+ desired: { ...desired, recreate: true },
3100
+ },
3101
+ step: {
3102
+ description: `recreate ZITADEL app 'kosmos' for ${org.slug} as web (was ${String(live.config.appType)})`,
3103
+ destroys: true,
3104
+ migrates: false,
3105
+ },
3106
+ };
3107
+ }
3108
+ /**
3109
+ * foundry#949: the org's own kosmos, a per-org singleton keyed by the org id under its own `kosmos` kind, built
3110
+ * from https://github.com/wtfalch/kosmos's root Dockerfile (`executor.ts`'s `coolifyKosmos`), with a database of
3111
+ * its own. Unlike Boule and Agora, kosmos is set up only once the company turns it on (`kosmosOn(org)`); while
3112
+ * it is off, an org that never had it plans nothing, and a live app is parked (stopped, database kept), the
3113
+ * same rule a switched-off service always follows here.
3114
+ *
3115
+ * Once live, a step is planned again whenever the live app's sign-in env lacks a desired key or holds a
3116
+ * different value (`kosmos.ts`'s `desiredKosmosEnv`, the function the executor writes from). The only host
3117
+ * value is the issuer address (the one Agora and Boule sign in at); without it kosmos is blocked, not planned,
3118
+ * so every other step still applies.
3119
+ */
3120
+ function planKosmos(org, byKey, claimed, recordedIds, issuer, otherSlugs) {
3121
+ if (!kosmosOn(org)) {
3122
+ // Switched off: park a live app (stop it, keep the database), like CMS and calls. A stopped app is
3123
+ // already parked, so the plan reaches empty.
3124
+ const parked = planHostingToggle('coolify', 'kosmos', false, org, byKey, claimed, () => `provision ${org.slug}'s kosmos`, () => `park ${org.slug}'s kosmos (stops serving; its database is retained, not deleted)`, org.id, (live) => !/^(exited|stopped)/.test(String(live.config.status ?? '')));
3125
+ return { step: parked, blocked: null };
3126
+ }
3127
+ const key = resourceKey('coolify', 'kosmos', org.id);
3128
+ const live = byKey.get(key);
3129
+ if (live)
3130
+ claimed.add(key);
3131
+ // Never truncated: Postgres cuts an identifier at 63 bytes, and a DNS label
3132
+ // over 63 is refused by Coolify and Cloudflare -- the same check `planBoule` makes.
3133
+ const tooLong = [
3134
+ [`${org.slug}-kosmos`, 'the kosmos host label and app name'],
3135
+ [`${org.slug}_kosmos_db_rt`, 'the kosmos database role'],
3136
+ ].filter(([name]) => name.length > 63);
3137
+ if (tooLong.length > 0) {
3138
+ return {
3139
+ step: null,
3140
+ blocked: `${org.slug}'s kosmos is blocked, not planned: ${tooLong.map(([n, what]) => `${what} '${n}' is over 63 characters`).join(', ')}; shorten the slug`,
3141
+ };
3142
+ }
3143
+ if (otherSlugs.includes(`${org.slug}-kosmos`)) {
3144
+ return {
3145
+ step: null,
3146
+ blocked: `${org.slug}'s kosmos is blocked, not planned: another declared org has the slug '${org.slug}-kosmos', which is this kosmos's hostname`,
3147
+ };
3148
+ }
3149
+ if (!issuer) {
3150
+ return {
3151
+ step: null,
3152
+ blocked: `${org.slug}'s kosmos is blocked, not planned: the host supplies no issuer`,
3153
+ };
3154
+ }
3155
+ const action = {
3156
+ system: 'coolify',
3157
+ kind: 'kosmos',
3158
+ key: org.id,
3159
+ desired: { issuer },
3160
+ };
3161
+ if (!live) {
3162
+ return {
3163
+ step: {
3164
+ action,
3165
+ step: { description: `provision ${org.slug}'s kosmos`, destroys: false, migrates: false },
3166
+ },
3167
+ blocked: null,
3168
+ };
3169
+ }
3170
+ const liveEnv = (live.config.env ?? {});
3171
+ const { env } = desiredKosmosEnv(org, recordedIds, issuer);
3172
+ const drift = kosmosEnvDrift(env, liveEnv);
3173
+ // Switched back on while the app is still stopped: the step starts it (`coolifyKosmos` starts a stopped app).
3174
+ if (/^(exited|stopped)/.test(String(live.config.status ?? ''))) {
3175
+ return {
3176
+ step: {
3177
+ action,
3178
+ step: {
3179
+ description: `start ${org.slug}'s kosmos again (it was parked)`,
3180
+ destroys: false,
3181
+ migrates: false,
3182
+ },
3183
+ },
3184
+ blocked: null,
3185
+ };
3186
+ }
3187
+ if (drift.length === 0)
3188
+ return { step: null, blocked: null };
3189
+ return {
3190
+ step: {
3191
+ action,
3192
+ step: {
3193
+ description: `update ${org.slug}'s kosmos config (${drift.join(', ')} missing or different)`,
3194
+ destroys: false,
3195
+ migrates: false,
3196
+ },
3197
+ },
3198
+ blocked: null,
3199
+ };
3200
+ }
2870
3201
  /**
2871
3202
  * C3: which credentials the org's Boule is given, and why one is not. Archon's credential goes to every
2872
3203
  * Boule the host can mint it for; the customers directory credential only while `customers.enabled`. Each
@@ -2919,7 +3250,7 @@ function planBouleCredentials(org, recordedIds, host, canHoldSecrets, nowMs) {
2919
3250
  * address (the one Agora signs in at); without it the Boule is blocked, not
2920
3251
  * planned, so every other step still applies.
2921
3252
  */
2922
- function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonUrl, readiness, credentials) {
3253
+ function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonUrl, readiness, credentials, customerGrantsShape = null) {
2923
3254
  const key = resourceKey('coolify', 'boule', org.id);
2924
3255
  const live = byKey.get(key);
2925
3256
  if (live)
@@ -2991,6 +3322,9 @@ function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonU
2991
3322
  platformOwner,
2992
3323
  archonCredential: credentials.archonCredential === true,
2993
3324
  customersDirectory: credentials.customersDirectory === true,
3325
+ // Only an org that declares the flag carries the key, so every other plan is unchanged.
3326
+ ...(credentials.customerGrants === true ? { customerGrants: true } : {}),
3327
+ ...(credentials.registrar === true ? { registrar: true } : {}),
2994
3328
  },
2995
3329
  };
2996
3330
  if (!live) {
@@ -3020,6 +3354,26 @@ function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonU
3020
3354
  if (!drift.includes(key))
3021
3355
  drift.push(key);
3022
3356
  }
3357
+ // F13: the grants entry says what the flagged entries say (credentials by presence only). Taken away only
3358
+ // once no entry declares the flag and foundry wrote it; held credentials of dropped entries are forgotten.
3359
+ if (customerGrantsShape) {
3360
+ if (!customerGrantsShapeMatches(liveEnv[CUSTOMER_GRANTS_ENV], customerGrantsShape)) {
3361
+ drift.push(CUSTOMER_GRANTS_ENV);
3362
+ }
3363
+ }
3364
+ else if (customerGrantsEntries(org).length === 0 &&
3365
+ recordedIds[CUSTOMER_GRANTS_WRITTEN_SCOPE_KEY] &&
3366
+ liveEnv[CUSTOMER_GRANTS_ENV] !== undefined) {
3367
+ drift.push(CUSTOMER_GRANTS_ENV);
3368
+ }
3369
+ if (customerGrantsToDrop(org, recordedIds).length > 0 && !drift.includes(CUSTOMER_GRANTS_ENV)) {
3370
+ drift.push(CUSTOMER_GRANTS_ENV);
3371
+ }
3372
+ // T3: the registrar key by presence and by which mint Boule got; taken away once customers are off.
3373
+ for (const key of bouleRegistrarDrift(org, recordedIds, liveEnv, credentials.registrar === true, credentials.registrarKept === true)) {
3374
+ if (!drift.includes(key))
3375
+ drift.push(key);
3376
+ }
3023
3377
  // The token is compared, never planned with its value.
3024
3378
  if (mailOn &&
3025
3379
  bouleMailTokenDrift(liveEnv[BOULE_MAIL_TOKEN_ENV], live.config.appMailToken)) {
@@ -3191,7 +3545,7 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
3191
3545
  // right now, and gating this on `mailHost` let a platform-apex org with an
3192
3546
  // already-recorded domain take the unprotected path the moment platform
3193
3547
  // mail went back to off.
3194
- if (entryKey === mainEntry(org.hosting)?.key) {
3548
+ if (entryKey === mainEntry(org.hosting)?.key || entry?.uses?.mail) {
3195
3549
  const mailDomain = recordedIds[MAIL_DOMAIN_SCOPE_KEY];
3196
3550
  const platformApex = readiness.platformApex;
3197
3551
  const platformLettermintProjectId = readiness.platformLettermintProjectId;
@@ -3519,11 +3873,53 @@ function planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer, re
3519
3873
  if (org.hosting.entries.some((e) => e.key === key && e.workerApp))
3520
3874
  continue;
3521
3875
  const app = planApp(org, key, byKey, claimed, recordedIds, plannedSteps, authIssuer, readiness);
3876
+ // An app built from the company's own account is created through its GitHub source: with no source
3877
+ // recorded or planned, a new app waits (blocked, not planned) instead of failing at create.
3878
+ const sourceEntry = org.hosting.entries.find((e) => e.key === key);
3879
+ if (app.step &&
3880
+ app.step.action.desired.enabled !== false &&
3881
+ sourceEntry &&
3882
+ companyGithubFor(org.hosting, sourceEntry.repo) &&
3883
+ !recordedIds[githubSourceResourceKey(org.id)] &&
3884
+ !recordedIds[`coolify:app:${appStepKey(org, key)}`] &&
3885
+ !plannedSteps.some((p) => p.action.system === 'coolify' && p.action.kind === 'github-source')) {
3886
+ blocked.push({
3887
+ description: `${org.slug}'s app for entry '${key}' is blocked, not planned: it is built from the company's GitHub account and its GitHub source is not made yet`,
3888
+ key: 'github-source',
3889
+ });
3890
+ blocked.push(...app.blocked);
3891
+ continue;
3892
+ }
3893
+ // The entry's workspaces key is made and held by this step; a store that cannot put leaves the step failing every run.
3894
+ if (app.step &&
3895
+ app.step.action.desired.enabled !== false &&
3896
+ org.hosting.entries.some((e) => e.key === key && e.uses?.auth?.workspaces) &&
3897
+ readiness.canHoldSecrets !== true) {
3898
+ blocked.push({
3899
+ description: `${org.slug}'s app for entry '${key}' is blocked, not planned: its workspaces key needs a held-key store that can put, and the host has none`,
3900
+ key: `coolify:app:${key}:workspaces-key`,
3901
+ });
3902
+ blocked.push(...app.blocked);
3903
+ continue;
3904
+ }
3905
+ // The entry's customer-grants credential is made and held by this step; a store that cannot put leaves it failing every run.
3906
+ if (app.step &&
3907
+ app.step.action.desired.enabled !== false &&
3908
+ org.hosting.entries.some((e) => e.key === key && e.uses?.customerGrants) &&
3909
+ readiness.canHoldSecrets !== true) {
3910
+ blocked.push({
3911
+ description: `${org.slug}'s app for entry '${key}' is blocked, not planned: its customer grants credential needs a held-key store that can put, and the host has none`,
3912
+ key: `coolify:app:${key}:customer-grants`,
3913
+ });
3914
+ blocked.push(...app.blocked);
3915
+ continue;
3916
+ }
3522
3917
  // The executor pushes the issuer the plan read, the way Agora gets its host values.
3523
3918
  // The same availability the plan's marker used: the executor records its
3524
3919
  // marker from this, never from its own port, so the two cannot disagree.
3525
3920
  const usesSecrets = org.hosting.entries.some((e) => e.key === key &&
3526
- Object.values(e.env).some((v) => typeof v !== 'string' && (v.from === 'sharedSecret' || v.from === 'heldSecret')));
3921
+ (e.uses?.customerGrants === true ||
3922
+ Object.values(e.env).some((v) => typeof v !== 'string' && (v.from === 'sharedSecret' || v.from === 'heldSecret'))));
3527
3923
  if (usesSecrets && app.step && app.step.action.desired.enabled !== false) {
3528
3924
  const entry = org.hosting.entries.find((e) => e.key === key);
3529
3925
  app.step.action.desired = {
@@ -3538,15 +3934,16 @@ function planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer, re
3538
3934
  if (signsIn)
3539
3935
  app.step.action.desired = { ...app.step.action.desired, authIssuer };
3540
3936
  }
3541
- // #491: only the `app` entry's own step runs `ensureAppLettermintMail`
3542
- // (`executor.ts`'s own `isMainEntry(entry)` gate). Unlike
3937
+ // #491: only the `app` entry's step and an entry with `uses.mail` run
3938
+ // `ensureAppLettermintMail` (`executor.ts`'s own gate). Unlike
3543
3939
  // `secretSources`/`authIssuer` above, these two are set unconditionally
3544
3940
  // -- `null` when no platform is declared, never omitted -- because
3545
3941
  // `executor.ts` treats the key being *absent* as a stale plan predating
3546
3942
  // this field and refuses to run (review round 2, finding 3): an omitted
3547
3943
  // key here would read identically to a stale plan and silently fall
3548
3944
  // back to the unprotected pre-#491 path instead.
3549
- if (key === mainKey && app.step && app.step.action.desired.enabled !== false) {
3945
+ const sendsMail = key === mainKey || org.hosting.entries.some((e) => e.key === key && e.uses?.mail);
3946
+ if (sendsMail && app.step && app.step.action.desired.enabled !== false) {
3550
3947
  app.step.action.desired = {
3551
3948
  ...app.step.action.desired,
3552
3949
  platformApex: readiness.platformApex,
@@ -3558,17 +3955,53 @@ function planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer, re
3558
3955
  // edge: blocked, not planned, while edge is not applied.
3559
3956
  const appReason = edgeNeeded(readiness, ['zones', 'dns']);
3560
3957
  if (appReason &&
3561
- key === mainKey &&
3958
+ sendsMail &&
3562
3959
  app.step &&
3563
3960
  app.step.action.desired.enabled !== false &&
3564
3961
  recordedIds[MAIL_DOMAIN_SCOPE_KEY]) {
3565
- blocked.push(edgeBlockedItem(org, 'app', 'coolify:app', appReason));
3962
+ blocked.push(edgeBlockedItem(org, key === mainKey ? 'app' : `${key} app`, key === mainKey ? 'coolify:app' : `coolify:app:${key}`, appReason));
3566
3963
  blocked.push(...app.blocked);
3567
3964
  continue;
3568
3965
  }
3966
+ // A workspace entry's live app is made from the commit its workspace runs: none is made before one is known.
3967
+ const goLive = org.hosting.entries.find((e) => e.key === key && e.workspace !== undefined && e.workerApp === undefined);
3968
+ if (goLive &&
3969
+ app.step &&
3970
+ app.step.action.desired.enabled !== false &&
3971
+ !recordedIds[`coolify:app:${appStepKey(org, key)}`]) {
3972
+ const commit = goLive.liveCommit ?? recordedIds[wsKey.commit(key)];
3973
+ if (!commit) {
3974
+ blocked.push({
3975
+ description: `${org.slug}'s app for entry '${key}' is blocked, not planned: its workspace has not deployed a commit yet, and the live app is made from that commit`,
3976
+ key: `workspace:${key}:golive`,
3977
+ });
3978
+ blocked.push(...app.blocked);
3979
+ continue;
3980
+ }
3981
+ app.step.action.desired = { ...app.step.action.desired, workspaceCommit: commit };
3982
+ app.step.step.description = `${app.step.step.description} from the commit its workspace runs (${commit.slice(0, 12)})`;
3983
+ }
3569
3984
  if (app.step)
3570
3985
  steps.push(app.step);
3571
3986
  blocked.push(...app.blocked);
3987
+ // A promote: the live app exists and runs another commit than the entry's `liveCommit`.
3988
+ if (goLive?.liveCommit !== undefined &&
3989
+ recordedIds[`coolify:app:${appStepKey(org, key)}`] &&
3990
+ recordedIds[wsKey.liveCommit(key)] !== goLive.liveCommit) {
3991
+ steps.push({
3992
+ action: {
3993
+ system: 'coolify',
3994
+ kind: 'promote',
3995
+ key: appStepKey(org, key),
3996
+ desired: { entry: key, commit: goLive.liveCommit },
3997
+ },
3998
+ step: {
3999
+ description: `put ${org.slug}'s live app for entry '${key}' on commit ${goLive.liveCommit.slice(0, 12)} and deploy it`,
4000
+ destroys: false,
4001
+ migrates: false,
4002
+ },
4003
+ });
4004
+ }
3572
4005
  }
3573
4006
  return { steps, blocked };
3574
4007
  }
@@ -3713,6 +4146,8 @@ function planTunnel(org, byKey, claimed, recordedIds, platformApex) {
3713
4146
  (org.site.enabled && Boolean(org.domain)) ||
3714
4147
  (org.calls.enabled && org.calls.usesDomain && Boolean(org.domain)) ||
3715
4148
  Boolean(org.domain) ||
4149
+ // F4 (#925): apps on, the tunnel carries the route to the company's Postgres server for Hyperdrive.
4150
+ appsOn(org) ||
3716
4151
  // A company without a domain still gets a tunnel of its own for an entry at `<slug>.oikos.host`.
3717
4152
  entryRoutes(org, recordedIds).length > 0;
3718
4153
  const toggled = planHostingToggle('cloudflare', 'tunnel', desired, org, byKey, claimed, () => `provision a Cloudflare tunnel for ${org.slug}'s app`, () => `park ${org.slug}'s Cloudflare tunnel (configuration is retained, not deleted -- #7 corrections 1)`, org.id,
@@ -3764,10 +4199,13 @@ function planTunnel(org, byKey, claimed, recordedIds, platformApex) {
3764
4199
  sortedNames(tunnelDnsHostnames(org, org.domain, loginHost));
3765
4200
  // The entry names in the platform's oikos.host zone are recorded apart from the company's own zone.
3766
4201
  const oikosStale = (recordedIds[CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY] ?? '') !==
3767
- entryOikosHostnames(org).join(',');
4202
+ entryOikosHostnames(org, recordedIds).join(',');
4203
+ const appsStale = (recordedIds[CLOUDFLARE_TUNNEL_APPS_HOSTNAMES_SCOPE_KEY] ?? '') !==
4204
+ entryAppsHostnames(org, recordedIds).join(',');
3768
4205
  const dnsStale = (org.domain !== null && recordedIds[CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY] !== org.domain) ||
3769
4206
  edgeChanged ||
3770
- oikosStale;
4207
+ oikosStale ||
4208
+ appsStale;
3771
4209
  // An entry's hostname goes straight to its container, so a changed port, key or name, or an ingress
3772
4210
  // written before entries were routed that way, needs the ingress written again. Nothing recorded
3773
4211
  // equals no routes.
@@ -3962,7 +4400,7 @@ function planEntryClients(org, byKey, claimed) {
3962
4400
  * record blocks it (rule 2). A foreign broker entry the last write found
3963
4401
  * invalid is listed as a warning: the broker drops it, foundry keeps it.
3964
4402
  */
3965
- export function planBrokerEntries(org, recordedIds, plannedSteps, brokerWired) {
4403
+ export function planBrokerEntries(org, recordedIds, plannedSteps, brokerWired, brokerWorkspacesDatabase) {
3966
4404
  const steps = [];
3967
4405
  const blocked = [];
3968
4406
  for (const entry of org.hosting.entries) {
@@ -3985,6 +4423,14 @@ export function planBrokerEntries(org, recordedIds, plannedSteps, brokerWired) {
3985
4423
  });
3986
4424
  continue;
3987
4425
  }
4426
+ // The broker does not start once an entry declares workspaces without its database, and then no one signs in.
4427
+ if (auth.workspaces && !brokerWorkspacesDatabase) {
4428
+ blocked.push({
4429
+ description: `${at} is blocked, not planned: the broker has no ${BROKER_WORKSPACES_DATABASE_ENV}; hold the connection string of its workspaces database at ${workspacesDatabaseRef(org)}, then apply again`,
4430
+ key,
4431
+ });
4432
+ continue;
4433
+ }
3988
4434
  // Auth ADR 0002: a broker given an `mcp` entry before it holds its database and secret exits at boot.
3989
4435
  // A company's own sign-in step sets them and records it; a shared org's broker is the platform's, whose
3990
4436
  // marker this plan cannot see, so `writeBrokerEntry` refuses there until the platform step has run.
@@ -4205,10 +4651,12 @@ const signInMetadataBlocked = (org) => ({
4205
4651
  * gates (`planGates`) come first. Under a `'*'` gate, a bypass on each name
4206
4652
  * foundry owns comes before them, so no owned name is ever gated.
4207
4653
  */
4208
- function planAccess(org, recordedIds, providerId, platformHostnames) {
4209
- const { steps: gates, blocked } = planGates(org, recordedIds, providerId);
4654
+ function planAccess(org, recordedIds, providerId, platformHostnames, declaredOrg = org) {
4655
+ const { steps: gates, blocked } = planGates(org, recordedIds, providerId, declaredOrg);
4656
+ // F11: a machine path of a gated entry is let through its gate, and a removed one is closed again.
4657
+ const machine = planMachineBypasses(org, recordedIds, declaredOrg);
4210
4658
  if (!org.hosting.enabled)
4211
- return { steps: gates, blocked };
4659
+ return { steps: [...gates, ...machine.steps], blocked, notes: machine.notes };
4212
4660
  const owned = (hasWildcardGate(org) ? [...lockedHostnames(org, platformHostnames)].sort() : [])
4213
4661
  .filter((hostname) => !recordedIds[resourceKey('cloudflare', 'access', appStepKey(org, hostname))])
4214
4662
  .map((hostname) => ({
@@ -4239,7 +4687,7 @@ function planAccess(org, recordedIds, providerId, platformHostnames) {
4239
4687
  migrates: false,
4240
4688
  },
4241
4689
  }));
4242
- return { steps: [...owned, ...gates, ...rules], blocked };
4690
+ return { steps: [...owned, ...gates, ...machine.steps, ...rules], blocked, notes: machine.notes };
4243
4691
  }
4244
4692
  /** The plan description for one `hosting.access` rule, by kind (#246). */
4245
4693
  function accessRuleDescription(org, rule) {