@wtfalch/foundry 0.4.0 → 0.6.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 (76) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +111 -0
  2. package/dist/bootstrap/bootstrap.js +323 -0
  3. package/dist/bootstrap/declare.d.ts +36 -0
  4. package/dist/bootstrap/declare.js +99 -0
  5. package/dist/bootstrap/drive.d.ts +40 -0
  6. package/dist/bootstrap/drive.js +51 -0
  7. package/dist/bootstrap/index.d.ts +3 -0
  8. package/dist/bootstrap/index.js +1 -0
  9. package/dist/calls-sfu-deploy-support.d.ts +17 -6
  10. package/dist/calls-sfu-deploy-support.js +19 -7
  11. package/dist/calls-sfu-keys.d.ts +21 -0
  12. package/dist/calls-sfu-keys.js +27 -0
  13. package/dist/calls-sfu-ssh-adapter.d.ts +55 -0
  14. package/dist/calls-sfu-ssh-adapter.js +78 -0
  15. package/dist/calls-sfu-ssh-client.d.ts +49 -0
  16. package/dist/calls-sfu-ssh-client.js +82 -0
  17. package/dist/calls-sfu.d.ts +36 -12
  18. package/dist/calls-sfu.js +41 -9
  19. package/dist/index.d.ts +38 -7
  20. package/dist/index.js +20 -4
  21. package/dist/migrations/0016_platform.sql +61 -0
  22. package/dist/migrations/0017_requested_changes.sql +42 -0
  23. package/dist/migrations/0018_platform_apply.sql +19 -0
  24. package/dist/migrations/0019_mail_owner_mailbox.sql +15 -0
  25. package/dist/migrations/0020_mail_apps.sql +7 -0
  26. package/dist/migrations/0021_mail_role_mailboxes.sql +6 -0
  27. package/dist/org.d.ts +81 -0
  28. package/dist/org.js +96 -0
  29. package/dist/plan/agora.d.ts +77 -0
  30. package/dist/plan/agora.js +127 -0
  31. package/dist/plan/cloudflared-connector.d.ts +70 -0
  32. package/dist/plan/cloudflared-connector.js +106 -0
  33. package/dist/plan/diff.d.ts +20 -3
  34. package/dist/plan/diff.js +713 -115
  35. package/dist/plan/executor.d.ts +680 -172
  36. package/dist/plan/executor.js +755 -189
  37. package/dist/plan/index.d.ts +28 -5
  38. package/dist/plan/index.js +15 -2
  39. package/dist/plan/jobs-scheduler-http.d.ts +29 -0
  40. package/dist/plan/jobs-scheduler-http.js +50 -0
  41. package/dist/plan/live.d.ts +51 -2
  42. package/dist/plan/live.js +8 -0
  43. package/dist/plan/mail-copy.d.ts +125 -0
  44. package/dist/plan/mail-copy.js +166 -0
  45. package/dist/plan/mail-identity-allowlist.d.ts +50 -0
  46. package/dist/plan/mail-identity-allowlist.js +11 -0
  47. package/dist/plan/mail-identity.d.ts +68 -0
  48. package/dist/plan/mail-identity.js +17 -0
  49. package/dist/plan/plan-apply.d.ts +197 -3
  50. package/dist/plan/plan-apply.js +278 -9
  51. package/dist/plan/platform-diff.d.ts +487 -0
  52. package/dist/plan/platform-diff.js +1220 -0
  53. package/dist/plan/platform-org-ports.d.ts +50 -0
  54. package/dist/plan/platform-org-ports.js +70 -0
  55. package/dist/plan/platform-org.d.ts +121 -0
  56. package/dist/plan/platform-org.js +115 -0
  57. package/dist/plan/platform-providers.d.ts +615 -0
  58. package/dist/plan/platform-providers.js +4410 -0
  59. package/dist/plan/platform-sfu.d.ts +128 -0
  60. package/dist/plan/platform-sfu.js +142 -0
  61. package/dist/plan/providers-readers.js +22 -1
  62. package/dist/plan/zitadel-machine-user.d.ts +83 -0
  63. package/dist/plan/zitadel-machine-user.js +238 -0
  64. package/dist/plan.d.ts +6 -0
  65. package/dist/platform.d.ts +55 -0
  66. package/dist/platform.js +46 -0
  67. package/dist/requested-changes.d.ts +118 -0
  68. package/dist/requested-changes.js +125 -0
  69. package/dist/slug.js +15 -2
  70. package/dist/store/audit.js +4 -2
  71. package/dist/store/postgres.d.ts +29 -1
  72. package/dist/store/postgres.js +203 -4
  73. package/dist/store/schema.d.ts +755 -0
  74. package/dist/store/schema.js +70 -0
  75. package/dist/store.d.ts +53 -0
  76. package/package.json +1 -1
