@wtfalch/foundry 0.59.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 +47 -40
  181. package/dist/plan/worker-app-database.js +150 -152
  182. package/dist/plan/worker-app.d.ts +6 -65
  183. package/dist/plan/worker-app.js +29 -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 +6 -10
  213. package/dist/worker-app-config.js +22 -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
package/dist/plan/diff.js CHANGED
@@ -1,10 +1,13 @@
1
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';
2
+ import { APP_ENTRY_KEY, ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, appsOn, companyGithubFor, heldEnvRef, 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,48 @@ 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';
19
29
  import { APP_GONE_KIND } from './live.js';
20
30
  import { lockedHostnames } from './locked-hostnames.js';
21
31
  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';
32
+ import { planMachineBypasses } from './machine-paths.js';
22
33
  import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, } from './mail-copy.js';
23
34
  import { mailStoreSwitchChange } from './mail-store.js';
24
35
  import { planMcp } from './mcp.js';
25
36
  import { hasAdoptedResources, networkMoveNeeded, planNetworkMove } from './network-move.js';
37
+ import { planAppsAdmin } from './org-apps-admin.js';
26
38
  import { planOrgPostgresMigration } from './org-postgres-migrate.js';
27
39
  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';
40
+ 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
41
  import { ownDnsHostnames } from './own-dns.js';
30
42
  import { ownHostnames } from './own-hostnames.js';
43
+ import { platformMailMxHost } from './platform-diff.js';
31
44
  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
45
  import { DEFAULT_ORG_PLATFORM_READINESS, } from './platform-org.js';
33
46
  import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
34
47
  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';
48
+ 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
49
  import { planRemoteDatabase } from './remote-database.js';
37
50
  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';
51
+ 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
52
  import { withStepSystem } from './step-system.js';
40
53
  import { WORKER_APP_DB_BLOCKED_KEY, WORKER_APP_DB_LEGACY_NOTE_KEY, WORKER_APP_DB_SCOPE_KEYS, planWorkerAppDb, } from './worker-app-db.js';
41
54
  import { brokerKeyDelivered, planWorkerApps } from './worker-app.js';
42
55
  import { planWorkers } from './worker.js';
56
+ import { testCustomersOrgKey, withDarkWorkspaceSignIn, workspaceClientKey, } from './workspace-client.js';
57
+ import { planTestCustomers, planWorkspaceBrokerEntries, planWorkspaceClients, workspaceSignInMarker, } from './workspace-sign-in.js';
58
+ import { workspaceWorkerParent } from './workspace-worker.js';
59
+ import { planWorkspaces, withWorkspaceWorkers, withoutDarkEntries, workspaceGateKey, wsKey, } from './workspace.js';
43
60
  /** The `Plan` (#19's public contract) a `PlanDetail` carries. */
