@wtfalch/foundry 0.60.0 → 0.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (218) hide show
  1. package/dist/apps/cloudflare.d.ts +173 -0
  2. package/dist/apps/cloudflare.js +17 -0
  3. package/dist/apps/deploy.d.ts +116 -0
  4. package/dist/apps/deploy.js +791 -0
  5. package/dist/apps/github.d.ts +47 -0
  6. package/dist/apps/github.js +201 -0
  7. package/dist/apps/index.d.ts +9 -0
  8. package/dist/apps/index.js +9 -0
  9. package/dist/apps/migrations.d.ts +110 -0
  10. package/dist/apps/migrations.js +458 -0
  11. package/dist/audit/connector.d.ts +43 -0
  12. package/dist/audit/connector.js +22 -0
  13. package/dist/audit/evidence.d.ts +49 -0
  14. package/dist/audit/evidence.js +56 -0
  15. package/dist/audit/index.d.ts +9 -0
  16. package/dist/audit/index.js +9 -0
  17. package/dist/audit/migrations.d.ts +5 -0
  18. package/dist/audit/migrations.js +5 -0
  19. package/dist/audit/registry.d.ts +49 -0
  20. package/dist/audit/registry.js +44 -0
  21. package/dist/audit/seal.d.ts +49 -0
  22. package/dist/audit/seal.js +131 -0
  23. package/dist/audit/sealer-role.d.ts +33 -0
  24. package/dist/audit/sealer-role.js +56 -0
  25. package/dist/audit/signer.d.ts +40 -0
  26. package/dist/audit/signer.js +78 -0
  27. package/dist/audit/tick.d.ts +36 -0
  28. package/dist/audit/tick.js +87 -0
  29. package/dist/catalogue/copy-en.d.ts +1 -0
  30. package/dist/catalogue/copy-en.js +129 -0
  31. package/dist/catalogue/index.d.ts +9 -0
  32. package/dist/catalogue/index.js +21 -0
  33. package/dist/catalogue/integrations.d.ts +4 -0
  34. package/dist/catalogue/integrations.js +104 -0
  35. package/dist/catalogue/systems.d.ts +7 -0
  36. package/dist/catalogue/systems.js +31 -0
  37. package/dist/catalogue/types.d.ts +110 -0
  38. package/dist/catalogue/types.js +1 -0
  39. package/dist/entry-rules.d.ts +53 -0
  40. package/dist/entry-rules.js +71 -0
  41. package/dist/hosted-label.d.ts +13 -0
  42. package/dist/hosted-label.js +81 -0
  43. package/dist/index.d.ts +10 -5
  44. package/dist/index.js +5 -3
  45. package/dist/migrations/0062_hosting_github.sql +6 -0
  46. package/dist/migrations/0063_boule_apps.sql +7 -0
  47. package/dist/migrations/0064_requested_changes_boule_apps.sql +6 -0
  48. package/dist/modules/ai.d.ts +3 -0
  49. package/dist/modules/ai.js +64 -0
  50. package/dist/modules/assistants.d.ts +3 -0
  51. package/dist/modules/assistants.js +72 -0
  52. package/dist/modules/audit-ledger.d.ts +29 -0
  53. package/dist/modules/audit-ledger.js +49 -0
  54. package/dist/modules/audit.d.ts +7 -0
  55. package/dist/modules/audit.js +44 -0
  56. package/dist/modules/database.d.ts +23 -0
  57. package/dist/modules/database.js +189 -0
  58. package/dist/modules/email.d.ts +3 -0
  59. package/dist/modules/email.js +18 -0
  60. package/dist/modules/entry-parts.d.ts +23 -0
  61. package/dist/modules/entry-parts.js +63 -0
  62. package/dist/modules/files.d.ts +3 -0
  63. package/dist/modules/files.js +64 -0
  64. package/dist/modules/index.d.ts +74 -0
  65. package/dist/modules/index.js +774 -0
  66. package/dist/modules/jobs.d.ts +3 -0
  67. package/dist/modules/jobs.js +23 -0
  68. package/dist/modules/organisations.d.ts +3 -0
  69. package/dist/modules/organisations.js +20 -0
  70. package/dist/modules/outputs.d.ts +9 -0
  71. package/dist/modules/outputs.js +38 -0
  72. package/dist/modules/sign-in.d.ts +3 -0
  73. package/dist/modules/sign-in.js +197 -0
  74. package/dist/modules/signed-records.d.ts +3 -0
  75. package/dist/modules/signed-records.js +32 -0
  76. package/dist/modules/types.d.ts +194 -0
  77. package/dist/modules/types.js +1 -0
  78. package/dist/modules/workspaces.d.ts +3 -0
  79. package/dist/modules/workspaces.js +75 -0
  80. package/dist/org.d.ts +163 -22
  81. package/dist/org.js +419 -47
  82. package/dist/plan/apps-db-route.d.ts +90 -0
  83. package/dist/plan/apps-db-route.js +168 -0
  84. package/dist/plan/apps-zones.d.ts +56 -0
  85. package/dist/plan/apps-zones.js +118 -0
  86. package/dist/plan/archon-blue-green.js +11 -1
  87. package/dist/plan/blue-green-apply.d.ts +37 -1
  88. package/dist/plan/blue-green-apply.js +55 -15
  89. package/dist/plan/blue-green.d.ts +41 -1
  90. package/dist/plan/blue-green.js +80 -2
  91. package/dist/plan/boule.d.ts +23 -1
  92. package/dist/plan/boule.js +35 -2
  93. package/dist/plan/broker-entries.d.ts +28 -3
  94. package/dist/plan/broker-entries.js +74 -3
  95. package/dist/plan/broker-registrar.d.ts +168 -0
  96. package/dist/plan/broker-registrar.js +440 -0
  97. package/dist/plan/broker-workspaces-database.d.ts +48 -0
  98. package/dist/plan/broker-workspaces-database.js +87 -0
  99. package/dist/plan/desktop-callback.js +12 -7
  100. package/dist/plan/diff.d.ts +8 -2
  101. package/dist/plan/diff.js +373 -42
  102. package/dist/plan/edge-access.d.ts +6 -0
  103. package/dist/plan/edge-dns.d.ts +6 -0
  104. package/dist/plan/edge-dns.js +9 -7
  105. package/dist/plan/entry-ai-key.d.ts +1 -1
  106. package/dist/plan/entry-ai-key.js +2 -2
  107. package/dist/plan/entry-audit.d.ts +58 -0
  108. package/dist/plan/entry-audit.js +122 -0
  109. package/dist/plan/entry-customer-grants.d.ts +75 -0
  110. package/dist/plan/entry-customer-grants.js +154 -0
  111. package/dist/plan/entry-edge-key.d.ts +26 -1
  112. package/dist/plan/entry-edge-key.js +42 -3
  113. package/dist/plan/entry-jobs.d.ts +64 -0
  114. package/dist/plan/entry-jobs.js +92 -0
  115. package/dist/plan/entry-repo-key-apply.d.ts +55 -0
  116. package/dist/plan/entry-repo-key-apply.js +185 -0
  117. package/dist/plan/entry-repo-key.d.ts +92 -0
  118. package/dist/plan/entry-repo-key.js +235 -0
  119. package/dist/plan/entry-workspaces.d.ts +55 -0
  120. package/dist/plan/entry-workspaces.js +90 -0
  121. package/dist/plan/executor.d.ts +191 -1
  122. package/dist/plan/executor.js +1505 -90
  123. package/dist/plan/gates.d.ts +1 -1
  124. package/dist/plan/gates.js +62 -9
  125. package/dist/plan/github-deploy-keys.d.ts +78 -0
  126. package/dist/plan/github-deploy-keys.js +305 -0
  127. package/dist/plan/github-source.d.ts +53 -0
  128. package/dist/plan/github-source.js +140 -0
  129. package/dist/plan/held-key-refs.d.ts +12 -0
  130. package/dist/plan/held-key-refs.js +12 -0
  131. package/dist/plan/hostnames.d.ts +9 -0
  132. package/dist/plan/hostnames.js +28 -1
  133. package/dist/plan/index.d.ts +9 -0
  134. package/dist/plan/index.js +6 -0
  135. package/dist/plan/jobs-scheduler-http.js +3 -0
  136. package/dist/plan/keys-registrar-http.d.ts +32 -0
  137. package/dist/plan/keys-registrar-http.js +105 -0
  138. package/dist/plan/keys-registrar.d.ts +67 -0
  139. package/dist/plan/keys-registrar.js +103 -0
  140. package/dist/plan/live.d.ts +1 -1
  141. package/dist/plan/locked-hostnames.d.ts +2 -2
  142. package/dist/plan/locked-hostnames.js +2 -2
  143. package/dist/plan/login-host.js +11 -5
  144. package/dist/plan/machine-paths.d.ts +36 -0
  145. package/dist/plan/machine-paths.js +137 -0
  146. package/dist/plan/mcp.d.ts +1 -1
  147. package/dist/plan/mcp.js +49 -17
  148. package/dist/plan/org-apps-admin.d.ts +108 -0
  149. package/dist/plan/org-apps-admin.js +434 -0
  150. package/dist/plan/org-names.js +12 -1
  151. package/dist/plan/own-diff.d.ts +23 -0
  152. package/dist/plan/own-diff.js +53 -0
  153. package/dist/plan/own-hostnames.d.ts +2 -0
  154. package/dist/plan/own-hostnames.js +1 -0
  155. package/dist/plan/own-jobs-route.d.ts +47 -0
  156. package/dist/plan/own-jobs-route.js +91 -0
  157. package/dist/plan/own-services.d.ts +4 -0
  158. package/dist/plan/own-services.js +4 -0
  159. package/dist/plan/plan-apply.d.ts +39 -0
  160. package/dist/plan/plan-apply.js +163 -9
  161. package/dist/plan/platform-diff.d.ts +2 -2
  162. package/dist/plan/platform-diff.js +7 -2
  163. package/dist/plan/platform-org.d.ts +30 -0
  164. package/dist/plan/platform-org.js +12 -1
  165. package/dist/plan/platform-providers.d.ts +2 -0
  166. package/dist/plan/platform-providers.js +7 -2
  167. package/dist/plan/post-logout-uris.js +7 -1
  168. package/dist/plan/private-pages.d.ts +26 -0
  169. package/dist/plan/private-pages.js +39 -0
  170. package/dist/plan/proxy-labels.d.ts +12 -1
  171. package/dist/plan/proxy-labels.js +32 -2
  172. package/dist/plan/release-host.d.ts +6 -2
  173. package/dist/plan/release-host.js +36 -8
  174. package/dist/plan/stage-env.d.ts +19 -1
  175. package/dist/plan/stage-env.js +25 -1
  176. package/dist/plan/stage-guards.d.ts +20 -0
  177. package/dist/plan/stage-guards.js +24 -0
  178. package/dist/plan/stage-isolation.d.ts +24 -0
  179. package/dist/plan/stage-isolation.js +29 -1
  180. package/dist/plan/worker-app-database.d.ts +46 -42
  181. package/dist/plan/worker-app-database.js +150 -153
  182. package/dist/plan/worker-app.d.ts +5 -65
  183. package/dist/plan/worker-app.js +28 -105
  184. package/dist/plan/worker.d.ts +2 -2
  185. package/dist/plan/worker.js +2 -1
  186. package/dist/plan/workspace-client.d.ts +25 -0
  187. package/dist/plan/workspace-client.js +48 -0
  188. package/dist/plan/workspace-sign-in.d.ts +55 -0
  189. package/dist/plan/workspace-sign-in.js +273 -0
  190. package/dist/plan/workspace-worker.d.ts +4 -0
  191. package/dist/plan/workspace-worker.js +13 -0
  192. package/dist/plan/workspace.d.ts +101 -0
  193. package/dist/plan/workspace.js +305 -0
  194. package/dist/release/decide.d.ts +30 -1
  195. package/dist/release/decide.js +73 -11
  196. package/dist/release/health.d.ts +8 -3
  197. package/dist/release/health.js +58 -5
  198. package/dist/release/run.d.ts +2 -5
  199. package/dist/release/run.js +4 -0
  200. package/dist/release/state.d.ts +3 -1
  201. package/dist/release/state.js +6 -0
  202. package/dist/release/types.d.ts +20 -0
  203. package/dist/requested-changes.d.ts +11 -2
  204. package/dist/requested-changes.js +11 -0
  205. package/dist/service-labels.d.ts +4 -0
  206. package/dist/service-labels.js +11 -0
  207. package/dist/slug.d.ts +2 -0
  208. package/dist/slug.js +10 -6
  209. package/dist/store/postgres.js +12 -2
  210. package/dist/store/schema.d.ts +37 -1
  211. package/dist/store/schema.js +4 -0
  212. package/dist/worker-app-config.d.ts +1 -11
  213. package/dist/worker-app-config.js +4 -17
  214. package/dist/worker-app-migrations.d.ts +105 -0
  215. package/dist/worker-app-migrations.js +166 -0
  216. package/dist/worker-app-upload.d.ts +103 -0
  217. package/dist/worker-app-upload.js +128 -0
  218. package/package.json +18 -1