@@ -0,0 +1,1220 @@
1
+ /**
2
+ * The `Plan` (#19's public contract) a {@link PlatformPlanDetail} carries,
3
+ * mirroring `diff.ts`'s `toPlan`. There is no separate platform-shaped `Plan`
4
+ * type: `orgId` is stamped with the platform's own `ownerOrgId` -- Company
5
+ * Included's own {@link OrgId}, the org whose money and access the platform's
6
+ * resources are billed and owned by (`platform.ts`'s `DeclaredPlatform.ownerOrgId`
7
+ * doc comment) -- so `AuditLog`/`AuthzGate` (`plan-apply.ts`), both keyed by
8
+ * `OrgId`, need no platform-specific shape of their own either.
9
+ */
10
+ export function toPlatformPlan(detail, ownerOrgId) {
11
+ return {
12
+ orgId: ownerOrgId,
13
+ steps: detail.plannedSteps.map((p) => p.step),
14
+ unmanaged: detail.unmanaged,
15
+ };
16
+ }
17
+ function resourceKey(system, kind, key) {
18
+ return `${system}:${kind}:${key}`;
19
+ }
20
+ /**
21
+ * #168: the resource key `planAuth` plans under and `FoundryPlatformStepExecutor`
22
+ * (`platform-providers.ts`) dispatches on -- `platform:<service>`, exactly the
23
+ * shape `planPlatform`'s own doc comment above already named as the eventual
24
+ * convention, so a platform service's `coolify:app` step never collides with
25
+ * an org's own (which is keyed by that org's id).
26
+ */
27
+ export const PLATFORM_AUTH_KEY = 'platform:auth';
28
+ /**
29
+ * Scope keys (colon-free, so never mistaken for a `system:kind:key` resource
30
+ * id) for foundry's ZITADEL admin machine user. The delivered key is the guard
31
+ * that skips the mint, recorded only once the token reached the host. The
32
+ * undelivered key is recorded first, the moment the user is minted: an id
33
+ * under it with no delivered key means the one-time token was lost, and the
34
+ * planner reports that instead of minting again.
35
+ */
36
+ /**
37
+ * #175: the resource key `planArchon` plans under and
38
+ * `FoundryPlatformStepExecutor` (`platform-providers.ts`) dispatches on --
39
+ * the same `platform:<service>` shape as {@link PLATFORM_AUTH_KEY}.
40
+ */
41
+ export const PLATFORM_ARCHON_KEY = 'platform:archon';
42
+ /**
43
+ * #175: the hostname and `gitRef` the last successful Archon run applied.
44
+ * `planArchon` plans the step again when either differs from the declared
45
+ * value; a re-run patches `git_branch`, `APP_URL` and the sign-in client and
46
+ * redeploys, and mints nothing.
47
+ */
48
+ export const PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY = 'archon_applied_hostname';
49
+ export const PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY = 'archon_applied_git_ref';
50
+ /**
51
+ * #211 round 4: the broker app id Archon last registered its entry with.
52
+ * `provisionPlatformArchon` reads the broker to register with from
53
+ * {@link PLATFORM_AUTH_BROKER_SCOPE_KEY} alone, so a broker recreated under a
54
+ * new app id (the old one deleted by hand, or `platform:auth` minting a fresh
55
+ * one) leaves Archon "applied" against a broker that no longer holds its
56
+ * entry. `planArchon` plans the step again whenever this differs from the
57
+ * currently recorded {@link PLATFORM_AUTH_BROKER_SCOPE_KEY}, the same way a
58
+ * changed hostname or `gitRef` does.
59
+ */
60
+ export const PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY = 'archon_applied_broker_app';
61
+ /**
62
+ * #175: set to '1' before the step points the tunnel at `http_status:404`
63
+ * (Access app rebuild), cleared to '' once the real ingress is written back.
64
+ * While it is non-empty `planArchon` plans the step, so a run that failed in
65
+ * between is repaired by the next one.
66
+ */
67
+ export const PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY = 'archon_gate_closed';
68
+ export const PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY = 'zitadel_platform_admin_user_id';
69
+ export const PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY = 'zitadel_platform_admin_user_undelivered';
70
+ /**
71
+ * #216 (building on #209/#215): the `IAM_LOGIN_CLIENT` machine user minted
72
+ * for the login app and the broker, mirroring
73
+ * {@link PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY}/
74
+ * {@link PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY} in shape -- a recorded
75
+ * id once delivered, an undelivered id recorded first, the moment the user
76
+ * is minted.
77
+ *
78
+ * Unlike the admin pair, an undelivered login-client key never blocks
79
+ * {@link planAuth} from planning the step again -- #216 round 2: `planAuth`
80
+ * below does read it, but only to surface a non-blocking note (alongside a
81
+ * planned step, the way #211 round 3's broker-deploy block already is)
82
+ * rather than to hold the step back. Revoking a stale login-client PAT
83
+ * destroys nothing -- unlike the admin user's, whose loss the admin pair's
84
+ * own "delete that user by hand" message exists for because nothing else
85
+ * here re-derives the ZITADEL masterkey a lost admin PAT might have been the
86
+ * only record of -- so `mintLoginClientMachineUser` (`zitadel-machine-user.ts`)
87
+ * always lists and deletes every PAT the user already holds before minting a
88
+ * fresh one. That is what lets `provisionPlatformAuth` simply re-mint on the
89
+ * next run, with no manual step, which is #216's whole fix for the bug PR
90
+ * #215 shipped with: PR #215 mirrored the admin pair's forever-blocking
91
+ * behaviour for this key too, which meant one failed Coolify call lost the
92
+ * login-client token for good.
93
+ *
94
+ * #216 round 4: the value recorded here is JSON ({@link
95
+ * PlatformAuthLoginClientRecord}, parsed back by
96
+ * {@link parseLoginClientRecord}), not the bare user id round 2-3 wrote --
97
+ * the user id alone can never tell a healthy delivery from one a concurrent
98
+ * apply has since clobbered, because `mintLoginClientMachineUser` reuses the
99
+ * same user on every mint. The PAT id travels alongside it so {@link
100
+ * planAuth} can ask ZITADEL, on every plan, whether that exact PAT is still
101
+ * the sole live one.
102
+ *
103
+ * #216 round 5: that alone is still not enough -- a recorded PAT can be the
104
+ * sole live one in ZITADEL while Coolify holds a *different*, already-dead
105
+ * token, when two concurrent applies interleave their own writes and
106
+ * records in the right (wrong) order (see `authLoginClientDrifted`'s own
107
+ * doc comment in `platform-providers.ts` for the exact ordering). So the
108
+ * record also carries `tokenHash`, a sha256 of the exact token that was
109
+ * delivered -- never the token itself -- which `authLoginClientDrifted`
110
+ * compares against what is actually live in both apps' Coolify envs.
111
+ */
112
+ export const PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY = 'zitadel_platform_login_client_user_id';
113
+ export const PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY = 'zitadel_platform_login_client_user_undelivered';
114
+ /**
115
+ * Parses a {@link PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY} scope id back
116
+ * into a {@link PlatformAuthLoginClientRecord}. `null` for anything that is
117
+ * not one -- absent, the pre-round-4 plain user-id string, the round 2-4
118
+ * `{userId, patId}` shape with no `tokenHash`, or corrupt JSON -- the same
119
+ * "malformed reads as unapplied, never throws" rule
120
+ * {@link parseBrokerDeployRecord} follows and for the same reason: this is a
121
+ * planning read, not a validation boundary. A pre-round-5 record reading as
122
+ * unapplied is the intended migration: the next apply's fresh mint recomputes
123
+ * it in the new shape, the same way a pre-round-4 plain-string record already
124
+ * did.
125
+ */
126
+ export function parseLoginClientRecord(raw) {
127
+ if (!raw)
128
+ return null;
129
+ let parsed;
130
+ try {
131
+ parsed = JSON.parse(raw);
132
+ }
133
+ catch {
134
+ return null;
135
+ }
136
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
137
+ return null;
138
+ const candidate = parsed;
139
+ if (typeof candidate.userId !== 'string' || !candidate.userId)
140
+ return null;
141
+ if (typeof candidate.patId !== 'string' || !candidate.patId)
142
+ return null;
143
+ if (typeof candidate.tokenHash !== 'string' || !candidate.tokenHash)
144
+ return null;
145
+ return { userId: candidate.userId, patId: candidate.patId, tokenHash: candidate.tokenHash };
146
+ }
147
+ /**
148
+ * #204/#211: the sign-in broker app `provisionPlatformAuth` creates alongside
149
+ * ZITADEL and the login app. Defined here, not in `platform-providers.ts`
150
+ * (which imports it), because `planAuth` below needs it too -- a recorded
151
+ * `coolify:app:platform:auth` key with no recorded broker id is a platform
152
+ * provisioned before #204 shipped, and must still plan the step again so the
153
+ * broker gets created rather than being read as fully applied.
154
+ */
155
+ export const PLATFORM_AUTH_BROKER_SCOPE_KEY = 'coolify_platform_auth_broker_app';
156
+ /**
157
+ * #211: recorded once the broker has actually been deployed -- distinct from
158
+ * {@link PLATFORM_AUTH_BROKER_SCOPE_KEY} (the app exists) precisely because
159
+ * the app can exist for a while before it is ever deployed (see
160
+ * `missingBrokerInputs` in `platform-providers.ts`). Defined here, not there,
161
+ * for the same reason as {@link PLATFORM_AUTH_BROKER_SCOPE_KEY}: `planAuth`
162
+ * below needs it too, so a broker that is created but not yet deployed still
163
+ * plans the step again instead of reading as fully applied for good.
164
+ */
165
+ export const PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY = 'coolify_platform_auth_broker_deployed';
166
+ /**
167
+ * Parses a {@link PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY} scope id back into
168
+ * a {@link BrokerDeployRecord}. `null` for anything that is not one --
169
+ * absent, the pre-round-5 plain broker-id string, or corrupt JSON -- because
170
+ * this is a planning read, not a validation boundary: a malformed record
171
+ * must read as "not deployed" so the step plans (and overwrites it) again,
172
+ * never throw and take the rest of the plan down with it. Exported here
173
+ * rather than defined in `platform-providers.ts` for the same reason
174
+ * {@link PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY} itself was moved here in
175
+ * round 2 -- {@link planAuth} below needs it too, without a circular import.
176
+ */
177
+ export function parseBrokerDeployRecord(raw) {
178
+ if (!raw)
179
+ return null;
180
+ let parsed;
181
+ try {
182
+ parsed = JSON.parse(raw);
183
+ }
184
+ catch {
185
+ return null;
186
+ }
187
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
188
+ return null;
189
+ const candidate = parsed;
190
+ if (typeof candidate.brokerAppId !== 'string' || !candidate.brokerAppId)
191
+ return null;
192
+ if (typeof candidate.authAppsHash !== 'string' || !candidate.authAppsHash)
193
+ return null;
194
+ return { brokerAppId: candidate.brokerAppId, authAppsHash: candidate.authAppsHash };
195
+ }
196
+ /**
197
+ * #197: the `cloudflared` connector app `provisionPlatformAuth` runs for its
198
+ * own tunnel. Consulted by `planAuth` below so that a tunnel recorded with no
199
+ * connector recorded is not treated as fully applied -- the same rule that
200
+ * lets an instance provisioned before #197 shipped pick up a connector on its
201
+ * next apply, without minting anything again.
202
+ */
203
+ export const PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY = 'coolify_platform_auth_connector_app';
204
+ /** #197: the same connector scope id for the mail step's own tunnel, read by `planMail`. */
205
+ export const PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY = 'coolify_platform_mail_connector_app';
206
+ /** #197: the same connector scope id for the Archon step's own tunnel, read by `planArchon`. */
207
+ export const PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY = 'coolify_platform_archon_connector_app';
208
+ /**
209
+ * #213: the three held-key refs, under tenant `platform`, Archon's own
210
+ * onboarding reads before it can start (archon#101 round 3, archon#104).
211
+ * `planArchon`'s step puts each one, once, before Archon's app deploys; an
212
+ * entry already held is left alone, never overwritten
213
+ * (`putArchonMasterKeys` in `platform-providers.ts`). The Coolify URL and
214
+ * token are values this package already deploys with; the Cloudflare
215
+ * parent token ("API Tokens Write", so Archon can mint its own scoped
216
+ * child tokens from it) is a human-created credential the host supplies --
217
+ * neither Coolify nor Cloudflare lets either be minted over an API (#213
218
+ * "Human steps").
219
+ */
220
+ export const PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF = 'platform:master:coolify-url';
221
+ export const PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF = 'platform:master:coolify-token';
222
+ export const PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF = 'platform:master:cloudflare-token';
223
+ /**
224
+ * #169: `platform:<service>` for the platform's own mail server (Stalwart),
225
+ * mirroring {@link PLATFORM_AUTH_KEY} exactly.
226
+ */
227
+ export const PLATFORM_MAIL_KEY = 'platform:mail';
228
+ /**
229
+ * Scope keys for Stalwart's admin API key, mirroring
230
+ * {@link PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY}/{@link PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY}
231
+ * -- except `createApiKey` (`@wtfalch/email/postmaster`'s `StalwartClient`)
232
+ * returns only the minted secret, never an id, so there is no id to record
233
+ * the way ZITADEL's `userId` is. `PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION`
234
+ * (`platform-providers.ts`) is recorded instead -- the fixed description the
235
+ * key is minted under, which is what a human revokes it by in Stalwart's own
236
+ * admin UI once it is lost.
237
+ */
238
+ export const PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY = 'stalwart_platform_admin_key_delivered';
239
+ export const PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY = 'stalwart_platform_admin_key_undelivered';
240
+ /**
241
+ * #169: the hostname and `gitRef` (the Stalwart image tag) the last successful
242
+ * mail run applied. `planMail` plans the step again when either differs from
243
+ * the declared value; a re-run patches the image tag and
244
+ * `STALWART_PUBLIC_URL`, redeploys, and mints nothing.
245
+ */
246
+ export const PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY = 'mail_applied_hostname';
247
+ export const PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY = 'mail_applied_git_ref';
248
+ /**
249
+ * #177: where the mail server answers on the Coolify network
250
+ * (`http://<custom_internal_name>:<port>`), recorded by the step the same way
251
+ * {@link PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY}/{@link PLATFORM_AI_INTERNAL_URL_SCOPE_KEY}
252
+ * already are -- what an org's own `coolify:mail`/`coolify:agora` steps read
253
+ * for `AGORA_MAIL_UPSTREAM` (`platform-org.ts`'s `agoraHostValuesFromPlatform`)
254
+ * instead of a literal a host used to have to supply. Not a secret: Stalwart's
255
+ * own internal address, never its admin credential.
256
+ */
257
+ export const PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY = 'mail_internal_url';
258
+ /**
259
+ * #177: the one host the platform's mail server takes SMTP/MX on, and so the
260
+ * `mailHost` every org's own mail step writes. `provisionPlatformMail` (the
261
+ * DNS record and the server) and `platform-org.ts` (each org's mail step)
262
+ * both call this, so the two cannot name different hosts. Not
263
+ * `platform.mail.hostname`, which is the web (JMAP/admin) host.
264
+ */
265
+ export function platformMailMxHost(apex) {
266
+ return `mx.${apex}`;
267
+ }
268
+ /**
269
+ * #177: the platform apex and mail host an org's `coolify:mail` step last
270
+ * wrote, recorded in that org's applied state. `diff.ts`'s `planMail` plans
271
+ * the step again when either differs from the platform's current values, so a
272
+ * later platform address change reaches orgs that are already applied.
273
+ */
274
+ export const ORG_MAIL_APPLIED_APEX_SCOPE_KEY = 'mail_applied_apex';
275
+ export const ORG_MAIL_APPLIED_HOST_SCOPE_KEY = 'mail_applied_host';
276
+ /**
277
+ * #172: the resource key `planAi` plans under and `FoundryPlatformStepExecutor`
278
+ * dispatches on, the same `platform:<service>` shape as
279
+ * {@link PLATFORM_AUTH_KEY}. The step's own recorded resource id (the ai
280
+ * host's runner app) lands at `coolify:app:platform:ai`.
281
+ */
282
+ export const PLATFORM_AI_KEY = 'platform:ai';
283
+ /**
284
+ * #172: where the ai host answers on the Coolify network
285
+ * (`http://<custom_internal_name>:<port>`), recorded by the step so a host
286
+ * building `AiOrganisationPort` (`executor.ts`) can reach it without a
287
+ * public hostname. Holds whichever way the public-hostname decision goes.
288
+ */
289
+ export const PLATFORM_AI_INTERNAL_URL_SCOPE_KEY = 'ai_platform_internal_url';
290
+ /**
291
+ * #172: the same two-key discipline as the ZITADEL admin key above, for
292
+ * foundry's AI management key (the bearer key `ai:organisation`, `ai:budget`
293
+ * and `ai:key` use). The undelivered key holds the credential id the moment
294
+ * it is minted; the delivered key is recorded only once the secret reached
295
+ * the host, and is what skips the mint on a later run.
296
+ */
297
+ export const PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY = 'ai_platform_management_key_id';
298
+ export const PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY = 'ai_platform_management_key_undelivered';
299
+ /**
300
+ * #201: set when ai's `POST /v1/platform/keys` reports its `archon` key
301
+ * already exists but returns no secret -- a mint this record never learned
302
+ * about (most likely the crash window {@link PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY}'s
303
+ * own comment describes: the secret returned once and was never recorded).
304
+ * ai shows a credential's secret only at mint time, so it cannot be
305
+ * recovered; the planner reports this instead of minting under another
306
+ * name. A person revokes the live `archon` key in ai and removes this
307
+ * recorded key to let the step mint a fresh one.
308
+ */
309
+ export const PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY = 'ai_platform_management_key_conflict';
310
+ /**
311
+ * #201: the same two-key discipline as {@link PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY}
312
+ * and {@link PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY}, for the Ed25519 pair
313
+ * foundry mints to sign its calls to ai's `POST /v1/platform/keys`. The
314
+ * undelivered key is recorded the moment the pair is minted; the set key,
315
+ * only once the private half was delivered and the public half pinned on
316
+ * the runner as `MANAGEMENT_PUBLIC_KEYS`.
317
+ */
318
+ export const PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY = 'ai_platform_signing_key_undelivered';
319
+ export const PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY = 'ai_platform_signing_key_set';
320
+ /**
321
+ * #172: ai's platform (operator) tenant id, which the management key belongs
322
+ * to -- what ai's own README calls `AI_ORGANISATION_ID`. Not a secret.
323
+ */
324
+ export const PLATFORM_AI_ORGANISATION_SCOPE_KEY = 'ai_platform_organisation_id';
325
+ /**
326
+ * #172: the hostname and gitRef the last apply finished with. A declared
327
+ * value that differs plans the step again (without minting again, the
328
+ * management key's own guard stays set).
329
+ */
330
+ export const PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY = 'ai_platform_applied_hostname';
331
+ export const PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY = 'ai_platform_applied_gitref';
332
+ /**
333
+ * #172: set (with the migrate job's last status) when the migrate job did
334
+ * not turn healthy in time. The planner reports it as blocked and plans
335
+ * nothing, so an unhealthy migration is not retried and timed out on every
336
+ * apply. A person fixes the job and removes this recorded key.
337
+ */
338
+ export const PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY = 'ai_platform_migrate_blocked';
339
+ /**
340
+ * #172: set before an env or branch change is written and cleared once the
341
+ * deploys that carry it finished, so a run that fails halfway (a migrate
342
+ * that throws) is repaired by the next one.
343
+ */
344
+ export const PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY = 'ai_platform_deploy_pending';
345
+ /**
346
+ * #171: the resource key `planFiles` plans under and `FoundryPlatformStepExecutor`
347
+ * dispatches on, the {@link PLATFORM_AUTH_KEY} shape for the files host.
348
+ * `runPlatformSteps` records the files runner app's uuid under
349
+ * `coolify:app:platform:files` once the step succeeds, which is what stops
350
+ * `planFiles` planning it again.
351
+ */
352
+ export const PLATFORM_FILES_KEY = 'platform:files';
353
+ /**
354
+ * #171: scope keys (colon-free) the files step records in the applied
355
+ * platform.
356
+ *
357
+ * - {@link PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY}: the files host's address on
358
+ * the Coolify network (`http://<custom_internal_name>:3000`), the value an
359
+ * Agora's `AGORA_FILES_UPSTREAM` (and a host's `FilesOrganisationPort`
360
+ * client) is read from. Not a secret. The host has no public address.
361
+ * - {@link PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY}: the files ZITADEL
362
+ * project's id in the platform's own sign-in, which is the person-token
363
+ * audience (`AUTH_PERSON_TOKEN_AUDIENCE`) an Agora requests as an extra
364
+ * scope (`urn:zitadel:iam:org:project:id:<id>:aud`).
365
+ */
366
+ export const PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY = 'files_internal_url';
367
+ export const PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY = 'zitadel_platform_files_project';
368
+ /**
369
+ * #171: scope keys (colon-free) for the files host's two one-time secrets,
370
+ * the {@link PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY} pattern twice. Each
371
+ * `*_UNDELIVERED_*` key is recorded and persisted the moment its secret is
372
+ * minted, before delivery. Each guard is recorded only once the secret was
373
+ * delivered AND written to the runner's env. An undelivered id with no guard
374
+ * means the secret is lost: the planner reports it and never mints again.
375
+ * None of these holds a secret.
376
+ */
377
+ export const PLATFORM_FILES_INTROSPECTION_SCOPE_KEY = 'zitadel_platform_files_introspection_app';
378
+ export const PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY = 'zitadel_platform_files_introspection_undelivered';
379
+ export const PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY = 'files_management_key_id';
380
+ export const PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY = 'files_management_key_undelivered';
381
+ /**
382
+ * #171: the hostname and `gitRef` the last successful files run applied.
383
+ * `planFiles` plans the step again when either differs from the declared
384
+ * value; a re-run updates `git_branch` and the runner env and redeploys,
385
+ * and mints nothing (the guards above hold).
386
+ */
387
+ export const PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY = 'files_applied_hostname';
388
+ export const PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY = 'files_applied_git_ref';
389
+ /**
390
+ * #167: the platform's own diff engine, mirroring `diff.ts`'s
391
+ * `computePlanDetail` for the one shared {@link DeclaredPlatform} record
392
+ * (#166) rather than one `DeclaredOrg` per call. It reuses the same step
393
+ * kinds an org plans against (`coolify:app`, `coolify:database`,
394
+ * `cloudflare:tunnel`, `cloudflare:zone`, `zitadel:app`), keyed under the
395
+ * platform's own resources rather than an org id -- a platform service's
396
+ * eventual `coolify:app` step, for one example, is keyed `platform:<service>`
397
+ * (`platform.ts`'s `PlatformServiceKey`) rather than an `OrgId`, once that
398
+ * step exists.
399
+ *
400
+ * Scope for #167: the frame plus one step, {@link planApexZone}. #173 adds
401
+ * {@link planJobs}, #170 adds {@link planSfu}, #168 adds {@link planAuth},
402
+ * #169 adds {@link planMail}, #174 adds {@link planIntegrations}, #171 adds
403
+ * {@link planFiles}, #172 adds {@link planAi} and #175 adds
404
+ * {@link planArchon}, the same way #19 shipped org plan/apply against
405
+ * three singletons before #27, #71, #80 and the rest added the remaining
406
+ * twelve. Until then, a live resource under one of those eventual keys is
407
+ * reported unmanaged rather than silently ignored (#10's rule, carried over
408
+ * unchanged).
409
+ */
410
+ export function planPlatform(platform, live, options = {}) {
411
+ const { jobsObservable = true, sfuBlocked = null, recordedIds = {}, blocked = {}, drifted = {}, } = options;
412
+ const byKey = new Map(live.map((r) => [resourceKey(r.system, r.kind, r.key), r]));
413
+ const claimed = new Set();
414
+ const plannedSteps = [];
415
+ const zone = planApexZone(platform, byKey, claimed);
416
+ if (zone)
417
+ plannedSteps.push(zone);
418
+ // #173: the jobs scheduler, second of the eight services this frame leaves
419
+ // open (this file's own doc comment above).
420
+ const jobs = planJobs(platform, byKey, claimed, jobsObservable);
421
+ if (jobs.step)
422
+ plannedSteps.push(jobs.step);
423
+ // A blocked SFU is reported in `unmanaged`, not planned: a step the
424
+ // executor must refuse would halt every step after it (`diff.ts` does the
425
+ // same for a blocked Agora).
426
+ const sfu = platform.sfu.enabled && sfuBlocked ? null : planSfu(platform);
427
+ if (sfu)
428
+ plannedSteps.push(sfu);
429
+ const auth = planAuth(platform, recordedIds, blocked.auth, blocked.authExecutorMissing ?? false, drifted.auth ?? false, blocked.authOtherInputsMissing ?? []);
430
+ if (auth.step)
431
+ plannedSteps.push(auth.step);
432
+ // #174: the integrations host. Planned after auth; neither step reads the
433
+ // other's recorded ids today, so the order is only for a readable plan.
434
+ const integrations = planIntegrations(platform, recordedIds, blocked.integrations);
435
+ if (integrations.step)
436
+ plannedSteps.push(integrations.step);
437
+ // #171: after auth, so a first apply builds the sign-in the files step
438
+ // creates its ZITADEL project in before that step runs.
439
+ const authReady = auth.step !== null || Boolean(recordedIds[resourceKey('coolify', 'app', PLATFORM_AUTH_KEY)]);
440
+ const files = planFiles(platform, recordedIds, blocked.files, authReady);
441
+ if (files.step)
442
+ plannedSteps.push(files.step);
443
+ // #169: the platform's own mail server. After auth, because its OIDC
444
+ // directory is built against the platform's sign-in.
445
+ const mail = planMail(platform, recordedIds, blocked.mail, authReady);
446
+ if (mail.step)
447
+ plannedSteps.push(mail.step);
448
+ // #172: a throw halts every step after it, so this one goes late.
449
+ const ai = planAi(platform, recordedIds, blocked.ai);
450
+ if (ai.step)
451
+ plannedSteps.push(ai.step);
452
+ // #175: last, on purpose. Archon's sign-in client lives in the sign-in
453
+ // `planAuth` builds, and its env reads what every other service's step
454
+ // records or delivers, so in one apply it runs after all of them.
455
+ const archon = planArchon(platform, recordedIds, blocked.archon, authReady, drifted.archon);
456
+ if (archon.step)
457
+ plannedSteps.push(archon.step);
458
+ const unmanaged = live
459
+ .filter((r) => !claimed.has(resourceKey(r.system, r.kind, r.key)))
460
+ .map((r) => ({
461
+ description: `${r.system} ${r.kind} "${r.key}" is live but not named by the declared platform`,
462
+ }));
463
+ // A blocked jobs scheduler is reported here, not planned: a step that
464
+ // throws every apply, or that can never be seen live and so re-plans
465
+ // forever, is worse than none.
466
+ if (jobs.blocked)
467
+ unmanaged.push({ description: jobs.blocked, key: 'jobs' });
468
+ if (platform.sfu.enabled && sfuBlocked) {
469
+ unmanaged.push({ description: sfuBlocked, key: 'sfu' });
470
+ }
471
+ // Blocked, not planned: reported with the unmanaged items, like a blocked Agora.
472
+ if (auth.blocked)
473
+ unmanaged.push({ description: auth.blocked, key: 'auth' });
474
+ if (mail.blocked)
475
+ unmanaged.push({ description: mail.blocked, key: 'mail' });
476
+ if (integrations.blocked)
477
+ unmanaged.push({ description: integrations.blocked, key: 'integrations' });
478
+ if (files.blocked)
479
+ unmanaged.push({ description: files.blocked, key: 'files' });
480
+ if (ai.blocked)
481
+ unmanaged.push({ description: ai.blocked, key: 'ai' });
482
+ if (archon.blocked)
483
+ unmanaged.push({ description: archon.blocked, key: 'archon' });
484
+ return { plannedSteps, unmanaged };
485
+ }
486
+ /**
487
+ * The platform's own Cloudflare zone for its shared apex (`platform.ts`'s
488
+ * `DeclaredPlatform.apex`) -- modeled on `diff.ts`'s `planZone`, keyed by the
489
+ * domain itself rather than an org id, for the same reason: a Cloudflare
490
+ * zone belongs to a domain, not whoever declares it. Provision-only, like
491
+ * that planner: there is no `usesDomain` toggle to turn off here (the
492
+ * platform's apex is not optional the way an org's own domain is), so this
493
+ * step is never destructive and a live zone is never parked -- it only ever
494
+ * needs creating once, which is exactly #167's "so the empty
495
+ * companyincluded.com zone is managed."
496
+ */
497
+ function planApexZone(platform, byKey, claimed) {
498
+ const key = resourceKey('cloudflare', 'zone', platform.apex);
499
+ const live = byKey.get(key);
500
+ if (live) {
501
+ claimed.add(key);
502
+ return null;
503
+ }
504
+ const action = {
505
+ system: 'cloudflare',
506
+ kind: 'zone',
507
+ key: platform.apex,
508
+ desired: { domain: platform.apex },
509
+ };
510
+ return {
511
+ action,
512
+ step: {
513
+ description: `provision a Cloudflare zone for ${platform.apex}`,
514
+ destroys: false,
515
+ migrates: false,
516
+ },
517
+ };
518
+ }
519
+ /**
520
+ * The one Coolify application (and key) every recorded jobs-scheduler
521
+ * resource, and its `coolify:jobs` step, is keyed under -- there is exactly
522
+ * one scheduler for the whole platform, so this is a fixed constant rather
523
+ * than something derived from `platform` (`platform-providers.ts`'s
524
+ * `FoundryPlatformStepExecutor`/`ProvidersPlatformCoolifyReader` both import
525
+ * it, so the planner and the executor/reader can never key it apart).
526
+ */
527
+ export const JOBS_SCHEDULER_KEY = 'jobs';
528
+ /**
529
+ * #173: the jobs scheduler (https://github.com/wtfalch/jobs) itself, as a
530
+ * platform-level Coolify application with its own database -- provision-only,
531
+ * like {@link planApexZone}: there is no park branch yet (`platform.ts`'s
532
+ * `PlatformService.enabled` going from true to false plans nothing, the same
533
+ * scope note {@link planApexZone}'s own doc comment leaves for a future
534
+ * issue), and once live it is never replanned -- a hostname or `gitRef`
535
+ * change is a manual redeploy today, not a diffed field, matching #167's own
536
+ * "the frame plus one step" scope for this campaign.
537
+ *
538
+ * Not planned at all while `platform.jobs.enabled` is false -- an org step
539
+ * (`diff.ts`'s eventual `jobs:registration`) has nothing to register against
540
+ * before this exists.
541
+ */
542
+ function planJobs(platform, byKey, claimed, observable) {
543
+ const key = resourceKey('coolify', 'jobs', JOBS_SCHEDULER_KEY);
544
+ const live = byKey.get(key);
545
+ if (live)
546
+ claimed.add(key);
547
+ if (!platform.jobs.enabled)
548
+ return { step: null, blocked: null };
549
+ if (live)
550
+ return { step: null, blocked: null };
551
+ // No Coolify reader means the host wired no Coolify at all for the
552
+ // platform: the step's executor would throw every apply, and the scheduler
553
+ // could never be seen live, so it would re-plan forever. A fixed text: it
554
+ // is part of the stale-plan comparison.
555
+ if (!observable) {
556
+ return {
557
+ step: null,
558
+ blocked: 'the jobs scheduler is blocked, not planned: the host supplies no Coolify reader for the platform',
559
+ };
560
+ }
561
+ return {
562
+ blocked: null,
563
+ step: {
564
+ action: {
565
+ system: 'coolify',
566
+ kind: 'jobs',
567
+ key: JOBS_SCHEDULER_KEY,
568
+ desired: { hostname: platform.jobs.hostname, gitRef: platform.jobs.gitRef },
569
+ },
570
+ step: {
571
+ description: `provision the jobs scheduler at ${platform.jobs.hostname}`,
572
+ destroys: false,
573
+ migrates: false,
574
+ },
575
+ },
576
+ };
577
+ }
578
+ /**
579
+ * The platform's shared calls SFU (#170), keyed `sfu:livekit:platform` --
580
+ * there is exactly one, so `key` names the platform itself rather than a
581
+ * domain or an org id, the way `LiveResource`'s own doc comment explains for
582
+ * this system. Unlike {@link planApexZone}, this step is never matched
583
+ * against a live resource: there is no cheap way to read the SFU VM's own
584
+ * state over SSH during planning (`LiveResource`'s doc comment on the `sfu`
585
+ * system), so the step is planned whenever `platform.sfu.enabled` is true,
586
+ * full stop. That is safe rather than wasteful: `applyCallsSfu`
587
+ * (`calls-sfu.ts`'s own top doc comment) is built to be re-run, so an apply
588
+ * that runs this step again writes the same files and runs the same
589
+ * idempotent `docker compose`/`ufw` commands, changing nothing on a VM
590
+ * already caught up.
591
+ */
592
+ function planSfu(platform) {
593
+ if (!platform.sfu.enabled)
594
+ return null;
595
+ const action = {
596
+ system: 'sfu',
597
+ kind: 'livekit',
598
+ key: 'platform',
599
+ desired: { enabled: true },
600
+ };
601
+ return {
602
+ action,
603
+ step: {
604
+ description: 'apply the shared calls SFU (LiveKit + cloudflared) over SSH',
605
+ destroys: false,
606
+ migrates: false,
607
+ },
608
+ };
609
+ }
610
+ /**
611
+ * #168: the platform's own sign-in (ZITADEL), built fresh rather than
612
+ * adopted (#165) -- a Coolify Postgres, a ZITADEL app and a zitadel-login
613
+ * app, reached through a Cloudflare tunnel at `platform.auth.hostname`, plus
614
+ * (once the instance is up) minting foundry's own ZITADEL machine user with
615
+ * the admin role. All of that is one step, `coolify:app` keyed
616
+ * {@link PLATFORM_AUTH_KEY} -- mirroring `diff.ts`'s `coolifyCalls`, which
617
+ * bundles two apps and two databases into its own single `coolify:calls`
618
+ * step the same way, rather than four separate ones.
619
+ *
620
+ * Idempotent on the *recorded* resource id
621
+ * (`recordedIds[resourceKey('coolify','app',PLATFORM_AUTH_KEY)]`), not a live
622
+ * resource -- the same choice `diff.ts`'s `planOwner` makes and explains: no
623
+ * reader reports this step's Coolify/Cloudflare resources yet (`planPlatform`'s
624
+ * own doc comment: "every other service ... is left for its own issue to
625
+ * plan"), so a live check here would never see them and would replan this
626
+ * step, and its one-time machine-user mint, forever. This also means a
627
+ * second `planPlatform()` call plans nothing further once the first apply's
628
+ * `runPlatformSteps` has recorded that key -- so a second `applyPlatform`
629
+ * never even calls the executor for this step, let alone mints again (#168
630
+ * "Done when").
631
+ *
632
+ * Off by default like every {@link PlatformService} (`platform.ts`'s
633
+ * `basePlatform` fixture: every service starts `enabled: false`); this
634
+ * planner never parks or updates what it already provisioned -- v1's scope,
635
+ * matching #167's own apex-zone step, is "provision once."
636
+ *
637
+ * #197: the resource id alone is not "fully applied" any more. A tunnel with
638
+ * no `cloudflared` connector answers every platform hostname with a
639
+ * Cloudflare error, so a recorded app with no recorded connector
640
+ * ({@link PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY}) still plans the step again
641
+ * -- `provisionPlatformAuth` re-runs idempotently against everything it
642
+ * already built and only adds the connector, so this never mints or
643
+ * redeploys what a prior run already finished.
644
+ *
645
+ * #211: the same holds for the broker ({@link PLATFORM_AUTH_BROKER_SCOPE_KEY}).
646
+ * A platform applied before #204 shipped the broker recorded the app and the
647
+ * connector and never the broker, so without this check it would read as
648
+ * fully applied and Archon would never find a broker to register with.
649
+ *
650
+ * #211 round 2: a recorded broker id is not the same as a *deployed* broker
651
+ * ({@link PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY}) -- `provisionPlatformAuth`
652
+ * creates and records the app before it has `ZITADEL_SERVICE_USER_TOKEN`/
653
+ * `RESEND_API_TOKEN` to deploy it with (#209, #210). So this still plans the
654
+ * step again while the broker sits undeployed: `executorBlocked` (from
655
+ * `authBrokerBlockedReason`) reports it blocked, by name, for as long as the
656
+ * inputs stay missing, and the moment they arrive the same replanned step
657
+ * deploys it -- no apply is left blocked for good once its inputs exist.
658
+ *
659
+ * #211 round 5: "deployed" ({@link PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY})
660
+ * is read back through {@link parseBrokerDeployRecord}, not `Boolean(...)`,
661
+ * and only counts while the record's `brokerAppId` matches the currently
662
+ * recorded {@link PLATFORM_AUTH_BROKER_SCOPE_KEY} -- a broker recreated under
663
+ * a new app id (the old one deleted by hand, or a fresh mint) leaves the old
664
+ * record pointing at an app that no longer exists, and a plain `Boolean`
665
+ * check (round 2-4) could never see that. This function has no live Coolify
666
+ * access, so it cannot also compare the record's `authAppsHash` against a
667
+ * live `AUTH_APPS` -- that half of the same rule (`brokerDeployNeeded` in
668
+ * `platform-providers.ts`) only runs inside the two `provision*` functions,
669
+ * which do have one. A malformed record parses to `null` and is treated the
670
+ * same as none recorded -- see {@link parseBrokerDeployRecord}'s own doc
671
+ * comment.
672
+ *
673
+ * #211 round 3: `executorBlocked` must not hold back the step itself once
674
+ * the broker exists (`brokerApplied`). `authBrokerBlockedReason` only ever
675
+ * returns non-null once a broker app id is recorded, so `executorBlocked &&
676
+ * brokerApplied` can only mean "broker created, not yet deployed" -- never
677
+ * "no executor". Blocking the whole step on that would deadlock: #209 mints
678
+ * the broker's own token as part of running this step, and a step that is
679
+ * never planned never runs. So this still plans the step -- surfacing
680
+ * `executorBlocked` in `blocked` for visibility, not to stop planning.
681
+ *
682
+ * #211 round 6: round 3's `!brokerApplied` guard assumed "no executor" and
683
+ * "broker not yet created" always coincide. They don't: a broker recorded on
684
+ * an earlier apply, then run again against a host built without its auth
685
+ * dependencies (`this.auth` undefined in `PlatformProvidersExecutor`), has
686
+ * `brokerApplied === true` and `executorBlocked` set to the no-deps message,
687
+ * so round 3's check let the step through and apply threw on the missing
688
+ * executor. `executorMissing` is that case's own signal
689
+ * ({@link PlanPlatformOptions.blocked}'s `authExecutorMissing`) and holds the
690
+ * step back unconditionally -- there is no executor to run it with, whatever
691
+ * `recordedIds` says.
692
+ *
693
+ * #216: a platform with every one of the above applied but no recorded
694
+ * {@link PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY} is not fully applied
695
+ * either -- the same "provisioned before this shipped" case
696
+ * {@link PLATFORM_AUTH_BROKER_SCOPE_KEY} already covers, here for a platform
697
+ * applied before #216's login-client mint existed at all.
698
+ * `provisionPlatformAuth` re-runs idempotently against everything it already
699
+ * built and only mints and delivers the login-client token, so this never
700
+ * redoes what a prior run already finished. Unlike the admin key, an
701
+ * undelivered login-client key never blocks this check -- see
702
+ * {@link PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY}'s own doc comment
703
+ * for why -- though it is still read, below, for a non-blocking note.
704
+ *
705
+ * #216 round 4: a *recorded* login-client key is also not enough on its own
706
+ * -- `authDrifted` (the host's own `driftedSteps`, reading ZITADEL's live
707
+ * PATs for the recorded user) means the PAT this platform's two apps were
708
+ * handed is no longer the sole live one: a concurrent apply's own mint
709
+ * revoked or replaced it, or minted an extra one alongside it. Round 2 and
710
+ * round 3 both tried to catch that from inside the same run that delivered
711
+ * the token (checking once after every write, then checking again before
712
+ * every write) and both left a window a slow concurrent write could still
713
+ * land in. This closes it from the other end instead: every plan re-checks
714
+ * the live state no matter which run last touched it, so whichever run's
715
+ * write actually won is caught on the very next plan, not raced against
716
+ * from inside the run that might lose.
717
+ *
718
+ * #216 round 5: `otherInputsMissing` keeps the note below honest when a
719
+ * re-mint is needed (undelivered or drifted) but cannot actually happen yet
720
+ * -- `provisionPlatformAuth` only mints and delivers once the broker's
721
+ * inputs *other than* `ZITADEL_SERVICE_USER_TOKEN` are all present
722
+ * (deferred, not blocked -- see that function's own doc comment), so a note
723
+ * promising "this apply re-mints" while one of those is still missing would
724
+ * be wrong the moment it is read.
725
+ */
726
+ function planAuth(platform, recordedIds, executorBlocked, executorMissing, authDrifted, otherInputsMissing = []) {
727
+ if (!platform.auth.enabled)
728
+ return { step: null, blocked: null };
729
+ const key = resourceKey('coolify', 'app', PLATFORM_AUTH_KEY);
730
+ const connectorApplied = Boolean(recordedIds[PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY]);
731
+ // #211: a recorded app and connector with no recorded broker id is a
732
+ // platform provisioned before #204 shipped the broker, or a run that threw
733
+ // before the broker's own id was recorded -- still not fully applied, the
734
+ // same rule `connectorApplied` already follows for #197's connector.
735
+ const brokerApplied = Boolean(recordedIds[PLATFORM_AUTH_BROKER_SCOPE_KEY]);
736
+ // #211 round 2: also require the broker to be deployed, not merely created.
737
+ // #211 round 5: and only while the deploy record still names the broker
738
+ // that is currently recorded -- see this function's own doc comment.
739
+ const brokerDeployRecord = parseBrokerDeployRecord(recordedIds[PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY]);
740
+ const brokerDeployed = brokerDeployRecord !== null &&
741
+ brokerDeployRecord.brokerAppId === recordedIds[PLATFORM_AUTH_BROKER_SCOPE_KEY];
742
+ // #216 round 4: parsed back, not just `Boolean(...)` -- see this
743
+ // function's own doc comment and {@link parseLoginClientRecord}.
744
+ const loginClientRecord = parseLoginClientRecord(recordedIds[PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY]);
745
+ // #216 round 4: a recorded record whose PAT has since drifted (revoked,
746
+ // replaced, or joined by an extra live PAT) reads as unapplied, same as
747
+ // none recorded at all -- see this function's own doc comment.
748
+ const loginClientApplied = loginClientRecord !== null && !authDrifted;
749
+ // #216 round 2: read for a non-blocking note only -- see
750
+ // {@link PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY}'s own doc
751
+ // comment for why this never holds the step back the way the admin key's
752
+ // own `undelivered` check below does.
753
+ const loginClientUndelivered = recordedIds[PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY];
754
+ if (recordedIds[key] &&
755
+ connectorApplied &&
756
+ brokerApplied &&
757
+ brokerDeployed &&
758
+ loginClientApplied) {
759
+ return { step: null, blocked: null };
760
+ }
761
+ // The admin key was minted but never reached the host. ZITADEL shows a
762
+ // token once, so it is lost: minting another would hide that, and skipping
763
+ // would leave foundry without its admin key. Report it, plan nothing.
764
+ const undelivered = recordedIds[PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY];
765
+ if (undelivered && !recordedIds[PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY]) {
766
+ return {
767
+ step: null,
768
+ blocked: `the platform's sign-in is blocked, not planned: the admin key of ZITADEL machine user ${undelivered} was minted but never delivered and cannot be shown again; delete that user in ZITADEL and remove the recorded key ${PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY} to mint a new one`,
769
+ };
770
+ }
771
+ // #211 round 6: the executor itself is unusable on this host -- nothing to
772
+ // plan, whatever the broker's recorded state says.
773
+ if (executorMissing)
774
+ return { step: null, blocked: executorBlocked ?? null };
775
+ // #211 round 3: a blocked reason with no broker yet created also means the
776
+ // executor is unusable (no auth dependencies) -- nothing to plan.
777
+ // A blocked reason with the broker already created means only its deploy
778
+ // is held back; plan the step anyway so it can keep retrying and, once
779
+ // #209/#210 land, deploy on the very next apply.
780
+ if (executorBlocked && !brokerApplied)
781
+ return { step: null, blocked: executorBlocked };
782
+ return {
783
+ step: {
784
+ action: {
785
+ system: 'coolify',
786
+ kind: 'app',
787
+ key: PLATFORM_AUTH_KEY,
788
+ desired: { hostname: platform.auth.hostname, gitRef: platform.auth.gitRef },
789
+ },
790
+ step: {
791
+ description: `provision the platform's own sign-in (ZITADEL, the login app and the sign-in broker) at ${platform.auth.hostname}`,
792
+ destroys: false,
793
+ migrates: false,
794
+ },
795
+ },
796
+ // #211 round 3: still surface a broker-deploy block for visibility even
797
+ // though the step above is planned -- see the doc comment above. #216
798
+ // round 2: when nothing executor-side is blocked, surface an in-flight
799
+ // (or abandoned) login-client mint the same way -- informational only,
800
+ // never a reason to skip planning. #216 round 4: same treatment for a
801
+ // drifted (already-delivered, now stale) login-client token. #216 round
802
+ // 5: and only claim "this apply re-mints" when `otherInputsMissing` is
803
+ // actually empty -- see this function's own doc comment.
804
+ blocked: executorBlocked ??
805
+ loginClientNeedsMintNote(loginClientUndelivered, loginClientApplied, authDrifted, loginClientRecord, otherInputsMissing),
806
+ };
807
+ }
808
+ /**
809
+ * #216 round 5: the note {@link planAuth} surfaces when the login-client key
810
+ * needs a re-mint it cannot yet perform, split out of `planAuth`'s own return
811
+ * statement so the "needs minting" question and the "can it actually mint
812
+ * right now" question are each answered in one place.
813
+ */
814
+ function loginClientNeedsMintNote(loginClientUndelivered, loginClientApplied, authDrifted, loginClientRecord, otherInputsMissing) {
815
+ const undeliveredNeedsMint = Boolean(loginClientUndelivered) && !loginClientApplied;
816
+ let subject = null;
817
+ if (undeliveredNeedsMint) {
818
+ subject = `the login-client key of ZITADEL machine user ${loginClientUndelivered} was minted but not yet confirmed delivered`;
819
+ }
820
+ else if (authDrifted && loginClientRecord) {
821
+ subject = `the login-client token delivered for ZITADEL machine user ${loginClientRecord.userId} is no longer the sole live token on that user, likely revoked or replaced by a concurrent apply`;
822
+ }
823
+ if (subject === null)
824
+ return null;
825
+ if (otherInputsMissing.length > 0) {
826
+ return `${subject}; this apply cannot re-mint and redeliver it yet -- the platform's sign-in broker still needs ${otherInputsMissing.join(' and ')} first`;
827
+ }
828
+ const action = undeliveredNeedsMint
829
+ ? 'this apply re-mints (revoking it) and retries'
830
+ : 'this apply re-mints and redelivers';
831
+ return `${subject}; ${action} -- no manual step needed`;
832
+ }
833
+ /**
834
+ * #169: the platform's own mail server (Stalwart), built fresh -- Stalwart on
835
+ * Coolify with its own storage, reached at `platform.mail.hostname` for
836
+ * web/JMAP/management and at `mx.<apex>` for SMTP, then (once the instance is
837
+ * up) the OIDC directory against the platform's own sign-in and foundry's
838
+ * admin API key, minted once. One `coolify:app` step, mirroring
839
+ * {@link planAuth} exactly -- see `platform-providers.ts`'s
840
+ * `provisionPlatformMail` for what it actually does and its own documented
841
+ * gaps.
842
+ *
843
+ * `platform.mail.gitRef` carries the Stalwart image tag rather than a git
844
+ * ref (`platform.ts`'s `PlatformService.gitRef` doc comment: "the release it
845
+ * runs" -- true of an image tag too, and Stalwart has no repo of its own to
846
+ * build from), so `validatePlatform`'s existing "gitRef must not be empty
847
+ * once enabled" rule already covers it with no change there.
848
+ *
849
+ * Idempotent on the recorded resource id plus the applied hostname and
850
+ * `gitRef`, for the reason {@link planAuth} gives: no reader reports this
851
+ * step's Coolify/Cloudflare resources yet. A changed hostname or image tag
852
+ * plans the step again (a redeploy, no new key). Blocked, not planned, while
853
+ * the platform's sign-in is neither applied nor planned.
854
+ *
855
+ * #197: a recorded app with no recorded `cloudflared` connector
856
+ * ({@link PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY}) plans the step again, as
857
+ * {@link planAuth} does.
858
+ */
859
+ function planMail(platform, recordedIds, executorBlocked, authReady) {
860
+ if (!platform.mail.enabled)
861
+ return { step: null, blocked: null };
862
+ const key = resourceKey('coolify', 'app', PLATFORM_MAIL_KEY);
863
+ if (recordedIds[key] &&
864
+ recordedIds[PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY] &&
865
+ recordedIds[PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY] === platform.mail.hostname &&
866
+ recordedIds[PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY] === platform.mail.gitRef &&
867
+ // #177: a mail step applied before this key existed never recorded it, and
868
+ // org steps read it for Agora's mail upstream. Missing means unapplied.
869
+ recordedIds[PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY]) {
870
+ return { step: null, blocked: null };
871
+ }
872
+ // Stalwart's `x:ApiKey/set` shows a secret once. An id was minted but never
873
+ // reached the host: report it, plan nothing, the same rule {@link planAuth}
874
+ // follows for ZITADEL's own admin key.
875
+ const undelivered = recordedIds[PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY];
876
+ if (undelivered && !recordedIds[PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY]) {
877
+ return {
878
+ step: null,
879
+ blocked: `the platform's mail server is blocked, not planned: an admin API key ("${undelivered}") was minted but never delivered and cannot be shown again; revoke that key in Stalwart and remove the recorded key ${PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY} to mint a new one`,
880
+ };
881
+ }
882
+ if (executorBlocked)
883
+ return { step: null, blocked: executorBlocked };
884
+ if (!authReady) {
885
+ return {
886
+ step: null,
887
+ blocked: "the platform's mail server is blocked, not planned: its OIDC directory uses the platform's own sign-in, which is neither applied nor planned",
888
+ };
889
+ }
890
+ return {
891
+ step: {
892
+ action: {
893
+ system: 'coolify',
894
+ kind: 'app',
895
+ key: PLATFORM_MAIL_KEY,
896
+ desired: { hostname: platform.mail.hostname, gitRef: platform.mail.gitRef },
897
+ },
898
+ step: {
899
+ description: `provision mail (Stalwart) at ${platform.mail.hostname}`,
900
+ destroys: false,
901
+ migrates: false,
902
+ },
903
+ },
904
+ blocked: null,
905
+ };
906
+ }
907
+ /**
908
+ * #174: the resource key the integrations host is planned and recorded under,
909
+ * `platform:integrations`, like {@link PLATFORM_AUTH_KEY}: the step is
910
+ * `coolify:app` keyed by it, and the id `runPlatformSteps` records is
911
+ * `coolify:app:platform:integrations`. The planner and the executor
912
+ * (`platform-providers.ts`) both import this one value.
913
+ */
914
+ export const PLATFORM_INTEGRATIONS_KEY = 'platform:integrations';
915
+ /**
916
+ * Scope keys (colon-free) for the integrations host's Ed25519 management key.
917
+ * The undelivered key is recorded first, the moment the pair is minted. The
918
+ * set key is the guard, recorded only after the private half was delivered
919
+ * and the public half written to the runner. An id under the undelivered key
920
+ * with no guard means the pair may be lost or half-applied, and the planner
921
+ * reports that instead of minting again.
922
+ */
923
+ export const PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY = 'integrations_management_key_undelivered';
924
+ export const PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY = 'integrations_management_key_set';
925
+ /** The hostname and gitRef the last successful apply put live; a difference from the declared ones plans the step again. */
926
+ export const PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY = 'integrations_applied_hostname';
927
+ export const PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY = 'integrations_applied_gitref';
928
+ /**
929
+ * #174: the integrations host (https://github.com/wtfalch/integrations) -- its
930
+ * migrate job, web app and queue worker, their shared database, and its
931
+ * Ed25519 management key, minted once. One step, `coolify:app` keyed
932
+ * {@link PLATFORM_INTEGRATIONS_KEY}, bundling every part the way
933
+ * {@link planAuth} bundles its database, two apps and tunnel.
934
+ *
935
+ * Idempotent on the *recorded* resource id, like {@link planAuth} and for the
936
+ * same reason: no platform reader reports these Coolify apps. Unlike auth, a
937
+ * declared `hostname` or `gitRef` that differs from the applied one (recorded
938
+ * as scope ids) plans the step again; the step then patches the apps and
939
+ * redeploys, and never mints the key again.
940
+ *
941
+ * Blocked, not planned: an undelivered key, and an executor that cannot run
942
+ * the step (no integrations dependencies, or no `@wtfalch/keys` values for
943
+ * the host).
944
+ */
945
+ function planIntegrations(platform, recordedIds, executorBlocked) {
946
+ if (!platform.integrations.enabled)
947
+ return { step: null, blocked: null };
948
+ const { hostname, gitRef } = platform.integrations;
949
+ const applied = Boolean(recordedIds[resourceKey('coolify', 'app', PLATFORM_INTEGRATIONS_KEY)]) &&
950
+ recordedIds[PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY] === hostname &&
951
+ recordedIds[PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY] === gitRef;
952
+ if (applied)
953
+ return { step: null, blocked: null };
954
+ const undelivered = recordedIds[PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY];
955
+ if (undelivered && !recordedIds[PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY]) {
956
+ return {
957
+ step: null,
958
+ blocked: `the integrations host is blocked, not planned: its management key ${undelivered} was minted but not confirmed delivered and pinned, and the private half cannot be recovered; check the held key and the runner's MANAGEMENT_PUBLIC_KEYS, then remove the recorded key ${PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY} to mint a new one`,
959
+ };
960
+ }
961
+ if (executorBlocked)
962
+ return { step: null, blocked: executorBlocked };
963
+ return {
964
+ step: {
965
+ action: {
966
+ system: 'coolify',
967
+ kind: 'app',
968
+ key: PLATFORM_INTEGRATIONS_KEY,
969
+ desired: { hostname, gitRef },
970
+ },
971
+ step: {
972
+ description: `provision the integrations host at ${hostname}`,
973
+ destroys: false,
974
+ migrates: false,
975
+ },
976
+ },
977
+ blocked: null,
978
+ };
979
+ }
980
+ /**
981
+ * #171: the files host (https://github.com/wtfalch/files) -- its database, a
982
+ * one-shot `files-migrate` app and the `files` runner, all on Coolify's
983
+ * internal network with no public address, plus the files ZITADEL project and
984
+ * introspection client in the platform's own sign-in, and foundry's own
985
+ * management key. One `coolify:app` step keyed {@link PLATFORM_FILES_KEY},
986
+ * the same bundling {@link planAuth} does.
987
+ *
988
+ * Idempotent on the recorded runner app id plus the applied hostname and
989
+ * `gitRef`, for the reason {@link planAuth} gives: no platform reader reports
990
+ * these resources, so a live check would replan the step forever. A changed
991
+ * hostname or `gitRef` plans the step again (a redeploy, no new secrets).
992
+ *
993
+ * Blocked, not planned, while the platform's sign-in is neither recorded nor
994
+ * planned in this same plan: the step creates its ZITADEL project and client
995
+ * there, so it would throw on every apply.
996
+ */
997
+ function planFiles(platform, recordedIds, executorBlocked, authReady) {
998
+ if (!platform.files.enabled)
999
+ return { step: null, blocked: null };
1000
+ // A secret minted but never delivered and written is lost (the private key
1001
+ // is never kept; ZITADEL shows a client secret once). Minting another would
1002
+ // hide that, so report it and plan nothing.
1003
+ for (const [undeliveredKey, guardKey, what] of [
1004
+ [
1005
+ PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY,
1006
+ PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY,
1007
+ 'the files management key',
1008
+ ],
1009
+ [
1010
+ PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY,
1011
+ PLATFORM_FILES_INTROSPECTION_SCOPE_KEY,
1012
+ "the files host's introspection client secret",
1013
+ ],
1014
+ ]) {
1015
+ const undelivered = recordedIds[undeliveredKey];
1016
+ if (undelivered && !recordedIds[guardKey]) {
1017
+ return {
1018
+ step: null,
1019
+ blocked: `the files host is blocked, not planned: ${what} (${undelivered}) was minted but never delivered to the host and cannot be shown again; remove the recorded key ${undeliveredKey} (and delete the ZITADEL client for the introspection secret) to mint a new one`,
1020
+ };
1021
+ }
1022
+ }
1023
+ if (recordedIds[resourceKey('coolify', 'app', PLATFORM_FILES_KEY)] &&
1024
+ recordedIds[PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY] === platform.files.hostname &&
1025
+ recordedIds[PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY] === platform.files.gitRef) {
1026
+ return { step: null, blocked: null };
1027
+ }
1028
+ if (executorBlocked)
1029
+ return { step: null, blocked: executorBlocked };
1030
+ if (!authReady) {
1031
+ return {
1032
+ step: null,
1033
+ blocked: "the files host is blocked, not planned: its ZITADEL project and introspection client live in the platform's own sign-in, which is neither applied nor planned",
1034
+ };
1035
+ }
1036
+ return {
1037
+ step: {
1038
+ action: {
1039
+ system: 'coolify',
1040
+ kind: 'app',
1041
+ key: PLATFORM_FILES_KEY,
1042
+ desired: { hostname: platform.files.hostname, gitRef: platform.files.gitRef },
1043
+ },
1044
+ step: {
1045
+ description: "provision the files host on Coolify's internal network, with its ZITADEL project and foundry's management key",
1046
+ destroys: false,
1047
+ migrates: false,
1048
+ },
1049
+ },
1050
+ blocked: null,
1051
+ };
1052
+ }
1053
+ /**
1054
+ * #172: the platform's AI host (https://github.com/wtfalch/ai) -- a Coolify
1055
+ * Postgres plus three apps built from that repo's root Dockerfile, one per
1056
+ * build target (`migration`, `runner`, `worker`), and foundry's own AI
1057
+ * management key. One `coolify:app` step keyed {@link PLATFORM_AI_KEY}, the
1058
+ * same bundling {@link planAuth} uses.
1059
+ *
1060
+ * Idempotent on the recorded resource id, like {@link planAuth} and for the
1061
+ * same reason: no reader reports these resources. The executor returns that
1062
+ * id only after the management key was delivered, so the step keeps being
1063
+ * planned until it has been.
1064
+ *
1065
+ * `desired.hostname` is `platform.ai.hostname` as declared. Whether ai gets a
1066
+ * public route there (and Access in front) or stays on the Coolify network
1067
+ * only is an open decision (#172 "Decide"); nothing here wires a tunnel, DNS
1068
+ * record or Access app, so either choice can be added later on top.
1069
+ */
1070
+ function planAi(platform, recordedIds, executorBlocked) {
1071
+ if (!platform.ai.enabled)
1072
+ return { step: null, blocked: null };
1073
+ const key = resourceKey('coolify', 'app', PLATFORM_AI_KEY);
1074
+ // A finished apply recorded the resource id (only once the key was
1075
+ // delivered). It is planned again only when the declared hostname or gitRef
1076
+ // is not the one that apply finished with; the management key's guard is
1077
+ // already set then, so nothing is minted again.
1078
+ const applied = Boolean(recordedIds[key]);
1079
+ const drifted = recordedIds[PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY] !== platform.ai.hostname ||
1080
+ recordedIds[PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY] !== platform.ai.gitRef;
1081
+ if (applied && !drifted)
1082
+ return { step: null, blocked: null };
1083
+ // #201: the signing pair foundry mints to authenticate its own calls to
1084
+ // ai's platform/keys route. Minted but not confirmed delivered and pinned:
1085
+ // the private half cannot be recovered, so report it rather than mint
1086
+ // another (the same reason the management key check below never mints
1087
+ // twice).
1088
+ const signingUndelivered = recordedIds[PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY];
1089
+ if (signingUndelivered && !recordedIds[PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY]) {
1090
+ return {
1091
+ step: null,
1092
+ blocked: `the platform's AI host is blocked, not planned: its signing key ${signingUndelivered} was minted but not confirmed delivered and pinned, and the private half cannot be recovered; check the held key and the runner's MANAGEMENT_PUBLIC_KEYS, then remove the recorded key ${PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY} to mint a new one`,
1093
+ };
1094
+ }
1095
+ // Minted but never delivered: ai shows a key's secret once, so it is lost.
1096
+ // Minting another would hide that; report it and plan nothing.
1097
+ const undelivered = recordedIds[PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY];
1098
+ if (undelivered && !recordedIds[PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY]) {
1099
+ return {
1100
+ step: null,
1101
+ blocked: `the platform's AI host is blocked, not planned: AI management key ${undelivered} was minted but never delivered and cannot be shown again; revoke it in ai (keys:revoke --id ${undelivered}) and remove the recorded key ${PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY} to mint a new one`,
1102
+ };
1103
+ }
1104
+ // #201: ai reported its 'archon' key already exists with no secret to
1105
+ // show -- a mint this record never learned about. Never minted under
1106
+ // another name; a person must revoke the live key first.
1107
+ const conflict = recordedIds[PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY];
1108
+ if (conflict) {
1109
+ return {
1110
+ step: null,
1111
+ blocked: `the platform's AI host is blocked, not planned: ai already holds a management key named 'archon' (${conflict}) that was never recorded delivered, so its secret cannot be shown again; revoke it in ai and remove the recorded key ${PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY} to mint a new one`,
1112
+ };
1113
+ }
1114
+ const migrateBlocked = recordedIds[PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY];
1115
+ if (migrateBlocked) {
1116
+ return {
1117
+ step: null,
1118
+ blocked: `the platform's AI host is blocked, not planned: the migrate job did not turn healthy (${migrateBlocked}); fix it from its logs in Coolify, then remove the recorded key ${PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY}`,
1119
+ };
1120
+ }
1121
+ if (executorBlocked)
1122
+ return { step: null, blocked: executorBlocked };
1123
+ return {
1124
+ step: {
1125
+ action: {
1126
+ system: 'coolify',
1127
+ kind: 'app',
1128
+ key: PLATFORM_AI_KEY,
1129
+ desired: { hostname: platform.ai.hostname, gitRef: platform.ai.gitRef },
1130
+ },
1131
+ step: {
1132
+ description: applied
1133
+ ? `apply the platform's AI host at ${platform.ai.hostname} on ${platform.ai.gitRef} (migrate job, host and worker)`
1134
+ : `provision the platform's AI host for ${platform.ai.hostname} (migrate job, host and worker) and mint foundry's AI management key`,
1135
+ destroys: false,
1136
+ migrates: false,
1137
+ },
1138
+ },
1139
+ blocked: null,
1140
+ };
1141
+ }
1142
+ /**
1143
+ * #175: Archon (https://github.com/wtfalch/archon), the one shared console,
1144
+ * built fresh at `platform.archon.hostname` (`archon.<apex>`) rather than the
1145
+ * hand-deployed `operator.wtfalch.dev` it replaces (#165). One step,
1146
+ * `coolify:app` keyed {@link PLATFORM_ARCHON_KEY}, covers the app, its
1147
+ * database, its Cloudflare tunnel, Access app and DNS record, and its sign-in
1148
+ * client in the platform's own ZITADEL -- the same bundling {@link planAuth}
1149
+ * does, for the same reason.
1150
+ *
1151
+ * Idempotent on the recorded resource id plus the applied hostname and
1152
+ * `gitRef`, for the reason {@link planAuth} gives: no platform reader reports
1153
+ * these resources, so a live check would replan the step forever. A changed
1154
+ * hostname or `gitRef` plans the step again, and so does `drifted` (the Access
1155
+ * policy's include list is not the declared one). A re-run mints nothing.
1156
+ *
1157
+ * Blocked, not planned, while the platform's sign-in is neither recorded nor
1158
+ * planned in this same plan: Archon's sign-in client is created inside it, so
1159
+ * the step could only throw.
1160
+ *
1161
+ * #213: also puts {@link PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF},
1162
+ * {@link PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF} and
1163
+ * {@link PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF} as held keys, before
1164
+ * the app deploys -- `putArchonMasterKeys` in `platform-providers.ts`.
1165
+ *
1166
+ * #197: a recorded app with no recorded `cloudflared` connector
1167
+ * ({@link PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY}) plans the step again, as
1168
+ * {@link planAuth} does.
1169
+ */
1170
+ function planArchon(platform, recordedIds, executorBlocked, authReady, drifted) {
1171
+ if (!platform.archon.enabled)
1172
+ return { step: null, blocked: null };
1173
+ const key = resourceKey('coolify', 'app', PLATFORM_ARCHON_KEY);
1174
+ const applied = Boolean(recordedIds[key]) &&
1175
+ Boolean(recordedIds[PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY]) &&
1176
+ recordedIds[PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY] === platform.archon.hostname &&
1177
+ recordedIds[PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY] === platform.archon.gitRef &&
1178
+ // #211 round 4: a broker recreated under a new app id (the old one
1179
+ // deleted by hand, or `platform:auth` minting a fresh one) leaves
1180
+ // Archon's last registration on a broker that no longer exists, and
1181
+ // nothing else here would ever plan the step again to redo it.
1182
+ Boolean(recordedIds[PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY]) &&
1183
+ recordedIds[PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY] ===
1184
+ recordedIds[PLATFORM_AUTH_BROKER_SCOPE_KEY] &&
1185
+ !recordedIds[PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY];
1186
+ if (applied && !drifted)
1187
+ return { step: null, blocked: null };
1188
+ if (!platform.auth.enabled) {
1189
+ return {
1190
+ step: null,
1191
+ blocked: "Archon is blocked, not planned: its sign-in client lives in the platform's own sign-in, and auth is not enabled",
1192
+ };
1193
+ }
1194
+ if (!authReady) {
1195
+ return {
1196
+ step: null,
1197
+ blocked: "Archon is blocked, not planned: its sign-in client lives in the platform's own sign-in, which is neither applied nor planned",
1198
+ };
1199
+ }
1200
+ if (executorBlocked)
1201
+ return { step: null, blocked: executorBlocked };
1202
+ return {
1203
+ step: {
1204
+ action: {
1205
+ system: 'coolify',
1206
+ kind: 'app',
1207
+ key: PLATFORM_ARCHON_KEY,
1208
+ desired: { hostname: platform.archon.hostname, gitRef: platform.archon.gitRef },
1209
+ },
1210
+ step: {
1211
+ description: `provision Archon at ${platform.archon.hostname}, putting its master keys ` +
1212
+ `(${PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF}, ${PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF}, ` +
1213
+ `${PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF}) as held keys if they are not held yet`,
1214
+ destroys: false,
1215
+ migrates: false,
1216
+ },
1217
+ },
1218
+ blocked: null,
1219
+ };
1220
+ }