44
61
  export function toPlan(detail) {
45
62
  return {
@@ -84,7 +101,17 @@ function madeByFoundry(org, r, recordedIds, recordedValues, platformNames) {
84
101
  if (r.system === 'coolify') {
85
102
  if (r.kind === 'environment')
86
103
  return true;
87
- return r.kind === 'application' && r.config.name === chatInternalNames(org.slug).mas;
104
+ if (r.kind !== 'application')
105
+ return false;
106
+ // The in-app login app of the org's own sign-in stack; its id is not recorded, so its name says it.
107
+ const login = `${org.slug}-auth-login`;
108
+ const inAppLogin = signInLoginNames({
109
+ names: { authLogin: login },
110
+ internal: { authLogin: login },
111
+ });
112
+ if (r.config.name === inAppLogin.name)
113
+ return Boolean(recordedIds[`coolify:own-auth:${org.id}`]);
114
+ return r.config.name === chatInternalNames(org.slug).mas;
88
115
  }
89
116
  if (r.system !== 'cloudflare' || r.kind !== 'dns_record')
90
117
  return false;
@@ -122,6 +149,7 @@ const OWN_MARKER_KINDS = new Set([
122
149
  'own-files',
123
150
  'own-files-r2',
124
151
  'own-jobs',
152
+ 'own-jobs-route',
125
153
  'own-sfu',
126
154
  ]);
127
155
  /** The kinds a provider lists a resource under before a recorded `system:kind:key` maps it to foundry's own. */
@@ -197,7 +225,10 @@ function liveResourceName(r) {
197
225
  * Company Factory end to end", which wires real readers and executors
198
226
  * against this seam.
199
227
  */
200
- export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AGORA_HOST_VALUES, jobsAvailable = false, platformReadiness = DEFAULT_ORG_PLATFORM_READINESS, options = {}) {
228
+ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost = NO_AGORA_HOST_VALUES, jobsAvailable = false, platformReadiness = DEFAULT_ORG_PLATFORM_READINESS, options = {}) {
229
+ // An entry that is not live has no app, address or Worker of its own: only its workspace exists (the
230
+ // workspace steps, the gate and the tunnel read `declaredOrg`).
231
+ const org = withoutDarkEntries(declaredOrg);
201
232
  const byKey = new Map(live.map((r) => [resourceKey(r.system, r.kind, r.key), r]));
202
233
  const claimed = new Set();
203
234
  const plannedSteps = [];
@@ -243,7 +274,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
243
274
  // #267: after the ZITADEL org step, before any app's own issuer
244
275
  // application. Apps created later in this run are not in the binding yet;
245
276
  // the next run replans this step and repoints them.
246
- const loginHost = planLoginHost(org, recordedIds, {
277
+ const loginHost = planLoginHost(withDarkWorkspaceSignIn(org, declaredOrg), recordedIds, {
247
278
  authReady: platformReadiness.authReady,
248
279
  issuerPlanned: issuer.step !== null,
249
280
  bindingsWired: options.loginHostBindings ?? false,
@@ -423,9 +454,14 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
423
454
  const customers = planCustomers(org, live, claimed, recordedIds, options.zitadelOrgMetadata ?? false);
424
455
  if (customers.step)
425
456
  plannedSteps.push(customers.step);
457
+ // The workspaces' own customers, before the clients and the workspace steps that read the organisation's id.
458
+ const testCustomers = planTestCustomers(declaredOrg, live, claimed, recordedIds, options.zitadelOrgMetadata ?? false);
459
+ if (testCustomers.step)
460
+ plannedSteps.push(testCustomers.step);
426
461
  for (const step of [
427
462
  ...planIssuerApps(org, byKey, claimed),
428
463
  ...planEntryClients(org, byKey, claimed),
464
+ ...planWorkspaceClients(declaredOrg, byKey, claimed),
429
465
  ]) {
430
466
  const guarded = guardApp(step);
431
467
  if (guarded)
@@ -433,14 +469,16 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
433
469
  }
434
470
  // Auth ADR 0002: the MCP project and the JWT app, after the app they change
435
471
  // and before the broker entry and the Worker that read the project id.
436
- const mcp = planMcp(org, recordedIds, plannedSteps, options.zitadelMcp ?? false);
472
+ const mcp = planMcp(org, recordedIds, plannedSteps, options.zitadelMcp ?? false, declaredOrg);
437
473
  plannedSteps.push(...mcp.steps);
438
474
  // The Access gate before the database, app, tunnel and zone (#84): on a
439
475
  // first apply the hostname is gated before anything answers on it.
440
476
  // An Access gate that cannot be planned must not leave the hostname it
441
477
  // guards routed: the tunnel (and its DNS) that exposes it waits too.
442
478
  let accessGateBlocked = false;
443
- const access = planAccess(org, recordedIds, platformReadiness.accessProviderId ?? null, options.platformHostnames ?? []);
479
+ const access = planAccess(org, recordedIds, platformReadiness.accessProviderId ?? null, options.platformHostnames ?? [], declaredOrg);
480
+ for (const note of access.notes)
481
+ dnsBlocked.push({ description: note, key: 'cloudflare:access' });
444
482
  if (access.blocked) {
445
483
  accessGateBlocked = true;
446
484
  dnsBlocked.push({
@@ -477,11 +515,32 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
477
515
  });
478
516
  if (workerAppDb.step)
479
517
  plannedSteps.push(workerAppDb.step);
518
+ // Before the apps: an app built from the company's own account is created through this source.
519
+ const githubSource = planGithubSource(org, recordedIds, platformReadiness.githubAppKeys);
520
+ if (githubSource.step)
521
+ plannedSteps.push(githubSource.step);
480
522
  const authIssuer = 'error' in agoraHost ? null : agoraHost.issuer;
481
523
  const app = planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer, platformReadiness);
482
524
  plannedSteps.push(...app.steps);
525
+ // The broker's workspaces database, before any broker entry that needs it: made once, when none is held or set.
526
+ const workspacesDatabasePlanned = declaredOrg.hosting.enabled &&
527
+ declaredOrg.hosting.entries.some((e) => e.uses?.auth?.workspaces) &&
528
+ (options.broker ?? false) &&
529
+ !(options.brokerWorkspacesDatabase ?? false) &&
530
+ (options.provisionWorkspacesDatabase ?? false);
531
+ if (workspacesDatabasePlanned) {
532
+ plannedSteps.push({
533
+ action: { system: 'coolify', kind: 'workspaces-database', key: org.id, desired: {} },
534
+ step: {
535
+ description: `make the database of ${org.slug}'s broker for customer workspaces and hold its URL at ${workspacesDatabaseRef(org)}`,
536
+ destroys: false,
537
+ migrates: false,
538
+ },
539
+ });
540
+ }
541
+ const brokerWorkspacesDatabase = (options.brokerWorkspacesDatabase ?? false) || workspacesDatabasePlanned;
483
542
  // 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);
543
+ const brokerEntries = planBrokerEntries(org, recordedIds, plannedSteps, options.broker ?? false, brokerWorkspacesDatabase);
485
544
  plannedSteps.push(...brokerEntries.steps);
486
545
  // F6a: after the apps and the org's own ai key, which it mints below.
487
546
  const entryAiKeys = planEntryAiKeys(org, recordedIds, plannedSteps, platformReadiness, (options.now ?? Date.now)());
@@ -495,6 +554,10 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
495
554
  const edgeReservedLabels = planEdgeReservedLabels(org, recordedIds, platformReadiness, options.platformHostnames ?? []);
496
555
  if (edgeReservedLabels.step)
497
556
  plannedSteps.push(edgeReservedLabels.step);
557
+ // F3: the slug edge keys a company's platform hostnames on, next to the company's own edge key.
558
+ const edgeSlugStep = planEdgeSlug(org, recordedIds, platformReadiness);
559
+ if (edgeSlugStep)
560
+ plannedSteps.push(edgeSlugStep);
498
561
  // Blue/green (`blue-green.ts`): the apps and balancers before the tunnel routes to them, a second
499
562
  // server's connector after the tunnel exists.
500
563
  const blueGreen = planBlueGreen({
@@ -509,7 +572,49 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
509
572
  p.action.key === appStepKey(org, entryKey)),
510
573
  });
511
574
  plannedSteps.push(...blueGreen.steps.filter((x) => x.phase === 'before').map(blueGreenDraft()));
512
- const tunnel = planTunnel(org, byKey, claimed, recordedIds, platformReadiness.platformApex);
575
+ // A workspace is made only behind its gate: recorded already, or planned earlier in this run.
576
+ const workspaceGateReady = (gateKey) => Boolean(recordedIds[gateIdRecordKey(org.id, gateKey)]) ||
577
+ plannedSteps.some((p) => p.action.system === 'cloudflare' &&
578
+ p.action.kind === 'gate' &&
579
+ p.action.key === appStepKey(org, gateKey));
580
+ const stepPlanned = (system, kind, key) => plannedSteps.some((p) => p.action.system === system &&
581
+ p.action.kind === kind &&
582
+ (key === undefined || p.action.key === key));
583
+ // A workspace's sign-in is ready when its values are recorded, or the steps that record them are planned.
584
+ const workspaceSignInWanted = (entry) => {
585
+ const marker = workspaceSignInMarker(declaredOrg, entry, recordedIds, authIssuer);
586
+ if (marker)
587
+ return marker;
588
+ const customers = entry.uses?.auth?.users === 'customers';
589
+ const orgKnown = customers
590
+ ? Boolean(recordedIds[testCustomersOrgKey(org.id)]) ||
591
+ stepPlanned('zitadel', 'customers-test')
592
+ : Boolean(recordedIds.zitadel_org);
593
+ const clientKnown = Boolean(recordedIds[entryClientIdScopeKey(workspaceClientKey(entry.key))]) ||
594
+ stepPlanned('zitadel', 'app', workspaceClientKey(entry.key));
595
+ const mcpKnown = !entry.uses?.auth?.mcp ||
596
+ Boolean(recordedIds[entryMcpProjectScopeKey(workspaceClientKey(entry.key))]) ||
597
+ stepPlanned('zitadel', 'mcp-project', `${org.id}:${workspaceClientKey(entry.key)}`);
598
+ return orgKnown && clientKnown && mcpKnown && authIssuer ? 'pending' : null;
599
+ };
600
+ // Blue/green's cousin: each server workspace is a stage with a host of its own, behind its gate.
601
+ const workspaces = planWorkspaces({
602
+ org: declaredOrg,
603
+ recordedIds,
604
+ servers: options.blueGreenServers,
605
+ gateReady: workspaceGateReady,
606
+ signInWanted: workspaceSignInWanted,
607
+ authIssuer,
608
+ ...(options.stageSinkDown ? { stageSinkDown: true } : {}),
609
+ });
610
+ plannedSteps.push(...workspaces.steps);
611
+ // After the workspace step has put the stage's own key in its env.
612
+ const workspaceBrokerEntries = planWorkspaceBrokerEntries(declaredOrg, recordedIds, plannedSteps, options.broker ?? false, brokerWorkspacesDatabase, (entry) => recordedIds[bgKey.stageState(entry.key)] === 'ok' ||
613
+ plannedSteps.some((p) => p.action.system === 'coolify' &&
614
+ p.action.kind === 'workspace' &&
615
+ p.action.desired.entry === entry.key));
616
+ plannedSteps.push(...workspaceBrokerEntries.steps);
617
+ const tunnel = planTunnel(declaredOrg, byKey, claimed, recordedIds, platformReadiness.platformApex);
513
618
  // #238: a tunnel that is being provisioned waits for an active zone; parking
514
619
  // one never does.
515
620
  // #555: provisioning writes the tunnel config, the zone lookup and DNS; a
@@ -577,7 +682,33 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
577
682
  archon: options.bouleArchonCredential === true,
578
683
  directory: options.customersDirectory === true,
579
684
  }, platformReadiness.canHoldSecrets === true, (options.now ?? Date.now)());
580
- const boule = planBoule(org, byKey, claimed, recordedIds, authIssuer, options.otherSlugs ?? [], options.archonUrl ?? null, platformReadiness, bouleCredentials.wants);
685
+ // F13: Boule's `CUSTOMER_GRANTS`, from the entries that declare `uses.customerGrants`. Every reason it
686
+ // says nothing yet is a plan line, never silence.
687
+ const customerGrants = wantedCustomerGrants(org, recordedIds, {
688
+ live: byKey.has(resourceKey('coolify', 'boule', org.id)),
689
+ });
690
+ const customerGrantsNotes = [...customerGrants.notes];
691
+ let customerGrantsShape = null;
692
+ if (customerGrants.shape) {
693
+ if (bouleCredentials.wants.customersDirectory) {
694
+ customerGrantsShape = customerGrants.shape;
695
+ }
696
+ else {
697
+ customerGrantsNotes.push(`${org.slug}'s customer grants are not planned: they need the customers directory credential, which is blocked above`);
698
+ }
699
+ }
700
+ // T3: a company with customers gets a registrar key at the sign-in broker; Boule is given it.
701
+ const registrar = planBrokerRegistrar(org, recordedIds, plannedSteps, {
702
+ brokerWired: options.broker ?? false,
703
+ canHoldSecrets: platformReadiness.canHoldSecrets === true,
704
+ issuerKnown: Boolean(authIssuer),
705
+ });
706
+ const boule = planBoule(org, byKey, claimed, recordedIds, authIssuer, options.otherSlugs ?? [], options.archonUrl ?? null, platformReadiness, {
707
+ ...bouleCredentials.wants,
708
+ ...(customerGrantsShape ? { customerGrants: true } : {}),
709
+ ...(registrar.deliver ? { registrar: true } : {}),
710
+ ...(registrar.keepBouleEnv ? { registrarKept: true } : {}),
711
+ }, customerGrantsShape);
581
712
  if (boule.step)
582
713
  plannedSteps.push(boule.step);
583
714
  // Boule's and Agora's blue/green: after their own steps, which make the app it copies.
@@ -598,11 +729,26 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
598
729
  // the same dependency `planEntryAiKeys`/`planFilesKeys` have.
599
730
  const entryEdgeKeys = planEntryEdgeKeys(org, recordedIds, plannedSteps, platformReadiness, (options.now ?? Date.now)());
600
731
  plannedSteps.push(...entryEdgeKeys.steps);
732
+ // F7: the signed-records registrar key, after Boule like the edge key.
733
+ const keysRegistrar = planKeysRegistrar(org, recordedIds, plannedSteps, { ...(options.keysRegistrarUrl !== undefined ? { url: options.keysRegistrarUrl } : {}) }, (options.now ?? Date.now)());
734
+ plannedSteps.push(...keysRegistrar.steps);
735
+ // A company on shared services registers on the scheduler the host supplies (the owner company's); a company
736
+ // that runs its own services never does, only on its own.
737
+ const schedulerUsable = (jobsAvailable && !ownsServices(org)) || platformReadiness.jobsReady;
738
+ // A `uses.jobs` entry registers with the scheduler after its app, whose env the step writes.
739
+ const entryJobs = planEntryJobs(org, recordedIds, plannedSteps, platformReadiness, schedulerUsable);
740
+ plannedSteps.push(...entryJobs.steps);
741
+ // F12: a deploy key for an entry's own repo, after its app (whose env the step writes).
742
+ const entryRepoKeys = planEntryRepoKeys(org, recordedIds, plannedSteps, platformReadiness);
743
+ plannedSteps.push(...entryRepoKeys.steps);
601
744
  // Boule's own broker entry, after its app: the entry needs the AUTH_APP_KEY
602
745
  // that step puts in the app's env.
603
746
  const bouleBroker = planBouleBrokerEntry(org, recordedIds, plannedSteps, options.broker ?? false);
604
747
  if (bouleBroker.step)
605
748
  plannedSteps.push(bouleBroker.step);
749
+ // The company's registrar record in the broker, after Boule's own broker entry.
750
+ if (registrar.step)
751
+ plannedSteps.push(registrar.step);
606
752
  // #440: Agora's desktop sign-in backend, after Agora and Boule: its readiness probe goes through Agora's proxy, and
607
753
  // Agora writes the upstream only once the service ran healthy (so a first apply needs a second to turn it on).
608
754
  const desktopAuth = planDesktopAuth(org, recordedIds, {
@@ -622,12 +768,20 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
622
768
  });
623
769
  if (ownJobs.step)
624
770
  plannedSteps.push(ownJobs.step);
771
+ const ownJobsRoute = planOwnJobsRoute({
772
+ org,
773
+ recordedIds,
774
+ readiness: platformReadiness,
775
+ ownServices: options.ownServices ?? false,
776
+ zoneActive: ownZoneActive,
777
+ schedulerPlanned: ownJobs.step !== null,
778
+ });
779
+ if (ownJobsRoute.step)
780
+ plannedSteps.push(ownJobsRoute.step);
625
781
  // #173: register the org's own app with the platform's jobs scheduler.
626
782
  // After Agora, so a failing registration can never stop Agora from
627
783
  // 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);
784
+ const jobsRegistration = planJobsRegistration(org, recordedIds, schedulerUsable, platformReadiness.missingKeys.jobs, platformReadiness.noKeyReader, platformReadiness.jobsUrlMissing ?? false);
631
785
  if (jobsRegistration.step)
632
786
  plannedSteps.push(jobsRegistration.step);
633
787
  const remoteDatabase = planRemoteDatabase(org, options.remoteDatabase, recordedIds);
@@ -660,7 +814,8 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
660
814
  }
661
815
  // ADR 0002: Worker apps, after the Workers for the same reason: a route, record
662
816
  // or script name that is not ours throws at apply and `runSteps` stops there.
663
- const workerApps = planWorkerApps(org, recordedIds, {
817
+ const workerOrg = withWorkspaceWorkers(org, declaredOrg);
818
+ const workerApps = planWorkerApps(workerOrg, recordedIds, {
664
819
  available: options.workerApps ?? false,
665
820
  ...(options.workerAppKeysUrl !== undefined ? { keysUrl: options.workerAppKeysUrl } : {}),
666
821
  ...(options.workerAppRegistryAccountId !== undefined
@@ -683,7 +838,10 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
683
838
  });
684
839
  for (const step of workerApps.steps) {
685
840
  const stepEntry = step.action.key.slice(org.id.length + 1);
686
- const ownZone = org.hosting.usesDomain && Boolean(org.domain);
841
+ // A workspace Worker lives in the platform's apps zone, not the org's own.
842
+ const wsEntry = workerOrg.hosting.entries.find((e) => e.key === stepEntry);
843
+ const wsParent = wsEntry ? workspaceWorkerParent(wsEntry) : null;
844
+ const ownZone = wsParent === null && org.hosting.usesDomain && Boolean(org.domain);
687
845
  const apply = step.action.desired.mode === 'apply';
688
846
  // Retire never looks a zone up; a deploy needs the org's zone, or the shared oikos.host zone.
689
847
  const hasDatabase = Boolean(org.hosting.entries.find((e) => e.key === stepEntry)?.workerApp?.database);
@@ -691,10 +849,16 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
691
849
  ? null
692
850
  : hasDatabase
693
851
  ? edgeNeeded(platformReadiness, ['zones', 'dns', 'access', 'tunnels'])
694
- : ownZone
852
+ : ownZone || wsParent !== null
695
853
  ? edgeNeeded(platformReadiness, ['zones', 'dns'])
696
854
  : edgeNeeded(platformReadiness, ['zones']);
697
- if (reason) {
855
+ if (apply && wsParent !== null && !workspaceGateReady(workspaceGateKey(wsParent))) {
856
+ appBlocked.push({
857
+ description: `${org.slug}'s ${wsParent} workspace Worker is blocked, not planned: its Access gate is not recorded or planned yet`,
858
+ key: step.action.key,
859
+ });
860
+ }
861
+ else if (reason) {
698
862
  dnsBlocked.push(edgeBlockedItem(org, `${stepEntry} Worker app`, step.action.key, reason));
699
863
  }
700
864
  else if (apply && ownZone && zoneWaiting) {
@@ -703,7 +867,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
703
867
  key: step.action.key,
704
868
  });
705
869
  }
706
- else if (apply && !ownZone && !(options.oikosZoneReady ?? false)) {
870
+ else if (apply && wsParent === null && !ownZone && !(options.oikosZoneReady ?? false)) {
707
871
  zoneBlocked.push({
708
872
  description: `${org.slug}'s ${stepEntry} Worker app is blocked, not planned: the shared oikos.host zone is not ready yet`,
709
873
  key: step.action.key,
@@ -713,11 +877,14 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
713
877
  plannedSteps.push(step);
714
878
  }
715
879
  }
880
+ // `uses.audit`: registers the entry's ledger for platform sealing, after its Worker app.
881
+ const entryAudit = planEntryAudit(org, recordedIds, plannedSteps, platformReadiness);
882
+ plannedSteps.push(...entryAudit.steps);
716
883
  // Last: a repair that fails must not stop any step after it (`runSteps` stops at the first failure).
717
884
  const idTokenUserinfo = planIdTokenUserinfo(org, live, recordedIds, plannedSteps, platformReadiness.authReady, options.zitadelIdTokenUserinfo ?? false);
718
885
  if (idTokenUserinfo)
719
886
  plannedSteps.push(idTokenUserinfo);
720
- const postLogoutUris = planPostLogoutUris(org, live, recordedIds, plannedSteps, platformReadiness.authReady, options.zitadelPostLogoutUris ?? false);
887
+ const postLogoutUris = planPostLogoutUris(declaredOrg, live, recordedIds, plannedSteps, platformReadiness.authReady, options.zitadelPostLogoutUris ?? false);
721
888
  if (postLogoutUris)
722
889
  plannedSteps.push(postLogoutUris);
723
890
  const desktopCallbacks = planDesktopCallbacks(org, live, recordedIds, plannedSteps, platformReadiness.authReady, options.zitadelRedirectUris ?? false);
@@ -725,6 +892,24 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
725
892
  plannedSteps.push(desktopCallbacks);
726
893
  // #444, last: a Postgres step that fails must not stop the zone, mail, app, Agora and Boule steps of a live company.
727
894
  plannedSteps.push(...orgPostgresSteps);
895
+ // F4 (#925): with apps on, the apps admin role on that server and the route Hyperdrive reaches it by.
896
+ const appsAdmin = planAppsAdmin(org, recordedIds, plannedSteps, {
897
+ canHoldSecrets: platformReadiness.canHoldSecrets === true,
898
+ postgresPlanned: orgPostgresSteps.some((p) => p.action.system === 'coolify' && p.action.kind === 'org-postgres'),
899
+ });
900
+ plannedSteps.push(...appsAdmin.steps);
901
+ const appsDbRoute = planAppsDbRoute(org, recordedIds, plannedSteps, {
902
+ accessReady: edgeNeeded(platformReadiness, ['access', 'tunnels', 'dns', 'zones']) === null,
903
+ canHoldSecrets: platformReadiness.canHoldSecrets === true,
904
+ tunnelReady: Boolean(recordedIds[`cloudflare:tunnel:${org.id}`]) ||
905
+ plannedSteps.some((s) => s.action.system === 'cloudflare' && s.action.kind === 'tunnel'),
906
+ });
907
+ plannedSteps.push(...appsDbRoute.steps);
908
+ // FZ: the company slug and the zone ids, in the same Boule env.
909
+ const appsZones = planAppsZones(org, recordedIds, plannedSteps, {
910
+ zonesReady: edgeNeeded(platformReadiness, ['zones']) === null,
911
+ });
912
+ plannedSteps.push(...appsZones.steps);
728
913
  // After the org-postgres steps: the desktop sign-in service lives on a home they may create in this same plan.
729
914
  if (desktopAuth.step)
730
915
  plannedSteps.push(desktopAuth.step);
@@ -742,7 +927,12 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
742
927
  for (const id of platformReadiness.platformZitadelIds ?? [])
743
928
  recordedValues.add(id);
744
929
  }
745
- const platformNames = new Set([...(options.platformHostnames ?? []), platformReadiness.mailHost?.mailHost ?? ''].map((h) => h.toLowerCase()));
930
+ const platformNames = new Set([
931
+ ...(options.platformHostnames ?? []),
932
+ platformReadiness.mailHost?.mailHost ?? '',
933
+ // The shared mail relay's MX host, which the platform's relay step writes.
934
+ platformReadiness.platformApex ? platformMailMxHost(platformReadiness.platformApex) : '',
935
+ ].map((h) => h.toLowerCase()));
746
936
  platformNames.delete('');
747
937
  const unmanaged = live
748
938
  .filter((r) => !claimed.has(resourceKey(r.system, r.kind, r.key)))
@@ -778,6 +968,12 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
778
968
  for (const description of bouleCredentials.blocked) {
779
969
  unmanaged.push({ description, key: 'coolify:boule:credentials' });
780
970
  }
971
+ customerGrantsNotes.forEach((description, index) => {
972
+ unmanaged.push({ description, key: `coolify:boule:customer-grants:${index}` });
973
+ });
974
+ registrar.blocked.forEach((description, index) => {
975
+ unmanaged.push({ description, key: `coolify:broker-registrar:${index}` });
976
+ });
781
977
  if (boule.mailBlocked) {
782
978
  unmanaged.push({ description: boule.mailBlocked, key: 'coolify:boule:mail' });
783
979
  }
@@ -786,6 +982,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
786
982
  if (aiKey.blocked)
787
983
  unmanaged.push({ description: aiKey.blocked, key: 'ai:key' });
788
984
  unmanaged.push(...blueGreen.unmanaged);
985
+ unmanaged.push(...workspaces.unmanaged);
789
986
  unmanaged.push(...filesKeys.blocked);
790
987
  unmanaged.push(...entryAiKeys.blocked);
791
988
  unmanaged.push(...edgeWorkers.blocked);
@@ -798,7 +995,15 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
798
995
  }
799
996
  unmanaged.push(...mcp.blocked);
800
997
  unmanaged.push(...customers.blocked);
998
+ unmanaged.push(...testCustomers.blocked);
801
999
  unmanaged.push(...entryEdgeKeys.blocked);
1000
+ unmanaged.push(...keysRegistrar.blocked);
1001
+ unmanaged.push(...appsAdmin.blocked);
1002
+ unmanaged.push(...appsDbRoute.blocked);
1003
+ unmanaged.push(...appsZones.blocked);
1004
+ unmanaged.push(...entryJobs.blocked);
1005
+ unmanaged.push(...entryAudit.blocked);
1006
+ unmanaged.push(...entryRepoKeys.blocked);
802
1007
  unmanaged.push(...edgeReservedLabels.blocked);
803
1008
  if (ownAuth.blocked)
804
1009
  unmanaged.push({ description: ownAuth.blocked, key: OWN_AUTH_BLOCKED_KEY });
@@ -807,6 +1012,9 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
807
1012
  }
808
1013
  if (ownJobs.blocked)
809
1014
  unmanaged.push({ description: ownJobs.blocked, key: OWN_JOBS_BLOCKED_KEY });
1015
+ if (ownJobsRoute.blocked) {
1016
+ unmanaged.push({ description: ownJobsRoute.blocked, key: OWN_JOBS_ROUTE_BLOCKED_KEY });
1017
+ }
810
1018
  if (ownMail.blocked)
811
1019
  unmanaged.push({ description: ownMail.blocked, key: OWN_MAIL_BLOCKED_KEY });
812
1020
  if (ownSfu.blocked)
@@ -895,8 +1103,10 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
895
1103
  });
896
1104
  }
897
1105
  // #243: an appEnv source nothing will record is reported, not silently skipped.
1106
+ unmanaged.push(...githubSource.blocked);
898
1107
  unmanaged.push(...app.blocked);
899
1108
  unmanaged.push(...brokerEntries.blocked);
1109
+ unmanaged.push(...workspaceBrokerEntries.blocked);
900
1110
  unmanaged.push(...bouleBroker.blocked);
901
1111
  // F6: last, so every database step above runs before the backup of its database.
902
1112
  const backups = planCompanyBackups(org, recordedIds, options.backupStorageUuid);
@@ -940,6 +1150,7 @@ const NEEDS_OWN_ZITADEL = new Set([
940
1150
  'zitadel:owner',
941
1151
  'zitadel:app',
942
1152
  'zitadel:customers',
1153
+ 'zitadel:customers-test',
943
1154
  'zitadel:id-token-userinfo',
944
1155
  'zitadel:desktop-callback',
945
1156
  'zitadel:login-host',
@@ -947,7 +1158,10 @@ const NEEDS_OWN_ZITADEL = new Set([
947
1158
  'coolify:calls-service',
948
1159
  'coolify:calls-chat',
949
1160
  'coolify:broker-entry',
1161
+ 'coolify:workspace-broker-entry',
1162
+ 'coolify:workspaces-database',
950
1163
  'coolify:boule-broker-entry',
1164
+ 'coolify:broker-registrar',
951
1165
  'files:organisation',
952
1166
  ]);
953
1167
  /**
@@ -2919,7 +3133,7 @@ function planBouleCredentials(org, recordedIds, host, canHoldSecrets, nowMs) {
2919
3133
  * address (the one Agora signs in at); without it the Boule is blocked, not
2920
3134
  * planned, so every other step still applies.
2921
3135
  */
2922
- function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonUrl, readiness, credentials) {
3136
+ function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonUrl, readiness, credentials, customerGrantsShape = null) {
2923
3137
  const key = resourceKey('coolify', 'boule', org.id);
2924
3138
  const live = byKey.get(key);
2925
3139
  if (live)
@@ -2991,6 +3205,9 @@ function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonU
2991
3205
  platformOwner,
2992
3206
  archonCredential: credentials.archonCredential === true,
2993
3207
  customersDirectory: credentials.customersDirectory === true,
3208
+ // Only an org that declares the flag carries the key, so every other plan is unchanged.
3209
+ ...(credentials.customerGrants === true ? { customerGrants: true } : {}),
3210
+ ...(credentials.registrar === true ? { registrar: true } : {}),
2994
3211
  },
2995
3212
  };
2996
3213
  if (!live) {
@@ -3020,6 +3237,26 @@ function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonU
3020
3237
  if (!drift.includes(key))
3021
3238
  drift.push(key);
3022
3239
  }
3240
+ // F13: the grants entry says what the flagged entries say (credentials by presence only). Taken away only
3241
+ // once no entry declares the flag and foundry wrote it; held credentials of dropped entries are forgotten.
3242
+ if (customerGrantsShape) {
3243
+ if (!customerGrantsShapeMatches(liveEnv[CUSTOMER_GRANTS_ENV], customerGrantsShape)) {
3244
+ drift.push(CUSTOMER_GRANTS_ENV);
3245
+ }
3246
+ }
3247
+ else if (customerGrantsEntries(org).length === 0 &&
3248
+ recordedIds[CUSTOMER_GRANTS_WRITTEN_SCOPE_KEY] &&
3249
+ liveEnv[CUSTOMER_GRANTS_ENV] !== undefined) {
3250
+ drift.push(CUSTOMER_GRANTS_ENV);
3251
+ }
3252
+ if (customerGrantsToDrop(org, recordedIds).length > 0 && !drift.includes(CUSTOMER_GRANTS_ENV)) {
3253
+ drift.push(CUSTOMER_GRANTS_ENV);
3254
+ }
3255
+ // T3: the registrar key by presence and by which mint Boule got; taken away once customers are off.
3256
+ for (const key of bouleRegistrarDrift(org, recordedIds, liveEnv, credentials.registrar === true, credentials.registrarKept === true)) {
3257
+ if (!drift.includes(key))
3258
+ drift.push(key);
3259
+ }
3023
3260
  // The token is compared, never planned with its value.
3024
3261
  if (mailOn &&
3025
3262
  bouleMailTokenDrift(liveEnv[BOULE_MAIL_TOKEN_ENV], live.config.appMailToken)) {
@@ -3191,7 +3428,7 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
3191
3428
  // right now, and gating this on `mailHost` let a platform-apex org with an
3192
3429
  // already-recorded domain take the unprotected path the moment platform
3193
3430
  // mail went back to off.
3194
- if (entryKey === mainEntry(org.hosting)?.key) {
3431
+ if (entryKey === mainEntry(org.hosting)?.key || entry?.uses?.mail) {
3195
3432
  const mailDomain = recordedIds[MAIL_DOMAIN_SCOPE_KEY];
3196
3433
  const platformApex = readiness.platformApex;
3197
3434
  const platformLettermintProjectId = readiness.platformLettermintProjectId;
@@ -3519,11 +3756,53 @@ function planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer, re
3519
3756
  if (org.hosting.entries.some((e) => e.key === key && e.workerApp))
3520
3757
  continue;
3521
3758
  const app = planApp(org, key, byKey, claimed, recordedIds, plannedSteps, authIssuer, readiness);
3759
+ // An app built from the company's own account is created through its GitHub source: with no source
3760
+ // recorded or planned, a new app waits (blocked, not planned) instead of failing at create.
3761
+ const sourceEntry = org.hosting.entries.find((e) => e.key === key);
3762
+ if (app.step &&
3763
+ app.step.action.desired.enabled !== false &&
3764
+ sourceEntry &&
3765
+ companyGithubFor(org.hosting, sourceEntry.repo) &&
3766
+ !recordedIds[githubSourceResourceKey(org.id)] &&
3767
+ !recordedIds[`coolify:app:${appStepKey(org, key)}`] &&
3768
+ !plannedSteps.some((p) => p.action.system === 'coolify' && p.action.kind === 'github-source')) {
3769
+ blocked.push({
3770
+ 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`,
3771
+ key: 'github-source',
3772
+ });
3773
+ blocked.push(...app.blocked);
3774
+ continue;
3775
+ }
3776
+ // The entry's workspaces key is made and held by this step; a store that cannot put leaves the step failing every run.
3777
+ if (app.step &&
3778
+ app.step.action.desired.enabled !== false &&
3779
+ org.hosting.entries.some((e) => e.key === key && e.uses?.auth?.workspaces) &&
3780
+ readiness.canHoldSecrets !== true) {
3781
+ blocked.push({
3782
+ 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`,
3783
+ key: `coolify:app:${key}:workspaces-key`,
3784
+ });
3785
+ blocked.push(...app.blocked);
3786
+ continue;
3787
+ }
3788
+ // The entry's customer-grants credential is made and held by this step; a store that cannot put leaves it failing every run.
3789
+ if (app.step &&
3790
+ app.step.action.desired.enabled !== false &&
3791
+ org.hosting.entries.some((e) => e.key === key && e.uses?.customerGrants) &&
3792
+ readiness.canHoldSecrets !== true) {
3793
+ blocked.push({
3794
+ 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`,
3795
+ key: `coolify:app:${key}:customer-grants`,
3796
+ });
3797
+ blocked.push(...app.blocked);
3798
+ continue;
3799
+ }
3522
3800
  // The executor pushes the issuer the plan read, the way Agora gets its host values.
3523
3801
  // The same availability the plan's marker used: the executor records its
3524
3802
  // marker from this, never from its own port, so the two cannot disagree.
3525
3803
  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')));
3804
+ (e.uses?.customerGrants === true ||
3805
+ Object.values(e.env).some((v) => typeof v !== 'string' && (v.from === 'sharedSecret' || v.from === 'heldSecret'))));
3527
3806
  if (usesSecrets && app.step && app.step.action.desired.enabled !== false) {
3528
3807
  const entry = org.hosting.entries.find((e) => e.key === key);
3529
3808
  app.step.action.desired = {
@@ -3538,15 +3817,16 @@ function planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer, re
3538
3817
  if (signsIn)
3539
3818
  app.step.action.desired = { ...app.step.action.desired, authIssuer };
3540
3819
  }
3541
- // #491: only the `app` entry's own step runs `ensureAppLettermintMail`
3542
- // (`executor.ts`'s own `isMainEntry(entry)` gate). Unlike
3820
+ // #491: only the `app` entry's step and an entry with `uses.mail` run
3821
+ // `ensureAppLettermintMail` (`executor.ts`'s own gate). Unlike
3543
3822
  // `secretSources`/`authIssuer` above, these two are set unconditionally
3544
3823
  // -- `null` when no platform is declared, never omitted -- because
3545
3824
  // `executor.ts` treats the key being *absent* as a stale plan predating
3546
3825
  // this field and refuses to run (review round 2, finding 3): an omitted
3547
3826
  // key here would read identically to a stale plan and silently fall
3548
3827
  // back to the unprotected pre-#491 path instead.
3549
- if (key === mainKey && app.step && app.step.action.desired.enabled !== false) {
3828
+ const sendsMail = key === mainKey || org.hosting.entries.some((e) => e.key === key && e.uses?.mail);
3829
+ if (sendsMail && app.step && app.step.action.desired.enabled !== false) {
3550
3830
  app.step.action.desired = {
3551
3831
  ...app.step.action.desired,
3552
3832
  platformApex: readiness.platformApex,
@@ -3558,17 +3838,53 @@ function planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer, re
3558
3838
  // edge: blocked, not planned, while edge is not applied.
3559
3839
  const appReason = edgeNeeded(readiness, ['zones', 'dns']);
3560
3840
  if (appReason &&
3561
- key === mainKey &&
3841
+ sendsMail &&
3562
3842
  app.step &&
3563
3843
  app.step.action.desired.enabled !== false &&
3564
3844
  recordedIds[MAIL_DOMAIN_SCOPE_KEY]) {
3565
- blocked.push(edgeBlockedItem(org, 'app', 'coolify:app', appReason));
3845
+ blocked.push(edgeBlockedItem(org, key === mainKey ? 'app' : `${key} app`, key === mainKey ? 'coolify:app' : `coolify:app:${key}`, appReason));
3566
3846
  blocked.push(...app.blocked);
3567
3847
  continue;
3568
3848
  }
3849
+ // A workspace entry's live app is made from the commit its workspace runs: none is made before one is known.
3850
+ const goLive = org.hosting.entries.find((e) => e.key === key && e.workspace !== undefined && e.workerApp === undefined);
3851
+ if (goLive &&
3852
+ app.step &&
3853
+ app.step.action.desired.enabled !== false &&
3854
+ !recordedIds[`coolify:app:${appStepKey(org, key)}`]) {
3855
+ const commit = goLive.liveCommit ?? recordedIds[wsKey.commit(key)];
3856
+ if (!commit) {
3857
+ blocked.push({
3858
+ 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`,
3859
+ key: `workspace:${key}:golive`,
3860
+ });
3861
+ blocked.push(...app.blocked);
3862
+ continue;
3863
+ }
3864
+ app.step.action.desired = { ...app.step.action.desired, workspaceCommit: commit };
3865
+ app.step.step.description = `${app.step.step.description} from the commit its workspace runs (${commit.slice(0, 12)})`;
3866
+ }
3569
3867
  if (app.step)
3570
3868
  steps.push(app.step);
3571
3869
  blocked.push(...app.blocked);
3870
+ // A promote: the live app exists and runs another commit than the entry's `liveCommit`.
3871
+ if (goLive?.liveCommit !== undefined &&
3872
+ recordedIds[`coolify:app:${appStepKey(org, key)}`] &&
3873
+ recordedIds[wsKey.liveCommit(key)] !== goLive.liveCommit) {
3874
+ steps.push({
3875
+ action: {
3876
+ system: 'coolify',
3877
+ kind: 'promote',
3878
+ key: appStepKey(org, key),
3879
+ desired: { entry: key, commit: goLive.liveCommit },
3880
+ },
3881
+ step: {
3882
+ description: `put ${org.slug}'s live app for entry '${key}' on commit ${goLive.liveCommit.slice(0, 12)} and deploy it`,
3883
+ destroys: false,
3884
+ migrates: false,
3885
+ },
3886
+ });
3887
+ }
3572
3888
  }
3573
3889
  return { steps, blocked };
3574
3890
  }
@@ -3713,6 +4029,8 @@ function planTunnel(org, byKey, claimed, recordedIds, platformApex) {
3713
4029
  (org.site.enabled && Boolean(org.domain)) ||
3714
4030
  (org.calls.enabled && org.calls.usesDomain && Boolean(org.domain)) ||
3715
4031
  Boolean(org.domain) ||
4032
+ // F4 (#925): apps on, the tunnel carries the route to the company's Postgres server for Hyperdrive.
4033
+ appsOn(org) ||
3716
4034
  // A company without a domain still gets a tunnel of its own for an entry at `<slug>.oikos.host`.
3717
4035
  entryRoutes(org, recordedIds).length > 0;
3718
4036
  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 +4082,13 @@ function planTunnel(org, byKey, claimed, recordedIds, platformApex) {
3764
4082
  sortedNames(tunnelDnsHostnames(org, org.domain, loginHost));
3765
4083
  // The entry names in the platform's oikos.host zone are recorded apart from the company's own zone.
3766
4084
  const oikosStale = (recordedIds[CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY] ?? '') !==
3767
- entryOikosHostnames(org).join(',');
4085
+ entryOikosHostnames(org, recordedIds).join(',');
4086
+ const appsStale = (recordedIds[CLOUDFLARE_TUNNEL_APPS_HOSTNAMES_SCOPE_KEY] ?? '') !==
4087
+ entryAppsHostnames(org, recordedIds).join(',');
3768
4088
  const dnsStale = (org.domain !== null && recordedIds[CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY] !== org.domain) ||
3769
4089
  edgeChanged ||
3770
- oikosStale;
4090
+ oikosStale ||
4091
+ appsStale;
3771
4092
  // An entry's hostname goes straight to its container, so a changed port, key or name, or an ingress
3772
4093
  // written before entries were routed that way, needs the ingress written again. Nothing recorded
3773
4094
  // equals no routes.
@@ -3962,7 +4283,7 @@ function planEntryClients(org, byKey, claimed) {
3962
4283
  * record blocks it (rule 2). A foreign broker entry the last write found
3963
4284
  * invalid is listed as a warning: the broker drops it, foundry keeps it.
3964
4285
  */
3965
- export function planBrokerEntries(org, recordedIds, plannedSteps, brokerWired) {
4286
+ export function planBrokerEntries(org, recordedIds, plannedSteps, brokerWired, brokerWorkspacesDatabase) {
3966
4287
  const steps = [];
3967
4288
  const blocked = [];
3968
4289
  for (const entry of org.hosting.entries) {
@@ -3985,6 +4306,14 @@ export function planBrokerEntries(org, recordedIds, plannedSteps, brokerWired) {
3985
4306
  });
3986
4307
  continue;
3987
4308
  }
4309
+ // The broker does not start once an entry declares workspaces without its database, and then no one signs in.
4310
+ if (auth.workspaces && !brokerWorkspacesDatabase) {
4311
+ blocked.push({
4312
+ 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`,
4313
+ key,
4314
+ });
4315
+ continue;
4316
+ }
3988
4317
  // Auth ADR 0002: a broker given an `mcp` entry before it holds its database and secret exits at boot.
3989
4318
  // A company's own sign-in step sets them and records it; a shared org's broker is the platform's, whose
3990
4319
  // marker this plan cannot see, so `writeBrokerEntry` refuses there until the platform step has run.
@@ -4205,10 +4534,12 @@ const signInMetadataBlocked = (org) => ({
4205
4534
  * gates (`planGates`) come first. Under a `'*'` gate, a bypass on each name
4206
4535
  * foundry owns comes before them, so no owned name is ever gated.
4207
4536
  */
4208
- function planAccess(org, recordedIds, providerId, platformHostnames) {
4209
- const { steps: gates, blocked } = planGates(org, recordedIds, providerId);
4537
+ function planAccess(org, recordedIds, providerId, platformHostnames, declaredOrg = org) {
4538
+ const { steps: gates, blocked } = planGates(org, recordedIds, providerId, declaredOrg);
4539
+ // F11: a machine path of a gated entry is let through its gate, and a removed one is closed again.
4540
+ const machine = planMachineBypasses(org, recordedIds, declaredOrg);
4210
4541
  if (!org.hosting.enabled)
4211
- return { steps: gates, blocked };
4542
+ return { steps: [...gates, ...machine.steps], blocked, notes: machine.notes };
4212
4543
  const owned = (hasWildcardGate(org) ? [...lockedHostnames(org, platformHostnames)].sort() : [])
4213
4544
  .filter((hostname) => !recordedIds[resourceKey('cloudflare', 'access', appStepKey(org, hostname))])
4214
4545
  .map((hostname) => ({
@@ -4239,7 +4570,7 @@ function planAccess(org, recordedIds, providerId, platformHostnames) {
4239
4570
  migrates: false,
4240
4571
  },
4241
4572
  }));
4242
- return { steps: [...owned, ...gates, ...rules], blocked };
4573
+ return { steps: [...owned, ...gates, ...machine.steps, ...rules], blocked, notes: machine.notes };
4243
4574
  }
4244
4575
  /** The plan description for one `hosting.access` rule, by kind (#246). */
4245
4576
  function accessRuleDescription(org, rule) {