@@ -0,0 +1,91 @@
1
+ import { withTimeout } from './call-timeout.js';
2
+ import { ensureCloudflaredConnector } from './cloudflared-connector.js';
3
+ import { recordOwnDnsHostname } from './own-dns.js';
4
+ /**
5
+ * A company's jobs scheduler on a public hostname: a tunnel that dials the scheduler by its internal address,
6
+ * a connector app, and a proxied CNAME for `jobs.<domain>`. An entry runs on its own company's network and
7
+ * cannot dial the scheduler's container name, so `uses.jobs` gives it `https://<hostname>` once this is
8
+ * recorded. The owner company (hq) runs the scheduler every shared-services company registers on; a company
9
+ * that runs its own services publishes its own the same way. No Coolify domain and no Access app: the
10
+ * scheduler authenticates every caller itself (a project's sync key, the admin token), and a gate in front
11
+ * would stop the apps.
12
+ *
13
+ * One `coolify:own-jobs-route` step. It records {@link JOBS_PUBLIC_URL_SCOPE_KEY} last, so a run that fails
14
+ * halfway is planned again, finds the tunnel and connector by name, and no entry is given a dead URL.
15
+ */
16
+ export const JOBS_PUBLIC_URL_SCOPE_KEY = 'jobs_public_url';
17
+ export const OWN_JOBS_ROUTE_TUNNEL_SCOPE_KEY = 'cloudflare_own_jobs_tunnel';
18
+ export const OWN_JOBS_ROUTE_CONNECTOR_APP_SCOPE_KEY = 'coolify_own_jobs_connector_app';
19
+ /**
20
+ * The only paths the public route serves: what a project's app calls (`/sync`, `/timers`, `/timers/<name>`,
21
+ * `/deliveries`). `/admin/*` is not on the internet; foundry reaches it on the Coolify network.
22
+ */
23
+ export const JOBS_ENTRY_PATH_PATTERN = '^/(sync$|timers(/|$)|deliveries$)';
24
+ export const JOBS_ROUTE_CALL_TIMEOUT_MS = 60_000;
25
+ /** The tunnel's name and the connector app's, one pair per company. */
26
+ export const jobsRouteNames = (target) => ({
27
+ tunnel: `${target.id}-jobs-tunnel`,
28
+ connector: `${target.id}-jobs-cloudflared`,
29
+ });
30
+ /**
31
+ * Makes the route in this order: tunnel, its ingress, the connector app, the CNAME, then the recorded URL.
32
+ * Each is found by name or content first, so a second run makes nothing twice. A failure leaves what was made
33
+ * (found again on the next run) and no recorded URL. Errors carry the call's label only.
34
+ */
35
+ export async function provisionJobsRoute(scope, ports, target, input) {
36
+ const ms = ports.callTimeoutMs ?? JOBS_ROUTE_CALL_TIMEOUT_MS;
37
+ const at = `own step coolify:own-jobs-route for ${target.id}`;
38
+ const names = jobsRouteNames(target);
39
+ const record = async (key, id) => {
40
+ scope.recordScopeId(key, id);
41
+ await scope.persist();
42
+ };
43
+ const zoneId = await withTimeout(`${at}: zone lookup`, ms, () => ports.network.zoneId(input.apex));
44
+ if (!zoneId)
45
+ throw new Error(`${at}: no Cloudflare zone for ${input.apex}`);
46
+ const tunnels = await ports.network.tunnels();
47
+ const dns = await ports.network.dns();
48
+ const { environment } = await withTimeout(`${at}: environment`, ms, () => ports.coolify.findOrCreateEnvironment(input.projectUuid, 'production'));
49
+ const tunnel = (await withTimeout(`${at}: tunnel lookup`, ms, () => tunnels.findTunnelByName(names.tunnel))) ??
50
+ (await withTimeout(`${at}: tunnel create`, ms, () => tunnels.createTunnel(names.tunnel)));
51
+ await record(OWN_JOBS_ROUTE_TUNNEL_SCOPE_KEY, tunnel.id);
52
+ await withTimeout(`${at}: tunnel ingress`, ms, () => tunnels.putTunnelConfig(tunnel.id, {
53
+ ingress: [
54
+ { hostname: input.hostname, path: JOBS_ENTRY_PATH_PATTERN, service: input.internalUrl },
55
+ { service: 'http_status:404' },
56
+ ],
57
+ }));
58
+ await withTimeout(`${at}: connector app`, ms * 5, () => ensureCloudflaredConnector({
59
+ coolify: ports.coolify,
60
+ tunnels,
61
+ tunnelId: tunnel.id,
62
+ appName: names.connector,
63
+ projectUuid: input.projectUuid,
64
+ environment,
65
+ resolveServer: async () => {
66
+ const servers = await ports.coolify.listServers();
67
+ const server = servers.find((s) => s.is_usable && s.is_reachable) ?? servers[0];
68
+ if (!server)
69
+ throw new Error('no usable Coolify server is registered');
70
+ return server;
71
+ },
72
+ recordedConnectorAppId: scope.recordedIds()[OWN_JOBS_ROUTE_CONNECTOR_APP_SCOPE_KEY] ?? null,
73
+ recordAppId: (appId) => record(OWN_JOBS_ROUTE_CONNECTOR_APP_SCOPE_KEY, appId),
74
+ }));
75
+ const wanted = {
76
+ type: 'CNAME',
77
+ name: input.hostname,
78
+ content: `${tunnel.id}.cfargotunnel.com`,
79
+ proxied: true,
80
+ };
81
+ const [live] = await withTimeout(`${at}: dns lookup`, ms, () => dns.findDnsRecord(input.apex, input.hostname, 'CNAME'));
82
+ if (!live) {
83
+ await withTimeout(`${at}: dns create`, ms, () => dns.createDnsRecord(input.apex, wanted));
84
+ }
85
+ else if (live.content !== wanted.content) {
86
+ await withTimeout(`${at}: dns update`, ms, () => dns.updateDnsRecord(input.apex, live.id, wanted));
87
+ }
88
+ await recordOwnDnsHostname(scope, input.hostname);
89
+ await record(JOBS_PUBLIC_URL_SCOPE_KEY, `https://${input.hostname}`);
90
+ return { resourceId: tunnel.id };
91
+ }
@@ -27,6 +27,8 @@ export interface ServiceTarget {
27
27
  authZitadel: string;
28
28
  authLogin: string;
29
29
  authBroker: string;
30
+ /** The Coolify Postgres database of the broker's customer workspaces. */
31
+ workspacesDb: string;
30
32
  authTunnel: string;
31
33
  authCloudflared: string;
32
34
  mailApp: string;
@@ -61,6 +63,8 @@ export interface ServiceTarget {
61
63
  instanceBearer: string;
62
64
  authAdmin: string;
63
65
  brokerMailKey: string;
66
+ /** The connection string of the database for the broker's customer workspaces: foundry holds it after making the database, or the operator holds one first. */
67
+ brokerWorkspacesDatabase: string;
64
68
  mailAdminKey: string;
65
69
  filesManagement: string;
66
70
  filesIntrospection: string;
@@ -16,6 +16,7 @@ export function platformTarget() {
16
16
  authZitadel: 'platform-auth-zitadel',
17
17
  authLogin: 'platform-auth-login',
18
18
  authBroker: 'platform-auth-broker',
19
+ workspacesDb: 'platform-workspaces-db',
19
20
  authTunnel: 'platform-auth-tunnel',
20
21
  authCloudflared: 'platform-auth-cloudflared',
21
22
  mailApp: 'platform-mail-stalwart',
@@ -47,6 +48,7 @@ export function platformTarget() {
47
48
  instanceBearer: 'zitadel:platform:instanceBearer',
48
49
  authAdmin: 'zitadel:platform:admin',
49
50
  brokerMailKey: 'lettermint:platform:apiToken',
51
+ brokerWorkspacesDatabase: 'auth:platform:workspacesDatabaseUrl',
50
52
  mailAdminKey: 'stalwart:platform:admin',
51
53
  filesManagement: 'files:platform:managementKey',
52
54
  filesIntrospection: 'zitadel:platform:files:introspection',
@@ -71,6 +73,7 @@ export function orgTarget(org) {
71
73
  authZitadel: `${s}-auth-zitadel`,
72
74
  authLogin: `${s}-auth-login`,
73
75
  authBroker: `${s}-auth-broker`,
76
+ workspacesDb: `${s}-workspaces-db`,
74
77
  authTunnel: `${s}-auth-tunnel`,
75
78
  authCloudflared: `${s}-auth-cloudflared`,
76
79
  mailApp: `${s}-mail-stalwart`,
@@ -102,6 +105,7 @@ export function orgTarget(org) {
102
105
  instanceBearer: `zitadel:org:${s}:instanceBearer`,
103
106
  authAdmin: `zitadel:org:${s}:admin`,
104
107
  brokerMailKey: `lettermint:org:${s}:apiToken`,
108
+ brokerWorkspacesDatabase: `auth:org:${s}:workspacesDatabaseUrl`,
105
109
  mailAdminKey: `stalwart:org:${s}:admin`,
106
110
  filesManagement: `files:org:${s}:managementKey`,
107
111
  filesIntrospection: `zitadel:org:${s}:files:introspection`,
@@ -1,4 +1,6 @@
1
1
  import type { DestroyProgress, PlanApply } from '../apply.js';
2
+ import type { SealResult } from '../audit/seal.js';
3
+ import { type AuditSealingOptions } from '../audit/tick.js';
2
4
  import type { DeclaredOrg, HostingEntry, OrgId } from '../org.js';
3
5
  import type { Plan, PlanStep } from '../plan.js';
4
6
  import type { DeclaredPlatform } from '../platform.js';
@@ -12,6 +14,7 @@ import { type MoveGatePorts } from './backup-gate.js';
12
14
  import { type BlueGreenServer, type BlueGreenSubject } from './blue-green.js';
13
15
  import { type UnconfirmedUpgrade } from './confirm-upgrade.js';
14
16
  import { type StepAction } from './diff.js';
17
+ import type { GithubDeployKeysPort } from './github-deploy-keys.js';
15
18
  import { type LiveReaders, type LiveResource, type PlatformLiveReaders } from './live.js';
16
19
  import { type LoginHostDiscoveryPort } from './login-host.js';
17
20
  import { type OrgPostgresMigrationConfirmation } from './org-postgres-migrate.js';
@@ -249,6 +252,14 @@ export interface StepExecutor {
249
252
  * without it leaves the option as the answer.
250
253
  */
251
254
  ownBrokerPort?(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>): Promise<boolean>;
255
+ /**
256
+ * Whether the host can make the broker's workspaces database: it can hold the URL and the broker and the held keys
257
+ * were just read and have none. Read only for an org whose entry declares workspaces and only while
258
+ * {@link brokerHasWorkspacesDatabase} is false. Optional: without it the plan only reports the entry blocked.
259
+ */
260
+ canProvisionWorkspacesDatabase?(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>): Promise<boolean>;
261
+ /** Whether the broker this org's entries are written to has `WORKSPACES_DATABASE_URL`. Read only for an org whose entry declares workspaces. */
262
+ brokerHasWorkspacesDatabase?(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>): Promise<boolean>;
252
263
  /**
253
264
  * Every secret this executor minted or read so far (peer password, bridge
254
265
  * secret, invite code and link). The host's run adds them to its redactor
@@ -462,6 +473,8 @@ export interface FoundryApplyOptions {
462
473
  typed: string;
463
474
  /** The removed entry's gate keys, read by the caller from the company before the change. */
464
475
  gates?: readonly string[];
476
+ /** The paths the entry's private pages had before the change, for an entry that still exists: their `pp-`/`wp-` gate deletes pass with its confirmation. */
477
+ privatePages?: readonly string[];
465
478
  };
466
479
  }
467
480
  export interface FoundryPlanApplyOptions {
@@ -550,6 +563,8 @@ export interface FoundryPlanApplyOptions {
550
563
  workerApps?: boolean;
551
564
  /** ADR 0002: the keys custody Worker address, when the executor has a `KeysCustodyPort`. A `keysCustody` secret is blocked without it. */
552
565
  workerAppKeysUrl?: string;
566
+ /** F7: the keys service's registrar address, when the executor has a `KeysRegistrarPort`. */
567
+ keysRegistrarUrl?: string;
553
568
  /** C2: true when the host's executor was built with a `WorkerAppDatabasePort`. */
554
569
  workerAppDatabase?: boolean;
555
570
  /** ADR 0002: the platform's Cloudflare account id. A container image outside its registry is blocked. */
@@ -604,6 +619,16 @@ export interface FoundryPlanApplyOptions {
604
619
  * the release runner. Without it no release runs, in an apply or in {@link FoundryPlanApply.runReleaseTick}.
605
620
  */
606
621
  releaseStore?: ReleaseStore;
622
+ /**
623
+ * F12: the GitHub deploy-key client (`createGithubDeployKeys`), to ask which deploy keys an entry's repo has.
624
+ * Without it an entry with `uses.repo` is reported blocked and nothing else changes.
625
+ */
626
+ githubDeployKeys?: GithubDeployKeysPort;
627
+ /**
628
+ * `uses.audit` (foundry#929): what {@link FoundryPlanApply.runAuditSealTick} seals with. Without it the tick
629
+ * seals nothing and logs that it was not given.
630
+ */
631
+ auditSealing?: AuditSealingOptions;
607
632
  /** Overridable for tests; defaults to `() => new Date()`. */
608
633
  now?: () => Date;
609
634
  /** #441: how long a mutation lease lasts before another caller may take it over. Defaults to 30 minutes. */
@@ -693,6 +718,7 @@ export declare class FoundryPlanApply implements PlanApply {
693
718
  private readonly edgeWorkers;
694
719
  private readonly workerApps;
695
720
  private readonly workerAppKeysUrl;
721
+ private readonly keysRegistrarUrl;
696
722
  private readonly workerAppDatabase;
697
723
  private readonly workerAppRegistryAccountId;
698
724
  private readonly ownerInviteMail;
@@ -709,6 +735,8 @@ export declare class FoundryPlanApply implements PlanApply {
709
735
  private readonly platformDeps;
710
736
  private readonly runner;
711
737
  private readonly releaseStore;
738
+ private readonly githubDeployKeys;
739
+ private readonly auditSealing;
712
740
  constructor(options: FoundryPlanApplyOptions);
713
741
  plan(id: OrgId): Promise<Plan>;
714
742
  /**
@@ -872,6 +900,13 @@ export declare class FoundryPlanApply implements PlanApply {
872
900
  target: string;
873
901
  error?: string;
874
902
  }>>;
903
+ /**
904
+ * The recurring audit sealing run (foundry#929): seals every ledger registered by an `audit:ledger` step with the
905
+ * platform signer and has the timestamp authority stamp the new checkpoints. Foundry has no scheduler, so the host
906
+ * calls this every `AUDIT_SEAL_EVERY_MINUTES` (15). One ledger failing never stops the others; each result and a
907
+ * log line say what happened and why when nothing did. It writes nothing to the org's records.
908
+ */
909
+ runAuditSealTick(): Promise<SealResult[]>;
875
910
  private runOrgReleaseTick;
876
911
  /** An own org with calls on: the host's answer about its slot's ports. A failure or a slow answer is "not open". */
877
912
  private readSfuPortsOpen;
@@ -891,6 +926,10 @@ export declare class FoundryPlanApply implements PlanApply {
891
926
  * org step that needs one is blocked.
892
927
  */
893
928
  private orgPlatformReadiness;
929
+ /** F12: asks GitHub about the repo of each entry with a deploy key to make, check or remove. Never throws; a failure is a plain line. */
930
+ private repoKeysReadiness;
931
+ /** Which refs of the platform GitHub App hold no value now. A resolver that throws counts as not held; only refs are kept. */
932
+ private githubAppKeysReadiness;
894
933
  /**
895
934
  * The org's `heldSecret` envs: the refs that hold a value now, which of those differ from the live
896
935
  * production env, which could not be read, and which fit no ref. A resolver that throws counts as
@@ -1,4 +1,5 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
+ import { runAuditSealTick } from '../audit/tick.js';
2
3
  import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
3
4
  import { sfuSlotPorts } from '../calls-sfu.js';
4
5
  import { ORG_SERVICE_SCHEMAS, heldEnvRef, ownsServices, validateHosting } from '../org.js';
@@ -8,6 +9,7 @@ import { StaleLeaseError, } from '../store.js';
8
9
  import { entryHasDatabase } from '../worker-app-config.js';
9
10
  import { AdoptRefusedError, adoptCandidates, adoptedKeysUnreadable, adoptedScopeKey, parseAdoptSnapshot, planAdoptFrom, } from './adopt.js';
10
11
  import { NO_AGORA_HOST_VALUES, readAgoraHost, } from './agora.js';
12
+ import { APPS_DB_ENTRY_KEY } from './apps-db-route.js';
11
13
  import { NetworkMoveBackupMissingError, OrgPostgresMigrationBackupMissingError, SigninDbMoveBackupMissingError, UpgradeBackupMissingError, platformDatabasesForService, requireFreshBackupForOrgPostgresMigration, requireFreshBackupForSigninMove, requireFreshBackupsForMove, requireFreshBackupsForUpgrades, } from './backup-gate.js';
12
14
  import { ARCHON_REPO, PRIMARY_SERVER_SCOPE_KEY, archonSubject, blueGreenSubjects, declaresBlueGreen, lbOrigin, } from './blue-green.js';
13
15
  import { readLiveBuilds } from './build-record.js';
@@ -15,22 +17,27 @@ import { LEGACY_BACKUP_NAMES } from './company-backups.js';
15
17
  import { unconfirmedUpgrades } from './confirm-upgrade.js';
16
18
  import { AI_KEY_UNDELIVERED_SCOPE_KEY, JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY, computePlanDetail, toPlan, } from './diff.js';
17
19
  import { entryAiUndeliveredScopeKey } from './entry-ai-key.js';
18
- import { BOULE_EDGE_ENTRY_KEY, entryEdgeRetiredScopeKey, entryEdgeUndeliveredScopeKey, } from './entry-edge-key.js';
20
+ import { BOULE_EDGE_ENTRY_KEY, bouleEdgeKeyWanted, entryEdgeRetiredScopeKey, entryEdgeUndeliveredScopeKey, } from './entry-edge-key.js';
19
21
  import { FILES_ROOT_UNDELIVERED_SCOPE_KEY, entryFilesUndeliveredScopeKey, } from './entry-files-key.js';
22
+ import { probeRepoKeys, repoKeyProbeTargets, repoKeysInPlay, } from './entry-repo-key.js';
23
+ import { PLATFORM_GITHUB_APP_REFS } from './held-key-refs.js';
24
+ import { KEYS_REGISTRAR_MINT_STARTED_SCOPE_KEY, KEYS_REGISTRAR_SCOPE_KEY, KEYS_REGISTRAR_UNDELIVERED_SCOPE_KEY, } from './keys-registrar.js';
20
25
  import { readLivePlatformResources, readLiveResources, } from './live.js';
21
26
  import { hostingNames } from './locked-hostnames.js';
22
27
  import { agoraIssuerHostFor, loginHostServesAgora, readLoginHostDiscovery, } from './login-host.js';
23
28
  import { OWN_MAIL_SMTP_INTERNAL_SCOPE_KEY, OWN_MAIL_SMTP_PORT_BASE, OWN_MAIL_SMTP_PORT_MAX, OWN_MAIL_SMTP_PORT_SCOPE_KEY, PLATFORM_MAIL_RELAY_ROUTES_SCOPE_KEY, isOwnMailSmtpPort, mailRelayFingerprint, } from './mail-relay.js';
29
+ import { ORG_APPS_ADMIN_UNDELIVERED_SCOPE_KEY } from './org-apps-admin.js';
24
30
  import { findNameClashes, nameClashMessage } from './org-names.js';
25
31
  import { InvalidMigrationConfirmationError, ORG_POSTGRES_MIGRATION_CONFIRMED_SCOPE_KEY, OrgPostgresMigrationNotConfirmedError, invalidMigrationConfirmation, migrateHomeOf, parseMigrationConfirmation, } from './org-postgres-migrate.js';
26
32
  import { ownHostnames } from './own-hostnames.js';
27
33
  import { OWN_FILES_R2_UNDELIVERED_SCOPE_KEY, orgTarget } from './own-services.js';
28
34
  import { PLATFORM_ARCHON_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_KEY, appStepKey, planPlatform as computePlatformPlanDetail, toPlatformPlan, } from './platform-diff.js';
29
- import { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesForOwn, agoraHostValuesFromPlatform, dnsBlockedReason, heldOwnKeyRefs, heldPlatformKeyRefs, orgPlatformReadinessFor, orgPlatformReadinessFrom, ownerChatFrom, ownerFilesFrom, ownerSignInFrom, platformOwnerUserIdFrom, serviceGitRefsOf, } from './platform-org.js';
35
+ import { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesForOwn, agoraHostValuesFromPlatform, dnsBlockedReason, heldOwnKeyRefs, heldPlatformKeyRefs, orgPlatformReadinessFor, orgPlatformReadinessFrom, ownerChatFrom, ownerFilesFrom, ownerJobsPublicUrl, ownerSignInFrom, platformOwnerUserIdFrom, serviceGitRefsOf, } from './platform-org.js';
30
36
  import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF, PLATFORM_BACKUP_S3_STORAGE_SCOPE_KEY, } from './platform-providers.js';
31
- import { CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY, } from './proxy-labels.js';
37
+ import { privatePageGateKey } from './private-pages.js';
38
+ import { CLOUDFLARE_TUNNEL_APPS_HOSTNAMES_SCOPE_KEY, CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY, } from './proxy-labels.js';
32
39
  import { runSubjectRelease } from './release-host.js';
33
- import { remoteDatabaseFactsFrom } from './remote-database.js';
40
+ import { remoteDatabaseFactsFrom, remoteDatabaseKeys } from './remote-database.js';
34
41
  import { liveWorkerApps, workerAppTraceScopeKeys } from './worker-app.js';
35
42
  import { liveWorkers, workerTraceScopeKeys } from './worker.js';
36
43
  /** #441: the lease scope that serializes every mutation of one company, and the one for the shared platform. */
@@ -129,7 +136,10 @@ const HOSTNAME_STEPS = new Set([
129
136
  'coolify:bluegreen',
130
137
  'coolify:bluegreen-lb',
131
138
  'coolify:bluegreen-connector',
139
+ 'coolify:workspace',
140
+ 'coolify:promote',
132
141
  'coolify:broker-entry',
142
+ 'coolify:workspace-broker-entry',
133
143
  'coolify:database',
134
144
  'cloudflare:tunnel',
135
145
  'cloudflare:access',
@@ -173,6 +183,7 @@ export class FoundryPlanApply {
173
183
  edgeWorkers;
174
184
  workerApps;
175
185
  workerAppKeysUrl;
186
+ keysRegistrarUrl;
176
187
  workerAppDatabase;
177
188
  workerAppRegistryAccountId;
178
189
  ownerInviteMail;
@@ -189,7 +200,11 @@ export class FoundryPlanApply {
189
200
  platformDeps;
190
201
  runner;
191
202
  releaseStore;
203
+ githubDeployKeys;
204
+ auditSealing;
192
205
  constructor(options) {
206
+ this.githubDeployKeys = options.githubDeployKeys;
207
+ this.auditSealing = options.auditSealing;
193
208
  this.store = options.store;
194
209
  this.readers = options.readers;
195
210
  this.executor = options.executor;
@@ -211,6 +226,7 @@ export class FoundryPlanApply {
211
226
  this.edgeWorkers = options.edgeWorkers ?? false;
212
227
  this.workerApps = options.workerApps ?? false;
213
228
  this.workerAppKeysUrl = options.workerAppKeysUrl;
229
+ this.keysRegistrarUrl = options.keysRegistrarUrl;
214
230
  this.workerAppDatabase = options.workerAppDatabase ?? false;
215
231
  this.workerAppRegistryAccountId = options.workerAppRegistryAccountId;
216
232
  this.ownerInviteMail = options.ownerInviteMail ?? false;
@@ -796,6 +812,7 @@ export class FoundryPlanApply {
796
812
  // The tunnel is gone, so what was written into it is too.
797
813
  delete serviceIds[CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY];
798
814
  delete serviceIds[CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY];
815
+ delete serviceIds[CLOUDFLARE_TUNNEL_APPS_HOSTNAMES_SCOPE_KEY];
799
816
  }
800
817
  if (target.system === 'cloudflare' && target.kind === 'worker') {
801
818
  // #318: its partial-deploy traces too, or a retry after a later
@@ -1249,6 +1266,37 @@ export class FoundryPlanApply {
1249
1266
  }
1250
1267
  return results;
1251
1268
  }
1269
+ /**
1270
+ * The recurring audit sealing run (foundry#929): seals every ledger registered by an `audit:ledger` step with the
1271
+ * platform signer and has the timestamp authority stamp the new checkpoints. Foundry has no scheduler, so the host
1272
+ * calls this every `AUDIT_SEAL_EVERY_MINUTES` (15). One ledger failing never stops the others; each result and a
1273
+ * log line say what happened and why when nothing did. It writes nothing to the org's records.
1274
+ */
1275
+ async runAuditSealTick() {
1276
+ const orgs = await this.store.list();
1277
+ const options = this.auditSealing;
1278
+ if (!options) {
1279
+ if (orgs.some((org) => org.hosting.enabled && org.hosting.entries.some((e) => e.uses?.audit === true))) {
1280
+ console.info('audit sealing: the host gave no auditSealing options, so no ledger is sealed');
1281
+ }
1282
+ return [];
1283
+ }
1284
+ const recorded = new Map();
1285
+ return runAuditSealTick({
1286
+ ...options,
1287
+ orgs,
1288
+ now: this.now,
1289
+ recorded: async (org) => {
1290
+ const cached = recorded.get(org.id);
1291
+ if (cached)
1292
+ return cached;
1293
+ const ids = (await this.store.getAppliedState(org.id))?.serviceIds ?? {};
1294
+ recorded.set(org.id, ids);
1295
+ return ids;
1296
+ },
1297
+ allowed: (org) => this.authz.canApply(this.actor, org.id),
1298
+ });
1299
+ }
1252
1300
  async runOrgReleaseTick(id) {
1253
1301
  const empty = { orgId: id, steps: [], unmanaged: [] };
1254
1302
  if (!(await this.authz.canApply(this.actor, id))) {
@@ -1317,6 +1365,12 @@ export class FoundryPlanApply {
1317
1365
  const stageSinkDown = sinkId && recorded.stage_sink_applied && declaresBlueGreen(org) && this.executor.stageSinkDown
1318
1366
  ? await this.executor.stageSinkDown(sinkId).catch(() => false)
1319
1367
  : false;
1368
+ const brokerWorkspacesDatabase = org.hosting.entries.some((e) => e.uses?.auth?.workspaces)
1369
+ ? ((await this.executor.brokerHasWorkspacesDatabase?.(org, recorded)) ?? false)
1370
+ : false;
1371
+ const provisionWorkspacesDatabase = !brokerWorkspacesDatabase && org.hosting.entries.some((e) => e.uses?.auth?.workspaces)
1372
+ ? ((await this.executor.canProvisionWorkspacesDatabase?.(org, recorded)) ?? false)
1373
+ : false;
1320
1374
  const detail = computePlanDetail(org, live, applied?.serviceIds ?? {}, agoraHost, this.jobsScheduler, platformReadiness, {
1321
1375
  loginHostBindings: this.loginHostBindings,
1322
1376
  loginHostClientOrganizations: this.loginHostClientOrganizations,
@@ -1326,12 +1380,15 @@ export class FoundryPlanApply {
1326
1380
  zitadelRedirectUris: this.zitadelRedirectUris,
1327
1381
  zitadelMcp: this.zitadelMcp,
1328
1382
  zitadelOrgMetadata: this.zitadelOrgMetadata,
1383
+ brokerWorkspacesDatabase,
1384
+ provisionWorkspacesDatabase,
1329
1385
  broker: ownsServices(org) && this.hostOwnServices && this.executor.ownBrokerPort
1330
1386
  ? await this.executor.ownBrokerPort(org, recorded)
1331
1387
  : this.broker,
1332
1388
  edgeWorkers: this.edgeWorkers,
1333
1389
  workerApps: this.workerApps,
1334
1390
  workerAppKeysUrl: this.workerAppKeysUrl,
1391
+ keysRegistrarUrl: this.keysRegistrarUrl,
1335
1392
  workerAppDatabase: this.workerAppDatabase,
1336
1393
  workerAppRegistryAccountId: this.workerAppRegistryAccountId,
1337
1394
  ownerInviteMail: this.ownerInviteMail,
@@ -1462,6 +1519,10 @@ export class FoundryPlanApply {
1462
1519
  const base = {
1463
1520
  ...(await this.sharedPlatformReadiness()),
1464
1521
  heldEnv: await this.heldEnvReadiness(org),
1522
+ ...(org.hosting.github ? { githubAppKeys: await this.githubAppKeysReadiness() } : {}),
1523
+ ...(repoKeysInPlay(org, orgRecordedIds)
1524
+ ? { repoKeys: await this.repoKeysReadiness(org, orgRecordedIds) }
1525
+ : {}),
1465
1526
  };
1466
1527
  const platform = await this.platformDeps?.store.get();
1467
1528
  // The auth release gates the shared-host organisation check and the cookie domain of every company.
@@ -1474,6 +1535,37 @@ export class FoundryPlanApply {
1474
1535
  const readiness = orgPlatformReadinessFor(org, base, orgRecordedIds, held ? await heldOwnKeyRefs(held, org) : new Set());
1475
1536
  return withRefs(readiness);
1476
1537
  }
1538
+ /** F12: asks GitHub about the repo of each entry with a deploy key to make, check or remove. Never throws; a failure is a plain line. */
1539
+ async repoKeysReadiness(org, recorded) {
1540
+ const port = this.githubDeployKeys;
1541
+ if (!port)
1542
+ return { store: false, probes: {} };
1543
+ const probes = {};
1544
+ for (const { entryKey, target } of repoKeyProbeTargets(org, recorded)) {
1545
+ probes[entryKey] = await probeRepoKeys(port, target);
1546
+ }
1547
+ return { store: true, probes };
1548
+ }
1549
+ /** Which refs of the platform GitHub App hold no value now. A resolver that throws counts as not held; only refs are kept. */
1550
+ async githubAppKeysReadiness() {
1551
+ const heldKeys = this.platformDeps?.heldKeys;
1552
+ if (!heldKeys)
1553
+ return { store: false, missing: [] };
1554
+ const missing = [];
1555
+ for (const ref of Object.values(PLATFORM_GITHUB_APP_REFS)) {
1556
+ let held;
1557
+ try {
1558
+ held = await heldKeys.resolve(ref);
1559
+ }
1560
+ catch {
1561
+ // Not held: the plan reports the ref, and the error is dropped.
1562
+ held = null;
1563
+ }
1564
+ if (!held)
1565
+ missing.push(ref);
1566
+ }
1567
+ return { store: true, missing };
1568
+ }
1477
1569
  /**
1478
1570
  * The org's `heldSecret` envs: the refs that hold a value now, which of those differ from the live
1479
1571
  * production env, which could not be read, and which fit no ref. A resolver that throws counts as
@@ -1614,8 +1706,10 @@ export class FoundryPlanApply {
1614
1706
  const ownerRecorded = ownerOrg
1615
1707
  ? (await this.store.getAppliedState(ownerOrg.id))?.serviceIds
1616
1708
  : {};
1709
+ const jobsPublicUrl = ownerJobsPublicUrl(ownerRecorded);
1617
1710
  return {
1618
1711
  ...readiness,
1712
+ ...(jobsPublicUrl ? { jobsPublicUrl } : {}),
1619
1713
  platformOwnerUserId: platformOwnerUserIdFrom(ownerOrg, ownerRecorded ?? {}),
1620
1714
  mailKeyHeld,
1621
1715
  canHoldSecrets: typeof heldKeys?.put === 'function',
@@ -1888,6 +1982,9 @@ export function stepEntryKey(step, orgId, entries = []) {
1888
1982
  }
1889
1983
  const entryOwned = (system === 'coolify' && kind === 'app') ||
1890
1984
  (system === 'cloudflare' && (kind === 'worker' || kind === 'worker-app' || kind === 'gate')) ||
1985
+ (system === 'jobs' && kind === 'project') ||
1986
+ (system === 'github' && kind === 'deploy-key') ||
1987
+ (system === 'audit' && kind === 'ledger') ||
1891
1988
  (kind === 'key' && (system === 'ai' || system === 'edge' || system === 'files'));
1892
1989
  if (!entryOwned)
1893
1990
  return null;
@@ -1914,7 +2011,8 @@ function isOrgDatabasePark(step, orgId) {
1914
2011
  }
1915
2012
  /**
1916
2013
  * The delete of one of the org's gates that no entry declares any more. The step names only the
1917
- * gate, so the caller names the removed entry's gates (`entryConfirmation.gates`) and only those pass.
2014
+ * gate, so the caller names the removed entry's gates (`entryConfirmation.gates`), or the former paths of a
2015
+ * remaining entry's private pages (`entryConfirmation.privatePages`), and only those pass.
1918
2016
  */
1919
2017
  function isUndeclaredGateDelete(step, orgId) {
1920
2018
  const { system, kind, key, desired } = step.action;
@@ -1923,6 +2021,10 @@ function isUndeclaredGateDelete(step, orgId) {
1923
2021
  key.startsWith(`${orgId}:`) &&
1924
2022
  desired?.mode === 'delete');
1925
2023
  }
2024
+ /** Whether `gateKey` is the gate of a page the confirmed entry had (live or workspace) and has no more. */
2025
+ function isFormerPrivatePageGate(gateKey, confirmation) {
2026
+ return (confirmation.privatePages ?? []).some((path) => ['live', 'workspace'].some((scope) => privatePageGateKey(scope, confirmation.entryKey, path) === gateKey));
2027
+ }
1926
2028
  /** Whether `confirmation` passes every destroying or migrating step of `steps`: all belong to its entry, and it typed the key. */
1927
2029
  export function entryConfirms(confirmation, steps, orgId, entries) {
1928
2030
  if (!confirmation ||
@@ -1936,8 +2038,9 @@ export function entryConfirms(confirmation, steps, orgId, entries) {
1936
2038
  (isOrgDatabasePark(planned, orgId) && !entries.some(entryHasDatabase)) ||
1937
2039
  (isUndeclaredGateDelete(planned, orgId) &&
1938
2040
  !planned.step.migrates &&
1939
- (confirmation.gates ?? []).includes(planned.action.key.slice(orgId.length + 1)) &&
1940
- !entries.some((e) => e.key === confirmation.entryKey)));
2041
+ (entries.some((e) => e.key === confirmation.entryKey)
2042
+ ? isFormerPrivatePageGate(planned.action.key.slice(orgId.length + 1), confirmation)
2043
+ : (confirmation.gates ?? []).includes(planned.action.key.slice(orgId.length + 1)))));
1941
2044
  }
1942
2045
  /** `AppliedState.serviceIds`' two flat ZITADEL scope keys (`executor.ts`'s `ensureZitadelProject`/`zitadelIssuer`) -- no `:`, unlike a resource key. */
1943
2046
  const ZITADEL_PROJECT_SCOPE_KEY = 'zitadel_project';
@@ -1987,9 +2090,13 @@ function destroyTargets(serviceIds, orgId) {
1987
2090
  'cloudflare:worker-app:',
1988
2091
  'coolify:database:',
1989
2092
  'coolify:app:',
2093
+ // The company's GitHub source, after its apps; the shared private key it uses is left alone.
2094
+ 'coolify:github-source:',
1990
2095
  // An own files host's storage key: the token id is the step's recorded resource id.
1991
2096
  'coolify:own-files-r2:',
1992
2097
  'zitadel:customers:',
2098
+ // The workspaces' test customers organisation.
2099
+ 'zitadel:customers-test:',
1993
2100
  // F6a: an entry's ai key, `<orgId>:<entry>`; the org's own key has no `:` and is left alone.
1994
2101
  'ai:key:',
1995
2102
  // F6b: an entry's files key, `<orgId>:<entry>`; the org's own key has no `:` and is left alone.
@@ -2083,6 +2190,21 @@ function destroyTargets(serviceIds, orgId) {
2083
2190
  continue;
2084
2191
  targets.push({ system: 'edge', kind: 'key', key: entryKey, id });
2085
2192
  }
2193
+ // F7: the signed-records registrar key, live or minted and never delivered, is revoked with the org.
2194
+ for (const [what, scopeKey] of [
2195
+ ['live', KEYS_REGISTRAR_SCOPE_KEY],
2196
+ ['undelivered', KEYS_REGISTRAR_UNDELIVERED_SCOPE_KEY],
2197
+ ]) {
2198
+ const id = serviceIds[scopeKey];
2199
+ if (id)
2200
+ targets.push({ system: 'coolify', kind: 'keys-registrar', key: what, id });
2201
+ }
2202
+ // A mint whose answer was lost may have left a key no id points at: the company's registrar keys all go.
2203
+ if (serviceIds[KEYS_REGISTRAR_SCOPE_KEY] ||
2204
+ serviceIds[KEYS_REGISTRAR_UNDELIVERED_SCOPE_KEY] ||
2205
+ serviceIds[KEYS_REGISTRAR_MINT_STARTED_SCOPE_KEY]) {
2206
+ targets.push({ system: 'coolify', kind: 'keys-registrar', key: 'all', id: orgId });
2207
+ }
2086
2208
  // A storage key minted but never delivered has no main id; its token is deleted too.
2087
2209
  const r2Undelivered = serviceIds[OWN_FILES_R2_UNDELIVERED_SCOPE_KEY];
2088
2210
  if (r2Undelivered) {
@@ -2198,12 +2320,15 @@ const STEP_FIELDS = {
2198
2320
  'zitadel:issuer': 'issuer.usesDomain',
2199
2321
  'zitadel:owner': 'owners',
2200
2322
  'zitadel:customers': 'hosting.entries.uses.auth',
2323
+ 'zitadel:customers-test': 'hosting.entries.workspace',
2201
2324
  'zitadel:id-token-userinfo': 'hosting.entries.uses.auth',
2202
2325
  'zitadel:desktop-callback': 'hosting.entries.uses.auth',
2203
2326
  'zitadel:mcp-project': 'hosting.entries.uses.auth.mcp',
2204
2327
  'zitadel:mcp-access': 'hosting.entries.uses.auth.mcp',
2205
2328
  'zitadel:access-token-lifetime': 'hosting.entries.uses.auth.mcp',
2206
2329
  'coolify:broker-entry': 'hosting.entries.uses.auth',
2330
+ 'coolify:workspace-broker-entry': 'hosting.entries.workspace',
2331
+ 'coolify:workspaces-database': 'hosting.entries.uses.auth.workspaces',
2207
2332
  'coolify:mail': 'mail',
2208
2333
  'coolify:chat': 'chat.usesDomain',
2209
2334
  'coolify:own-auth': 'services.own',
@@ -2212,6 +2337,7 @@ const STEP_FIELDS = {
2212
2337
  'coolify:own-files': 'services.own',
2213
2338
  'coolify:own-files-r2': 'services.own',
2214
2339
  'coolify:own-jobs': 'services.own',
2340
+ 'coolify:own-jobs-route': 'services.own',
2215
2341
  'coolify:own-mail': 'services.own',
2216
2342
  'coolify:own-sfu': 'services.own',
2217
2343
  'coolify:calls': 'calls.enabled',
@@ -2221,9 +2347,12 @@ const STEP_FIELDS = {
2221
2347
  'ai:key': 'ai',
2222
2348
  'cms:site': 'site',
2223
2349
  'coolify:app': 'hosting.enabled',
2350
+ 'coolify:github-source': 'hosting.github',
2224
2351
  'coolify:bluegreen': 'hosting.entries.blueGreen',
2225
2352
  'coolify:bluegreen-lb': 'hosting.entries.blueGreen',
2226
2353
  'coolify:bluegreen-connector': 'hosting.entries.blueGreen',
2354
+ 'coolify:workspace': 'hosting.entries.workspace',
2355
+ 'coolify:promote': 'hosting.entries.liveCommit',
2227
2356
  'coolify:database': 'hosting.database',
2228
2357
  'cloudflare:tunnel': 'hosting.tunnel',
2229
2358
  'cloudflare:zone': 'hosting.usesDomain',
@@ -2232,6 +2361,10 @@ const STEP_FIELDS = {
2232
2361
  'cloudflare:ratelimit': 'hosting.usesDomain',
2233
2362
  'cloudflare:worker': 'hosting.entries.worker',
2234
2363
  'cloudflare:worker-app': 'hosting.entries.workerApp',
2364
+ // F4 (#925): both hang off the company's Apps switch.
2365
+ 'coolify:org-apps-admin': 'boule.apps',
2366
+ 'cloudflare:apps-db-route': 'boule.apps',
2367
+ 'cloudflare:apps-zones': 'boule.apps',
2235
2368
  };
2236
2369
  function stepField(action) {
2237
2370
  if (action.system === 'coolify' &&
@@ -2249,6 +2382,15 @@ function stepField(action) {
2249
2382
  // An entry's edge key (F7): always `<orgId>:<entry>`, edge has no org-root key.
2250
2383
  if (action.system === 'edge' && action.kind === 'key')
2251
2384
  return 'hosting.entries.uses.edge';
2385
+ // An entry's jobs project: always `<orgId>:<entry>`.
2386
+ if (action.system === 'jobs' && action.kind === 'project')
2387
+ return 'hosting.entries.uses.jobs';
2388
+ // An entry's audit ledger registration: always `<orgId>:<entry>`.
2389
+ if (action.system === 'audit' && action.kind === 'ledger')
2390
+ return 'hosting.entries.uses.audit';
2391
+ // An entry's deploy key for its own repo (F12): always `<orgId>:<entry>`.
2392
+ if (action.system === 'github' && action.kind === 'deploy-key')
2393
+ return 'hosting.entries.uses.repo';
2252
2394
  // An org declares several issuer apps, each keyed by its name (#71).
2253
2395
  if (action.system === 'zitadel' && action.kind === 'app') {
2254
2396
  return `hosting.issuerApps.${action.key}`;
@@ -2257,6 +2399,10 @@ function stepField(action) {
2257
2399
  if (action.system === 'cloudflare' && action.kind === 'access' && action.desired.ownedBypass) {
2258
2400
  return 'hosting.entries.gates';
2259
2401
  }
2402
+ // F11: a bypass for a machine path of a gated entry.
2403
+ if (action.system === 'cloudflare' && action.kind === 'access' && action.desired.machineBypass) {
2404
+ return 'hosting.entries.uses';
2405
+ }
2260
2406
  return STEP_FIELDS[`${action.system}:${action.kind}`] ?? `${action.system}.${action.kind}`;
2261
2407
  }
2262
2408
  /**
@@ -2312,10 +2458,18 @@ function failedFields(org, detail, completed, serviceIds) {
2312
2458
  failed.add('hosting.entries.uses.edge');
2313
2459
  }
2314
2460
  }
2315
- // F3: Boule's own key, wanted whenever the org has a domain.
2316
- if (org.domain && serviceIds[entryEdgeUndeliveredScopeKey(BOULE_EDGE_ENTRY_KEY)]) {
2461
+ // F3: Boule's own key, wanted whenever the org has a domain or its Boule runs apps.
2462
+ if (bouleEdgeKeyWanted(org) && serviceIds[entryEdgeUndeliveredScopeKey(BOULE_EDGE_ENTRY_KEY)]) {
2317
2463
  failed.add('hosting.entries.uses.edge');
2318
2464
  }
2465
+ // F7: a registrar key minted and never delivered stays unresolved until a later apply revokes it.
2466
+ if (serviceIds[KEYS_REGISTRAR_UNDELIVERED_SCOPE_KEY])
2467
+ failed.add('boule.apps');
2468
+ // F4 (#925): the same for an apps admin role or a route service token minted and never delivered.
2469
+ if (serviceIds[ORG_APPS_ADMIN_UNDELIVERED_SCOPE_KEY] ||
2470
+ serviceIds[remoteDatabaseKeys(APPS_DB_ENTRY_KEY).undelivered]) {
2471
+ failed.add('boule.apps');
2472
+ }
2319
2473
  // The same for a jobs registration whose credentials never reached the host.
2320
2474
  if (serviceIds[JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY] &&
2321
2475
  !serviceIds[`jobs:registration:${org.id}`]) {
@@ -1416,8 +1416,8 @@ export declare const JOBS_SCHEDULER_KEY = "jobs";
1416
1416
  /**
1417
1417
  * The scheduler's address on the Coolify network (`http://<custom_internal_name>:8080`),
1418
1418
  * the value the org steps' jobs port and Archon's `JOBS_URL` are read from.
1419
- * Not a secret. The scheduler's public hostname has no tunnel and no DNS
1420
- * record, so nothing on the platform can reach it there.
1419
+ * Not a secret. Only containers on that network reach it; an entry's app uses
1420
+ * the owner company's public origin `JOBS_PUBLIC_URL_SCOPE_KEY` (`own-jobs-route.ts`) instead.
1421
1421
  */
1422
1422
  export declare const JOBS_INTERNAL_URL_SCOPE_KEY = "jobs_internal_url";
1423
1423
  /**