@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,4410 @@
1
+ import { createHash, createPrivateKey, generateKeyPairSync, randomBytes, randomUUID, sign, } from 'node:crypto';
2
+ import { isIPv4 } from 'node:net';
3
+ import { redactSecrets } from '../calls-sfu-deploy-support.js';
4
+ import { ensureCloudflaredConnector } from './cloudflared-connector.js';
5
+ import { ACCESS_POLICY_NAME, isNotFoundError, isRunningOrStarting } from './executor.js';
6
+ import { JOBS_SCHEDULER_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, parseBrokerDeployRecord, parseLoginClientRecord, platformMailMxHost, } from './platform-diff.js';
7
+ import { applyCallsSfuPlatformStep } from './platform-sfu.js';
8
+ /** The key `runPlatformSteps` records the apex zone's id under (`system:kind:key`). */
9
+ function apexZoneKey(apex) {
10
+ return `cloudflare:zone:${apex}`;
11
+ }
12
+ /** The key `runPlatformSteps` records the jobs scheduler app's id under (`system:kind:key`, #173). */
13
+ const JOBS_APP_KEY = `coolify:jobs:${JOBS_SCHEDULER_KEY}`;
14
+ /**
15
+ * #173: the jobs scheduler's own Coolify project and database, flat scope
16
+ * ids recorded via `PlatformStepExecutionContext.recordScopeId` -- not
17
+ * `system:kind:key` (that form is reserved for the one resource
18
+ * `runPlatformSteps` auto-records from a step's own `resourceId`, the app
19
+ * itself), the same distinction `executor.ts`'s `coolify_project`/
20
+ * `CALLS_SERVICE_DB_SCOPE_KEY` draw for an org's own steps.
21
+ */
22
+ const JOBS_PROJECT_SCOPE_KEY = 'platform_coolify_project';
23
+ const JOBS_DATABASE_SCOPE_KEY = 'coolify_jobs_database';
24
+ /**
25
+ * Set (to the app's uuid) once the scheduler's `SCHEDULER_ADMIN_TOKEN` has
26
+ * been delivered to the host and written to the app. The token is only
27
+ * generated while this is unset, so a rerun of a step that failed earlier
28
+ * never rotates a token the host already holds.
29
+ */
30
+ const JOBS_ADMIN_TOKEN_SCOPE_KEY = 'jobs_admin_token_set';
31
+ /** How many times `coolifyJobs` tries `deliverSecret` before giving up. */
32
+ const JOBS_ADMIN_TOKEN_DELIVERY_ATTEMPTS = 3;
33
+ /** The ref the scheduler's admin token is delivered under: the host's real `JobsSchedulerPort` adapter reads it back. */
34
+ export const JOBS_ADMIN_TOKEN_REF = 'jobs:scheduler:adminToken';
35
+ /** The name of the Coolify project every platform-level service (starting with jobs, #173) lives in. */
36
+ const PLATFORM_COOLIFY_PROJECT_NAME = 'platform';
37
+ /** https://github.com/wtfalch/jobs -- the scheduler's own repo, Dockerfile at its root (its own `Dockerfile`'s doc comment). */
38
+ const JOBS_SCHEDULER_REPO = 'wtfalch/jobs';
39
+ /**
40
+ * The real {@link PlatformStepExecutor} for the two steps built so far:
41
+ * `cloudflare:zone` for the platform's apex (#167) and `coolify:jobs` for the
42
+ * jobs scheduler (#173). `coolify` is optional so every #167-era caller --
43
+ * anything that only ever plans the apex zone -- keeps constructing this with
44
+ * two arguments; a `coolify:jobs` step reaching this executor with no
45
+ * `coolify` port configured throws, naming what is missing, rather than
46
+ * silently doing nothing.
47
+ *
48
+ * `cloudflareZone` mirrors `executor.ts`'s `cloudflareZone`: find the zone by
49
+ * apex and adopt it, create it only when absent. companyincluded.com already
50
+ * exists on Cloudflare, empty, so the first apply adopts it. The owner org
51
+ * (Company Included, whose own domain is the apex) may record the same zone
52
+ * under its own applied state, so that owner is allowed. Any other org that
53
+ * recorded it makes this refuse to adopt. There is no delete here and
54
+ * `PlatformStepExecutor` has none: neither path ever removes the zone.
55
+ *
56
+ * `coolifyJobs` mirrors `executor.ts`'s `coolifyApp`/`coolifyDatabase`,
57
+ * simplified for one platform-level singleton rather than one per org: find
58
+ * or create a dedicated `platform` Coolify project, find or create the
59
+ * scheduler's own database, find or create the scheduler app itself (from
60
+ * https://github.com/wtfalch/jobs, one Dockerfile at its repo root, on the
61
+ * declared `gitRef`), then push the database's `internal_db_url` in as
62
+ * `DATABASE_URL`. There is no park branch yet, matching `planJobs`'s own
63
+ * scope note -- `platform.jobs.enabled` going from true to false plans
64
+ * nothing, so this method is never asked to stop or delete anything.
65
+ *
66
+ * #170 adds `sfu:livekit`, the platform's shared calls SFU. `sfu` is
67
+ * optional: an existing host that constructs this executor for the zone step
68
+ * alone keeps compiling, and a plan that includes an `sfu:livekit` step
69
+ * without it configured refuses clearly rather than silently doing nothing.
70
+ * #152: the step mints one LiveKit key per org with calls enabled rather
71
+ * than one shared pair, so it also needs `declaredOrgStore.list()` -- the
72
+ * same store this class already takes for `findOrgIdForZone`.
73
+ *
74
+ * #168 adds `coolify:app` keyed {@link PLATFORM_AUTH_KEY}, the platform's own
75
+ * sign-in ({@link provisionPlatformAuth}), once a host passes
76
+ * {@link PlatformAuthDeps}.
77
+ *
78
+ * #169 adds `coolify:app` keyed {@link PLATFORM_MAIL_KEY}, the platform's own
79
+ * mail server ({@link provisionPlatformMail}), once a host passes
80
+ * {@link PlatformMailDeps}.
81
+ *
82
+ * #174 adds `coolify:app` keyed {@link PLATFORM_INTEGRATIONS_KEY},
83
+ * the integrations host ({@link provisionPlatformIntegrations}), once a host
84
+ * passes {@link PlatformIntegrationsDeps}.
85
+ *
86
+ * #171 adds `coolify:app` keyed {@link PLATFORM_FILES_KEY}, the files host
87
+ * ({@link provisionPlatformFiles}), once a host passes {@link PlatformFilesDeps}.
88
+ *
89
+ * #172 adds `coolify:app` keyed {@link PLATFORM_AI_KEY}, the platform's AI
90
+ * host ({@link provisionPlatformAi}), once a host passes {@link PlatformAiDeps}.
91
+ *
92
+ * #175 adds `coolify:app` keyed {@link PLATFORM_ARCHON_KEY}, Archon itself
93
+ * ({@link provisionPlatformArchon}), once a host passes
94
+ * {@link PlatformArchonDeps}.
95
+ */
96
+ export class FoundryPlatformStepExecutor {
97
+ cloudflare;
98
+ declaredOrgStore;
99
+ coolify;
100
+ deliverSecret;
101
+ sfu;
102
+ auth;
103
+ mail;
104
+ integrations;
105
+ files;
106
+ ai;
107
+ archon;
108
+ constructor(cloudflare, declaredOrgStore, options = {}) {
109
+ this.cloudflare = cloudflare;
110
+ this.declaredOrgStore = declaredOrgStore;
111
+ this.coolify = options.coolify;
112
+ this.deliverSecret = options.deliverSecret;
113
+ this.sfu = options.sfu;
114
+ this.auth = options.auth;
115
+ this.mail = options.mail;
116
+ this.integrations = options.integrations;
117
+ this.files = options.files;
118
+ this.ai = options.ai;
119
+ this.archon = options.archon;
120
+ }
121
+ async blockedSteps(platform, recordedIds = {}) {
122
+ const blocked = {};
123
+ if (platform.auth.enabled && !this.auth) {
124
+ blocked.auth =
125
+ "the platform's sign-in is blocked, not planned: the host built the platform executor without its auth dependencies";
126
+ // #211 round 6: its own signal, distinct from `auth` -- `planAuth` must
127
+ // withhold the step for this case whatever the recorded broker state
128
+ // says, unlike "broker created, deploy still blocked" (below).
129
+ blocked.authExecutorMissing = true;
130
+ }
131
+ else if (platform.auth.enabled && this.auth) {
132
+ const { reason: authBlocked, otherInputsMissing } = await authBrokerBlockedReason(this.auth, recordedIds);
133
+ if (authBlocked)
134
+ blocked.auth = authBlocked;
135
+ // #216 round 5: see `PlanPlatformOptions.blocked.authOtherInputsMissing`.
136
+ if (otherInputsMissing.length > 0)
137
+ blocked.authOtherInputsMissing = otherInputsMissing;
138
+ }
139
+ if (platform.mail.enabled) {
140
+ const mailBlocked = await mailBlockedReason(platform, this.mail, recordedIds);
141
+ if (mailBlocked)
142
+ blocked.mail = mailBlocked;
143
+ }
144
+ if (platform.integrations.enabled && !this.integrations) {
145
+ blocked.integrations =
146
+ 'the integrations host is blocked, not planned: the host built the platform executor without its integrations dependencies';
147
+ }
148
+ else if (platform.integrations.enabled && this.integrations) {
149
+ const missing = INTEGRATIONS_KEYS_ENV_NAMES.filter((n) => !this.integrations?.keysEnv?.[n]);
150
+ if (missing.length > 0) {
151
+ blocked.integrations = `the integrations host is blocked, not planned: the host supplies no value for ${missing.join(', ')}, the host's own @wtfalch/keys settings`;
152
+ }
153
+ }
154
+ if (platform.files.enabled && !this.files) {
155
+ blocked.files =
156
+ 'the files host is blocked, not planned: the host built the platform executor without its files dependencies';
157
+ }
158
+ else if (platform.files.enabled && !filesKeys(this.files)) {
159
+ blocked.files =
160
+ 'the files host is blocked, not planned: the host supplies no keys worker URL and credential (KEYS_WORKER_URL, KEYS_CREDENTIAL), so files-migrate would never become healthy';
161
+ }
162
+ if (platform.ai.enabled && !this.ai) {
163
+ blocked.ai =
164
+ "the platform's AI host is blocked, not planned: the host built the platform executor without its ai dependencies";
165
+ }
166
+ else if (platform.ai.enabled &&
167
+ !this.ai?.managementKeyMinter &&
168
+ !this.ai?.heldKeys &&
169
+ recordedIds[PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY] &&
170
+ !recordedIds[PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY]) {
171
+ // The signing pair was pinned on an earlier apply; minting the
172
+ // management key needs its private half read back, and no minter was
173
+ // injected to skip that (#201).
174
+ blocked.ai =
175
+ "the platform's AI host is blocked, not planned: its signing key is already pinned but the host built the platform executor without a held-key reader to read back its private half (PlatformAiDeps.heldKeys)";
176
+ }
177
+ if (platform.archon.enabled) {
178
+ if (!this.archon) {
179
+ blocked.archon =
180
+ 'Archon is blocked, not planned: the host built the platform executor without its archon dependencies';
181
+ }
182
+ else if (this.archon.accessEmails.length === 0) {
183
+ blocked.archon =
184
+ 'Archon is blocked, not planned: the host names no email the Cloudflare Access app in front of it may let through';
185
+ }
186
+ else {
187
+ const inputs = await collectArchonServiceInputs(platform, this.archon, recordedIds);
188
+ if ('blocked' in inputs) {
189
+ blocked.archon = inputs.blocked;
190
+ }
191
+ else if (!this.archon.masterKeys || !this.archon.heldKeys?.put) {
192
+ // #213: Archon's own onboarding reads these back as held keys
193
+ // before it can start; a host that cannot put them blocks Archon,
194
+ // the same rule a missing service ref follows above.
195
+ blocked.archon =
196
+ 'Archon is blocked, not planned: the host built the platform executor without a way to put its master keys (masterKeys, and heldKeys with a put)';
197
+ }
198
+ }
199
+ }
200
+ return blocked;
201
+ }
202
+ /**
203
+ * Whether Archon's live Access policy lets through anyone but the declared
204
+ * emails, or (#211 round 4) the broker it last registered with no longer
205
+ * carries its entry; or (#216 round 4) the platform's recorded
206
+ * login-client token is no longer the sole live PAT on its ZITADEL machine
207
+ * user.
208
+ */
209
+ async driftedSteps(platform, recordedIds = {}) {
210
+ const result = {};
211
+ if (platform.archon.enabled &&
212
+ this.archon &&
213
+ recordedIds[`coolify:app:${PLATFORM_ARCHON_KEY}`]) {
214
+ const accessDrifted = await archonAccessDrifted(this.archon.cloudflare, platform.archon.hostname, this.archon.accessEmails);
215
+ const brokerDrifted = !accessDrifted && (await archonBrokerRegistrationDrifted(this.archon.coolify, recordedIds));
216
+ if (accessDrifted || brokerDrifted)
217
+ result.archon = true;
218
+ }
219
+ if (platform.auth.enabled && this.auth) {
220
+ const loginClientDrifted = await authLoginClientDrifted(platform, this.auth, recordedIds);
221
+ if (loginClientDrifted)
222
+ result.auth = true;
223
+ }
224
+ return result;
225
+ }
226
+ async execute(action, context) {
227
+ if (action.system === 'coolify' && action.kind === 'app' && action.key === PLATFORM_AUTH_KEY) {
228
+ if (!this.auth) {
229
+ throw new Error('platform:auth needs FoundryPlatformStepExecutor to be constructed with its auth dependencies (#168)');
230
+ }
231
+ return provisionPlatformAuth(context, this.auth);
232
+ }
233
+ if (action.system === 'coolify' && action.kind === 'app' && action.key === PLATFORM_FILES_KEY) {
234
+ if (!this.files) {
235
+ throw new Error('platform:files needs FoundryPlatformStepExecutor to be constructed with its files dependencies (#171)');
236
+ }
237
+ return provisionPlatformFiles(context, this.files);
238
+ }
239
+ if (action.system === 'coolify' &&
240
+ action.kind === 'app' &&
241
+ action.key === PLATFORM_INTEGRATIONS_KEY) {
242
+ if (!this.integrations) {
243
+ throw new Error('platform:integrations needs FoundryPlatformStepExecutor to be constructed with its integrations dependencies (#174)');
244
+ }
245
+ return provisionPlatformIntegrations(action, context, this.integrations);
246
+ }
247
+ if (action.system === 'coolify' && action.kind === 'app' && action.key === PLATFORM_AI_KEY) {
248
+ if (!this.ai) {
249
+ throw new Error('platform:ai needs FoundryPlatformStepExecutor to be constructed with its ai dependencies (#172)');
250
+ }
251
+ return provisionPlatformAi(context, this.ai);
252
+ }
253
+ if (action.system === 'coolify' &&
254
+ action.kind === 'app' &&
255
+ action.key === PLATFORM_ARCHON_KEY) {
256
+ if (!this.archon) {
257
+ throw new Error('platform:archon needs FoundryPlatformStepExecutor to be constructed with its archon dependencies (#175)');
258
+ }
259
+ return provisionPlatformArchon(context, this.archon);
260
+ }
261
+ if (action.system === 'coolify' && action.kind === 'app' && action.key === PLATFORM_MAIL_KEY) {
262
+ if (!this.mail) {
263
+ throw new Error('platform:mail needs FoundryPlatformStepExecutor to be constructed with its mail dependencies (#169)');
264
+ }
265
+ return provisionPlatformMail(context, this.mail);
266
+ }
267
+ if (action.system === 'cloudflare' && action.kind === 'zone') {
268
+ return this.cloudflareZone(action, context);
269
+ }
270
+ if (action.system === 'coolify' && action.kind === 'jobs') {
271
+ return this.coolifyJobs(action, context);
272
+ }
273
+ if (action.system === 'sfu' && action.kind === 'livekit') {
274
+ return this.executeSfu();
275
+ }
276
+ throw new Error(`platform step ${action.system}:${action.kind} is not built yet`);
277
+ }
278
+ async cloudflareZone(action, context) {
279
+ const domain = action.key;
280
+ const existing = await exactZone(this.cloudflare, domain);
281
+ if (existing) {
282
+ const owner = await this.declaredOrgStore.findOrgIdForZone(existing.id);
283
+ if (owner !== null && owner !== context.platform.ownerOrgId) {
284
+ throw new Error(`cloudflare:zone for the platform: zone ${existing.id} (${domain}) is already recorded under org ${owner}; foundry does not adopt it`);
285
+ }
286
+ return { resourceId: existing.id };
287
+ }
288
+ const created = await this.cloudflare.createZone(domain);
289
+ return { resourceId: created.id };
290
+ }
291
+ /**
292
+ * #152: the platform `sfu` step owns the shared LiveKit config, so it
293
+ * lists every org with calls enabled itself -- `DeclaredOrgStore.list()`,
294
+ * filtered the same way an org's own `calls.enabled` flag gates its
295
+ * `coolify:calls-service` step (`executor.ts`'s `coolifyCallsService`) --
296
+ * rather than taking the list as an argument nothing else would supply.
297
+ * Sorted by slug so a re-run with the same set of enabled orgs is a no-op
298
+ * even before `applyCallsSfuPlatformStep`'s own sort (its doc comment).
299
+ */
300
+ async executeSfu() {
301
+ if (!this.sfu) {
302
+ throw new Error('platform step sfu:livekit is not built yet (#170: pass `sfu` to FoundryPlatformStepExecutor)');
303
+ }
304
+ const orgs = await this.declaredOrgStore.list();
305
+ const orgSlugs = orgs
306
+ .filter((org) => org.calls.enabled)
307
+ .map((org) => org.slug)
308
+ .sort();
309
+ await applyCallsSfuPlatformStep(this.sfu, orgSlugs);
310
+ return undefined;
311
+ }
312
+ async coolifyJobs(action, context) {
313
+ const coolify = this.coolify;
314
+ if (!coolify) {
315
+ throw new Error('platform step coolify:jobs needs a Coolify port -- construct FoundryPlatformStepExecutor with one (#173)');
316
+ }
317
+ const deliverSecret = this.deliverSecret;
318
+ if (!deliverSecret) {
319
+ throw new Error('platform step coolify:jobs needs a deliverSecret -- construct FoundryPlatformStepExecutor with one, to hand the scheduler admin token to the host (#173)');
320
+ }
321
+ const hostname = action.desired.hostname;
322
+ const gitRef = action.desired.gitRef;
323
+ const recorded = context.recordedIds();
324
+ let projectUuid = recorded[JOBS_PROJECT_SCOPE_KEY];
325
+ if (!projectUuid) {
326
+ const found = await coolify.findProjectByName(PLATFORM_COOLIFY_PROJECT_NAME);
327
+ projectUuid = found
328
+ ? found.uuid
329
+ : (await coolify.createProject(PLATFORM_COOLIFY_PROJECT_NAME)).uuid;
330
+ context.recordScopeId(JOBS_PROJECT_SCOPE_KEY, projectUuid);
331
+ }
332
+ const { environment } = await coolify.findOrCreateEnvironment(projectUuid, 'production');
333
+ let dbId = recorded[JOBS_DATABASE_SCOPE_KEY];
334
+ if (!dbId) {
335
+ const dbName = `${JOBS_SCHEDULER_KEY}-scheduler-db`;
336
+ const found = (await coolify.listDatabases()).find((db) => db.name === dbName && db.environment_id === environment.id);
337
+ if (found) {
338
+ dbId = found.uuid;
339
+ }
340
+ else {
341
+ const server = await resolvePlatformServer(coolify);
342
+ const created = await coolify.createPostgresDatabase({
343
+ project_uuid: projectUuid,
344
+ server_uuid: server.uuid,
345
+ environment_name: environment.name,
346
+ environment_uuid: environment.uuid,
347
+ name: dbName,
348
+ postgres_db: 'jobs',
349
+ postgres_user: 'jobs',
350
+ });
351
+ dbId = created.uuid;
352
+ }
353
+ context.recordScopeId(JOBS_DATABASE_SCOPE_KEY, dbId);
354
+ }
355
+ const existingApp = await coolify.findApplicationByName(JOBS_SCHEDULER_KEY, {
356
+ environmentIds: [environment.id],
357
+ });
358
+ let appId;
359
+ if (existingApp) {
360
+ appId = existingApp.uuid;
361
+ }
362
+ else {
363
+ const server = await resolvePlatformServer(coolify);
364
+ const githubApp = await coolify.requireGithubApp();
365
+ const created = await coolify.createGithubAppApplication({
366
+ project_uuid: projectUuid,
367
+ server_uuid: server.uuid,
368
+ environment_name: environment.name,
369
+ environment_uuid: environment.uuid,
370
+ github_app_uuid: githubApp.uuid,
371
+ git_repository: JOBS_SCHEDULER_REPO,
372
+ git_branch: gitRef,
373
+ build_pack: 'dockerfile',
374
+ name: JOBS_SCHEDULER_KEY,
375
+ domains: hostname,
376
+ });
377
+ appId = created.uuid;
378
+ }
379
+ const db = await coolify.getDatabase(dbId);
380
+ if (!db.internal_db_url) {
381
+ throw new Error(`platform step coolify:jobs: database ${dbId} has no internal_db_url yet; not deploying the scheduler without a database`);
382
+ }
383
+ const envs = [{ key: 'DATABASE_URL', value: db.internal_db_url }];
384
+ // SCHEDULER_ADMIN_TOKEN gates the admin API the org registrations use
385
+ // (jobs README, "The admin API"); unset, every admin route answers 401.
386
+ // Delivered to the host before it is written to the app, so a failed
387
+ // delivery leaves nothing set and the rerun simply generates a new one.
388
+ let tokenJustSet = false;
389
+ if (!context.recordedIds()[JOBS_ADMIN_TOKEN_SCOPE_KEY]) {
390
+ const token = randomBytes(32).toString('hex');
391
+ let delivered = false;
392
+ for (let attempt = 1; attempt <= JOBS_ADMIN_TOKEN_DELIVERY_ATTEMPTS && !delivered; attempt++) {
393
+ try {
394
+ await deliverSecret(JOBS_ADMIN_TOKEN_REF, token);
395
+ delivered = true;
396
+ }
397
+ catch {
398
+ // Retried; the error is dropped in case it echoes the token.
399
+ }
400
+ }
401
+ if (!delivered) {
402
+ throw new Error(`platform step coolify:jobs: the scheduler admin token could not be delivered after ${JOBS_ADMIN_TOKEN_DELIVERY_ATTEMPTS} attempts`);
403
+ }
404
+ envs.push({ key: 'SCHEDULER_ADMIN_TOKEN', value: token });
405
+ await coolify.bulkUpsertEnvs(appId, envs);
406
+ context.recordScopeId(JOBS_ADMIN_TOKEN_SCOPE_KEY, appId);
407
+ tokenJustSet = true;
408
+ }
409
+ else {
410
+ await coolify.bulkUpsertEnvs(appId, envs);
411
+ }
412
+ // The app is created with `instant_deploy: false` and an env change needs
413
+ // a redeploy, so deploy after a create or a new token. Also when it is not
414
+ // running, so a deploy that failed on an earlier run is retried.
415
+ const running = (await coolify.getApplication(appId)).status?.startsWith('running') ?? false;
416
+ if (!existingApp || tokenJustSet || !running)
417
+ await coolify.deploy(appId);
418
+ return { resourceId: appId };
419
+ }
420
+ }
421
+ // ---------------------------------------------------------------------------
422
+ // #168: the platform's own sign-in (ZITADEL)
423
+ // ---------------------------------------------------------------------------
424
+ const PLATFORM_AUTH_PROJECT = 'platform';
425
+ const PLATFORM_AUTH_DB_NAME = 'platform-auth-db';
426
+ const PLATFORM_AUTH_ZITADEL_APP_NAME = 'platform-auth-zitadel';
427
+ const PLATFORM_AUTH_LOGIN_APP_NAME = 'platform-auth-login';
428
+ const PLATFORM_AUTH_BROKER_APP_NAME = 'platform-auth-broker';
429
+ /**
430
+ * Fixed `custom_internal_name`s (`diff.ts`'s `coolifyCalls` sets the same
431
+ * kind of name on Synapse/MAS) so the Cloudflare tunnel below can address
432
+ * each app directly, by container name, on the shared Coolify Docker network
433
+ * -- bypassing Coolify's own Traefik, which dispatches purely by `Host`
434
+ * header and so cannot give two different apps the same public hostname.
435
+ */
436
+ const PLATFORM_AUTH_ZITADEL_INTERNAL_NAME = 'platform-auth-zitadel';
437
+ const PLATFORM_AUTH_LOGIN_INTERNAL_NAME = 'platform-auth-login';
438
+ const PLATFORM_AUTH_BROKER_INTERNAL_NAME = 'platform-auth-broker';
439
+ const PLATFORM_AUTH_TUNNEL_NAME = 'platform-auth-tunnel';
440
+ /** #197: the one Coolify app that runs `cloudflared` for {@link PLATFORM_AUTH_TUNNEL_NAME}. */
441
+ const PLATFORM_AUTH_CLOUDFLARED_APP_NAME = 'platform-auth-cloudflared';
442
+ /** `wtfalch/auth`'s `docker-compose.yml`: `image: ghcr.io/zitadel/zitadel:${ZITADEL_VERSION:-v4.17.3}`. */
443
+ const AUTH_ZITADEL_IMAGE = 'ghcr.io/zitadel/zitadel';
444
+ const AUTH_ZITADEL_IMAGE_TAG = 'v4.17.3';
445
+ /** `wtfalch/auth`'s own `login/Dockerfile` -- `docs/deploy.md`'s "One compose service, four containers... Two images are built from this repository: the proxy... and the login". */
446
+ const AUTH_REPO = 'wtfalch/auth';
447
+ const AUTH_LOGIN_DOCKERFILE = 'login/Dockerfile';
448
+ /** `wtfalch/auth`'s own `broker/Dockerfile` -- built from the repo root so the workspace resolves (#204). */
449
+ const AUTH_BROKER_DOCKERFILE = 'broker/Dockerfile';
450
+ /**
451
+ * `wtfalch/auth`'s login app answers on 3000 (its `Dockerfile`'s Next.js
452
+ * default); ZITADEL answers on 8080 -- both fixed by `docker-compose.yml`'s
453
+ * own `ZITADEL_PORT: 8080` and the login image's own `EXPOSE`.
454
+ */
455
+ const AUTH_ZITADEL_PORT = '8080';
456
+ const AUTH_LOGIN_PORT = '3000';
457
+ /** `broker/Dockerfile`'s own `ENV PORT=3000` / `EXPOSE 3000`. */
458
+ const AUTH_BROKER_PORT = '3000';
459
+ /**
460
+ * Every ZITADEL OIDC/management/console/asset path -- `deploy/proxy/Caddyfile`'s
461
+ * own explicit list, restated here as one regex since the tunnel routes by a
462
+ * single `path` per ingress rule rather than a set. Anything not matching
463
+ * falls through to the login app's own ingress rule below (login v2 answers
464
+ * everything else, including the root).
465
+ */
466
+ const AUTH_ZITADEL_PATH_PATTERN = '^/(oidc|oauth|saml|management|admin|system|auth|assets|ui/console|\\.well-known)(/|$)';
467
+ /**
468
+ * `@wtfalch/auth`'s own `brokerUrl` default, `<issuer>/api`
469
+ * (`packages/auth/src/config.ts`) -- `deploy/proxy/Caddyfile`'s own
470
+ * `handle /api/*`. Checked before {@link AUTH_ZITADEL_PATH_PATTERN}'s
471
+ * catch-all-else login rule below, so it must be its own ingress rule rather
472
+ * than folded into it.
473
+ */
474
+ const AUTH_BROKER_PATH_PATTERN = '^/api(/|$)';
475
+ const PLATFORM_AUTH_PROJECT_SCOPE_KEY = 'coolify_project_platform_auth';
476
+ const PLATFORM_AUTH_DB_SCOPE_KEY = 'coolify_platform_auth_database';
477
+ const PLATFORM_AUTH_ZITADEL_SCOPE_KEY = 'coolify_platform_auth_zitadel_app';
478
+ const PLATFORM_AUTH_LOGIN_SCOPE_KEY = 'coolify_platform_auth_login_app';
479
+ const PLATFORM_AUTH_TUNNEL_SCOPE_KEY = 'cloudflare_platform_auth_tunnel';
480
+ /** Recorded once the ZITADEL app has been deployed, so ZITADEL may have written to its database. */
481
+ const PLATFORM_AUTH_ZITADEL_DEPLOYED_SCOPE_KEY = 'zitadel_platform_auth_deployed';
482
+ const PLATFORM_AUTH_ADMIN_USER_NAME = 'foundry-admin';
483
+ /** #216 (building on #209/#215): the narrower `IAM_LOGIN_CLIENT` machine user for the login app and the broker. */
484
+ const PLATFORM_AUTH_LOGIN_CLIENT_USER_NAME = 'foundry-login-client';
485
+ const PLATFORM_AUTH_ORG_NAME = 'foundry';
486
+ /**
487
+ * Retries `action` up to `attempts` times, sleeping increasingly between
488
+ * tries -- the same shape the admin machine user's own delivery retry (just
489
+ * below) already uses inline, pulled out here because #216's login-client
490
+ * block needs it four times over (both apps' envs, both apps' deploys)
491
+ * rather than once. Rethrows the *last* failure unwrapped: callers that must
492
+ * never echo the underlying error (a Coolify validation error can include
493
+ * the rejected value -- here, the token itself, #216 follow-up review item
494
+ * 1) catch this function's own rejection and build a safe message instead of
495
+ * reading `.message` off what it throws.
496
+ */
497
+ async function retryPlatformAuthCall(attempts, sleep, action) {
498
+ for (let attempt = 1;; attempt += 1) {
499
+ try {
500
+ return await action();
501
+ }
502
+ catch (e) {
503
+ if (attempt >= attempts)
504
+ throw e;
505
+ await sleep(1000 * attempt);
506
+ }
507
+ }
508
+ }
509
+ /**
510
+ * #216 round 5: sha256 of the exact login-client token value -- never the
511
+ * token itself, only its hash, the same `hashAuthApps` rule for the same
512
+ * reason: a recorded scope id must never leak a secret.
513
+ */
514
+ function hashLoginClientToken(token) {
515
+ return createHash('sha256').update(token).digest('hex');
516
+ }
517
+ /**
518
+ * #216 round 4/5: encodes the {@link PlatformAuthLoginClientRecord} a
519
+ * successful login-client delivery writes under
520
+ * {@link PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY}, replacing the plain
521
+ * user-id string round 2-3 wrote there. Paired with
522
+ * {@link parseLoginClientRecord} (`platform-diff.ts`), which `planAuth`
523
+ * reads back to answer whether the delivered PAT is still live, and with
524
+ * {@link authLoginClientDrifted} below, which (round 5) also reads `token`'s
525
+ * hash back to answer whether what was actually delivered is still live.
526
+ */
527
+ function loginClientRecordValue(userId, patId, token) {
528
+ const record = {
529
+ userId,
530
+ patId,
531
+ tokenHash: hashLoginClientToken(token),
532
+ };
533
+ return JSON.stringify(record);
534
+ }
535
+ /**
536
+ * #216 round 4: whether the login-client PAT recorded for this platform
537
+ * (under {@link PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY}) is still, right
538
+ * now in ZITADEL, the one and only live PAT on that machine user. `false`
539
+ * when nothing is recorded yet -- that is "unapplied," which `planAuth`
540
+ * already plans for without this function's help, not drift.
541
+ *
542
+ * Every real mint revokes every other PAT on the user first
543
+ * (`zitadel-machine-user.ts`), so "exactly one live PAT, and it is the
544
+ * recorded one" is never a false negative: a concurrent apply's own mint
545
+ * landing at any point -- before this platform's last delivery, after it, or
546
+ * anywhere in between -- always leaves the recorded PAT either gone or
547
+ * joined by one it isn't. This is what lets `provisionPlatformAuth` stop
548
+ * trying to catch that interleaving from inside the run that delivers the
549
+ * token (round 2's check-after, round 3's check-before-every-write, each
550
+ * confirmed by review to still leave a window): whichever run's write
551
+ * actually won, the next plan's call here sees it and replans.
552
+ *
553
+ * #216 round 5: that ZITADEL-only check is itself not enough. Ordering that
554
+ * breaks it (confirmed by review): run A mints PAT a and is about to
555
+ * deliver; run B's own mint revokes a and mints PAT b first, delivers b to
556
+ * both apps, and records {b}; A's slower delivery of a then lands *last*,
557
+ * overwriting both apps' envs with the now-dead a, and A records {a} after
558
+ * B. Final state: ZITADEL's only live PAT is b, which is NOT what either
559
+ * record ends up naming last (a) -- so a check against ZITADEL's live PATs
560
+ * alone, compared only to whichever record happens to be the one read, can
561
+ * land on either side of this by accident. The fix is to also compare what
562
+ * is actually live in Coolify: `record.tokenHash` is a hash of the exact
563
+ * token this run itself delivered, and if what is live in Coolify right now
564
+ * does not hash to it, the record does not describe reality, whatever
565
+ * ZITADEL's PAT list says. Read fresh, every plan: a corrupted Coolify env
566
+ * is not something minting again fixes by itself, so if this is wrong, a
567
+ * later apply's own mint-and-redeliver is what actually repairs it, and the
568
+ * only way to know to run one is to keep checking.
569
+ *
570
+ * #216 round 5: reads the ZITADEL org id back from
571
+ * {@link PLATFORM_ZITADEL_ORG_SCOPE_KEY} rather than calling
572
+ * `zitadel.findOrCreateOrg` -- a plan must not create anything, and
573
+ * `findOrCreateOrg`'s name says it might. `provisionPlatformAuth` records
574
+ * that key itself now (mirroring `provisionPlatformArchon`), so it is always
575
+ * present by the time a login-client record exists to check. Any of the
576
+ * three ids this needs (`org`, the login app, the broker) missing is not a
577
+ * state this function can verify against, so it reads as drifted -- the
578
+ * next apply's own mint-and-deliver backfills all three.
579
+ *
580
+ * #216 round 5: wrapped in `try`/`catch` -- ZITADEL or Coolify being
581
+ * unreachable must read as "unknown, not drifted," the same rule
582
+ * `archonAccessDrifted`/`archonBrokerRegistrationDrifted` already follow and
583
+ * for the same reason (a plan must not fail outright over a transient read),
584
+ * but logged here: silently treating a real outage as healthy is a worse
585
+ * miss for a credential-liveness check than for Archon's own Access policy.
586
+ */
587
+ async function authLoginClientDrifted(platform, auth, recordedIds) {
588
+ const record = parseLoginClientRecord(recordedIds[PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY]);
589
+ if (!record)
590
+ return false;
591
+ const orgId = recordedIds[PLATFORM_ZITADEL_ORG_SCOPE_KEY];
592
+ const loginAppId = recordedIds[PLATFORM_AUTH_LOGIN_SCOPE_KEY];
593
+ const brokerAppId = recordedIds[PLATFORM_AUTH_BROKER_SCOPE_KEY];
594
+ if (!orgId || !loginAppId || !brokerAppId)
595
+ return true;
596
+ try {
597
+ const zitadel = auth.zitadelForInstance?.(`https://${platform.auth.hostname}`) ?? auth.zitadel;
598
+ const livePats = await zitadel.listLoginClientPats(orgId, record.userId);
599
+ if (livePats.length !== 1 || livePats[0] !== record.patId)
600
+ return true;
601
+ const [loginHash, brokerHash] = await Promise.all([
602
+ liveLoginClientTokenHash(auth.coolify, loginAppId),
603
+ liveLoginClientTokenHash(auth.coolify, brokerAppId),
604
+ ]);
605
+ // #216 (4th PR review): compare each side on its own. Either side can
606
+ // come back `undefined` -- hidden, with nothing to check it against, see
607
+ // `liveLoginClientTokenHash`'s own doc comment -- and that side alone
608
+ // reads as "unknown," the same as the outer `catch` below. But returning
609
+ // "not drifted" as soon as *either* side was unreadable (round 3's
610
+ // behavior) hid a stale token on the *other*, readable app whenever this
611
+ // app's own read happened to fail or come back hidden -- a readable and
612
+ // wrong hash must still drift even when its sibling is unknown.
613
+ for (const hash of [loginHash, brokerHash]) {
614
+ if (hash !== undefined && hash !== record.tokenHash)
615
+ return true;
616
+ }
617
+ return false;
618
+ }
619
+ catch (e) {
620
+ console.error(`platform:auth: could not check the login-client token's liveness for ZITADEL machine user ${record.userId}: ${e instanceof Error ? e.message : String(e)}`);
621
+ return false;
622
+ }
623
+ }
624
+ /**
625
+ * #216 round 5: `ZITADEL_SERVICE_USER_TOKEN`'s live value on one app, hashed
626
+ * -- see {@link authLoginClientDrifted}. `undefined` means "could not be
627
+ * read as a trustworthy value, treat as unknown" -- never a reason by itself
628
+ * to call the token missing.
629
+ *
630
+ * #216 (second PR review, round 2): Coolify's own `GET
631
+ * /applications/{uuid}/envs` response carries both `value` and `real_value`
632
+ * per row (its documented schema for `list-envs-by-application-uuid`), but
633
+ * `CoolifyPort.listEnvs`'s narrower return type (`executor.ts`, this
634
+ * package's own) names only `value` and `is_preview`. The real client
635
+ * (`@wtfalch/providers`' `CoolifyClient.listEnvs`, which does `await
636
+ * r.json()` and returns it as-is with no field-stripping, `coolify.ts:219-
637
+ * 253`) returns Coolify's full JSON row regardless of what this package's
638
+ * own narrower type declares, so `real_value` is present on the object at
639
+ * runtime to read even when absent from the declared type.
640
+ *
641
+ * #216 (4th PR review): "could not be read" has one real cause, confirmed
642
+ * against Coolify's own source rather than guessed -- there is no masked
643
+ * placeholder string anywhere in it. `routes/api.php` maps `GET
644
+ * /applications/{uuid}/envs` to `ApplicationsController::envs()`, which maps
645
+ * each row through `removeSensitiveData()`. That method calls
646
+ * `$env->makeVisible(['value', 'real_value'])` only when the request's
647
+ * `can_read_sensitive` attribute is true (set from the API token's
648
+ * `read:sensitive`/`root` ability), and separately calls
649
+ * `$env->makeHidden(['value', 'real_value'])` whenever the env var's own
650
+ * `is_shown_once` flag is set ("the saved value is hidden in the UI and API
651
+ * responses," per that flag's own API doc comment), regardless of ability.
652
+ * Laravel omits a `makeHidden` attribute from the JSON row entirely -- it is
653
+ * not present as a key, let alone populated with a placeholder string. So
654
+ * unreadable here never looks like `value: '••••••••'`; it looks like
655
+ * `found.value` and `found.real_value` both being `undefined`.
656
+ * `@wtfalch/providers`' own `CoolifyEnvVar` type documents a `real_value` of
657
+ * `null` for a genuinely empty variable (`coolify.ts:84`, "Empty variables
658
+ * come back as `\"\"` here and `null` in `real_value`"), which is a
659
+ * different, readable state -- a real, verified value (of the empty
660
+ * string), not a hidden one -- so only `undefined` on both counts as hidden.
661
+ *
662
+ * A wrong guess in the unsafe direction is costly here specifically: reading
663
+ * a hidden field as the empty string would never hash to what was recorded,
664
+ * so every single plan afterward would see "drift" and re-mint, revoking the
665
+ * one live PAT and redeploying both apps, taking sign-in down on every apply
666
+ * rather than the one apply a real drift deserves. So this reads as unknown
667
+ * (logged once, the same as `authLoginClientDrifted`'s own outer
668
+ * `try`/`catch` already treats a transient read failure) whenever the row
669
+ * exists but carries neither key.
670
+ */
671
+ async function liveLoginClientTokenHash(coolify, appId) {
672
+ const envs = await coolify.listEnvs(appId);
673
+ const found = envs.find((e) => e.key === 'ZITADEL_SERVICE_USER_TOKEN' && !e.is_preview);
674
+ if (found && found.value === undefined && found.real_value === undefined) {
675
+ console.error(`platform:auth: ZITADEL_SERVICE_USER_TOKEN on Coolify app ${appId} came back with neither value nor real_value -- Coolify hides both unless the caller's token has read:sensitive/root ability, or the env var's own is_shown_once flag is set; treating its liveness as unknown rather than forcing a re-mint`);
676
+ return undefined;
677
+ }
678
+ const value = found?.real_value ?? found?.value;
679
+ return hashLoginClientToken(value ?? '');
680
+ }
681
+ /**
682
+ * #168: makes `coolify:app` keyed {@link PLATFORM_AUTH_KEY} true -- a Coolify
683
+ * Postgres, the ZITADEL app itself, the zitadel-login app and (#204) the
684
+ * sign-in broker, reached through one Cloudflare tunnel and DNS record at
685
+ * `platform.auth.hostname`, then (only the first time) foundry's own admin
686
+ * machine user, handed to {@link PlatformAuthDeps.deliverSecret}. Env values
687
+ * and instance settings are `wtfalch/auth`'s own `docker-compose.yml`
688
+ * (checked 2026-09-29), with `ZITADEL_DOMAIN`/`wtfalch.dev` replaced by this
689
+ * platform's own `apex`/`auth.hostname` wherever the compose named one --
690
+ * every other setting it leaves open (login v2 required, external port 443,
691
+ * TLS terminated at Cloudflare, `PASSWORDCHANGEREQUIRED` false) is kept
692
+ * exactly as that compose has it (#168's "Decide"). The estate's old, shared
693
+ * sign-in host carries nothing once this runs (#204, `WTFALCH-DEV-EXIT.md`
694
+ * row A4).
695
+ *
696
+ * Every part is safe to run again after a throw partway: each id is recorded
697
+ * (and persisted) as soon as its resource exists, each app is named and
698
+ * deployed on every run that needs it, and the admin key is delivered before
699
+ * the guard that skips the mint is recorded.
700
+ *
701
+ * Three apps, one public hostname, no separate `proxy` app: `wtfalch/auth`
702
+ * splits ZITADEL's API, its login UI and its broker with a built Caddy proxy
703
+ * container inside one docker-compose stack; foundry instead gives each app a
704
+ * fixed `custom_internal_name` and lets the Cloudflare tunnel itself
705
+ * path-route between them (`AUTH_ZITADEL_PATH_PATTERN`,
706
+ * `AUTH_BROKER_PATH_PATTERN`), skipping Coolify's own Traefik (which can only
707
+ * dispatch by `Host`, not by path) rather than provisioning a fourth Coolify
708
+ * resource to do the same job. No app is created with a Coolify `domains`
709
+ * binding for the same reason -- the tunnel reaches each one directly by its
710
+ * `custom_internal_name`, never through Traefik.
711
+ *
712
+ * Known gaps, left for review (#168's own "the PR stays open for William's
713
+ * review"):
714
+ * - ~~Neither the login app nor the broker ever receives
715
+ * `ZITADEL_SERVICE_USER_TOKEN`.~~ Closed by #216 (building on #209/#215):
716
+ * this function's own login-client block below mints a dedicated
717
+ * `IAM_LOGIN_CLIENT` machine user (never the admin one, which is
718
+ * `IAM_OWNER`) and delivers its token to both apps, once the broker's
719
+ * other required inputs are already met.
720
+ * - The broker never receives `RESEND_API_TOKEN` either, so it will not start
721
+ * on `AUTH_APPS` alone. `wtfalch/auth#138` moves the broker's account mail
722
+ * onto held keys and `@wtfalch/mailer`; until that lands there is no held
723
+ * key to read here, so this stays a plain gap rather than a wired value.
724
+ * - `AUTH_APPS` starts unset: the broker has nothing to serve until a
725
+ * platform app registers with it ({@link provisionPlatformArchon} is the
726
+ * first), so it will not start (`parseApps` throws on an empty list) until
727
+ * then either. Safe to run again: the next apply that registers an app
728
+ * redeploys the broker with a non-empty list.
729
+ * - Whoever supplies `deps.zitadel` (or `zitadelForInstance`) must hold a
730
+ * bearer token for the new instance, which foundry cannot read from it.
731
+ *
732
+ * #197: a `cloudflared` connector for this tunnel is run
733
+ * ({@link ensureCloudflaredConnector}), so `hostname` answers.
734
+ */
735
+ async function provisionPlatformAuth(context, deps) {
736
+ const { platform } = context;
737
+ const hostname = platform.auth.hostname;
738
+ const gitRef = platform.auth.gitRef;
739
+ const recorded = context.recordedIds();
740
+ const runtime = deps.runtime ?? {};
741
+ const sleep = runtime.sleep ?? defaultSleep;
742
+ // Records an id and writes it out now, so a crash before the step ends
743
+ // cannot lose a resource a provider already created.
744
+ const record = async (key, id) => {
745
+ context.recordScopeId(key, id);
746
+ await context.persist?.();
747
+ };
748
+ let projectUuid = recorded[PLATFORM_AUTH_PROJECT_SCOPE_KEY];
749
+ if (!projectUuid) {
750
+ const found = await deps.coolify.findProjectByName(PLATFORM_AUTH_PROJECT);
751
+ projectUuid = found
752
+ ? found.uuid
753
+ : (await deps.coolify.createProject(PLATFORM_AUTH_PROJECT)).uuid;
754
+ await record(PLATFORM_AUTH_PROJECT_SCOPE_KEY, projectUuid);
755
+ }
756
+ const { environment } = await deps.coolify.findOrCreateEnvironment(projectUuid, 'production');
757
+ // ---- database ----------------------------------------------------------
758
+ let dbId = recorded[PLATFORM_AUTH_DB_SCOPE_KEY];
759
+ // A database ZITADEL has already run against may hold data encrypted with a
760
+ // masterkey this run cannot see (see `ensureMasterkey`). A database that
761
+ // exists but was never deployed against (a throw between its create and the
762
+ // first deploy) is still empty, so a retry may generate the key.
763
+ const databaseInUse = Boolean(dbId && recorded[PLATFORM_AUTH_ZITADEL_DEPLOYED_SCOPE_KEY]);
764
+ if (!dbId) {
765
+ const server = await resolvePlatformServer(deps.coolify);
766
+ const created = await deps.coolify.createPostgresDatabase({
767
+ project_uuid: projectUuid,
768
+ server_uuid: server.uuid,
769
+ environment_name: environment.name,
770
+ environment_uuid: environment.uuid,
771
+ name: PLATFORM_AUTH_DB_NAME,
772
+ postgres_db: 'zitadel',
773
+ postgres_user: 'zitadel',
774
+ });
775
+ dbId = created.uuid;
776
+ await record(PLATFORM_AUTH_DB_SCOPE_KEY, dbId);
777
+ }
778
+ const db = await deps.coolify.getDatabase(dbId);
779
+ if (!db.internal_db_url) {
780
+ // Static config check before either app is touched (`diff.ts`'s
781
+ // `coolifyCalls` follows the same rule for its own databases): Coolify
782
+ // can take a moment to report a freshly created database's
783
+ // `internal_db_url`, so the next apply retries from a clean state.
784
+ throw new Error('coolify:app for the platform (auth): the database has no internal_db_url yet -- retry this apply once Coolify reports one');
785
+ }
786
+ // ---- the ZITADEL app itself ---------------------------------------------
787
+ const zitadelApp = await findOrCreatePlatformApp(deps.coolify, PLATFORM_AUTH_ZITADEL_APP_NAME, environment, async () => {
788
+ const server = await resolvePlatformServer(deps.coolify);
789
+ return deps.coolify.createDockerImageApplication({
790
+ project_uuid: projectUuid,
791
+ server_uuid: server.uuid,
792
+ environment_name: environment.name,
793
+ environment_uuid: environment.uuid,
794
+ docker_registry_image_name: AUTH_ZITADEL_IMAGE,
795
+ docker_registry_image_tag: AUTH_ZITADEL_IMAGE_TAG,
796
+ ports_exposes: AUTH_ZITADEL_PORT,
797
+ name: PLATFORM_AUTH_ZITADEL_APP_NAME,
798
+ description: `ZITADEL for the platform's own sign-in (#168, ${AUTH_ZITADEL_IMAGE}:${AUTH_ZITADEL_IMAGE_TAG})`,
799
+ instant_deploy: false,
800
+ });
801
+ });
802
+ const zitadelAppId = zitadelApp.appId;
803
+ await record(PLATFORM_AUTH_ZITADEL_SCOPE_KEY, zitadelAppId);
804
+ // Set on every run, not only after a create: a throw right after the
805
+ // create must not leave the app unnamed for good, and the tunnel addresses
806
+ // the app by this name. (The port cannot read the current name back, and
807
+ // setting the same value again is harmless.)
808
+ await deps.coolify.updateApplication(zitadelAppId, {
809
+ custom_internal_name: PLATFORM_AUTH_ZITADEL_INTERNAL_NAME,
810
+ });
811
+ // ---- the zitadel-login app -----------------------------------------------
812
+ const loginApp = await findOrCreatePlatformApp(deps.coolify, PLATFORM_AUTH_LOGIN_APP_NAME, environment, async () => {
813
+ const server = await resolvePlatformServer(deps.coolify);
814
+ const githubApp = await deps.coolify.requireGithubApp();
815
+ return deps.coolify.createGithubAppApplication({
816
+ project_uuid: projectUuid,
817
+ server_uuid: server.uuid,
818
+ environment_name: environment.name,
819
+ environment_uuid: environment.uuid,
820
+ github_app_uuid: githubApp.uuid,
821
+ git_repository: AUTH_REPO,
822
+ git_branch: gitRef,
823
+ build_pack: 'dockerfile',
824
+ name: PLATFORM_AUTH_LOGIN_APP_NAME,
825
+ dockerfile_location: AUTH_LOGIN_DOCKERFILE,
826
+ });
827
+ });
828
+ const loginAppId = loginApp.appId;
829
+ await record(PLATFORM_AUTH_LOGIN_SCOPE_KEY, loginAppId);
830
+ await deps.coolify.updateApplication(loginAppId, {
831
+ custom_internal_name: PLATFORM_AUTH_LOGIN_INTERNAL_NAME,
832
+ });
833
+ // ---- the broker app (#204) -------------------------------------------------
834
+ const brokerApp = await findOrCreatePlatformApp(deps.coolify, PLATFORM_AUTH_BROKER_APP_NAME, environment, async () => {
835
+ const server = await resolvePlatformServer(deps.coolify);
836
+ const githubApp = await deps.coolify.requireGithubApp();
837
+ return deps.coolify.createGithubAppApplication({
838
+ project_uuid: projectUuid,
839
+ server_uuid: server.uuid,
840
+ environment_name: environment.name,
841
+ environment_uuid: environment.uuid,
842
+ github_app_uuid: githubApp.uuid,
843
+ git_repository: AUTH_REPO,
844
+ git_branch: gitRef,
845
+ build_pack: 'dockerfile',
846
+ name: PLATFORM_AUTH_BROKER_APP_NAME,
847
+ dockerfile_location: AUTH_BROKER_DOCKERFILE,
848
+ });
849
+ });
850
+ const brokerAppId = brokerApp.appId;
851
+ await record(PLATFORM_AUTH_BROKER_SCOPE_KEY, brokerAppId);
852
+ await deps.coolify.updateApplication(brokerAppId, {
853
+ custom_internal_name: PLATFORM_AUTH_BROKER_INTERNAL_NAME,
854
+ });
855
+ // ---- env -----------------------------------------------------------------
856
+ // Generated once, handed straight to `bulkUpsertEnvs` and nowhere else --
857
+ // never returned, recorded, logged or interpolated into a thrown error, the
858
+ // same rule `executor.ts`'s `ensureAuthCookieSecret` follows for a value
859
+ // this package mints itself rather than receiving from a provider.
860
+ const masterkey = await ensureMasterkey(deps.coolify, zitadelAppId, databaseInUse);
861
+ const adminPassword = await ensureGeneratedEnv(deps.coolify, zitadelAppId, 'ZITADEL_ADMIN_PASSWORD', generateAdminPassword);
862
+ const zitadelEnvs = [
863
+ { key: 'ZITADEL_PORT', value: AUTH_ZITADEL_PORT },
864
+ { key: 'ZITADEL_EXTERNALDOMAIN', value: hostname },
865
+ { key: 'ZITADEL_EXTERNALPORT', value: '443' },
866
+ { key: 'ZITADEL_EXTERNALSECURE', value: 'true' },
867
+ { key: 'ZITADEL_TLS_ENABLED', value: 'false' },
868
+ { key: 'ZITADEL_DATABASE_POSTGRES_DSN', value: db.internal_db_url },
869
+ { key: 'ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME', value: 'zitadel-admin' },
870
+ { key: 'ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED', value: 'false' },
871
+ { key: 'ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED', value: 'true' },
872
+ { key: 'ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI', value: `https://${hostname}/` },
873
+ { key: 'ZITADEL_OIDC_DEFAULTLOGINURLV2', value: `https://${hostname}/login?authRequest=` },
874
+ {
875
+ key: 'ZITADEL_OIDC_DEFAULTLOGOUTURLV2',
876
+ value: `https://${hostname}/logout?post_logout_redirect=`,
877
+ },
878
+ { key: 'ZITADEL_SAML_DEFAULTLOGINURLV2', value: `https://${hostname}/login?samlRequest=` },
879
+ { key: 'ZITADEL_LOGSTORE_ACCESS_STDOUT_ENABLED', value: 'true' },
880
+ ];
881
+ if (masterkey)
882
+ zitadelEnvs.push({ key: 'ZITADEL_MASTERKEY', value: masterkey });
883
+ if (adminPassword)
884
+ zitadelEnvs.push({ key: 'ZITADEL_ADMIN_PASSWORD', value: adminPassword });
885
+ const loginEnvs = [
886
+ {
887
+ key: 'ZITADEL_API_URL',
888
+ value: `http://${PLATFORM_AUTH_ZITADEL_INTERNAL_NAME}:${AUTH_ZITADEL_PORT}`,
889
+ },
890
+ { key: 'CUSTOM_REQUEST_HEADERS', value: `Host:${hostname},X-Forwarded-Proto:https` },
891
+ { key: 'OTEL_NODE_RESOURCE_DETECTORS', value: 'none' },
892
+ { key: 'API_CACHE_CONFIG', value: '{"defaultMinutes":2,"longMinutes":5}' },
893
+ ];
894
+ // `AUTH_APPS`, `ZITADEL_SERVICE_USER_TOKEN`/`_FILE` and `RESEND_API_TOKEN`
895
+ // are not pushed here -- this function's own doc comment, "Known gaps".
896
+ const brokerEnvs = [
897
+ {
898
+ key: 'ZITADEL_API_URL',
899
+ value: `http://${PLATFORM_AUTH_ZITADEL_INTERNAL_NAME}:${AUTH_ZITADEL_PORT}`,
900
+ },
901
+ { key: 'ZITADEL_DOMAIN', value: hostname },
902
+ ];
903
+ // Compared before the write: an env change only reaches a running
904
+ // container on its next deploy (`diff.ts`'s `coolifyCalls` follows the
905
+ // identical rule).
906
+ const zitadelEnvsChanged = await envsDiffer(deps.coolify, zitadelAppId, zitadelEnvs);
907
+ const loginEnvsChanged = await envsDiffer(deps.coolify, loginAppId, loginEnvs);
908
+ const brokerEnvsChanged = await envsDiffer(deps.coolify, brokerAppId, brokerEnvs);
909
+ await deps.coolify.bulkUpsertEnvs(zitadelAppId, zitadelEnvs);
910
+ await deps.coolify.bulkUpsertEnvs(loginAppId, loginEnvs);
911
+ await deps.coolify.bulkUpsertEnvs(brokerAppId, brokerEnvs);
912
+ // Deploy an app that was just created, is not running (a throw after the
913
+ // create left it never deployed, or it stopped), or whose envs changed.
914
+ if (zitadelApp.justCreated || !zitadelApp.running || zitadelEnvsChanged) {
915
+ await deps.coolify.deploy(zitadelAppId);
916
+ await record(PLATFORM_AUTH_ZITADEL_DEPLOYED_SCOPE_KEY, zitadelAppId);
917
+ }
918
+ if (loginApp.justCreated || !loginApp.running || loginEnvsChanged) {
919
+ await deps.coolify.deploy(loginAppId);
920
+ }
921
+ // #211: the broker's own `server.ts` refuses to start without
922
+ // `ZITADEL_SERVICE_USER_TOKEN`/`_FILE` and `RESEND_API_TOKEN` -- deploying
923
+ // it without them only produces a container that crash-loops behind
924
+ // `/api`. The app is still created and recorded above either way, so #209
925
+ // and #210 (or a human, directly) have something to push the env onto;
926
+ // only the deploy waits.
927
+ // #211 round 5: `brokerDeployNeeded` is its own reason to deploy (not only
928
+ // alongside justCreated/running/envsChanged, none of which this function's
929
+ // own `brokerEnvs` write above ever touches `AUTH_APPS`): it is what
930
+ // catches a broker recreated under a new app id, or a crash between some
931
+ // other step writing `AUTH_APPS` and deploying, cases none of the other
932
+ // three conditions see. This function never writes `AUTH_APPS` itself
933
+ // (this function's own doc comment, "Known gaps"), so whatever is live now
934
+ // (`authAppsRaw`) is the value this deploy, if it runs, puts into effect.
935
+ const brokerMissingInputs = await missingBrokerInputs(deps.coolify, brokerAppId);
936
+ const authAppsRaw = await currentEnvValue(deps.coolify, brokerAppId, 'AUTH_APPS');
937
+ const recordedBrokerDeploy = parseBrokerDeployRecord(recorded[PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY]);
938
+ if (brokerMissingInputs.length === 0 &&
939
+ (brokerApp.justCreated ||
940
+ !brokerApp.running ||
941
+ brokerEnvsChanged ||
942
+ brokerDeployNeeded(brokerMissingInputs, brokerAppId, authAppsRaw, recordedBrokerDeploy))) {
943
+ await deps.coolify.deploy(brokerAppId);
944
+ await record(PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, brokerDeployRecordValue(brokerAppId, authAppsRaw));
945
+ }
946
+ // ---- tunnel and DNS ------------------------------------------------------
947
+ const existingTunnel = await deps.cloudflare.findTunnelByName(PLATFORM_AUTH_TUNNEL_NAME);
948
+ const tunnel = existingTunnel ?? (await deps.cloudflare.createTunnel(PLATFORM_AUTH_TUNNEL_NAME));
949
+ await record(PLATFORM_AUTH_TUNNEL_SCOPE_KEY, tunnel.id);
950
+ await deps.cloudflare.putTunnelConfig(tunnel.id, {
951
+ ingress: [
952
+ {
953
+ hostname,
954
+ path: AUTH_ZITADEL_PATH_PATTERN,
955
+ service: `http://${PLATFORM_AUTH_ZITADEL_INTERNAL_NAME}:${AUTH_ZITADEL_PORT}`,
956
+ },
957
+ {
958
+ hostname,
959
+ path: AUTH_BROKER_PATH_PATTERN,
960
+ service: `http://${PLATFORM_AUTH_BROKER_INTERNAL_NAME}:${AUTH_BROKER_PORT}`,
961
+ },
962
+ { hostname, service: `http://${PLATFORM_AUTH_LOGIN_INTERNAL_NAME}:${AUTH_LOGIN_PORT}` },
963
+ { service: 'http_status:404' },
964
+ ],
965
+ });
966
+ // ---- cloudflared connector for the tunnel above (#197) --------------------
967
+ // Without this, `hostname` resolves to a tunnel nothing is dialed into and
968
+ // answers with a Cloudflare error -- the known gap #197 closes for this
969
+ // step's own tunnel only (see this function's doc comment).
970
+ await ensureCloudflaredConnector({
971
+ coolify: deps.coolify,
972
+ cloudflare: deps.cloudflare,
973
+ tunnelId: tunnel.id,
974
+ appName: PLATFORM_AUTH_CLOUDFLARED_APP_NAME,
975
+ projectUuid,
976
+ environment,
977
+ resolveServer: () => resolvePlatformServer(deps.coolify),
978
+ recordedConnectorAppId: recorded[PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY] ?? null,
979
+ recordAppId: (appId) => record(PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, appId),
980
+ });
981
+ // The apex zone step records the zone id before this step runs, but it
982
+ // creates no record for the hostname, so it is created here.
983
+ const zoneId = recorded[apexZoneKey(platform.apex)] ?? (await exactZone(deps.cloudflare, platform.apex))?.id;
984
+ if (!zoneId) {
985
+ throw new Error(`platform:auth: no Cloudflare zone for ${platform.apex} to put ${hostname} in`);
986
+ }
987
+ const dnsTarget = `${tunnel.id}.cfargotunnel.com`;
988
+ const [dnsRecord] = await deps.cloudflare.findDnsRecord(zoneId, hostname, 'CNAME');
989
+ if (!dnsRecord) {
990
+ await deps.cloudflare.createDnsRecord(zoneId, {
991
+ type: 'CNAME',
992
+ name: hostname,
993
+ content: dnsTarget,
994
+ proxied: true,
995
+ });
996
+ }
997
+ else if (dnsRecord.content !== dnsTarget) {
998
+ await deps.cloudflare.updateDnsRecord(zoneId, dnsRecord.id, {
999
+ type: 'CNAME',
1000
+ name: hostname,
1001
+ content: dnsTarget,
1002
+ proxied: true,
1003
+ });
1004
+ }
1005
+ // ---- foundry's own admin machine user, once ------------------------------
1006
+ if (!recorded[PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY]) {
1007
+ const lost = recorded[PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY];
1008
+ if (lost) {
1009
+ // The planner reports this and plans nothing; this is the same rule for
1010
+ // a caller that reaches the step anyway.
1011
+ throw new Error(`platform:auth: the admin key of ZITADEL machine user ${lost} was minted but never delivered; delete that user and the recorded key ${PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY} to mint a new one`);
1012
+ }
1013
+ // Minting needs a running instance behind the tunnel: the first apply
1014
+ // gets here right after the deploy.
1015
+ await waitForInstance(`https://${hostname}/.well-known/openid-configuration`, runtime, sleep);
1016
+ const zitadel = deps.zitadelForInstance?.(`https://${hostname}`) ?? deps.zitadel;
1017
+ const { org } = await zitadel.findOrCreateOrg(PLATFORM_AUTH_ORG_NAME);
1018
+ // #216 round 5: recorded here too (mirroring `provisionPlatformArchon`),
1019
+ // so `authLoginClientDrifted` (below) never needs its own ZITADEL call
1020
+ // to resolve the org -- see that function's own doc comment.
1021
+ if (recorded[PLATFORM_ZITADEL_ORG_SCOPE_KEY] !== org.id) {
1022
+ await record(PLATFORM_ZITADEL_ORG_SCOPE_KEY, org.id);
1023
+ }
1024
+ const { userId, token } = await zitadel.mintAdminMachineUser(org.id, {
1025
+ userName: PLATFORM_AUTH_ADMIN_USER_NAME,
1026
+ name: 'Foundry (platform admin)',
1027
+ });
1028
+ // Recorded and persisted before the token leaves this function: if
1029
+ // delivery fails for good, the next plan reports the lost key rather than
1030
+ // skipping or minting again. Only a delivered key records the guard.
1031
+ await record(PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, userId);
1032
+ // Handed off once, right here, before returning -- never in the return
1033
+ // value, `serviceIds`, the store, a log or a thrown error (see
1034
+ // `FoundryStepExecutorPorts.deliverSecret`'s own doc comment). The error
1035
+ // below names the user id only, never the token or the cause.
1036
+ const attempts = runtime.deliverAttempts ?? 3;
1037
+ for (let attempt = 1;; attempt += 1) {
1038
+ try {
1039
+ await deps.deliverSecret('zitadel:platform:admin', token);
1040
+ break;
1041
+ }
1042
+ catch {
1043
+ if (attempt >= attempts) {
1044
+ throw new Error(`platform:auth: the admin key of ZITADEL machine user ${userId} was minted but could not be delivered after ${attempts} tries`);
1045
+ }
1046
+ await sleep(1000 * attempt);
1047
+ }
1048
+ }
1049
+ await record(PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, userId);
1050
+ }
1051
+ // ---- foundry's own login-client machine user, once (#216, building on #209/#215) --
1052
+ // The login app and the broker both refuse to start without
1053
+ // `ZITADEL_SERVICE_USER_TOKEN`/`_FILE` (this function's own doc comment,
1054
+ // "Known gaps") -- the admin token above must never go there (it is
1055
+ // `IAM_OWNER`), so this mints a second, narrower machine user
1056
+ // (`IAM_LOGIN_CLIENT` only) and delivers its token into both apps' own
1057
+ // envs, then redeploys both.
1058
+ //
1059
+ // Deferred, not blocked, while the broker's other required inputs
1060
+ // (`RESEND_API_TOKEN`, `AUTH_APPS`) are still missing (composing with
1061
+ // #211's own gating): minting and delivering here would only redeploy a
1062
+ // broker that still refuses to start on the other missing input, spending
1063
+ // a mint and two deploys on nothing this apply can fix. `blockedSteps`'s
1064
+ // own `missingBrokerInputs` read already names what is still missing; once
1065
+ // the other inputs land (#210, or Archon's own registration for
1066
+ // `AUTH_APPS`), a later apply's fresh read of `brokerMissingInputs` clears
1067
+ // this gate and this block runs.
1068
+ //
1069
+ // Unlike the admin key above, an undelivered login-client key never blocks
1070
+ // planning or throws a "delete this user by hand" error -- #216's own fix
1071
+ // for the bug PR #215 shipped with, which mirrored the admin pair's
1072
+ // forever-blocking behaviour here too. Revoking a stale login-client PAT
1073
+ // destroys nothing (unlike ZITADEL's own masterkey, nothing else was ever
1074
+ // encrypted with it), so `mintLoginClientMachineUser`
1075
+ // (`zitadel-machine-user.ts`) always lists and deletes every PAT the user
1076
+ // already holds before minting a fresh one. A crash between mint and
1077
+ // record, or every delivery retry failing, both leave this `if` still true
1078
+ // on the next run, which re-mints (revoking the stale PAT first) and
1079
+ // retries delivery, with no manual step.
1080
+ //
1081
+ // #216 round 2 and round 3 each tried to catch a concurrent apply's own
1082
+ // mint (idempotent revoke-then-create on the same `userName`) racing this
1083
+ // one -- round 2 checked liveness once, after every write; round 3 checked
1084
+ // again before every write, inside each retry. Review confirmed each still
1085
+ // left a window: a write that lands between the last check this run makes
1086
+ // and the very next network call is never caught by either. #216 round 4
1087
+ // drops both: this block no longer tries to detect the race from inside
1088
+ // the run that might lose it. It mints, delivers with ordinary retries,
1089
+ // and records whatever it actually delivered -- the user id *and* the
1090
+ // exact PAT id (`loginClientRecordValue`). `planAuth` (`platform-diff.ts`)
1091
+ // re-checks that recorded PAT against ZITADEL's live state on *every*
1092
+ // plan, not only this run's: whichever run's write actually won is caught
1093
+ // on the very next plan and replanned, however the writes interleaved.
1094
+ // See `authLoginClientDrifted`'s own doc comment for why that check can
1095
+ // never read a healthy delivery as drifted.
1096
+ //
1097
+ // Read fresh here, immediately before deciding whether to mint -- not
1098
+ // `recorded`, the snapshot this function took at the top, several hundred
1099
+ // lines above. A concurrent apply's own mint-deliver-record can land in
1100
+ // that gap; deciding off a stale snapshot would mint and redeliver on top
1101
+ // of a token another run just finished setting up.
1102
+ //
1103
+ // A record present is still not enough on its own: `planAuth` may have
1104
+ // replanned this step because `authLoginClientDrifted` (the same check,
1105
+ // reused here) found the recorded PAT no longer live -- this run has to
1106
+ // make the same call, not just trust that a record exists, or a drifted
1107
+ // platform would be planned again forever without ever actually healing.
1108
+ const loginClientOtherInputsMissing = brokerMissingInputs.filter((m) => m !== 'ZITADEL_SERVICE_USER_TOKEN');
1109
+ const freshRecordedIds = context.recordedIds();
1110
+ const freshLoginClientRecord = parseLoginClientRecord(freshRecordedIds[PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY]);
1111
+ const loginClientNeedsMint = !freshLoginClientRecord || (await authLoginClientDrifted(platform, deps, freshRecordedIds));
1112
+ if (loginClientNeedsMint && loginClientOtherInputsMissing.length === 0) {
1113
+ const loginClientZitadel = deps.zitadelForInstance?.(`https://${hostname}`) ?? deps.zitadel;
1114
+ const { org: loginClientOrg } = await loginClientZitadel.findOrCreateOrg(PLATFORM_AUTH_ORG_NAME);
1115
+ // #216 round 5: backfills the org id for a platform whose admin block
1116
+ // (above) ran before this recording existed and has not run since --
1117
+ // see `authLoginClientDrifted`'s own doc comment.
1118
+ if (freshRecordedIds[PLATFORM_ZITADEL_ORG_SCOPE_KEY] !== loginClientOrg.id) {
1119
+ await record(PLATFORM_ZITADEL_ORG_SCOPE_KEY, loginClientOrg.id);
1120
+ }
1121
+ const { userId: loginClientUserId, token: loginClientToken, patId: loginClientPatId, revokedPriorPat, } = await loginClientZitadel.mintLoginClientMachineUser(loginClientOrg.id, {
1122
+ userName: PLATFORM_AUTH_LOGIN_CLIENT_USER_NAME,
1123
+ name: 'Foundry (platform login client)',
1124
+ });
1125
+ // Recorded and persisted before the token leaves this function -- the
1126
+ // same "one mint" rule the admin block above follows. Never read back to
1127
+ // block planning; `planAuth` (`platform-diff.ts`) reads it only to
1128
+ // surface a non-blocking note, for the reason this block's own doc
1129
+ // comment above gives.
1130
+ await record(PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, loginClientUserId);
1131
+ const deliverAttempts = runtime.deliverAttempts ?? 3;
1132
+ try {
1133
+ await retryPlatformAuthCall(deliverAttempts, sleep, () => deps.coolify.bulkUpsertEnvs(loginAppId, [
1134
+ { key: 'ZITADEL_SERVICE_USER_TOKEN', value: loginClientToken },
1135
+ ]));
1136
+ await retryPlatformAuthCall(deliverAttempts, sleep, () => deps.coolify.bulkUpsertEnvs(brokerAppId, [
1137
+ { key: 'ZITADEL_SERVICE_USER_TOKEN', value: loginClientToken },
1138
+ ]));
1139
+ // Forced: an unforced deploy answers 200 "skipped" when one for the
1140
+ // same commit is already queued -- likely here, since this function's
1141
+ // own deploys above may just have queued one for either app. An
1142
+ // ignored "skipped" would let this function record delivery as
1143
+ // succeeded while the running containers never actually see the token
1144
+ // (#216 follow-up review, item 2).
1145
+ await retryPlatformAuthCall(deliverAttempts, sleep, () => deps.coolify.deploy(loginAppId, true));
1146
+ await retryPlatformAuthCall(deliverAttempts, sleep, () => deps.coolify.deploy(brokerAppId, true));
1147
+ }
1148
+ catch {
1149
+ // Never echo the underlying error: Coolify's validation error can
1150
+ // include the rejected env value -- the token itself -- in its
1151
+ // message (#216 follow-up review, item 1). Name the user id only,
1152
+ // never the token or the cause. Nothing records delivered, so the
1153
+ // next apply re-mints (revoking whatever is live) and re-delivers.
1154
+ //
1155
+ // #216 (second PR review, round 2; third PR review, nit): on a
1156
+ // re-mint, `mintLoginClientMachineUser` already revoked this user's
1157
+ // prior PAT before minting the one that just failed to deliver -- see
1158
+ // this file's own doc comment above and `zitadel-machine-user.ts`'s
1159
+ // revoke-before-mint sweep. So on that path the login app and broker
1160
+ // are not merely stale, they are down: neither has any live token
1161
+ // until a later apply mints and delivers a fresh one. `revokedPriorPat`
1162
+ // is false on a first-ever mint, where nothing was live to revoke --
1163
+ // saying "its prior token was already revoked" there would describe a
1164
+ // revoke that never happened, so the message only says so when it did.
1165
+ throw new Error(revokedPriorPat
1166
+ ? `platform:auth: the login-client token of ZITADEL machine user ${loginClientUserId} could not be delivered after ${deliverAttempts} tries; its prior token was already revoked, so the login app and broker are down until a later apply mints and delivers a fresh one`
1167
+ : `platform:auth: the login-client token of ZITADEL machine user ${loginClientUserId} could not be delivered after ${deliverAttempts} tries; this is its first token, so the login app and broker still have none until a later apply mints and delivers one`);
1168
+ }
1169
+ // #216 round 4/5: records what was actually delivered -- the user id,
1170
+ // the exact PAT id, and (round 5) a hash of the exact token -- so the
1171
+ // next plan's `authLoginClientDrifted` check can tell a healthy delivery
1172
+ // from one a concurrent apply has since clobbered, including the
1173
+ // ordering round 4's own check alone could not catch. See this block's
1174
+ // own doc comment, and `authLoginClientDrifted`'s, for why.
1175
+ await record(PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, loginClientRecordValue(loginClientUserId, loginClientPatId, loginClientToken));
1176
+ }
1177
+ return { resourceId: zitadelAppId };
1178
+ }
1179
+ // ---------------------------------------------------------------------------
1180
+ // #169: the platform's own mail server (Stalwart)
1181
+ // ---------------------------------------------------------------------------
1182
+ const PLATFORM_MAIL_PROJECT = 'platform';
1183
+ const PLATFORM_MAIL_APP_NAME = 'platform-mail-stalwart';
1184
+ const PLATFORM_MAIL_INTERNAL_NAME = 'platform-mail-stalwart';
1185
+ const PLATFORM_MAIL_TUNNEL_NAME = 'platform-mail-tunnel';
1186
+ /** #197: the one Coolify app that runs `cloudflared` for {@link PLATFORM_MAIL_TUNNEL_NAME}. */
1187
+ const PLATFORM_MAIL_CLOUDFLARED_APP_NAME = 'platform-mail-cloudflared';
1188
+ /**
1189
+ * `stalw.art/docs/install/docker`: the official image. `platform.mail.gitRef`
1190
+ * carries its tag -- `platform.ts`'s `PlatformService.gitRef` doc comment
1191
+ * ("the release it runs") holds for an image tag too, and Stalwart has no
1192
+ * repo of its own to build from the way the login app does.
1193
+ */
1194
+ const STALWART_IMAGE = 'stalwartlabs/stalwart';
1195
+ const STALWART_HTTP_PORT = '8080';
1196
+ /**
1197
+ * SMTP (25), submission (587) and submissions (465) -- the three ports
1198
+ * #169's own "Human steps" names. IMAP/POP3/ManageSieve are left off:
1199
+ * nothing in this estate speaks them to Stalwart directly yet, and every
1200
+ * port opened here is one Coolify's host firewall must also open.
1201
+ */
1202
+ const STALWART_SMTP_PORTS = ['25', '465', '587'];
1203
+ const STALWART_PORTS_EXPOSES = [STALWART_HTTP_PORT, ...STALWART_SMTP_PORTS].join(',');
1204
+ /**
1205
+ * `host:container`, comma separated (`CoolifyPort.createDockerImageApplication`'s
1206
+ * own doc comment: "a mail server cannot do without" this). The HTTP port is
1207
+ * not here -- it stays behind the tunnel, exactly like `platform:auth`'s
1208
+ * ZITADEL/login apps.
1209
+ */
1210
+ const STALWART_PORTS_MAPPINGS = STALWART_SMTP_PORTS.map((p) => `${p}:${p}`).join(',');
1211
+ const STALWART_DATA_MOUNT = '/opt/stalwart';
1212
+ /** `STALWART_RECOVERY_ADMIN=<user>:<password>` -- the fixed recovery account name; only the password half is generated. */
1213
+ const STALWART_RECOVERY_USER = 'admin';
1214
+ /**
1215
+ * Stalwart's unauthenticated readiness endpoint (200 once its data store is
1216
+ * up; `crates/http/src/request.rs`'s `healthz` route in v0.16.24). Not
1217
+ * `/.well-known/jmap`, which answers 401 to a client with no credentials.
1218
+ */
1219
+ const STALWART_READY_PATH = '/healthz/ready';
1220
+ /** Where the server the app was created on is recorded, so the MX record follows the app, not a later pick. */
1221
+ const PLATFORM_MAIL_SERVER_SCOPE_KEY = 'coolify_platform_mail_server';
1222
+ const PLATFORM_MAIL_PROJECT_SCOPE_KEY = 'coolify_project_platform_mail';
1223
+ const PLATFORM_MAIL_APP_SCOPE_KEY = 'coolify_platform_mail_app';
1224
+ const PLATFORM_MAIL_TUNNEL_SCOPE_KEY = 'cloudflare_platform_mail_tunnel';
1225
+ /**
1226
+ * Recorded once the app has been deployed at least once -- same role as
1227
+ * `PLATFORM_AUTH_ZITADEL_DEPLOYED_SCOPE_KEY`, kept for parity though nothing
1228
+ * here reads it back the way `ensureMasterkey` does: losing
1229
+ * `STALWART_RECOVERY_ADMIN` is not destructive the same way losing ZITADEL's
1230
+ * masterkey is.
1231
+ */
1232
+ const PLATFORM_MAIL_DEPLOYED_SCOPE_KEY = 'stalwart_platform_mail_deployed';
1233
+ /**
1234
+ * The hostname and image tag the last successful deploy used, recorded right
1235
+ * after that deploy. `provisionPlatformMail` reads these (not the applied keys
1236
+ * the planner reads, which stay last) to decide on a redeploy, so a retry after
1237
+ * a later failure does not stop the running server again.
1238
+ */
1239
+ const PLATFORM_MAIL_DEPLOYED_HOSTNAME_SCOPE_KEY = 'mail_deployed_hostname';
1240
+ const PLATFORM_MAIL_DEPLOYED_GIT_REF_SCOPE_KEY = 'mail_deployed_git_ref';
1241
+ /**
1242
+ * The fixed description Stalwart's admin API key is minted under
1243
+ * (`StalwartClient.createApiKey`'s own signature returns only the secret,
1244
+ * never an id) -- what a human revokes it by in Stalwart's own admin UI if
1245
+ * it is ever lost between mint and delivery, and what
1246
+ * {@link PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY} records in that case.
1247
+ */
1248
+ export const PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION = 'foundry:platform:admin';
1249
+ /**
1250
+ * #169: makes `coolify:app` keyed {@link PLATFORM_MAIL_KEY} true -- Stalwart
1251
+ * on Coolify with its own persistent storage, reached at
1252
+ * `platform.mail.hostname` through a Cloudflare tunnel for web, JMAP and its
1253
+ * management API (mirroring #168's ZITADEL/login apps: TLS terminated at
1254
+ * Cloudflare, plain HTTP behind the tunnel) and at `mx.<apex>` over a direct,
1255
+ * unproxied `A` record to the Coolify server's own address for SMTP -- raw
1256
+ * SMTP cannot ride Cloudflare's HTTP-only tunnel the way a web app does,
1257
+ * which is exactly why #169's own "Human steps" asks a person to open ports
1258
+ * 25, 465 and 587 on that server's host firewall. Then, once the instance
1259
+ * answers, the OIDC directory against the platform's own sign-in and
1260
+ * foundry's admin API key, minted once and handed to
1261
+ * {@link PlatformMailDeps.deliverSecret}.
1262
+ *
1263
+ * Every part is safe to run again after a throw partway, the same rules
1264
+ * {@link provisionPlatformAuth} follows: each id is recorded (and persisted)
1265
+ * as soon as its resource exists, the app is deployed on every run that
1266
+ * needs it, and the admin key is delivered before the guard that skips the
1267
+ * mint is recorded. Unlike ZITADEL's `ZITADEL_ADMIN_PASSWORD`, the recovery
1268
+ * credential this step generates (`STALWART_RECOVERY_ADMIN`) is read back
1269
+ * from Coolify's own env store on every run rather than held only in memory
1270
+ * -- `CoolifyPort.listEnvs`'s own doc comment says these values come back
1271
+ * live -- so a retry after a delivery failure re-authenticates with the same
1272
+ * credential instead of losing it.
1273
+ *
1274
+ * **Why not `@wtfalch/email/postmaster`'s `mailObjects`/`applyObjects`**, the
1275
+ * instance-bootstrap helper `wtfalch-manage` already uses for the hand-built
1276
+ * `mail.wtfalch.dev`: that plan also wires an `AcmeProvider` and `DnsServer`
1277
+ * for Stalwart's own DNS-01 ACME challenge, and an `MtaRoute` that relays
1278
+ * every outbound message through Resend -- the estate moved transactional
1279
+ * mail off Resend onto Lettermint before this issue (`@wtfalch/mailer` ADR
1280
+ * 0006), and this step provisions no Cloudflare DNS token for Stalwart's own
1281
+ * ACME either. Calling it as-is would mint an outbound relay this platform
1282
+ * does not want and a credential requirement this issue does not cover, so
1283
+ * {@link PlatformMailAdminPort.applySettings} is a narrower, hand-built
1284
+ * settings write instead: the default hostname and the OIDC directory only.
1285
+ *
1286
+ * Known gaps, left for review (like #168's own "the PR stays open for
1287
+ * William's review"):
1288
+ * - **No ACME/TLS for the SMTP listener.** `mx.<apex>` answers with
1289
+ * Stalwart's own self-signed certificate until a follow-up wires a real
1290
+ * one (`mailObjects`'s `AcmeProvider`/`DnsServer` shape, minus the Resend
1291
+ * relay it also carries).
1292
+ * - **`oidcAudience` is supplied, not resolved.** #168 mints foundry's own
1293
+ * ZITADEL admin machine user but registers no resource API for Stalwart's
1294
+ * access-token `aud` check; whoever supplies
1295
+ * {@link PlatformMailDeps.oidcAudience} must have registered one by hand
1296
+ * until that is built.
1297
+ * - **The Lettermint team token the org app step reads still comes from
1298
+ * wherever the host already reads it, not held keys.** Needs
1299
+ * `StepExecutionContext` to carry the platform, which `plan-apply.ts`'s
1300
+ * own doc comment already scoped out of #167 as more than a small change
1301
+ * -- a follow-up issue, not this one. (#205 removed the org step
1302
+ * `coolify:mail`'s own fallback to the hand-built `wtfalch.dev`
1303
+ * instance -- `diff.ts`'s `planMail` already blocks the step until the
1304
+ * platform's own mail is applied, so the fallback only ever served a
1305
+ * plan recorded before that, and now fails loudly instead.)
1306
+ */
1307
+ async function provisionPlatformMail(context, deps) {
1308
+ const { platform } = context;
1309
+ const hostname = platform.mail.hostname;
1310
+ const mailHost = platformMailMxHost(platform.apex);
1311
+ const imageTag = platform.mail.gitRef;
1312
+ const recorded = context.recordedIds();
1313
+ const runtime = deps.runtime ?? {};
1314
+ const sleep = runtime.sleep ?? defaultSleep;
1315
+ const record = async (key, id) => {
1316
+ context.recordScopeId(key, id);
1317
+ await context.persist?.();
1318
+ };
1319
+ let projectUuid = recorded[PLATFORM_MAIL_PROJECT_SCOPE_KEY];
1320
+ if (!projectUuid) {
1321
+ const found = await deps.coolify.findProjectByName(PLATFORM_MAIL_PROJECT);
1322
+ projectUuid = found
1323
+ ? found.uuid
1324
+ : (await deps.coolify.createProject(PLATFORM_MAIL_PROJECT)).uuid;
1325
+ await record(PLATFORM_MAIL_PROJECT_SCOPE_KEY, projectUuid);
1326
+ }
1327
+ const { environment } = await deps.coolify.findOrCreateEnvironment(projectUuid, 'production');
1328
+ // ---- the Stalwart app itself ---------------------------------------------
1329
+ const imageTagBlocked = mailImageTagBlocked(imageTag);
1330
+ if (imageTagBlocked)
1331
+ throw new Error(imageTagBlocked);
1332
+ // One server for both the app and the MX record: recorded the first time,
1333
+ // read back after, so a later run never points `mx.<apex>` somewhere the
1334
+ // app is not.
1335
+ const server = await resolveMailServer(deps.coolify, recorded[PLATFORM_MAIL_SERVER_SCOPE_KEY]);
1336
+ await record(PLATFORM_MAIL_SERVER_SCOPE_KEY, server.uuid);
1337
+ const serverIpBlocked = mailServerIpBlocked(server.ip);
1338
+ if (serverIpBlocked)
1339
+ throw new Error(serverIpBlocked);
1340
+ const app = await findOrCreatePlatformApp(deps.coolify, PLATFORM_MAIL_APP_NAME, environment, async () => {
1341
+ return deps.coolify.createDockerImageApplication({
1342
+ project_uuid: projectUuid,
1343
+ server_uuid: server.uuid,
1344
+ environment_name: environment.name,
1345
+ environment_uuid: environment.uuid,
1346
+ docker_registry_image_name: STALWART_IMAGE,
1347
+ docker_registry_image_tag: imageTag,
1348
+ ports_exposes: STALWART_PORTS_EXPOSES,
1349
+ ports_mappings: STALWART_PORTS_MAPPINGS,
1350
+ name: PLATFORM_MAIL_APP_NAME,
1351
+ description: `Stalwart for the platform's own mail (#169, ${STALWART_IMAGE}:${imageTag})`,
1352
+ instant_deploy: false,
1353
+ });
1354
+ });
1355
+ const appId = app.appId;
1356
+ await record(PLATFORM_MAIL_APP_SCOPE_KEY, appId);
1357
+ // Set on every run, not only after a create -- same rule `provisionPlatformAuth` follows for its own apps.
1358
+ // The image tag too: a declared release change reaches the app here.
1359
+ await deps.coolify.updateApplication(appId, {
1360
+ custom_internal_name: PLATFORM_MAIL_INTERNAL_NAME,
1361
+ docker_registry_image_tag: imageTag,
1362
+ });
1363
+ // #177: org steps (an org's own `coolify:mail`/`coolify:agora`) read this
1364
+ // back instead of a literal, the same way files/ai's own internal
1365
+ // addresses are recorded.
1366
+ await record(PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, `http://${PLATFORM_MAIL_INTERNAL_NAME}:${STALWART_HTTP_PORT}`);
1367
+ // ---- storage ---------------------------------------------------------------
1368
+ const storages = await deps.coolify.listStorages(appId);
1369
+ const hasDataVolume = (storages.persistent_storages ?? []).some((s) => s.mount_path === STALWART_DATA_MOUNT);
1370
+ if (!hasDataVolume) {
1371
+ await deps.coolify.createPersistentStorage(appId, {
1372
+ name: `${PLATFORM_MAIL_APP_NAME}-data`,
1373
+ mount_path: STALWART_DATA_MOUNT,
1374
+ });
1375
+ }
1376
+ // ---- env ---------------------------------------------------------------------
1377
+ // Generated once; unlike ZITADEL's masterkey this is never re-derived from
1378
+ // "is the database already in use", because losing it is not destructive
1379
+ // the same way -- worst case a later apply cannot read it back and the
1380
+ // admin-key mint below throws, naming exactly that.
1381
+ const recoveryGenerated = await ensureGeneratedEnv(deps.coolify, appId, 'STALWART_RECOVERY_ADMIN', () => `${STALWART_RECOVERY_USER}:${randomBytes(18).toString('base64')}`);
1382
+ const envs = [
1383
+ { key: 'STALWART_PUBLIC_URL', value: `https://${hostname}` },
1384
+ ];
1385
+ if (recoveryGenerated)
1386
+ envs.push({ key: 'STALWART_RECOVERY_ADMIN', value: recoveryGenerated });
1387
+ const envsChanged = await envsDiffer(deps.coolify, appId, envs);
1388
+ await deps.coolify.bulkUpsertEnvs(appId, envs);
1389
+ const declaredChanged = recorded[PLATFORM_MAIL_DEPLOYED_HOSTNAME_SCOPE_KEY] !== hostname ||
1390
+ recorded[PLATFORM_MAIL_DEPLOYED_GIT_REF_SCOPE_KEY] !== imageTag;
1391
+ if (app.justCreated || !app.running || envsChanged || declaredChanged) {
1392
+ // A container still up from before would answer the readiness probe with
1393
+ // the old deployment's health. Stop it and see it stopped first, so the
1394
+ // wait below only ever reads the new deployment.
1395
+ if (!app.justCreated && app.running) {
1396
+ await deps.coolify.raw('POST', `/applications/${appId}/stop`);
1397
+ await waitForMailStopped(deps.coolify, appId, runtime, sleep);
1398
+ }
1399
+ await deps.coolify.deploy(appId);
1400
+ await record(PLATFORM_MAIL_DEPLOYED_SCOPE_KEY, appId);
1401
+ // Right after the deploy, so a later failure (tunnel, DNS, readiness,
1402
+ // settings, mint) does not make the next run stop and redeploy the app.
1403
+ await record(PLATFORM_MAIL_DEPLOYED_HOSTNAME_SCOPE_KEY, hostname);
1404
+ await record(PLATFORM_MAIL_DEPLOYED_GIT_REF_SCOPE_KEY, imageTag);
1405
+ }
1406
+ // ---- tunnel and DNS: web/JMAP/management ------------------------------------
1407
+ const existingTunnel = await deps.cloudflare.findTunnelByName(PLATFORM_MAIL_TUNNEL_NAME);
1408
+ const tunnel = existingTunnel ?? (await deps.cloudflare.createTunnel(PLATFORM_MAIL_TUNNEL_NAME));
1409
+ await record(PLATFORM_MAIL_TUNNEL_SCOPE_KEY, tunnel.id);
1410
+ await deps.cloudflare.putTunnelConfig(tunnel.id, {
1411
+ ingress: [
1412
+ { hostname, service: `http://${PLATFORM_MAIL_INTERNAL_NAME}:${STALWART_HTTP_PORT}` },
1413
+ { service: 'http_status:404' },
1414
+ ],
1415
+ });
1416
+ // #197: without a connector, `hostname` answers with a Cloudflare error.
1417
+ await ensureCloudflaredConnector({
1418
+ coolify: deps.coolify,
1419
+ cloudflare: deps.cloudflare,
1420
+ tunnelId: tunnel.id,
1421
+ appName: PLATFORM_MAIL_CLOUDFLARED_APP_NAME,
1422
+ projectUuid,
1423
+ environment,
1424
+ // The Stalwart app's own server, so the connector shares its Docker network.
1425
+ resolveServer: async () => server,
1426
+ recordedConnectorAppId: recorded[PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY] ?? null,
1427
+ recordAppId: (appId) => record(PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, appId),
1428
+ });
1429
+ const zoneId = recorded[apexZoneKey(platform.apex)] ?? (await exactZone(deps.cloudflare, platform.apex))?.id;
1430
+ if (!zoneId) {
1431
+ throw new Error(`platform:mail: no Cloudflare zone for ${platform.apex} to put ${hostname} in`);
1432
+ }
1433
+ const dnsTarget = `${tunnel.id}.cfargotunnel.com`;
1434
+ const [dnsRecord] = await deps.cloudflare.findDnsRecord(zoneId, hostname, 'CNAME');
1435
+ if (!dnsRecord) {
1436
+ await deps.cloudflare.createDnsRecord(zoneId, {
1437
+ type: 'CNAME',
1438
+ name: hostname,
1439
+ content: dnsTarget,
1440
+ proxied: true,
1441
+ });
1442
+ }
1443
+ else if (dnsRecord.content !== dnsTarget) {
1444
+ await deps.cloudflare.updateDnsRecord(zoneId, dnsRecord.id, {
1445
+ type: 'CNAME',
1446
+ name: hostname,
1447
+ content: dnsTarget,
1448
+ proxied: true,
1449
+ });
1450
+ }
1451
+ // ---- the MX hostname: a direct, unproxied A record --------------------------
1452
+ const serverIp = server.ip; // checked by mailServerIpBlocked above
1453
+ const [mxRecord] = await deps.cloudflare.findDnsRecord(zoneId, mailHost, 'A');
1454
+ if (!mxRecord) {
1455
+ await deps.cloudflare.createDnsRecord(zoneId, {
1456
+ type: 'A',
1457
+ name: mailHost,
1458
+ content: serverIp,
1459
+ proxied: false,
1460
+ });
1461
+ }
1462
+ else if (mxRecord.content !== serverIp) {
1463
+ await deps.cloudflare.updateDnsRecord(zoneId, mxRecord.id, {
1464
+ type: 'A',
1465
+ name: mailHost,
1466
+ content: serverIp,
1467
+ proxied: false,
1468
+ });
1469
+ }
1470
+ // ---- OIDC directory and foundry's own admin API key, once --------------------
1471
+ if (!recorded[PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY]) {
1472
+ const lost = recorded[PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY];
1473
+ if (lost) {
1474
+ throw new Error(`platform:mail: an admin API key ("${lost}") was minted but never delivered; revoke it in Stalwart and remove the recorded key ${PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY} to mint a new one`);
1475
+ }
1476
+ await waitForMailInstance(`https://${hostname}${STALWART_READY_PATH}`, runtime, sleep);
1477
+ const recoveryEnv = (await deps.coolify.listEnvs(appId)).find((e) => e.key === 'STALWART_RECOVERY_ADMIN' && !e.is_preview);
1478
+ if (!recoveryEnv) {
1479
+ throw new Error('platform:mail: STALWART_RECOVERY_ADMIN is not set on the app; cannot authenticate to mint the admin key');
1480
+ }
1481
+ const password = recoveryEnv.value.slice(`${STALWART_RECOVERY_USER}:`.length);
1482
+ try {
1483
+ const admin = deps.stalwartForInstance({
1484
+ url: `https://${hostname}`,
1485
+ user: STALWART_RECOVERY_USER,
1486
+ password,
1487
+ });
1488
+ await admin.applySettings({
1489
+ mailHost,
1490
+ oidc: { issuerUrl: `https://${platform.auth.hostname}`, audience: deps.oidcAudience },
1491
+ });
1492
+ // Recorded and persisted before the secret leaves this function: if
1493
+ // delivery fails for good, the next plan reports the lost key rather
1494
+ // than skipping or minting again.
1495
+ await record(PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION);
1496
+ const secret = await admin.createApiKey(PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, 'inherit');
1497
+ const attempts = runtime.deliverAttempts ?? 3;
1498
+ for (let attempt = 1;; attempt += 1) {
1499
+ try {
1500
+ await deps.deliverSecret('stalwart:platform:admin', secret);
1501
+ break;
1502
+ }
1503
+ catch {
1504
+ if (attempt >= attempts) {
1505
+ throw new Error(`platform:mail: the admin API key ("${PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION}") was minted but could not be delivered after ${attempts} tries`);
1506
+ }
1507
+ await sleep(1000 * attempt);
1508
+ }
1509
+ }
1510
+ await record(PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, appId);
1511
+ }
1512
+ catch (error) {
1513
+ throw redactedMailError(error, [password]);
1514
+ }
1515
+ }
1516
+ // Last, after every write the applied values depend on, so a run that
1517
+ // failed halfway plans again.
1518
+ await record(PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, hostname);
1519
+ await record(PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, imageTag);
1520
+ return { resourceId: appId };
1521
+ }
1522
+ /** Why the Stalwart image tag cannot be used, or `null`. A floating tag cannot be re-pulled by comparing tags. */
1523
+ function mailImageTagBlocked(tag) {
1524
+ if (tag.trim() === '' || tag.trim().toLowerCase() === 'latest') {
1525
+ return `the platform's mail server is blocked, not planned: the Stalwart image tag "${tag}" floats; declare a pinned release (for example v0.16.24) as the mail gitRef`;
1526
+ }
1527
+ return null;
1528
+ }
1529
+ /** Why the server's address cannot be the MX A record's content, or `null`. */
1530
+ function mailServerIpBlocked(ip) {
1531
+ if (!ip) {
1532
+ return "the platform's mail server is blocked, not planned: the Coolify server reports no ip, so mx.<apex> has no address to point at; set the server's IP in Coolify";
1533
+ }
1534
+ if (!isIPv4(ip)) {
1535
+ return `the platform's mail server is blocked, not planned: the Coolify server's ip "${ip}" is not an IPv4 address, so it cannot be the mx.<apex> A record; set the server's public IPv4 in Coolify`;
1536
+ }
1537
+ return null;
1538
+ }
1539
+ /** The recorded server when one is recorded and still listed, else the platform's usual pick. */
1540
+ async function resolveMailServer(coolify, recordedUuid) {
1541
+ if (!recordedUuid)
1542
+ return resolvePlatformServer(coolify);
1543
+ const server = (await coolify.listServers()).find((s) => s.uuid === recordedUuid);
1544
+ if (!server) {
1545
+ throw new Error(`platform:mail: the Coolify server ${recordedUuid} the mail app was created on is no longer listed`);
1546
+ }
1547
+ return server;
1548
+ }
1549
+ /**
1550
+ * The mail step's own reasons it cannot run yet, as fixed text: missing
1551
+ * dependencies or audience, a floating image tag, a server with no usable
1552
+ * address. Skipped once the step is applied at the declared values, so a
1553
+ * live app is not blocked by a later Coolify change to the server's address.
1554
+ */
1555
+ async function mailBlockedReason(platform, mail, recordedIds) {
1556
+ if (!mail) {
1557
+ return "the platform's mail server is blocked, not planned: the host built the platform executor without its mail dependencies";
1558
+ }
1559
+ if (recordedIds[`coolify:app:${PLATFORM_MAIL_KEY}`] &&
1560
+ recordedIds[PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY] === platform.mail.hostname &&
1561
+ recordedIds[PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY] === platform.mail.gitRef &&
1562
+ recordedIds[PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY]) {
1563
+ return null;
1564
+ }
1565
+ if (!mail.oidcAudience || mail.oidcAudience.trim() === '') {
1566
+ return "the platform's mail server is blocked, not planned: the host supplies no oidcAudience, the ZITADEL resource id Stalwart's OIDC directory checks access tokens against";
1567
+ }
1568
+ const tagBlocked = mailImageTagBlocked(platform.mail.gitRef);
1569
+ if (tagBlocked)
1570
+ return tagBlocked;
1571
+ try {
1572
+ const server = await resolveMailServer(mail.coolify, recordedIds[PLATFORM_MAIL_SERVER_SCOPE_KEY]);
1573
+ return mailServerIpBlocked(server.ip);
1574
+ }
1575
+ catch {
1576
+ return "the platform's mail server is blocked, not planned: no usable Coolify server could be resolved for it";
1577
+ }
1578
+ }
1579
+ /**
1580
+ * #211: the broker cannot start without `ZITADEL_SERVICE_USER_TOKEN`/`_FILE`,
1581
+ * `RESEND_API_TOKEN` and `AUTH_APPS`. `provisionPlatformAuth` already skips
1582
+ * deploying the broker while any is missing ({@link missingBrokerInputs});
1583
+ * this is the same check surfaced through `blockedSteps`, so a plan says
1584
+ * why, by name, rather than the broker simply sitting undeployed with no
1585
+ * explanation. `null` while there is no broker app id recorded yet (nothing
1586
+ * to check) or every input is present.
1587
+ *
1588
+ * #211 round 3: wraps the live read the way {@link mailBlockedReason} wraps
1589
+ * its own Coolify call -- a transient `listEnvs` failure must report a
1590
+ * blocked reason naming the broker app id, never throw out of
1591
+ * `blockedSteps` and take the rest of the plan down with it.
1592
+ *
1593
+ * #211 round 5 addendum: when `AUTH_APPS` is the reason, names which problem
1594
+ * it actually is ({@link authAppsProblem}) instead of always saying "it has
1595
+ * no ... AUTH_APPS yet" -- that wording is only true when nothing has been
1596
+ * set; a present-but-invalid value is a different failure (a person needs to
1597
+ * go fix an entry, not merely wait for one of #209/#210/Archon's own
1598
+ * registration to supply one), and reporting it identically hid which one
1599
+ * was true.
1600
+ */
1601
+ async function authBrokerBlockedReason(auth, recordedIds) {
1602
+ const brokerAppId = recordedIds[PLATFORM_AUTH_BROKER_SCOPE_KEY];
1603
+ if (!brokerAppId)
1604
+ return { reason: null, otherInputsMissing: [] };
1605
+ let missing;
1606
+ let authAppsRaw;
1607
+ try {
1608
+ missing = await missingBrokerInputs(auth.coolify, brokerAppId);
1609
+ authAppsRaw = await currentEnvValue(auth.coolify, brokerAppId, 'AUTH_APPS');
1610
+ }
1611
+ catch {
1612
+ return {
1613
+ reason: `the platform's sign-in broker ${brokerAppId} is blocked, not deployed: its live env could not be read`,
1614
+ // #216 round 5: unreadable, not "known missing" -- `planAuth` must not
1615
+ // assume the login-client mint is blocked by anything in particular.
1616
+ otherInputsMissing: [],
1617
+ };
1618
+ }
1619
+ // #216 round 5: what `provisionPlatformAuth`'s own `loginClientOtherInputsMissing`
1620
+ // gate is -- see `planAuth`'s own doc comment for why it needs this too.
1621
+ const otherInputsMissing = missing.filter((m) => m !== 'ZITADEL_SERVICE_USER_TOKEN');
1622
+ if (missing.length === 0)
1623
+ return { reason: null, otherInputsMissing };
1624
+ if (authAppsProblem(authAppsRaw, brokerAppId) === 'malformed') {
1625
+ const otherMissing = missing.filter((m) => m !== 'AUTH_APPS');
1626
+ const otherSuffix = otherMissing.length > 0
1627
+ ? `, and it has no ${otherMissing.join(' and no ')} yet (#209, #210)`
1628
+ : '';
1629
+ return {
1630
+ reason: `the platform's sign-in broker ${brokerAppId} is blocked, not deployed: its AUTH_APPS is malformed${otherSuffix}`,
1631
+ otherInputsMissing,
1632
+ };
1633
+ }
1634
+ // AUTH_APPS isn't gated on #209/#210 the way the two tokens are; only cite
1635
+ // those issues when one of them is actually among what's missing.
1636
+ const suffix = missing.some((m) => m !== 'AUTH_APPS') ? ' (#209, #210)' : '';
1637
+ return {
1638
+ reason: `the platform's sign-in broker is created but not deployed, blocked: it has no ${missing.join(' and no ')} yet${suffix}`,
1639
+ otherInputsMissing,
1640
+ };
1641
+ }
1642
+ /** Polls until Coolify reports the mail app stopped, so a healthy answer read afterwards is the new deployment's. */
1643
+ async function waitForMailStopped(coolify, appId, runtime, sleep) {
1644
+ const now = runtime.now ?? Date.now;
1645
+ const timeout = runtime.readyTimeoutMs ?? 300_000;
1646
+ const interval = runtime.readyIntervalMs ?? 5_000;
1647
+ const deadline = now() + timeout;
1648
+ for (;;) {
1649
+ const { status } = await coolify.getApplication(appId);
1650
+ if (!status || !isRunningOrStarting(status))
1651
+ return;
1652
+ if (now() >= deadline) {
1653
+ throw new Error(`platform:mail: the Stalwart app ${appId} did not stop within ${Math.round(timeout / 1000)}s (last status ${status ?? 'unknown'}); apply again`);
1654
+ }
1655
+ await sleep(interval);
1656
+ }
1657
+ }
1658
+ /** Polls `url` until it answers 2xx, or throws once `readyTimeoutMs` has passed. */
1659
+ async function waitForMailInstance(url, runtime, sleep) {
1660
+ const fetchImpl = runtime.fetch ?? globalThis.fetch;
1661
+ const now = runtime.now ?? Date.now;
1662
+ const timeout = runtime.readyTimeoutMs ?? 300_000;
1663
+ const interval = runtime.readyIntervalMs ?? 5_000;
1664
+ const deadline = now() + timeout;
1665
+ for (;;) {
1666
+ try {
1667
+ const response = await fetchImpl(url);
1668
+ if (response.ok)
1669
+ return;
1670
+ }
1671
+ catch {
1672
+ // Not up yet: DNS, the tunnel or the container.
1673
+ }
1674
+ if (now() >= deadline) {
1675
+ throw new Error(`platform:mail: the new Stalwart instance did not answer at ${url} within ${Math.round(timeout / 1000)}s; retry this apply`);
1676
+ }
1677
+ await sleep(interval);
1678
+ }
1679
+ }
1680
+ function redactedMailError(error, secrets) {
1681
+ const original = error instanceof Error ? error : new Error(String(error));
1682
+ const message = redactSecrets(original.message, secrets);
1683
+ const stack = redactSecrets(original.stack ?? message, secrets);
1684
+ if (message === original.message && stack === original.stack)
1685
+ return original;
1686
+ const clean = new Error(message);
1687
+ clean.name = original.name;
1688
+ clean.stack = stack;
1689
+ return clean;
1690
+ }
1691
+ const PLATFORM_ARCHON_PROJECT = 'platform';
1692
+ const PLATFORM_ARCHON_APP_NAME = 'platform-archon';
1693
+ const PLATFORM_ARCHON_DB_NAME = 'platform-archon-db';
1694
+ /**
1695
+ * The database Coolify creates for Archon. Archon's own
1696
+ * `drizzle/0001_runtime_role.sql` names its runtime role
1697
+ * `current_database() || '_rt'`, so {@link ARCHON_RUNTIME_ROLE} follows.
1698
+ */
1699
+ const ARCHON_POSTGRES_DB = 'archon';
1700
+ const ARCHON_RUNTIME_ROLE = `${ARCHON_POSTGRES_DB}_rt`;
1701
+ /** Fixed so the tunnel addresses the container directly (see `provisionPlatformAuth`). */
1702
+ const PLATFORM_ARCHON_INTERNAL_NAME = 'platform-archon';
1703
+ const PLATFORM_ARCHON_TUNNEL_NAME = 'platform-archon-tunnel';
1704
+ /** #197: the one Coolify app that runs `cloudflared` for {@link PLATFORM_ARCHON_TUNNEL_NAME}. */
1705
+ const PLATFORM_ARCHON_CLOUDFLARED_APP_NAME = 'platform-archon-cloudflared';
1706
+ /** https://github.com/wtfalch/archon: one Dockerfile at its root, `runner` stage on port 3000. */
1707
+ const ARCHON_REPO = 'wtfalch/archon';
1708
+ const ARCHON_PORT = '3000';
1709
+ /**
1710
+ * The name of foundry's own Cloudflare Access app in front of Archon. Same
1711
+ * `foundry:<key>` convention `executor.ts`'s `cloudflareAccess` uses for an
1712
+ * org (`foundry:<orgId>`), with the platform's step key in the org id's place.
1713
+ */
1714
+ export const PLATFORM_ARCHON_ACCESS_APP_NAME = `foundry:${PLATFORM_ARCHON_KEY}`;
1715
+ /** The ZITADEL project and OIDC app Archon's sign-in client lives in. */
1716
+ const PLATFORM_ARCHON_ZITADEL_PROJECT = 'archon';
1717
+ const PLATFORM_ARCHON_ZITADEL_APP = 'archon';
1718
+ const PLATFORM_ARCHON_PROJECT_SCOPE_KEY = 'coolify_project_platform_archon';
1719
+ const PLATFORM_ARCHON_DB_SCOPE_KEY = 'coolify_platform_archon_database';
1720
+ const PLATFORM_ARCHON_TUNNEL_SCOPE_KEY = 'cloudflare_platform_archon_tunnel';
1721
+ const PLATFORM_ARCHON_ACCESS_SCOPE_KEY = 'cloudflare_platform_archon_access_app';
1722
+ /** The platform's own ZITADEL org (`foundry`, the one `provisionPlatformAuth` mints its admin user in). */
1723
+ export const PLATFORM_ZITADEL_ORG_SCOPE_KEY = 'zitadel_platform_org';
1724
+ const PLATFORM_ARCHON_ZITADEL_PROJECT_SCOPE_KEY = 'zitadel_platform_archon_project';
1725
+ const PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY = 'zitadel_platform_archon_app';
1726
+ /** The hostname the sign-in client's redirect URIs were made for; a change makes a new client. */
1727
+ const PLATFORM_ARCHON_CLIENT_HOSTNAME_SCOPE_KEY = 'zitadel_platform_archon_client_hostname';
1728
+ /** Archon's OIDC client id: public, not a secret, so a plain scope id. */
1729
+ export const PLATFORM_ARCHON_CLIENT_ID_SCOPE_KEY = 'zitadel_platform_archon_client_id';
1730
+ /**
1731
+ * #175: makes `coolify:app` keyed {@link PLATFORM_ARCHON_KEY} true: Archon
1732
+ * from https://github.com/wtfalch/archon on `platform.archon.gitRef`, its own
1733
+ * Postgres, a sign-in client in the platform's own ZITADEL, a Cloudflare
1734
+ * Access app that lets only {@link PlatformArchonDeps.accessEmails} through,
1735
+ * and a tunnel plus DNS record at `platform.archon.hostname`.
1736
+ *
1737
+ * One Coolify app, no separate migrate job: Archon's `Dockerfile` has no
1738
+ * migration stage (its closing note says the old one was removed), and its
1739
+ * `runner` starts with `scripts/migrate-and-start.sh`, which applies
1740
+ * `drizzle/*.sql` as `DATABASE_URL_OWNER`, creates the `<database>_rt` runtime
1741
+ * role, sets its password from `DATABASE_URL`, unsets the owner URL and only
1742
+ * then starts the server.
1743
+ *
1744
+ * Order matters in two places. The Access app is created before the DNS
1745
+ * record, so the hostname never resolves without the gate in front of it
1746
+ * (Archon's `docs/operator-deployment.md`: "Install the Access gate before
1747
+ * publishing DNS"). The sign-in client is created before the env push, so
1748
+ * `AUTH_CLIENT_ID` is in the first deploy.
1749
+ *
1750
+ * Env written from the applied platform and held keys, never pasted:
1751
+ * - `APP_URL`, `AUTH_ISSUER` (`https://<auth.hostname>`),
1752
+ * `AUTH_ORGANIZATION_ID` and `AUTH_CLIENT_ID` (both from ZITADEL, this run),
1753
+ * `AUTH_COOKIE_SECRET` (generated once, never rotated).
1754
+ * - `DATABASE_URL_OWNER` (Coolify's `internal_db_url`) and `DATABASE_URL`
1755
+ * (the `archon_rt` runtime role, its password minted once, never rotated,
1756
+ * the same rule `executor.ts`'s `ensureCmsRuntimeUrl` follows). A URL that
1757
+ * does not parse fails the step without printing it.
1758
+ * - For each of files, ai, integrations and jobs that the platform declares
1759
+ * enabled: its `https://<hostname>` URL, and its management key when
1760
+ * {@link PlatformArchonDeps.heldKeys} holds one.
1761
+ *
1762
+ * The OIDC client is ZITADEL's public web client (authorization code with
1763
+ * PKCE, no client secret: `createOidcApp` never returns one, see
1764
+ * `executor.ts`'s `zitadelApp`), which is what Archon's `@wtfalch/auth`
1765
+ * sign-in reads -- it takes `AUTH_CLIENT_ID` and no client secret. So there
1766
+ * is no one-time secret to hand to `deliverSecret` here. The three values this
1767
+ * step does generate (cookie secret, runtime-role password, `AUTH_APP_KEY`)
1768
+ * go straight into the app's env and nowhere else: never returned, recorded,
1769
+ * logged or put in an error.
1770
+ *
1771
+ * `AUTH_APP_KEY` (#204): minted once, the same `ensureGeneratedEnv` way as
1772
+ * the cookie secret, and registered with the broker by rewriting its
1773
+ * `AUTH_APPS` env to include Archon's entry -- `deliverSecret` is not the
1774
+ * right shape here, since the destination is another app's env this same
1775
+ * function already writes to, not a human or a secret store. Requires
1776
+ * {@link PLATFORM_AUTH_BROKER_SCOPE_KEY} to be recorded, which
1777
+ * `provisionPlatformAuth` guarantees by the time this step is reachable (its
1778
+ * own resourceId records only once the broker app exists too). The broker
1779
+ * will not actually be able to serve a request with this key until
1780
+ * `provisionPlatformAuth`'s own "Known gaps" (`ZITADEL_SERVICE_USER_TOKEN`,
1781
+ * `RESEND_API_TOKEN`) are closed; wiring the key here is still correct and
1782
+ * needed so the two apps agree the moment the broker can run.
1783
+ *
1784
+ * Known gaps, left for review:
1785
+ * - `AUTHZ_BOOTSTRAP_OPERATOR`: the first operator's ZITADEL subject id. A
1786
+ * person has to exist in the new instance first; not something foundry
1787
+ * mints.
1788
+ * - Foundry's own database URL. Archon runs foundry in its panel, but foundry
1789
+ * does not know its own connection string at plan/apply time: that is host
1790
+ * config, not something `DeclaredPlatform` or held keys carry.
1791
+ * - The refs and scope ids Archon reads from the files, ai and integrations
1792
+ * steps are exported by those steps' own modules. A
1793
+ * missing one blocks the plan (`blockedSteps`); it is never skipped. The
1794
+ * env names `AI_*`, `INTEGRATIONS_*`, `JOBS_URL` and `JOBS_ADMIN_TOKEN` are
1795
+ * this step's proposal; Archon reads only `FILES_URL` and
1796
+ * `FILES_MANAGEMENT_KEY` of these today. Files' signed-assertion identity
1797
+ * (`FILES_MANAGEMENT_ISSUER`, `_KEY_ID`, `_PRIVATE_KEY`, `_SCOPE`) and
1798
+ * `FILES_ORGANISATION_ID` are not wired.
1799
+ * - The step plans again when the hostname, `gitRef` or the Access policy's
1800
+ * include list changes. Env for a service enabled later is not pushed until
1801
+ * one of those changes.
1802
+ * - No API updates an app's Access policy, so a changed include list closes
1803
+ * the tunnel hostname, deletes the Access app and builds it again.
1804
+ * - #197: a `cloudflared` connector runs for this tunnel, as in `provisionPlatformAuth`.
1805
+ * - Retiring `operator.wtfalch.dev` stays a human step.
1806
+ */
1807
+ async function provisionPlatformArchon(context, deps) {
1808
+ const { platform } = context;
1809
+ const hostname = platform.archon.hostname;
1810
+ const gitRef = platform.archon.gitRef;
1811
+ const recorded = context.recordedIds();
1812
+ const issuer = `https://${platform.auth.hostname}`;
1813
+ const appliedGitRef = recorded[PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY];
1814
+ if (deps.accessEmails.length === 0) {
1815
+ throw new Error('platform:archon: no Access emails given; refusing to put Archon behind an Access app nobody can pass');
1816
+ }
1817
+ // The sign-in client below is created inside the platform's own ZITADEL,
1818
+ // which `platform:auth` builds. The planner orders that step first and
1819
+ // blocks this one while auth is off; this is the same rule for a caller
1820
+ // that reaches the step anyway.
1821
+ if (!platform.auth.enabled || !recorded[`coolify:app:${PLATFORM_AUTH_KEY}`]) {
1822
+ throw new Error("platform:archon: the platform's own sign-in (platform:auth) has not been applied yet; Archon's sign-in client lives in it");
1823
+ }
1824
+ const record = async (key, id) => {
1825
+ context.recordScopeId(key, id);
1826
+ await context.persist?.();
1827
+ };
1828
+ // Every service the platform declares must have delivered what Archon reads.
1829
+ // The planner blocks first; this is the same rule for a caller that reaches
1830
+ // the step anyway, and it is where the values are read for the env below.
1831
+ const inputs = await collectArchonServiceInputs(platform, deps, recorded);
1832
+ if ('blocked' in inputs)
1833
+ throw new Error(`platform:archon: ${inputs.blocked}`);
1834
+ // #213: Archon's own onboarding reads these back as held keys before it
1835
+ // can start. Put before anything below touches Coolify, so a host that
1836
+ // cannot put them never gets a half-built app.
1837
+ if (!deps.masterKeys || !deps.heldKeys?.put) {
1838
+ throw new Error("platform:archon: the host built the platform executor without a way to put its master keys (masterKeys, and heldKeys with a put) -- Archon's onboarding reads them as held keys before it can start (#213)");
1839
+ }
1840
+ await putArchonMasterKeys(deps.heldKeys, deps.masterKeys);
1841
+ // ---- Coolify project and database -----------------------------------------
1842
+ let projectUuid = recorded[PLATFORM_ARCHON_PROJECT_SCOPE_KEY];
1843
+ if (!projectUuid) {
1844
+ const found = await deps.coolify.findProjectByName(PLATFORM_ARCHON_PROJECT);
1845
+ projectUuid = found
1846
+ ? found.uuid
1847
+ : (await deps.coolify.createProject(PLATFORM_ARCHON_PROJECT)).uuid;
1848
+ await record(PLATFORM_ARCHON_PROJECT_SCOPE_KEY, projectUuid);
1849
+ }
1850
+ const { environment } = await deps.coolify.findOrCreateEnvironment(projectUuid, 'production');
1851
+ let dbId = recorded[PLATFORM_ARCHON_DB_SCOPE_KEY];
1852
+ if (!dbId) {
1853
+ const server = await resolvePlatformServer(deps.coolify);
1854
+ const created = await deps.coolify.createPostgresDatabase({
1855
+ project_uuid: projectUuid,
1856
+ server_uuid: server.uuid,
1857
+ environment_name: environment.name,
1858
+ environment_uuid: environment.uuid,
1859
+ name: PLATFORM_ARCHON_DB_NAME,
1860
+ postgres_db: ARCHON_POSTGRES_DB,
1861
+ postgres_user: ARCHON_POSTGRES_DB,
1862
+ });
1863
+ dbId = created.uuid;
1864
+ await record(PLATFORM_ARCHON_DB_SCOPE_KEY, dbId);
1865
+ }
1866
+ const db = await deps.coolify.getDatabase(dbId);
1867
+ if (!db.internal_db_url) {
1868
+ throw new Error('coolify:app for the platform (archon): the database has no internal_db_url yet -- retry this apply once Coolify reports one');
1869
+ }
1870
+ // ---- the app ----------------------------------------------------------------
1871
+ const app = await findOrCreatePlatformApp(deps.coolify, PLATFORM_ARCHON_APP_NAME, environment, async () => {
1872
+ const server = await resolvePlatformServer(deps.coolify);
1873
+ const githubApp = await deps.coolify.requireGithubApp();
1874
+ return deps.coolify.createGithubAppApplication({
1875
+ project_uuid: projectUuid,
1876
+ server_uuid: server.uuid,
1877
+ environment_name: environment.name,
1878
+ environment_uuid: environment.uuid,
1879
+ github_app_uuid: githubApp.uuid,
1880
+ git_repository: ARCHON_REPO,
1881
+ git_branch: gitRef,
1882
+ build_pack: 'dockerfile',
1883
+ name: PLATFORM_ARCHON_APP_NAME,
1884
+ });
1885
+ });
1886
+ const appId = app.appId;
1887
+ // Set on every run: the tunnel addresses the app by this name, and a throw
1888
+ // right after the create must not leave it unnamed for good.
1889
+ // `git_branch` is patched too, so a changed `gitRef` reaches the app; the
1890
+ // deploy below is decided from the last applied `gitRef`.
1891
+ await deps.coolify.updateApplication(appId, {
1892
+ custom_internal_name: PLATFORM_ARCHON_INTERNAL_NAME,
1893
+ git_branch: gitRef,
1894
+ });
1895
+ // ---- sign-in client in the platform's own ZITADEL -------------------------
1896
+ const zitadel = deps.zitadelForInstance?.(issuer) ?? deps.zitadel;
1897
+ const { org } = await zitadel.findOrCreateOrg(PLATFORM_AUTH_ORG_NAME);
1898
+ if (recorded[PLATFORM_ZITADEL_ORG_SCOPE_KEY] !== org.id) {
1899
+ await record(PLATFORM_ZITADEL_ORG_SCOPE_KEY, org.id);
1900
+ }
1901
+ const { project } = await zitadel.findOrCreateProject(org.id, PLATFORM_ARCHON_ZITADEL_PROJECT);
1902
+ if (recorded[PLATFORM_ARCHON_ZITADEL_PROJECT_SCOPE_KEY] !== project.id) {
1903
+ await record(PLATFORM_ARCHON_ZITADEL_PROJECT_SCOPE_KEY, project.id);
1904
+ }
1905
+ // The client's redirect URIs name the hostname, and no call updates them, so
1906
+ // a client made for another hostname is deleted and made again. Its id is
1907
+ // public and there is no secret to lose.
1908
+ let clientId = recorded[PLATFORM_ARCHON_CLIENT_HOSTNAME_SCOPE_KEY] === hostname
1909
+ ? recorded[PLATFORM_ARCHON_CLIENT_ID_SCOPE_KEY]
1910
+ : undefined;
1911
+ if (!clientId) {
1912
+ // An app under this name with no usable client id is one whose create
1913
+ // response was lost, or one made for another hostname: its client id
1914
+ // cannot be read back (`listApps` does not carry it), so it is deleted and
1915
+ // made again, as `zitadelApp` does.
1916
+ const orphan = (await zitadel.listApps(org.id, project.id)).find((a) => a.name === PLATFORM_ARCHON_ZITADEL_APP &&
1917
+ a.appType === 'web' &&
1918
+ a.state !== 'APP_STATE_REMOVED');
1919
+ if (orphan) {
1920
+ try {
1921
+ await zitadel.deleteApp(org.id, project.id, orphan.id);
1922
+ }
1923
+ catch (error) {
1924
+ if (!isNotFoundError(error))
1925
+ throw error;
1926
+ }
1927
+ }
1928
+ const created = await zitadel.createOidcApp(org.id, project.id, {
1929
+ name: PLATFORM_ARCHON_ZITADEL_APP,
1930
+ appType: 'web',
1931
+ // `@wtfalch/auth`'s default callback path (Archon's own
1932
+ // `docs/operator-deployment.md`, step 4).
1933
+ redirectUris: [`https://${hostname}/auth/callback`],
1934
+ postLogoutRedirectUris: [`https://${hostname}/`],
1935
+ });
1936
+ context.recordScopeId(PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY, created.appId);
1937
+ context.recordScopeId(PLATFORM_ARCHON_CLIENT_HOSTNAME_SCOPE_KEY, hostname);
1938
+ await record(PLATFORM_ARCHON_CLIENT_ID_SCOPE_KEY, created.clientId);
1939
+ clientId = created.clientId;
1940
+ }
1941
+ // ---- env ------------------------------------------------------------------
1942
+ const envs = [
1943
+ { key: 'APP_URL', value: `https://${hostname}` },
1944
+ { key: 'AUTH_ISSUER', value: issuer },
1945
+ { key: 'AUTH_ORGANIZATION_ID', value: org.id },
1946
+ { key: 'AUTH_CLIENT_ID', value: clientId },
1947
+ { key: 'DATABASE_URL_OWNER', value: db.internal_db_url },
1948
+ { key: 'REPORTING_SITE', value: 'archon' },
1949
+ ];
1950
+ const cookieSecret = await ensureGeneratedEnv(deps.coolify, appId, 'AUTH_COOKIE_SECRET', () => randomBytes(32).toString('base64'));
1951
+ if (cookieSecret)
1952
+ envs.push({ key: 'AUTH_COOKIE_SECRET', value: cookieSecret });
1953
+ const runtimeUrl = await ensureArchonRuntimeUrl(deps.coolify, appId, db.internal_db_url);
1954
+ if (runtimeUrl)
1955
+ envs.push({ key: 'DATABASE_URL', value: runtimeUrl });
1956
+ // `AUTH_APP_KEY` (#204): minted once like the cookie secret above, but read
1957
+ // back (never regenerated) rather than skipped when already set, since its
1958
+ // value is needed below to keep the broker's own registration in sync even
1959
+ // on a run that mints nothing new. `randomBytes(32)` (#211), the same
1960
+ // random half `wtfalch/auth/broker/src/apps.ts`'s own `mintKey` gives an
1961
+ // app key -- `parseApps` there rejects a key under 32 *characters*, and
1962
+ // `randomBytes(24)` base64url-encodes to exactly 32, landing right on that
1963
+ // edge with no margin for a different encoding or a trim.
1964
+ // #211 round 5: `minLength: 32` on this call only -- a live value shorter
1965
+ // than the broker itself will accept is exactly as unusable as one that is
1966
+ // empty, and `readBrokerApps`/`validateBrokerAppEntry` would refuse it the
1967
+ // moment this step tried to register it. See `ensureGeneratedEnv`'s own
1968
+ // doc comment for why no other call site gets this argument.
1969
+ const appKey = (await ensureGeneratedEnv(deps.coolify, appId, 'AUTH_APP_KEY', () => randomBytes(32).toString('base64url'), 32)) ?? (await currentEnvValue(deps.coolify, appId, 'AUTH_APP_KEY'));
1970
+ if (!appKey) {
1971
+ throw new Error(`platform:archon: app ${appId} lost its AUTH_APP_KEY between two reads`);
1972
+ }
1973
+ envs.push({ key: 'AUTH_APP_KEY', value: appKey });
1974
+ envs.push(...inputs.envs);
1975
+ const changed = await envsDiffer(deps.coolify, appId, envs);
1976
+ await deps.coolify.bulkUpsertEnvs(appId, envs);
1977
+ // ---- register with the broker (#204) ---------------------------------------
1978
+ // After Archon's own `AUTH_APP_KEY` is durably written above, so a throw
1979
+ // earlier in this function (a bad database URL, a Cloudflare failure) never
1980
+ // touches the broker. `provisionPlatformAuth`'s own resourceId records only
1981
+ // once the broker app exists too (it is created before that function
1982
+ // returns), and this step is unreachable until that resourceId is recorded
1983
+ // (the guard at the top of this function) -- so a missing id here means a
1984
+ // caller reached this step some other way.
1985
+ const brokerAppId = recorded[PLATFORM_AUTH_BROKER_SCOPE_KEY];
1986
+ if (!brokerAppId) {
1987
+ throw new Error('platform:archon: platform:auth has not recorded its broker app id yet; apply platform:auth again');
1988
+ }
1989
+ const brokerApps = await readBrokerApps(deps.coolify, brokerAppId);
1990
+ // #211 round 2: merged into the matching entry, not replacing it -- the
1991
+ // broker's own `App` (`broker/src/apps.ts`) carries fields this step never
1992
+ // sets (`registration`, `locale`, `productName`, `emailLink`), and a
1993
+ // previous `filter`-then-append dropped every one of them, plus every
1994
+ // *other* app this organisation may have registered directly through the
1995
+ // broker's own registration API. Matched on `organizationId` and `name`
1996
+ // together: Archon is one app of possibly several this organisation runs,
1997
+ // and only Archon's own entry is this step's to touch.
1998
+ // #211 round 3: every entry matching Archon's org and name, not just the
1999
+ // first -- a previous bug, or a hand-edited AUTH_APPS, can leave more than
2000
+ // one. The first is kept (and updated below); the rest are dropped, so no
2001
+ // duplicate carries a stale key a broker client could still present.
2002
+ const archonMatchIndices = brokerApps
2003
+ .map((a, i) => (a.organizationId === org.id && a.name === PLATFORM_ARCHON_ZITADEL_APP ? i : -1))
2004
+ .filter((i) => i >= 0);
2005
+ const archonEntryIndex = archonMatchIndices[0] ?? -1;
2006
+ const duplicateIndices = new Set(archonMatchIndices.slice(1));
2007
+ const archonEntry = {
2008
+ ...(archonEntryIndex >= 0 ? brokerApps[archonEntryIndex] : {}),
2009
+ organizationId: org.id,
2010
+ key: appKey,
2011
+ name: PLATFORM_ARCHON_ZITADEL_APP,
2012
+ clientIds: [clientId],
2013
+ origins: [`https://${hostname}`],
2014
+ // #211: `wtfalch/auth`'s broker (`broker/src/apps.ts`'s `parseAppFields`)
2015
+ // falls back to `wtfalch <noreply@wtfalch.dev>` for an entry with no
2016
+ // `from`, and (`wtfalch/auth#179`) an entry with no `from` is dropped
2017
+ // entirely -- either way, unset here means Archon's mail comes from
2018
+ // the wrong platform or not at all.
2019
+ from: `Company Included <noreply@${platform.apex}>`,
2020
+ };
2021
+ const desiredBrokerApps = archonEntryIndex >= 0
2022
+ ? brokerApps
2023
+ .map((a, i) => (i === archonEntryIndex ? archonEntry : a))
2024
+ .filter((_, i) => !duplicateIndices.has(i))
2025
+ : [...brokerApps, archonEntry];
2026
+ const desiredAuthApps = [{ key: 'AUTH_APPS', value: JSON.stringify(desiredBrokerApps) }];
2027
+ // Rebuilt and compared every run (not gated on a freshly minted key), so a
2028
+ // clientId or hostname change reaches the broker too, and so a previous
2029
+ // run that wrote Archon's own key but failed before reaching the broker is
2030
+ // repaired by this one.
2031
+ const authAppsChanged = await envsDiffer(deps.coolify, brokerAppId, desiredAuthApps);
2032
+ if (authAppsChanged) {
2033
+ await deps.coolify.bulkUpsertEnvs(brokerAppId, desiredAuthApps);
2034
+ }
2035
+ // #211 round 2: deploying the broker before its own `server.ts` can start
2036
+ // (missing `ZITADEL_SERVICE_USER_TOKEN`/`_FILE` or `RESEND_API_TOKEN`, #209,
2037
+ // #210) only produces a container that crash-loops behind `/api` -- the
2038
+ // same rule `provisionPlatformAuth` follows for its own deploy of this
2039
+ // app. The env is still written either way, so the broker serves the new
2040
+ // registration the moment it is next deployed, by either step.
2041
+ // #211 round 5: the deploy decision is {@link brokerDeployNeeded}, the same
2042
+ // rule `provisionPlatformAuth` uses -- it is what replaces the old
2043
+ // `!brokerAlreadyDeployed` boolean, and with it catches both a broker
2044
+ // recreated under a new app id (this step's own last-registered-broker
2045
+ // check, `archonBrokerRegistrationDrifted`, is a different, already-correct
2046
+ // mechanism for *replanning*; this is what stops the *executor* from
2047
+ // reading a stale record as already deployed) and a crash between this
2048
+ // run's own `AUTH_APPS` write above and its deploy -- a rerun recomputes
2049
+ // the identical `desiredAuthApps` value, sees no recorded deploy naming
2050
+ // that hash, and deploys again. `desiredAuthApps[0].value` (not a re-read)
2051
+ // is the exact string now live on the app either way: written just above
2052
+ // when `authAppsChanged`, already matching when it was not.
2053
+ const brokerMissingInputs = await missingBrokerInputs(deps.coolify, brokerAppId);
2054
+ const recordedBrokerDeploy = parseBrokerDeployRecord(recorded[PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY]);
2055
+ const desiredAuthAppsRaw = desiredAuthApps[0]?.value;
2056
+ if (brokerDeployNeeded(brokerMissingInputs, brokerAppId, desiredAuthAppsRaw, recordedBrokerDeploy)) {
2057
+ await deps.coolify.deploy(brokerAppId);
2058
+ await record(PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, brokerDeployRecordValue(brokerAppId, desiredAuthAppsRaw));
2059
+ }
2060
+ // ---- Cloudflare Access, before the hostname resolves ----------------------
2061
+ let gateClosed = Boolean(recorded[PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY]);
2062
+ const accessAppId = await ensureArchonAccess(deps.cloudflare, hostname, deps.accessEmails, async () => {
2063
+ // The Access app is about to be rebuilt: answer 404 for every hostname
2064
+ // meanwhile, so it is never served without the gate. The marker is
2065
+ // persisted first: a failure before the real ingress is written back
2066
+ // leaves it set, and the next plan repairs the step.
2067
+ gateClosed = true;
2068
+ await record(PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, '1');
2069
+ const tunnel = await deps.cloudflare.findTunnelByName(PLATFORM_ARCHON_TUNNEL_NAME);
2070
+ if (tunnel)
2071
+ await deps.cloudflare.putTunnelConfig(tunnel.id, {
2072
+ ingress: [{ service: 'http_status:404' }],
2073
+ });
2074
+ });
2075
+ if (recorded[PLATFORM_ARCHON_ACCESS_SCOPE_KEY] !== accessAppId) {
2076
+ await record(PLATFORM_ARCHON_ACCESS_SCOPE_KEY, accessAppId);
2077
+ }
2078
+ // ---- deploy ---------------------------------------------------------------
2079
+ if (app.justCreated ||
2080
+ !app.running ||
2081
+ changed ||
2082
+ appliedGitRef !== gitRef ||
2083
+ recorded[PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY] !== hostname) {
2084
+ await deps.coolify.deploy(appId);
2085
+ }
2086
+ // ---- tunnel and DNS ---------------------------------------------------------
2087
+ const existingTunnel = await deps.cloudflare.findTunnelByName(PLATFORM_ARCHON_TUNNEL_NAME);
2088
+ const tunnel = existingTunnel ?? (await deps.cloudflare.createTunnel(PLATFORM_ARCHON_TUNNEL_NAME));
2089
+ if (recorded[PLATFORM_ARCHON_TUNNEL_SCOPE_KEY] !== tunnel.id) {
2090
+ await record(PLATFORM_ARCHON_TUNNEL_SCOPE_KEY, tunnel.id);
2091
+ }
2092
+ await deps.cloudflare.putTunnelConfig(tunnel.id, {
2093
+ ingress: [
2094
+ { hostname, service: `http://${PLATFORM_ARCHON_INTERNAL_NAME}:${ARCHON_PORT}` },
2095
+ { service: 'http_status:404' },
2096
+ ],
2097
+ });
2098
+ if (gateClosed) {
2099
+ await record(PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, '');
2100
+ }
2101
+ // #197: without a connector, `hostname` answers with a Cloudflare error.
2102
+ await ensureCloudflaredConnector({
2103
+ coolify: deps.coolify,
2104
+ cloudflare: deps.cloudflare,
2105
+ tunnelId: tunnel.id,
2106
+ appName: PLATFORM_ARCHON_CLOUDFLARED_APP_NAME,
2107
+ projectUuid,
2108
+ environment,
2109
+ resolveServer: () => resolvePlatformServer(deps.coolify),
2110
+ recordedConnectorAppId: recorded[PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY] ?? null,
2111
+ recordAppId: (appId) => record(PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, appId),
2112
+ });
2113
+ const zoneId = recorded[apexZoneKey(platform.apex)] ?? (await exactZone(deps.cloudflare, platform.apex))?.id;
2114
+ if (!zoneId) {
2115
+ throw new Error(`platform:archon: no Cloudflare zone for ${platform.apex} to put ${hostname} in`);
2116
+ }
2117
+ const dnsTarget = `${tunnel.id}.cfargotunnel.com`;
2118
+ const [dnsRecord] = await deps.cloudflare.findDnsRecord(zoneId, hostname, 'CNAME');
2119
+ if (!dnsRecord) {
2120
+ await deps.cloudflare.createDnsRecord(zoneId, {
2121
+ type: 'CNAME',
2122
+ name: hostname,
2123
+ content: dnsTarget,
2124
+ proxied: true,
2125
+ });
2126
+ }
2127
+ else if (dnsRecord.content !== dnsTarget) {
2128
+ await deps.cloudflare.updateDnsRecord(zoneId, dnsRecord.id, {
2129
+ type: 'CNAME',
2130
+ name: hostname,
2131
+ content: dnsTarget,
2132
+ proxied: true,
2133
+ });
2134
+ }
2135
+ // Last, after every write the applied values depend on, so a run that failed
2136
+ // halfway plans again.
2137
+ await record(PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, hostname);
2138
+ await record(PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, gitRef);
2139
+ // #211 round 4: the broker this run actually registered Archon's entry
2140
+ // with, so `planArchon` can tell a broker recreated under a new app id
2141
+ // (whose AUTH_APPS never carried this registration) from one still holding
2142
+ // it.
2143
+ await record(PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, brokerAppId);
2144
+ return { resourceId: appId };
2145
+ }
2146
+ // #211 round 6: mirrors `wtfalch/auth/broker/src/apps.ts`'s own `isValidFrom`
2147
+ // (and the `isSafeText`/control-character checks it shares with
2148
+ // `parseCommonAppFields`) exactly, character class for character class.
2149
+ // That module is `@wtfalch/auth-broker`, `private: true`, and exports
2150
+ // neither function, so there is nothing to import -- this has to stay a
2151
+ // faithful copy. Round 5's version only checked that an address sat inside
2152
+ // `<...>`, not the display name in front of it, so `bad<name> <a@b.co>` and
2153
+ // a name carrying a right-to-left override both passed this step's gate and
2154
+ // were then silently dropped by the broker's own parser at boot (`AUTH_APPS
2155
+ // has no usable entries` if it was the only one). Kept in lockstep by hand;
2156
+ // if the broker's rule ever changes, this one has to change with it.
2157
+ const FROM_MAX_LENGTH = 320;
2158
+ const LOCAL_PART = '[A-Za-z0-9._%+-]+';
2159
+ const DOMAIN_LABEL = '[A-Za-z0-9-]+';
2160
+ const SENDER_ADDRESS_RE = new RegExp(`^${LOCAL_PART}@${DOMAIN_LABEL}(?:\\.${DOMAIN_LABEL})*$`);
2161
+ const SENDER_NAME_CHAR = "[\\p{L}\\p{M}\\d .,'&()!\\u2019\\u2013-]";
2162
+ const SENDER_NAME_RE = new RegExp(`^${SENDER_NAME_CHAR}+$`, 'u');
2163
+ const NAMED_FROM_RE = /^(.*)<([^<>]+)>$/;
2164
+ // Every control character (`\p{Cc}`), every format character (`\p{Cf}`, how
2165
+ // an RTL override hides a reordering attack), both Unicode line/paragraph
2166
+ // separators, and the two blanks-in-disguise `trim()` cannot be trusted to
2167
+ // strip first (`\u00a0` NBSP, `\ufeff` BOM).
2168
+ const OTHER_UNSAFE_RE = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\u00a0\ufeff]/u;
2169
+ // #218 round 2: `\p{Cf}` also matches U+200C (ZWNJ) and U+200D (ZWJ), which
2170
+ // a real name can carry -- ZWNJ joins letters into one word in
2171
+ // Persian/Arabic script, ZWJ joins separate emoji into one glyph. Rejecting
2172
+ // every `\p{Cf}` character missed that the broker's own `hasUnsafeText`
2173
+ // exempts both, but only immediately between two letters or two emoji: a
2174
+ // loose one at either end, or beside anything else, is exactly the kind of
2175
+ // invisible character this check exists to catch, same as an RTL override.
2176
+ // Hand-copied from `wtfalch/auth/broker/src/apps.ts`'s own `hasUnsafeText`,
2177
+ // character class for character class, for the same reason the rest of
2178
+ // this block is a copy: that module is `@wtfalch/auth-broker`, `private:
2179
+ // true`.
2180
+ const JOINER_CHARS = new Set(['\u200c', '\u200d']);
2181
+ const LETTER_OR_EMOJI_RE = /^[\p{L}\p{Extended_Pictographic}]$/u;
2182
+ // A real joined sequence is rarely letter-immediately-next-to-joiner on
2183
+ // both sides. Devanagari's explicit half-form writes a virama (a combining
2184
+ // mark, `\p{M}`) before the ZWJ; a multi-part emoji chains a ZWJ onto a
2185
+ // variation selector (U+FE0F) or a skin-tone modifier
2186
+ // (`\p{Emoji_Modifier}`) rather than straight onto the base character. All
2187
+ // of these sit *before* the joiner, never after it, so only the "previous
2188
+ // character" side is widened.
2189
+ const JOINER_PREV_RE = /^(?:[\p{L}\p{M}\p{Extended_Pictographic}]|\u{FE0F}|\p{Emoji_Modifier})$/u;
2190
+ function hasUnsafeText(value) {
2191
+ const chars = Array.from(value);
2192
+ for (let i = 0; i < chars.length; i++) {
2193
+ const ch = chars[i]; // i < chars.length, so this index always exists
2194
+ if (JOINER_CHARS.has(ch)) {
2195
+ const prev = chars[i - 1];
2196
+ const next = chars[i + 1];
2197
+ if (!prev || !next || !JOINER_PREV_RE.test(prev) || !LETTER_OR_EMOJI_RE.test(next)) {
2198
+ return true;
2199
+ }
2200
+ continue;
2201
+ }
2202
+ if (OTHER_UNSAFE_RE.test(ch))
2203
+ return true;
2204
+ }
2205
+ return false;
2206
+ }
2207
+ function isSafeText(value, max) {
2208
+ return value.trim().length > 0 && value.length <= max && !hasUnsafeText(value);
2209
+ }
2210
+ function isValidSenderDisplayName(value) {
2211
+ return value.trim().length > 0 && SENDER_NAME_RE.test(value);
2212
+ }
2213
+ /**
2214
+ * A broker app entry's `from` -- not an RFC 5322 parser, just enough to
2215
+ * catch what the broker itself would refuse or silently drop. Accepts the
2216
+ * two shapes this package itself ever writes (`provisionPlatformArchon`'s
2217
+ * own `archonEntry.from`, `` `Company Included <noreply@${platform.apex}>` ``)
2218
+ * or reads back from a hand-edited `AUTH_APPS`: `Display Name <email@domain>`
2219
+ * (quoted or not) and a bare `email@domain` address.
2220
+ */
2221
+ function isValidSenderFrom(from) {
2222
+ if (from.length === 0 || from.length > FROM_MAX_LENGTH)
2223
+ return false;
2224
+ if (SENDER_ADDRESS_RE.test(from))
2225
+ return true;
2226
+ const named = NAMED_FROM_RE.exec(from);
2227
+ if (!named)
2228
+ return false;
2229
+ const rawName = named[1] ?? '';
2230
+ const address = named[2] ?? '';
2231
+ if (!SENDER_ADDRESS_RE.test(address))
2232
+ return false;
2233
+ if (!isSafeText(rawName, FROM_MAX_LENGTH))
2234
+ return false;
2235
+ const name = rawName.trim();
2236
+ if (name.startsWith('"') && name.endsWith('"') && name.length >= 2) {
2237
+ return isValidSenderDisplayName(name.slice(1, -1));
2238
+ }
2239
+ return isValidSenderDisplayName(name);
2240
+ }
2241
+ /** #211 round 5: the two locales `@wtfalch/mailer`'s `Locale` type restricts a broker app entry's optional `locale` to. */
2242
+ const VALID_BROKER_APP_LOCALES = new Set(['en', 'nb']);
2243
+ /** #211 round 6: matches the broker's own `CLIENT_ID_MAX_LENGTH`. */
2244
+ const BROKER_CLIENT_ID_MAX_LENGTH = 200;
2245
+ /**
2246
+ * #218: matches the broker's own `PRODUCT_NAME_MAX_LENGTH`
2247
+ * (`broker/src/apps.ts`), which it uses as the cap for both `name` and
2248
+ * `productName` -- `parseCommonAppFields`'s own `isSafeText(app.name,
2249
+ * PRODUCT_NAME_MAX_LENGTH)` and `isValidProductName`'s
2250
+ * `isSafeText(value, PRODUCT_NAME_MAX_LENGTH)`.
2251
+ */
2252
+ const BROKER_NAME_MAX_LENGTH = 100;
2253
+ /**
2254
+ * #211 round 5: whether `value` is already in the canonical origin form
2255
+ * `wtfalch/auth/broker/src/apps.ts`'s own `parseApps` requires for an entry's
2256
+ * `origins` -- http(s), and `new URL(value).origin === value` exactly, so no
2257
+ * path, query, trailing slash or differently-cased host survives. `new URL`
2258
+ * throws on an arbitrary string, so that failure is itself "not a URL".
2259
+ */
2260
+ function isCanonicalOrigin(value) {
2261
+ try {
2262
+ const parsed = new URL(value);
2263
+ return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && parsed.origin === value;
2264
+ }
2265
+ catch {
2266
+ return false;
2267
+ }
2268
+ }
2269
+ /**
2270
+ * One `AUTH_APPS[index]` entry, checked the way
2271
+ * `wtfalch/auth/broker/src/apps.ts`'s own `parseApps`/`parseAppFields`
2272
+ * (https://github.com/wtfalch/auth/blob/main/broker/src/apps.ts) check it at
2273
+ * boot, in the same order: a `key` of at least 32 characters and a non-empty
2274
+ * `organizationId`; a `name` that passes the broker's own `isSafeText` rule
2275
+ * (#218); a `productName`, when present, that is a string and also passes it
2276
+ * (#218); a `from` that passes {@link isValidSenderFrom}; a `locale` of
2277
+ * `'en'` or `'nb'` when present; non-blank `clientIds`; and `origins` each in
2278
+ * {@link isCanonicalOrigin} form. #211 round 2: on a bad
2279
+ * entry this throws naming the broker app id and the entry's index, never
2280
+ * the value -- `key` may hold another organisation's secret, and even
2281
+ * `organizationId`/`name` are not this step's to print in an error only an
2282
+ * operator reads. `entry` may be anything at all (`null`, a string, an
2283
+ * array): every check below is a `typeof`/`Array.isArray` guard first, so a
2284
+ * malformed entry never reaches a property read on a non-object and throws a
2285
+ * `TypeError` instead of this named error.
2286
+ */
2287
+ function validateBrokerAppEntry(entry, brokerAppId, index) {
2288
+ const label = `platform:archon: broker app ${brokerAppId}'s AUTH_APPS[${index}]`;
2289
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
2290
+ throw new Error(`${label} is not an object`);
2291
+ }
2292
+ const app = entry;
2293
+ if (typeof app.organizationId !== 'string' || !app.organizationId) {
2294
+ throw new Error(`${label} has no organizationId`);
2295
+ }
2296
+ if (typeof app.key !== 'string' || !app.key) {
2297
+ throw new Error(`${label} has no key`);
2298
+ }
2299
+ if (app.key.length < 32) {
2300
+ throw new Error(`${label} has too short a key`);
2301
+ }
2302
+ if (typeof app.name !== 'string' || !app.name) {
2303
+ throw new Error(`${label} has no name`);
2304
+ }
2305
+ // #218: the broker's own `parseCommonAppFields` runs `name` through
2306
+ // `isSafeText(app.name, PRODUCT_NAME_MAX_LENGTH)` too (`name` stands in for
2307
+ // `productName` in the mail subject whenever an entry declares no
2308
+ // `productName` of its own, so it needs the same safety check) -- this
2309
+ // step's own truthy check above missed a NUL, an RTL override, a line
2310
+ // separator, or a name over 100 characters, all of which this gate reads
2311
+ // back and keeps, and all of which the broker then throws on at boot.
2312
+ if (!isSafeText(app.name, BROKER_NAME_MAX_LENGTH)) {
2313
+ throw new Error(`${label} has an invalid name`);
2314
+ }
2315
+ if (app.productName !== undefined) {
2316
+ // #218: `productName` is optional on the broker's own `App`, but when
2317
+ // present it runs through `isValidProductName`
2318
+ // (`isSafeText(value, PRODUCT_NAME_MAX_LENGTH)`) exactly like `name`
2319
+ // above -- a non-string, a NUL, or one over 100 characters all throw at
2320
+ // the broker's boot the same way.
2321
+ if (typeof app.productName !== 'string') {
2322
+ throw new Error(`${label} has a non-string productName`);
2323
+ }
2324
+ if (!isSafeText(app.productName, BROKER_NAME_MAX_LENGTH)) {
2325
+ throw new Error(`${label} has an invalid productName`);
2326
+ }
2327
+ }
2328
+ if (typeof app.from !== 'string' || !app.from) {
2329
+ throw new Error(`${label} has no from`);
2330
+ }
2331
+ if (!isValidSenderFrom(app.from)) {
2332
+ throw new Error(`${label} has an invalid from`);
2333
+ }
2334
+ if (app.locale !== undefined && !VALID_BROKER_APP_LOCALES.has(app.locale)) {
2335
+ throw new Error(`${label} has an invalid locale`);
2336
+ }
2337
+ if (!Array.isArray(app.clientIds) || app.clientIds.length === 0) {
2338
+ throw new Error(`${label} has no clientIds`);
2339
+ }
2340
+ if (app.clientIds.some((id) => typeof id !== 'string' || id.trim() === '')) {
2341
+ throw new Error(`${label} has a blank clientIds entry`);
2342
+ }
2343
+ // #211 round 6: `clientIds:["a\u0000b"]` passed this gate before -- a
2344
+ // control character is not blank, so the check above missed it, and the
2345
+ // broker's own `parseCommonAppFields` throws on it at boot
2346
+ // (`isSafeText(candidate, CLIENT_ID_MAX_LENGTH)`).
2347
+ if (app.clientIds.some((id) => typeof id === 'string' && !isSafeText(id, BROKER_CLIENT_ID_MAX_LENGTH))) {
2348
+ throw new Error(`${label} has an invalid clientIds entry`);
2349
+ }
2350
+ if (!Array.isArray(app.origins) || app.origins.length === 0) {
2351
+ throw new Error(`${label} has no origins`);
2352
+ }
2353
+ if (app.origins.some((origin) => typeof origin !== 'string' || !isCanonicalOrigin(origin))) {
2354
+ throw new Error(`${label} has an invalid origins entry`);
2355
+ }
2356
+ return app;
2357
+ }
2358
+ /**
2359
+ * The broker's current `AUTH_APPS` (#204), parsed back so an entry can be
2360
+ * merged without losing whichever other organisations -- or other apps of
2361
+ * this same organisation -- have registered. Empty only for a broker that
2362
+ * has never had one set. #211: an *unparseable* value (bad JSON, or JSON
2363
+ * that is not an array) is never treated as empty -- the write that follows
2364
+ * replaces the whole env, so silently reading it as `[]` would drop every
2365
+ * other organisation's entry the moment this run cannot parse what is
2366
+ * there. Throws instead, naming the broker app id (the app holding the
2367
+ * unreadable value), never the value itself -- it may hold another
2368
+ * organisation's key. #211 round 2: each entry is validated the same way
2369
+ * the broker itself validates `AUTH_APPS` at boot ({@link validateBrokerAppEntry}),
2370
+ * so a bad entry is caught here, by this step, rather than only discovered
2371
+ * once the broker refuses to start on it.
2372
+ */
2373
+ async function readBrokerApps(coolify, brokerAppId) {
2374
+ const envs = await coolify.listEnvs(brokerAppId);
2375
+ const raw = envs.find((e) => e.key === 'AUTH_APPS' && !e.is_preview)?.value;
2376
+ if (!raw)
2377
+ return [];
2378
+ let parsed;
2379
+ try {
2380
+ parsed = JSON.parse(raw);
2381
+ }
2382
+ catch {
2383
+ throw new Error(`platform:archon: broker app ${brokerAppId} has an AUTH_APPS that is not JSON`);
2384
+ }
2385
+ if (!Array.isArray(parsed)) {
2386
+ throw new Error(`platform:archon: broker app ${brokerAppId} has an AUTH_APPS that is not a JSON array`);
2387
+ }
2388
+ return parsed.map((entry, index) => validateBrokerAppEntry(entry, brokerAppId, index));
2389
+ }
2390
+ /**
2391
+ * #213: puts {@link PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF},
2392
+ * {@link PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF} and
2393
+ * {@link PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF} as held keys, under
2394
+ * tenant `platform`, so Archon's own onboarding can read them back once its
2395
+ * app is live. Each ref is resolved first; `put` runs only when that comes
2396
+ * back empty, so an entry already held (put by an earlier apply, or by hand)
2397
+ * is left alone and never overwritten. Values never reach a log, a record or
2398
+ * an error: `heldKeys.put`'s own contract, mirroring `deliverSecret`'s.
2399
+ */
2400
+ async function putArchonMasterKeys(heldKeys, masterKeys) {
2401
+ const entries = [
2402
+ [PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, masterKeys.coolifyUrl],
2403
+ [PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, masterKeys.coolifyToken],
2404
+ [PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, masterKeys.cloudflareParentToken],
2405
+ ];
2406
+ for (const [ref, value] of entries) {
2407
+ const existing = await heldKeys.resolve(ref);
2408
+ if (existing)
2409
+ continue;
2410
+ await heldKeys.put?.(ref, value);
2411
+ }
2412
+ }
2413
+ /**
2414
+ * `executor.ts`'s `cloudflareAccess`, for the platform's Archon: reuses only
2415
+ * an Access app named {@link PLATFORM_ARCHON_ACCESS_APP_NAME}, refuses to
2416
+ * adopt any other Access app already holding the hostname, and refuses when
2417
+ * its own app carries a policy foundry did not create, since that policy
2418
+ * could let others in.
2419
+ */
2420
+ async function ensureArchonAccess(cf, domain, emails, closeGate) {
2421
+ const onDomain = (await cf.listAccessApps()).filter((a) => a.domain === domain);
2422
+ const foreign = onDomain.find((a) => a.name !== PLATFORM_ARCHON_ACCESS_APP_NAME);
2423
+ if (foreign) {
2424
+ throw new Error(`platform:archon: Access app '${foreign.name}' already holds ${domain}; foundry does not adopt it`);
2425
+ }
2426
+ const create = async () => {
2427
+ const created = await cf.createAccessApp({
2428
+ name: PLATFORM_ARCHON_ACCESS_APP_NAME,
2429
+ domain,
2430
+ type: 'self_hosted',
2431
+ });
2432
+ await cf.createAccessPolicy(created.id, archonAccessPolicy(emails));
2433
+ return created;
2434
+ };
2435
+ const existing = onDomain[0];
2436
+ if (!existing)
2437
+ return (await create()).id;
2438
+ const policies = await cf.listAccessPolicies(existing.id);
2439
+ const other = policies.find((p) => p.name !== ACCESS_POLICY_NAME);
2440
+ if (other) {
2441
+ throw new Error(`platform:archon: Access app ${existing.id} has policy '${other.name}' that foundry did not create`);
2442
+ }
2443
+ if (policies.length === 0) {
2444
+ await cf.createAccessPolicy(existing.id, archonAccessPolicy(emails));
2445
+ return existing.id;
2446
+ }
2447
+ if (policies.every((p) => sameEmails(p.include, emails)))
2448
+ return existing.id;
2449
+ // No call updates an app's policy: rebuild the app, hostname closed meanwhile.
2450
+ await closeGate();
2451
+ await cf.deleteAccessApp(existing.id);
2452
+ return (await create()).id;
2453
+ }
2454
+ function archonAccessPolicy(emails) {
2455
+ return {
2456
+ name: ACCESS_POLICY_NAME,
2457
+ decision: 'allow',
2458
+ include: emails.map((email) => ({ email: { email } })),
2459
+ };
2460
+ }
2461
+ /** Whether an Access policy's include list is exactly `emails` (case-insensitive) and nothing else. */
2462
+ function sameEmails(include, emails) {
2463
+ if (!Array.isArray(include))
2464
+ return false;
2465
+ const live = new Set();
2466
+ for (const entry of include) {
2467
+ const email = entry?.email?.email;
2468
+ if (typeof email !== 'string' || Object.keys(entry).length !== 1)
2469
+ return false;
2470
+ live.add(email.toLowerCase());
2471
+ }
2472
+ const declared = new Set(emails.map((e) => e.toLowerCase()));
2473
+ return live.size === declared.size && [...declared].every((e) => live.has(e));
2474
+ }
2475
+ /**
2476
+ * Whether Archon's Access app or policy is gone or no longer lets through
2477
+ * exactly the declared emails. A failed read is not drift: the next plan reads
2478
+ * again.
2479
+ */
2480
+ async function archonAccessDrifted(cf, domain, emails) {
2481
+ try {
2482
+ const app = (await cf.listAccessApps()).find((a) => a.domain === domain && a.name === PLATFORM_ARCHON_ACCESS_APP_NAME);
2483
+ if (!app)
2484
+ return true;
2485
+ const policies = await cf.listAccessPolicies(app.id);
2486
+ return !(policies.length > 0 && policies.every((p) => sameEmails(p.include, emails)));
2487
+ }
2488
+ catch {
2489
+ return false;
2490
+ }
2491
+ }
2492
+ /**
2493
+ * #211 round 4: whether the broker Archon last registered with (its
2494
+ * `AUTH_APPS`) still carries an entry for Archon's organisation.
2495
+ * `planArchon`'s own recorded-id check
2496
+ * ({@link PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY} against
2497
+ * {@link PLATFORM_AUTH_BROKER_SCOPE_KEY}) catches the broker itself being
2498
+ * recreated under a new app id; this catches the same broker losing the
2499
+ * entry some other way -- a hand edit of `AUTH_APPS`, a previous write that
2500
+ * silently failed. A failed read is not drift, the same rule
2501
+ * {@link archonAccessDrifted} follows: the next plan reads again. Missing
2502
+ * `recordedIds` (no broker recorded yet, or Archon's own ZITADEL org not yet
2503
+ * recorded) is not drift either -- `planArchon`'s other checks already cover
2504
+ * a step that has never applied.
2505
+ */
2506
+ async function archonBrokerRegistrationDrifted(coolify, recordedIds) {
2507
+ const brokerAppId = recordedIds[PLATFORM_AUTH_BROKER_SCOPE_KEY];
2508
+ const orgId = recordedIds[PLATFORM_ZITADEL_ORG_SCOPE_KEY];
2509
+ if (!brokerAppId || !orgId)
2510
+ return false;
2511
+ try {
2512
+ const apps = await readBrokerApps(coolify, brokerAppId);
2513
+ return !apps.some((a) => a.organizationId === orgId && a.name === PLATFORM_ARCHON_ZITADEL_APP);
2514
+ }
2515
+ catch {
2516
+ return false;
2517
+ }
2518
+ }
2519
+ /**
2520
+ * What Archon reads from each enabled service, gathered before anything is
2521
+ * built. Either everything is there and the env values come back, or the first
2522
+ * missing piece comes back as one fixed sentence naming a step, scope id or
2523
+ * held-key ref, never a value. Also read at plan time, so the text must not
2524
+ * vary between calls.
2525
+ */
2526
+ async function collectArchonServiceInputs(platform, deps, recorded) {
2527
+ const block = (why) => ({ blocked: `Archon is blocked, not planned: ${why}` });
2528
+ const envs = [];
2529
+ const heldByRef = new Map();
2530
+ const services = ['files', 'ai', 'integrations', 'jobs'].filter((s) => platform[s].enabled);
2531
+ if (services.length > 0 && !deps.heldKeys) {
2532
+ return block(`the host built the platform executor without a held-key reader, and ${services.join(', ')} ${services.length === 1 ? 'is' : 'are'} enabled`);
2533
+ }
2534
+ for (const service of services) {
2535
+ const needs = service === 'jobs'
2536
+ ? { stepKey: null, scopeKeys: [], refs: [JOBS_ADMIN_TOKEN_REF] }
2537
+ : ARCHON_SERVICE_INPUTS[service];
2538
+ if (needs.stepKey && !recorded[`coolify:app:${needs.stepKey}`]) {
2539
+ return block(`the ${service} step (${needs.stepKey}) is not applied yet`);
2540
+ }
2541
+ for (const scopeKey of needs.scopeKeys) {
2542
+ if (!recorded[scopeKey]) {
2543
+ return block(`the ${service} step has not recorded the scope id ${scopeKey}`);
2544
+ }
2545
+ }
2546
+ for (const ref of needs.refs) {
2547
+ let held;
2548
+ try {
2549
+ held = (await deps.heldKeys?.resolve(ref)) ?? null;
2550
+ }
2551
+ catch {
2552
+ // The cause is dropped in case it echoes a secret.
2553
+ return block(`the held key ${ref} could not be read`);
2554
+ }
2555
+ if (!held)
2556
+ return block(`the held key ${ref} is not held yet`);
2557
+ heldByRef.set(ref, held);
2558
+ }
2559
+ }
2560
+ const held = (ref) => heldByRef.get(ref);
2561
+ for (const service of services) {
2562
+ if (service === 'files') {
2563
+ envs.push({ key: 'FILES_URL', value: recorded[PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY] }, { key: 'FILES_MANAGEMENT_KEY', value: held(ARCHON_SERVICE_INPUTS.files.refs[0]) });
2564
+ }
2565
+ else if (service === 'ai') {
2566
+ envs.push({ key: 'AI_URL', value: recorded[PLATFORM_AI_INTERNAL_URL_SCOPE_KEY] }, { key: 'AI_MANAGEMENT_KEY', value: held(ARCHON_SERVICE_INPUTS.ai.refs[0]) }, {
2567
+ key: 'AI_ORGANISATION_ID',
2568
+ value: recorded[PLATFORM_AI_ORGANISATION_SCOPE_KEY],
2569
+ });
2570
+ }
2571
+ else if (service === 'integrations') {
2572
+ const url = `https://${platform.integrations.hostname}`;
2573
+ envs.push({ key: 'INTEGRATIONS_URL', value: url }, {
2574
+ key: 'INTEGRATIONS_MANAGEMENT_PRIVATE_KEY',
2575
+ value: held(INTEGRATIONS_MANAGEMENT_KEY_REF),
2576
+ }, { key: 'INTEGRATIONS_MANAGEMENT_ISSUER', value: INTEGRATIONS_MANAGEMENT_ISSUER }, { key: 'INTEGRATIONS_MANAGEMENT_KEY_ID', value: INTEGRATIONS_MANAGEMENT_KEY_ID }, { key: 'INTEGRATIONS_MANAGEMENT_AUDIENCE', value: url });
2577
+ }
2578
+ else {
2579
+ envs.push({ key: 'JOBS_URL', value: `https://${platform.jobs.hostname}` }, { key: 'JOBS_ADMIN_TOKEN', value: held(JOBS_ADMIN_TOKEN_REF) });
2580
+ }
2581
+ }
2582
+ return { envs };
2583
+ }
2584
+ /**
2585
+ * `null` once the app already has a `DATABASE_URL`, never rotated: Archon's
2586
+ * `db-runtime-role.sh` sets the `archon_rt` password from this URL on every
2587
+ * boot while the previous container's pool is still open, so it must stay
2588
+ * the same across deploys. `executor.ts`'s `ensureCmsRuntimeUrl`, with
2589
+ * Archon's role name; see that method for why a parse failure is swallowed
2590
+ * (Node's `ERR_INVALID_URL` carries the whole input, credential included).
2591
+ * `base64url` fits `db-runtime-role.sh`'s password alphabet
2592
+ * (`A-Za-z0-9_.~-`).
2593
+ */
2594
+ async function ensureArchonRuntimeUrl(coolify, appId, dbInternalUrl) {
2595
+ const envs = await coolify.listEnvs(appId);
2596
+ if (envs.some((e) => e.key === 'DATABASE_URL' && !e.is_preview))
2597
+ return null;
2598
+ let host;
2599
+ let pathname;
2600
+ let search;
2601
+ try {
2602
+ ({ host, pathname, search } = new URL(dbInternalUrl));
2603
+ }
2604
+ catch {
2605
+ // Node's `ERR_INVALID_URL` carries the whole input, credential included, so
2606
+ // neither it nor the URL goes into this message.
2607
+ throw new Error('platform:archon: the database internal_db_url could not be parsed; not generating DATABASE_URL. Check the database in Coolify and apply again');
2608
+ }
2609
+ const password = randomBytes(24).toString('base64url');
2610
+ return `postgres://${ARCHON_RUNTIME_ROLE}:${password}@${host}${pathname}${search}`;
2611
+ }
2612
+ /**
2613
+ * Thrown by {@link createAiManagementKeyMinter} (never by a test's own
2614
+ * injected minter, unless it chooses to) when ai's `POST /v1/platform/keys`
2615
+ * answers 200: a live `archon` key already exists and no secret comes back.
2616
+ * See {@link PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY}.
2617
+ */
2618
+ export class AiManagementKeyExistsError extends Error {
2619
+ credentialId;
2620
+ organisationId;
2621
+ constructor(credentialId, organisationId) {
2622
+ super(`ai already holds a management key named 'archon' (${credentialId}) with no secret to show`);
2623
+ this.credentialId = credentialId;
2624
+ this.organisationId = organisationId;
2625
+ }
2626
+ }
2627
+ /**
2628
+ * Thrown by {@link createAiManagementKeyMinter} on a 409: a live `archon` key
2629
+ * already exists with different permissions or expiry than this request
2630
+ * asked for. ai never overwrites it, so this is never retried under another
2631
+ * name -- an operator revokes the live key in ai first.
2632
+ */
2633
+ export class AiManagementKeyConflictError extends Error {
2634
+ constructor() {
2635
+ super("ai already holds a live 'archon' key minted with different permissions or expiry; revoke it in ai, then apply again");
2636
+ }
2637
+ }
2638
+ /**
2639
+ * Thrown by {@link createAiManagementKeyMinter} on a 401 or 403: ai does not
2640
+ * trust foundry's signing key yet for this request. Most likely the runner
2641
+ * has not redeployed since {@link AI_SIGNING_KEY_REF}'s public half was
2642
+ * pinned as `MANAGEMENT_PUBLIC_KEYS`/`MANAGEMENT_ISSUER` -- a Coolify env
2643
+ * change reaches the container only on its next deploy.
2644
+ */
2645
+ export class AiManagementKeyUntrustedError extends Error {
2646
+ status;
2647
+ constructor(status) {
2648
+ super(`ai's platform/keys route refused foundry's signing key (${status}); check MANAGEMENT_PUBLIC_KEYS/MANAGEMENT_ISSUER on the ai runner and that it has redeployed since they were pinned`);
2649
+ this.status = status;
2650
+ }
2651
+ }
2652
+ /**
2653
+ * Both response bodies {@link createAiManagementKeyMinter} reads are parsed
2654
+ * rather than cast: a malformed body (an empty 201, a 200 with no
2655
+ * `credentialId`) must fail the mint outright rather than record an
2656
+ * `undefined` id or hand `deliverSecret` an `undefined` "secret". Neither
2657
+ * error message repeats the body, which could hold the very secret this
2658
+ * function must not leak.
2659
+ */
2660
+ function parseAiKeyExistsBody(body) {
2661
+ const record = body;
2662
+ if (typeof record !== 'object' ||
2663
+ record === null ||
2664
+ typeof record.credentialId !== 'string' ||
2665
+ typeof record.organisationId !== 'string') {
2666
+ throw new Error("ai's platform/keys route answered 200 with a body missing credentialId/organisationId");
2667
+ }
2668
+ return { credentialId: record.credentialId, organisationId: record.organisationId };
2669
+ }
2670
+ function parseAiKeyMintedBody(body) {
2671
+ const record = body;
2672
+ if (typeof record !== 'object' ||
2673
+ record === null ||
2674
+ typeof record.credentialId !== 'string' ||
2675
+ typeof record.organisationId !== 'string' ||
2676
+ typeof record.secret !== 'string') {
2677
+ throw new Error("ai's platform/keys route answered 201 with a body missing credentialId/organisationId/secret");
2678
+ }
2679
+ return {
2680
+ credentialId: record.credentialId,
2681
+ organisationId: record.organisationId,
2682
+ secret: record.secret,
2683
+ };
2684
+ }
2685
+ /** The ref the AI management key is delivered under; the host's `AiOrganisationPort` adapter reads it back. */
2686
+ export const PLATFORM_AI_MANAGEMENT_KEY_REF = 'ai:platform:managementKey';
2687
+ /**
2688
+ * What Archon's management key may do in ai: exactly `ai`'s own
2689
+ * `ARCHON_AI_PERMISSIONS` (`apps/host/scripts/authz/bootstrap-archon.ts`) --
2690
+ * configuration only (offerings, provisioning, offers, budgets, usage, cost)
2691
+ * plus deleting (archiving) an organisation, never connections, runs or
2692
+ * webhooks. ai's `/v1/platform/keys` route refuses any permission outside
2693
+ * this same list, so a request for more than this is a 403, not a wider key.
2694
+ */
2695
+ export const PLATFORM_AI_MANAGEMENT_PERMISSIONS = [
2696
+ 'ai.offerings:read',
2697
+ 'ai.offerings:create',
2698
+ 'ai.offerings:update',
2699
+ 'ai.offerings:disable',
2700
+ 'ai.organisations:provision',
2701
+ 'ai.organisations:offer',
2702
+ 'ai.organisations:delete',
2703
+ 'ai.keys:issue',
2704
+ 'ai.budgets:read',
2705
+ 'ai.budgets:update',
2706
+ 'ai.usage:read',
2707
+ 'ai.cost:read',
2708
+ ];
2709
+ /** The ref the AI signing key's private half (PKCS#8 PEM) is delivered under -- the {@link INTEGRATIONS_MANAGEMENT_KEY_REF} pattern, for ai instead of integrations. */
2710
+ export const AI_SIGNING_KEY_REF = 'ai:platform:signingPrivateKey';
2711
+ /** `MANAGEMENT_ISSUER` on the ai runner: whoever signs with the delivered key signs as this issuer. */
2712
+ export const AI_MANAGEMENT_ISSUER = 'foundry';
2713
+ /** The key id the public half is pinned under in `MANAGEMENT_PUBLIC_KEYS` (ai's keyring allows `^[A-Za-z0-9_-]{1,100}$`). Fixed, since there is one key until rotation is built. */
2714
+ export const AI_MANAGEMENT_KEY_ID = 'foundry-1';
2715
+ /**
2716
+ * ai's fixed application/platform identifier
2717
+ * (`apps/host/src/lib/authz/policy-catalogue.ts`'s `PLATFORM_ID`), bound
2718
+ * into every signed assertion's `platform` claim. foundry does not depend on
2719
+ * that app, so the value is mirrored here rather than imported; a mismatch
2720
+ * makes ai's `verifyManagementRequest` refuse every request.
2721
+ */
2722
+ const AI_MANAGEMENT_PLATFORM_ID = 'wtfalch';
2723
+ /** The header ai's `service-assertion.ts` (`MANAGEMENT_HEADER`) reads the signed assertion from. */
2724
+ const AI_MANAGEMENT_HEADER = 'x-wtfalch-management';
2725
+ /** `service-assertion.ts`'s own signed-message prefix; the signature covers exactly `PREFIX + message`. */
2726
+ const AI_MANAGEMENT_ASSERTION_PREFIX = 'wtfalch.service-request.v1\n';
2727
+ /** `service-assertion.ts`'s own token TTL, in seconds. */
2728
+ const AI_MANAGEMENT_ASSERTION_TTL_SECONDS = 30;
2729
+ /**
2730
+ * ai's signed-assertion `organisation` claim is required and validated as a
2731
+ * uuid, but unused for tenant selection on the platform/keys route
2732
+ * (`platform-keys-endpoint.ts`'s own comment: "callers may sign it as the
2733
+ * nil uuid"). There is no organisation to name before one exists.
2734
+ */
2735
+ const AI_MANAGEMENT_NIL_ORGANISATION = '00000000-0000-0000-0000-000000000000';
2736
+ /** ai's platform/keys route, relative to its internal address. */
2737
+ const AI_PLATFORM_KEYS_PATH = '/v1/platform/keys';
2738
+ /**
2739
+ * Signs one request to ai's `POST /v1/platform/keys` the way ai's own
2740
+ * `service-assertion.ts` (`signManagementRequest`) does. foundry does not
2741
+ * depend on that app's code, so the wire format -- header, prefix, claim
2742
+ * shape, digest -- is reproduced here exactly, rather than imported, so
2743
+ * ai's `verifyManagementRequest` accepts it unmodified.
2744
+ */
2745
+ function signAiManagementRequest(input) {
2746
+ const digest = createHash('sha256').update(input.body).digest('hex');
2747
+ const issued = Math.floor(input.now() / 1000);
2748
+ const claims = {
2749
+ version: 1,
2750
+ issuer: input.issuer,
2751
+ audience: input.audience,
2752
+ platform: AI_MANAGEMENT_PLATFORM_ID,
2753
+ subject: input.issuer,
2754
+ organisation: AI_MANAGEMENT_NIL_ORGANISATION,
2755
+ method: 'POST',
2756
+ target: input.target,
2757
+ contentType: input.contentType,
2758
+ digest,
2759
+ issued,
2760
+ expires: issued + AI_MANAGEMENT_ASSERTION_TTL_SECONDS,
2761
+ nonce: randomUUID(),
2762
+ };
2763
+ const payload = Buffer.from(JSON.stringify(claims)).toString('base64url');
2764
+ const message = `${input.keyId}.${payload}`;
2765
+ const signature = sign(null, Buffer.from(AI_MANAGEMENT_ASSERTION_PREFIX + message), input.privateKey).toString('base64url');
2766
+ return `${message}.${signature}`;
2767
+ }
2768
+ /**
2769
+ * The real {@link AiManagementKeyMinter}: signs a management assertion with
2770
+ * `privateKeyPem` (the PKCS#8 PEM {@link provisionPlatformAi} delivers under
2771
+ * {@link AI_SIGNING_KEY_REF} and pins on the runner under `keyId`) and calls
2772
+ * ai's `POST /v1/platform/keys`. `audience` must be the exact origin ai's own
2773
+ * `APP_URL` resolves to (`https://<platform.ai.hostname>`), since ai checks
2774
+ * the claim against that env var, not against the internal address the
2775
+ * request itself is sent to.
2776
+ */
2777
+ export function createAiManagementKeyMinter(options) {
2778
+ const fetchImpl = options.fetchImpl ?? fetch;
2779
+ const now = options.now ?? Date.now;
2780
+ const privateKey = createPrivateKey(options.privateKeyPem);
2781
+ return {
2782
+ async mintManagementKey({ internalUrl, name, expiresDays, permissions }) {
2783
+ const contentType = 'application/json';
2784
+ const body = JSON.stringify({ name, expiresDays, permissions });
2785
+ const token = signAiManagementRequest({
2786
+ target: AI_PLATFORM_KEYS_PATH,
2787
+ contentType,
2788
+ body,
2789
+ issuer: options.issuer,
2790
+ audience: options.audience,
2791
+ keyId: options.keyId,
2792
+ privateKey,
2793
+ now,
2794
+ });
2795
+ const response = await fetchImpl(`${internalUrl}${AI_PLATFORM_KEYS_PATH}`, {
2796
+ method: 'POST',
2797
+ headers: { 'content-type': contentType, [AI_MANAGEMENT_HEADER]: token },
2798
+ body,
2799
+ // A hung ai must not hold the apply open.
2800
+ signal: AbortSignal.timeout(30_000),
2801
+ });
2802
+ if (response.status === 200) {
2803
+ const existing = parseAiKeyExistsBody(await response.json());
2804
+ throw new AiManagementKeyExistsError(existing.credentialId, existing.organisationId);
2805
+ }
2806
+ if (response.status === 409) {
2807
+ throw new AiManagementKeyConflictError();
2808
+ }
2809
+ if (response.status === 401 || response.status === 403) {
2810
+ throw new AiManagementKeyUntrustedError(response.status);
2811
+ }
2812
+ if (response.status !== 201) {
2813
+ throw new Error(`ai's platform/keys route answered ${response.status}`);
2814
+ }
2815
+ return parseAiKeyMintedBody(await response.json());
2816
+ },
2817
+ };
2818
+ }
2819
+ const PLATFORM_AI_PROJECT = 'platform';
2820
+ const PLATFORM_AI_REPO = 'wtfalch/ai';
2821
+ const PLATFORM_AI_DB_NAME = 'ai-db';
2822
+ const PLATFORM_AI_POSTGRES_DB = 'ai';
2823
+ /**
2824
+ * One app per build target of `wtfalch/ai`'s root Dockerfile (its
2825
+ * `apps/host/README.md`, "Images and validation"). `target` is set as
2826
+ * Coolify's `dockerfile_target_build`; `internalName` as the app's fixed
2827
+ * `custom_internal_name`, the way `platform:auth` names its two apps.
2828
+ */
2829
+ const PLATFORM_AI_MIGRATE = {
2830
+ name: 'ai-migrate',
2831
+ target: 'migration',
2832
+ internalName: 'platform-ai-migrate',
2833
+ };
2834
+ const PLATFORM_AI_RUNNER = { name: 'ai', target: 'runner', internalName: 'platform-ai' };
2835
+ const PLATFORM_AI_WORKER = {
2836
+ name: 'ai-worker',
2837
+ target: 'worker',
2838
+ internalName: 'platform-ai-worker',
2839
+ };
2840
+ /** The runner target's `EXPOSE 3000` / `PORT=3000`. */
2841
+ const PLATFORM_AI_PORT = '3000';
2842
+ const PLATFORM_AI_PROJECT_SCOPE_KEY = 'coolify_project_platform_ai';
2843
+ const PLATFORM_AI_DB_SCOPE_KEY = 'coolify_platform_ai_database';
2844
+ const PLATFORM_AI_MIGRATE_SCOPE_KEY = 'coolify_platform_ai_migrate_app';
2845
+ const PLATFORM_AI_RUNNER_SCOPE_KEY = 'coolify_platform_ai_app';
2846
+ const PLATFORM_AI_WORKER_SCOPE_KEY = 'coolify_platform_ai_worker_app';
2847
+ /**
2848
+ * Written only when absent, never overwritten: an operator's own model list
2849
+ * or worker bounds survive every apply. Values are `.env.example`'s, and
2850
+ * `AI_MODELS=[]` is the README's "honest configuration for a host with no
2851
+ * approved text model".
2852
+ */
2853
+ const PLATFORM_AI_RUNNER_DEFAULTS = [
2854
+ { key: 'AI_MODELS', value: '[]' },
2855
+ { key: 'AI_FORGE_BINDINGS', value: '[]' },
2856
+ ];
2857
+ const PLATFORM_AI_WORKER_DEFAULTS = [
2858
+ ...PLATFORM_AI_RUNNER_DEFAULTS,
2859
+ { key: 'AI_WORKER_BATCH', value: '10' },
2860
+ { key: 'AI_WORKER_TIMEOUT_MS', value: '60000' },
2861
+ ];
2862
+ /**
2863
+ * #172: makes `coolify:app` keyed {@link PLATFORM_AI_KEY} true -- the ai host
2864
+ * (https://github.com/wtfalch/ai) on the platform's Coolify, then foundry's
2865
+ * own AI management key.
2866
+ *
2867
+ * The issue says to run the three parts "as `scripts/redeploy-dark.sh` in
2868
+ * that repo runs them". No such script exists anywhere in that repo's
2869
+ * history, so the shape here comes from what the repo does document: its
2870
+ * root Dockerfile's `migration`, `runner` and `worker` targets and
2871
+ * `apps/host/README.md`. One Coolify Postgres and three apps from the same
2872
+ * repo and `gitRef`, one per target:
2873
+ *
2874
+ * - `ai-migrate` (`migration`) gets `DATABASE_URL_OWNER` (the database's
2875
+ * own superuser URL, the only app that ever sees it) and `DATABASE_URL`,
2876
+ * from which `db-runtime-role.sh` sets the `<database>_rt` role's password.
2877
+ * Its container stays up after migrating ("Coolify reads an exited
2878
+ * container as a failed deploy") and turns healthy once the marker file is
2879
+ * written, so this waits for `running:healthy` and then stops it.
2880
+ * - `ai` (`runner`, port 3000) and `ai-worker` (`worker`, the continuously
2881
+ * polling `scripts/work.ts`) get only `DATABASE_URL`, the restricted
2882
+ * runtime role, plus the defaults in {@link PLATFORM_AI_WORKER_DEFAULTS}.
2883
+ *
2884
+ * Order: migrate first, wait, stop it, then the runner and worker, so neither
2885
+ * ever starts against an unmigrated schema. A migrate job that does not turn
2886
+ * healthy in time records {@link PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY}; the
2887
+ * planner then reports the step blocked instead of timing out on every apply.
2888
+ * A changed hostname or gitRef plans the step again (the applied values are
2889
+ * recorded last): `git_branch` is patched on every run, `APP_URL` follows the
2890
+ * hostname, and everything redeploys. The migration re-runs whenever
2891
+ * either of the other two is (re)deployed; it is idempotent and additive.
2892
+ * Coolify's auto-deploy on push is switched off on all three, because a push
2893
+ * would otherwise redeploy runner and worker without the migration first.
2894
+ *
2895
+ * `DATABASE_URL` is minted once (a base64url password, the alphabet
2896
+ * `db-runtime-role.sh` allows) and reused from whichever app already has it,
2897
+ * never rotated: the role's password is set from it at every migration.
2898
+ *
2899
+ * The runner's internal address, `http://platform-ai:3000`, is recorded under
2900
+ * {@link PLATFORM_AI_INTERNAL_URL_SCOPE_KEY}. Nothing public is wired: no
2901
+ * Coolify `domains`, tunnel, DNS record or Access app. Whether ai gets
2902
+ * `ai.<apex>` or stays internal like files is open (#172 "Decide"), and both
2903
+ * answers only add to this. `APP_URL` is set from `platform.ai.hostname` as
2904
+ * declared, since the host reads its own origin from it either way.
2905
+ *
2906
+ * The management key follows `platform:auth`'s admin-key discipline: the
2907
+ * credential id is recorded as undelivered before the secret leaves this
2908
+ * function, delivery is retried, and only a delivered key records the guard
2909
+ * that skips the mint. The secret is never returned, recorded, logged or
2910
+ * put in an error. The step's resource id is returned only after that, so
2911
+ * the planner keeps planning the step until the key is delivered.
2912
+ *
2913
+ * #201 built the real minter: ai's `POST /v1/platform/keys`
2914
+ * (github.com/wtfalch/ai/issues/141) mints the `archon` key over HTTP, signed
2915
+ * with an Ed25519 pair this function mints and pins on the runner once
2916
+ * ({@link ensureAiSigningKey}), the same two-key discipline as the
2917
+ * management key itself. A test may still inject a different
2918
+ * {@link AiManagementKeyMinter} in {@link PlatformAiDeps.managementKeyMinter},
2919
+ * which skips the signing-key machinery entirely.
2920
+ *
2921
+ * Known gaps, left for review (#172 stays open for review):
2922
+ * - Crash window: the credential id is recorded only after the minter
2923
+ * returns, so a crash between the mint and that record loses a key nobody
2924
+ * knows the id of. ai is idempotent by name, though, so the next apply's
2925
+ * mint call reaches the same live `archon` key rather than a second one --
2926
+ * it just cannot get the secret back, since ai shows it once. That is
2927
+ * reported as {@link PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY}, never
2928
+ * minted under another name.
2929
+ * - Coolify's `dockerfile_target_build` and `is_auto_deploy_enabled` are set
2930
+ * through the generic application PATCH; neither has been checked against
2931
+ * a live instance from this package.
2932
+ * - The key is minted for 90 days (the README's own examples); nothing
2933
+ * rotates it yet, so the platform loses its AI management key after 90 days.
2934
+ * - Deploys are decided from the recorded "pending" mark and the app's own
2935
+ * status, not from a deployment's age: the port cannot read one.
2936
+ * - Provider keys (`KEYS_WORKER_URL`, `KEYS_CREDENTIAL`, `KEYS_VERIFY_KEYS`)
2937
+ * and sign-in (`AUTH_*`) are not set: until an operator supplies them the
2938
+ * host answers `503 not_configured` for provider work, as its README says.
2939
+ */
2940
+ async function provisionPlatformAi(context, deps) {
2941
+ const { platform } = context;
2942
+ const hostname = platform.ai.hostname;
2943
+ const gitRef = platform.ai.gitRef;
2944
+ const recorded = context.recordedIds();
2945
+ const runtime = deps.runtime ?? {};
2946
+ const sleep = runtime.sleep ?? defaultSleep;
2947
+ const { coolify } = deps;
2948
+ const record = async (key, id) => {
2949
+ context.recordScopeId(key, id);
2950
+ await context.persist?.();
2951
+ };
2952
+ let projectUuid = recorded[PLATFORM_AI_PROJECT_SCOPE_KEY];
2953
+ if (!projectUuid) {
2954
+ const found = await coolify.findProjectByName(PLATFORM_AI_PROJECT);
2955
+ projectUuid = found ? found.uuid : (await coolify.createProject(PLATFORM_AI_PROJECT)).uuid;
2956
+ await record(PLATFORM_AI_PROJECT_SCOPE_KEY, projectUuid);
2957
+ }
2958
+ const { environment } = await coolify.findOrCreateEnvironment(projectUuid, 'production');
2959
+ // ---- database ----------------------------------------------------------
2960
+ let dbId = recorded[PLATFORM_AI_DB_SCOPE_KEY];
2961
+ if (!dbId) {
2962
+ const found = (await coolify.listDatabases()).find((d) => d.name === PLATFORM_AI_DB_NAME && d.environment_id === environment.id);
2963
+ if (found) {
2964
+ dbId = found.uuid;
2965
+ }
2966
+ else {
2967
+ const server = await resolvePlatformServer(coolify);
2968
+ const created = await coolify.createPostgresDatabase({
2969
+ project_uuid: projectUuid,
2970
+ server_uuid: server.uuid,
2971
+ environment_name: environment.name,
2972
+ environment_uuid: environment.uuid,
2973
+ name: PLATFORM_AI_DB_NAME,
2974
+ postgres_db: PLATFORM_AI_POSTGRES_DB,
2975
+ postgres_user: PLATFORM_AI_POSTGRES_DB,
2976
+ });
2977
+ dbId = created.uuid;
2978
+ }
2979
+ await record(PLATFORM_AI_DB_SCOPE_KEY, dbId);
2980
+ }
2981
+ const db = await coolify.getDatabase(dbId);
2982
+ if (!db.internal_db_url) {
2983
+ throw new Error('coolify:app for the platform (ai): the database has no internal_db_url yet -- retry this apply once Coolify reports one');
2984
+ }
2985
+ const ownerUrl = db.internal_db_url;
2986
+ // ---- the three apps ------------------------------------------------------
2987
+ const ensureApp = async (spec, scopeKey) => {
2988
+ const app = await findOrCreatePlatformApp(coolify, spec.name, environment, async () => {
2989
+ const server = await resolvePlatformServer(coolify);
2990
+ const githubApp = await coolify.requireGithubApp();
2991
+ return coolify.createGithubAppApplication({
2992
+ project_uuid: projectUuid,
2993
+ server_uuid: server.uuid,
2994
+ environment_name: environment.name,
2995
+ environment_uuid: environment.uuid,
2996
+ github_app_uuid: githubApp.uuid,
2997
+ git_repository: PLATFORM_AI_REPO,
2998
+ git_branch: gitRef,
2999
+ build_pack: 'dockerfile',
3000
+ name: spec.name,
3001
+ });
3002
+ });
3003
+ await record(scopeKey, app.appId);
3004
+ // Set on every run, so a throw right after the create never leaves an app
3005
+ // building the wrong target or deploying itself on a push.
3006
+ const patch = {
3007
+ custom_internal_name: spec.internalName,
3008
+ dockerfile_target_build: spec.target,
3009
+ is_auto_deploy_enabled: false,
3010
+ // Every run, so a changed gitRef reaches an app that already exists.
3011
+ git_branch: gitRef,
3012
+ };
3013
+ if (spec === PLATFORM_AI_RUNNER)
3014
+ patch.ports_exposes = PLATFORM_AI_PORT;
3015
+ await coolify.updateApplication(app.appId, patch);
3016
+ return app;
3017
+ };
3018
+ const migrate = await ensureApp(PLATFORM_AI_MIGRATE, PLATFORM_AI_MIGRATE_SCOPE_KEY);
3019
+ const runner = await ensureApp(PLATFORM_AI_RUNNER, PLATFORM_AI_RUNNER_SCOPE_KEY);
3020
+ const worker = await ensureApp(PLATFORM_AI_WORKER, PLATFORM_AI_WORKER_SCOPE_KEY);
3021
+ // ---- env -----------------------------------------------------------------
3022
+ // Both database URLs are live credentials: handed to `bulkUpsertEnvs` and
3023
+ // nowhere else, never returned, recorded, logged or put in an error.
3024
+ const runtimeUrl = await ensureAiRuntimeUrl(coolify, [runner.appId, worker.appId, migrate.appId], ownerUrl);
3025
+ const migrateEnvs = [
3026
+ { key: 'DATABASE_URL_OWNER', value: ownerUrl },
3027
+ { key: 'DATABASE_URL', value: runtimeUrl },
3028
+ ];
3029
+ const runnerEnvs = [
3030
+ { key: 'DATABASE_URL', value: runtimeUrl },
3031
+ { key: 'APP_URL', value: `https://${hostname}` },
3032
+ ...(await missingEnvs(coolify, runner.appId, PLATFORM_AI_RUNNER_DEFAULTS)),
3033
+ ];
3034
+ const workerEnvs = [
3035
+ { key: 'DATABASE_URL', value: runtimeUrl },
3036
+ ...(await missingEnvs(coolify, worker.appId, PLATFORM_AI_WORKER_DEFAULTS)),
3037
+ ];
3038
+ // Whether anything the containers run on differs from what they last
3039
+ // deployed with. Marked and persisted before the first write, and cleared
3040
+ // only after the deploys below, so a run that fails halfway (the migrate
3041
+ // wait throwing, for one) leaves the mark and the next run deploys again.
3042
+ // #201: pin foundry's signing key before the deploy decision below, so the
3043
+ // runner starts with MANAGEMENT_ISSUER/MANAGEMENT_PUBLIC_KEYS: Coolify env
3044
+ // changes reach a container only on a redeploy. Only the real minter needs
3045
+ // it; an injected one answers for its own authentication.
3046
+ const signingKeyPem = !recorded[PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY] && !deps.managementKeyMinter
3047
+ ? await ensureAiSigningKey(context, deps, runner.appId, runtime, sleep, record)
3048
+ : undefined;
3049
+ const migrateChanged = await envsDiffer(coolify, migrate.appId, migrateEnvs);
3050
+ const runnerChanged = await envsDiffer(coolify, runner.appId, runnerEnvs);
3051
+ const workerChanged = await envsDiffer(coolify, worker.appId, workerEnvs);
3052
+ const declaredChanged = recorded[PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY] !== hostname ||
3053
+ recorded[PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY] !== gitRef;
3054
+ const pending = Boolean(context.recordedIds()[PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY]) ||
3055
+ migrateChanged ||
3056
+ runnerChanged ||
3057
+ workerChanged ||
3058
+ declaredChanged;
3059
+ if (pending)
3060
+ await record(PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, '1');
3061
+ await coolify.bulkUpsertEnvs(migrate.appId, migrateEnvs);
3062
+ await coolify.bulkUpsertEnvs(runner.appId, runnerEnvs);
3063
+ await coolify.bulkUpsertEnvs(worker.appId, workerEnvs);
3064
+ const deployRunner = runner.justCreated || !runner.running || pending;
3065
+ const deployWorker = worker.justCreated || !worker.running || pending;
3066
+ const runMigration = migrate.justCreated || deployRunner || deployWorker;
3067
+ // ---- migrate, wait, stop, then runner and worker ------------------------
3068
+ if (runMigration) {
3069
+ // A migrate container still up from an earlier run (one that threw before
3070
+ // stopping it) would already read healthy. Stop it and wait until it
3071
+ // reads stopped, so the wait below only ever sees the new deployment.
3072
+ if (!migrate.justCreated && migrate.running) {
3073
+ await coolify.raw('POST', `/applications/${migrate.appId}/stop`);
3074
+ await waitForStopped(coolify, migrate.appId, runtime, sleep);
3075
+ }
3076
+ await coolify.deploy(migrate.appId);
3077
+ try {
3078
+ await waitForAiMigration(coolify, migrate.appId, runtime, sleep);
3079
+ }
3080
+ catch (error) {
3081
+ if (error instanceof MigrationNotHealthyError) {
3082
+ // Blocked from now on: the planner reports it and plans nothing until
3083
+ // a person fixes the job and removes this key, instead of waiting out
3084
+ // the timeout on every apply.
3085
+ await record(PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, error.lastStatus);
3086
+ }
3087
+ throw error;
3088
+ }
3089
+ await coolify.raw('POST', `/applications/${migrate.appId}/stop`);
3090
+ }
3091
+ if (deployRunner)
3092
+ await coolify.deploy(runner.appId);
3093
+ if (deployWorker)
3094
+ await coolify.deploy(worker.appId);
3095
+ await record(PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, '');
3096
+ // The deploys are done, so record what they deployed now. A later failure
3097
+ // at the mint or delivery must not make the next apply see a changed
3098
+ // hostname or gitRef and redeploy the live host again. The planner still
3099
+ // plans the step until the resource id is recorded, once the key is delivered.
3100
+ await record(PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, hostname);
3101
+ await record(PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, gitRef);
3102
+ const internalUrl = `http://${PLATFORM_AI_RUNNER.internalName}:${PLATFORM_AI_PORT}`;
3103
+ await record(PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, internalUrl);
3104
+ // ---- Archon's AI management key, once ------------------------------------
3105
+ const now = context.recordedIds();
3106
+ if (!now[PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY]) {
3107
+ const lost = now[PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY];
3108
+ if (lost) {
3109
+ throw new Error(`platform:ai: AI management key ${lost} was minted but never delivered; revoke it and remove the recorded key ${PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY} to mint a new one`);
3110
+ }
3111
+ // #201: a test injects its own minter; without one, build the real one
3112
+ // over ai's own POST /v1/platform/keys, signed with the key pinned above.
3113
+ const minter = deps.managementKeyMinter ??
3114
+ createAiManagementKeyMinter({
3115
+ privateKeyPem: signingKeyPem,
3116
+ keyId: AI_MANAGEMENT_KEY_ID,
3117
+ issuer: AI_MANAGEMENT_ISSUER,
3118
+ audience: `https://${hostname}`,
3119
+ fetchImpl: deps.fetchImpl,
3120
+ });
3121
+ let minted;
3122
+ try {
3123
+ minted = await minter.mintManagementKey({
3124
+ internalUrl,
3125
+ name: 'archon',
3126
+ expiresDays: 90,
3127
+ permissions: PLATFORM_AI_MANAGEMENT_PERMISSIONS,
3128
+ });
3129
+ }
3130
+ catch (error) {
3131
+ if (error instanceof AiManagementKeyExistsError) {
3132
+ // ai already holds an 'archon' key this record never learned about
3133
+ // (most likely the crash window below's own comment describes,
3134
+ // happening one mint earlier): its secret cannot be shown again, so
3135
+ // this is reported and never retried under another name.
3136
+ await record(PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, error.credentialId);
3137
+ throw new Error(`platform:ai: ai already holds a management key named 'archon' (${error.credentialId}) that foundry never recorded delivering; 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`);
3138
+ }
3139
+ // Both messages below are fixed strings with no interpolated body, so
3140
+ // safe to surface directly -- unlike the generic case, neither can
3141
+ // ever echo a credential the minter read off the wire.
3142
+ if (error instanceof AiManagementKeyConflictError) {
3143
+ throw new Error(`platform:ai: ${error.message}`);
3144
+ }
3145
+ if (error instanceof AiManagementKeyUntrustedError) {
3146
+ throw new Error(`platform:ai: ${error.message}`);
3147
+ }
3148
+ // The cause is dropped in case it echoes a credential.
3149
+ throw new Error('platform:ai: the AI management key could not be minted (the minter failed; its error is not shown). Nothing was recorded; apply again');
3150
+ }
3151
+ const { credentialId, organisationId, secret } = minted;
3152
+ await record(PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, credentialId);
3153
+ await record(PLATFORM_AI_ORGANISATION_SCOPE_KEY, organisationId);
3154
+ const attempts = runtime.deliverAttempts ?? 3;
3155
+ for (let attempt = 1;; attempt += 1) {
3156
+ try {
3157
+ await deps.deliverSecret(PLATFORM_AI_MANAGEMENT_KEY_REF, secret);
3158
+ break;
3159
+ }
3160
+ catch {
3161
+ // The cause is dropped in case it echoes the secret.
3162
+ if (attempt >= attempts) {
3163
+ throw new Error(`platform:ai: AI management key ${credentialId} was minted but could not be delivered after ${attempts} tries`);
3164
+ }
3165
+ await sleep(1000 * attempt);
3166
+ }
3167
+ }
3168
+ await record(PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, credentialId);
3169
+ }
3170
+ return { resourceId: runner.appId };
3171
+ }
3172
+ /**
3173
+ * #201: ensures foundry's Ed25519 signing pair for ai's `POST
3174
+ * /v1/platform/keys` exists, and returns the private half (PKCS#8 PEM)
3175
+ * needed to sign the mint call that follows -- freshly minted this run, or
3176
+ * read back via `deps.heldKeys` when an earlier run already delivered and
3177
+ * pinned it but crashed before the management key itself was minted and
3178
+ * delivered. Follows `platform:integrations`'s own two-key discipline
3179
+ * exactly ({@link INTEGRATIONS_MANAGEMENT_KEY_REF}/`_ISSUER`/`_KEY_ID`): the
3180
+ * id is recorded and persisted before the private half is delivered, the
3181
+ * guard only once delivery and the pin on the runner both succeeded, so a
3182
+ * crash in between never mints a second pair.
3183
+ */
3184
+ async function ensureAiSigningKey(context, deps, runnerAppId, runtime, sleep, record) {
3185
+ if (context.recordedIds()[PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY]) {
3186
+ if (!deps.heldKeys) {
3187
+ throw new Error('platform:ai: the AI management key cannot be minted: its signing key is already pinned but the host built the platform executor without a held-key reader to read back its private half (PlatformAiDeps.heldKeys)');
3188
+ }
3189
+ const held = await deps.heldKeys.resolve(AI_SIGNING_KEY_REF);
3190
+ if (!held) {
3191
+ throw new Error(`platform:ai: the AI management key cannot be minted: the signing key is pinned on the runner but its private half could not be read back (held key ${AI_SIGNING_KEY_REF})`);
3192
+ }
3193
+ return held;
3194
+ }
3195
+ if (context.recordedIds()[PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY]) {
3196
+ throw new Error(`platform:ai: signing key ${AI_MANAGEMENT_KEY_ID} was minted but not confirmed delivered and pinned; not minting another. 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`);
3197
+ }
3198
+ const { publicKey, privateKey } = generateKeyPairSync('ed25519', {
3199
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
3200
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
3201
+ });
3202
+ await record(PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, AI_MANAGEMENT_KEY_ID);
3203
+ const attempts = runtime.deliverAttempts ?? 3;
3204
+ for (let attempt = 1;; attempt += 1) {
3205
+ try {
3206
+ await deps.deliverSecret(AI_SIGNING_KEY_REF, privateKey);
3207
+ break;
3208
+ }
3209
+ catch {
3210
+ // The cause is dropped in case it echoes the key.
3211
+ if (attempt >= attempts) {
3212
+ throw new Error(`platform:ai: signing key ${AI_MANAGEMENT_KEY_ID} was minted but could not be delivered after ${attempts} attempts`);
3213
+ }
3214
+ await sleep(1000 * attempt);
3215
+ }
3216
+ }
3217
+ // The runner reads these only after a redeploy: mark it pending first, so
3218
+ // a crash between pinning and deploying still redeploys on the next apply.
3219
+ await record(PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, '1');
3220
+ await deps.coolify.bulkUpsertEnvs(runnerAppId, [
3221
+ { key: 'MANAGEMENT_ISSUER', value: AI_MANAGEMENT_ISSUER },
3222
+ {
3223
+ key: 'MANAGEMENT_PUBLIC_KEYS',
3224
+ value: JSON.stringify({ [AI_MANAGEMENT_KEY_ID]: publicKey }),
3225
+ },
3226
+ ]);
3227
+ await record(PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, runnerAppId);
3228
+ return privateKey;
3229
+ }
3230
+ /** The migrate job did not turn healthy in time; carries its last status, which holds no secret. */
3231
+ class MigrationNotHealthyError extends Error {
3232
+ lastStatus;
3233
+ constructor(lastStatus, message) {
3234
+ super(message);
3235
+ this.lastStatus = lastStatus;
3236
+ }
3237
+ }
3238
+ /**
3239
+ * Polls the app until Coolify reports it stopped (`exited`/`stopped`), so a
3240
+ * healthy status read afterwards belongs to a deployment queued after this.
3241
+ */
3242
+ async function waitForStopped(coolify, appId, runtime, sleep) {
3243
+ const now = runtime.now ?? Date.now;
3244
+ const timeout = runtime.migrateTimeoutMs ?? 600_000;
3245
+ const interval = runtime.migrateIntervalMs ?? 5_000;
3246
+ const deadline = now() + timeout;
3247
+ for (;;) {
3248
+ const { status } = await coolify.getApplication(appId);
3249
+ if (status && !isRunningOrStarting(status))
3250
+ return;
3251
+ if (now() >= deadline) {
3252
+ throw new Error(`platform:ai: the migrate job ${appId} did not stop within ${Math.round(timeout / 1000)}s (last status ${status ?? 'unknown'}); apply again`);
3253
+ }
3254
+ await sleep(interval);
3255
+ }
3256
+ }
3257
+ /**
3258
+ * Polls the migrate app until Coolify reports it `running:healthy` -- the
3259
+ * migration target's healthcheck passes only once `migrate.mjs` has finished
3260
+ * -- or throws {@link MigrationNotHealthyError} once `migrateTimeoutMs` has
3261
+ * passed. The caller stopped the app and saw it stopped before deploying, so
3262
+ * a healthy status here is the new deployment's. Any other status (queued,
3263
+ * building, exited, starting unhealthy) keeps waiting. On a timeout the job
3264
+ * is left as it is: stopping it mid-migration is worse.
3265
+ */
3266
+ async function waitForAiMigration(coolify, appId, runtime, sleep) {
3267
+ const now = runtime.now ?? Date.now;
3268
+ const timeout = runtime.migrateTimeoutMs ?? 600_000;
3269
+ const interval = runtime.migrateIntervalMs ?? 5_000;
3270
+ const deadline = now() + timeout;
3271
+ for (;;) {
3272
+ await sleep(interval);
3273
+ const { status } = await coolify.getApplication(appId);
3274
+ if (status?.startsWith('running:healthy'))
3275
+ return;
3276
+ if (now() >= deadline) {
3277
+ throw new MigrationNotHealthyError(status ?? 'unknown', `platform:ai: the migrate job ${appId} did not report healthy within ${Math.round(timeout / 1000)}s (last status ${status ?? 'unknown'}); the host and worker were not deployed. Check its logs; the step is blocked until the recorded key ${PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY} is removed`);
3278
+ }
3279
+ }
3280
+ }
3281
+ /**
3282
+ * The runtime role's `DATABASE_URL`, reused from the first of `appIds` that
3283
+ * already has one, else minted: `<database>_rt` (the name ai's
3284
+ * `0001_runtime_role.sql` derives) with a fresh base64url password, on the
3285
+ * owner URL's host, database and query string. The owner URL is parsed
3286
+ * inside a try: Node's parse error carries the whole input, credential
3287
+ * included, so it is replaced with a fixed message.
3288
+ */
3289
+ async function ensureAiRuntimeUrl(coolify, appIds, ownerUrl) {
3290
+ for (const appId of appIds) {
3291
+ const existing = (await coolify.listEnvs(appId)).find((e) => e.key === 'DATABASE_URL' && !e.is_preview && e.value);
3292
+ if (existing)
3293
+ return existing.value;
3294
+ }
3295
+ let host;
3296
+ let pathname;
3297
+ let search;
3298
+ try {
3299
+ ({ host, pathname, search } = new URL(ownerUrl));
3300
+ }
3301
+ catch {
3302
+ throw new Error("platform:ai: the database's internal_db_url could not be parsed");
3303
+ }
3304
+ const database = pathname.replace(/^\//, '');
3305
+ if (!/^[A-Za-z0-9_]+$/.test(database)) {
3306
+ throw new Error("platform:ai: the database's name cannot form a runtime role name (letters, digits and _ only)");
3307
+ }
3308
+ const password = randomBytes(24).toString('base64url');
3309
+ return `postgres://${database}_rt:${password}@${host}${pathname}${search}`;
3310
+ }
3311
+ /** The entries of `defaults` whose key the app does not have yet. */
3312
+ async function missingEnvs(coolify, appId, defaults) {
3313
+ const present = new Set((await coolify.listEnvs(appId)).filter((e) => !e.is_preview).map((e) => e.key));
3314
+ return defaults.filter((d) => !present.has(d.key));
3315
+ }
3316
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
3317
+ /** Polls `url` until it answers 2xx, or throws once `readyTimeoutMs` has passed. */
3318
+ async function waitForInstance(url, runtime, sleep) {
3319
+ const fetchImpl = runtime.fetch ?? globalThis.fetch;
3320
+ const now = runtime.now ?? Date.now;
3321
+ const timeout = runtime.readyTimeoutMs ?? 300_000;
3322
+ const interval = runtime.readyIntervalMs ?? 5_000;
3323
+ const deadline = now() + timeout;
3324
+ for (;;) {
3325
+ try {
3326
+ const response = await fetchImpl(url);
3327
+ if (response.ok)
3328
+ return;
3329
+ }
3330
+ catch {
3331
+ // Not up yet: DNS, the tunnel or the container.
3332
+ }
3333
+ if (now() >= deadline) {
3334
+ throw new Error(`platform:auth: the new ZITADEL instance did not answer at ${url} within ${Math.round(timeout / 1000)}s; retry this apply`);
3335
+ }
3336
+ await sleep(interval);
3337
+ }
3338
+ }
3339
+ async function resolvePlatformServer(coolify) {
3340
+ const servers = await coolify.listServers();
3341
+ const server = servers.find((s) => s.is_usable && s.is_reachable) ?? servers[0];
3342
+ if (!server)
3343
+ throw new Error('no usable Coolify server is registered on this instance');
3344
+ return server;
3345
+ }
3346
+ /**
3347
+ * `executor.ts`'s `coolifyCalls`/`findOrCreateCallsApp`, restated here rather
3348
+ * than imported: that method is private to `FoundryStepExecutor`, and this
3349
+ * file holds no instance of that class to call it on. `running` says whether
3350
+ * the app was already running or starting; the caller deploys it when not.
3351
+ */
3352
+ async function findOrCreatePlatformApp(coolify, name, environment, create) {
3353
+ const existing = await coolify.findApplicationByName(name, { environmentIds: [environment.id] });
3354
+ if (existing) {
3355
+ const live = await coolify.getApplication(existing.uuid);
3356
+ return {
3357
+ appId: existing.uuid,
3358
+ justCreated: false,
3359
+ running: isRunningOrStarting(live.status),
3360
+ };
3361
+ }
3362
+ const created = await create();
3363
+ return { appId: created.uuid, justCreated: true, running: false };
3364
+ }
3365
+ /**
3366
+ * #211: names of the envs the platform's sign-in broker (`wtfalch/auth`'s
3367
+ * `broker/server.ts`) refuses to start without, that `provisionPlatformAuth`
3368
+ * itself has no way to supply yet -- `ZITADEL_SERVICE_USER_TOKEN`/`_FILE`
3369
+ * (#209 mints and pushes this once it lands) and `RESEND_API_TOKEN` (#210,
3370
+ * blocked on `wtfalch/auth#138`). Read from the broker's own live env, not a
3371
+ * recorded scope id: whichever of #209/#210 lands first (or a human, setting
3372
+ * either directly on the app) is enough to clear that one input, in either
3373
+ * order, with no coupling to this step's own execution.
3374
+ *
3375
+ * #211 round 3: `AUTH_APPS` is a third required input -- the broker's own
3376
+ * `parseApps` refuses to start with no `AUTH_APPS` set or an empty array
3377
+ * (`wtfalch/auth/broker/src/apps.ts`), and this check used to name only the
3378
+ * other two, so a deploy could go ahead with a broker that still would not
3379
+ * come up. Also treat `null`/`undefined` as missing, not only `''`: the
3380
+ * `CoolifyPort.listEnvs` doc comment already warns a live value does not
3381
+ * always match its declared `string` type.
3382
+ */
3383
+ async function missingBrokerInputs(coolify, brokerAppId) {
3384
+ const envs = await coolify.listEnvs(brokerAppId);
3385
+ const value = (key) => envs.find((e) => e.key === key && !e.is_preview && e.value != null && e.value !== '')?.value;
3386
+ const has = (key) => value(key) !== undefined;
3387
+ const missing = [];
3388
+ if (!has('ZITADEL_SERVICE_USER_TOKEN') && !has('ZITADEL_SERVICE_USER_TOKEN_FILE')) {
3389
+ missing.push('ZITADEL_SERVICE_USER_TOKEN');
3390
+ }
3391
+ if (!has('RESEND_API_TOKEN'))
3392
+ missing.push('RESEND_API_TOKEN');
3393
+ if (authAppsProblem(value('AUTH_APPS'), brokerAppId) !== null)
3394
+ missing.push('AUTH_APPS');
3395
+ return missing;
3396
+ }
3397
+ /**
3398
+ * #211 round 5 addendum: why a live `AUTH_APPS` value would not pass the
3399
+ * broker's own `parseApps` at boot, distinguishing `'missing'` (nothing set,
3400
+ * or an empty array -- nobody has registered anything yet) from
3401
+ * `'malformed'` (something is set and it is wrong: bad JSON, not an array, or
3402
+ * an entry {@link validateBrokerAppEntry} itself refuses). `hasNonEmptyAuthApps`
3403
+ * (#211 round 4) collapsed both into one `false`, so `missingBrokerInputs`
3404
+ * (and, through it, `authBrokerBlockedReason`) always reported "it has no
3405
+ * ... AUTH_APPS yet" even when `AUTH_APPS` held garbage a person needs to go
3406
+ * fix, rather than an input nobody has supplied yet -- `authBrokerBlockedReason`
3407
+ * below is what turns this distinction into different wording. `null` when
3408
+ * the value is valid. A bad entry's own error is discarded here too: this
3409
+ * function only classifies the problem, and the named error still surfaces
3410
+ * wherever `readBrokerApps` is actually called.
3411
+ */
3412
+ function authAppsProblem(raw, brokerAppId) {
3413
+ if (raw === undefined || raw === '')
3414
+ return 'missing';
3415
+ let parsed;
3416
+ try {
3417
+ parsed = JSON.parse(raw);
3418
+ }
3419
+ catch {
3420
+ return 'malformed';
3421
+ }
3422
+ if (!Array.isArray(parsed))
3423
+ return 'malformed';
3424
+ if (parsed.length === 0)
3425
+ return 'missing';
3426
+ try {
3427
+ parsed.forEach((entry, index) => validateBrokerAppEntry(entry, brokerAppId, index));
3428
+ }
3429
+ catch {
3430
+ return 'malformed';
3431
+ }
3432
+ return null;
3433
+ }
3434
+ /**
3435
+ * #211 round 5: sha256 of the exact `AUTH_APPS` string a {@link BrokerDeployRecord}
3436
+ * names -- never the value itself, only its hash, so a recorded scope id can
3437
+ * never leak another organisation's broker key. `undefined` (no `AUTH_APPS`
3438
+ * set) hashes the same as `''`, both meaning "nothing deployed yet".
3439
+ */
3440
+ function hashAuthApps(raw) {
3441
+ return createHash('sha256')
3442
+ .update(raw ?? '')
3443
+ .digest('hex');
3444
+ }
3445
+ /**
3446
+ * #211 round 5: the one rule `provisionPlatformAuth` and
3447
+ * `provisionPlatformArchon` both call to decide whether the broker needs a
3448
+ * (re)deploy -- replacing the two ad hoc "was it ever ostensibly deployed"
3449
+ * booleans each function used to keep, which is what let a recreated broker,
3450
+ * and a crash between writing `AUTH_APPS` and deploying, both go unnoticed
3451
+ * (the round 5 issue text's own diagnosis: "they share one cause"). `false`
3452
+ * while any required input is still missing -- deploying a broker that
3453
+ * cannot start is pointless regardless of what changed. Otherwise `true`
3454
+ * when nothing has ever been recorded, when the broker that was deployed is
3455
+ * a different app (recreated under a new id), or when the `AUTH_APPS` that
3456
+ * was deployed is not the `AUTH_APPS` that is live/about-to-be-live now.
3457
+ * `authAppsRaw` is that exact string -- read live by `provisionPlatformAuth`,
3458
+ * which never writes `AUTH_APPS` itself, or supplied by
3459
+ * `provisionPlatformArchon` as the string it already knows it just wrote (or
3460
+ * left unchanged), so neither caller needs a second read to agree with
3461
+ * itself about what is now true of the app.
3462
+ */
3463
+ function brokerDeployNeeded(missingInputs, brokerAppId, authAppsRaw, recordedDeploy) {
3464
+ if (missingInputs.length > 0)
3465
+ return false;
3466
+ if (recordedDeploy === null)
3467
+ return true;
3468
+ if (recordedDeploy.brokerAppId !== brokerAppId)
3469
+ return true;
3470
+ return recordedDeploy.authAppsHash !== hashAuthApps(authAppsRaw);
3471
+ }
3472
+ /**
3473
+ * #211 round 5: the {@link BrokerDeployRecord} JSON a successful deploy
3474
+ * writes under {@link PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY} (via each
3475
+ * caller's own `record()`), replacing the plain broker-id string round 2-4
3476
+ * wrote there. Paired with {@link brokerDeployNeeded}, which reads it back.
3477
+ */
3478
+ function brokerDeployRecordValue(brokerAppId, authAppsRaw) {
3479
+ const record = { brokerAppId, authAppsHash: hashAuthApps(authAppsRaw) };
3480
+ return JSON.stringify(record);
3481
+ }
3482
+ /** Whether any desired env is missing from the app, or set to another value. */
3483
+ async function envsDiffer(coolify, appId, desired) {
3484
+ const live = new Map((await coolify.listEnvs(appId)).filter((e) => !e.is_preview).map((e) => [e.key, e.value]));
3485
+ return desired.some((e) => live.get(e.key) !== e.value);
3486
+ }
3487
+ /**
3488
+ * `null` once the app already has this key set -- generated only the first
3489
+ * time, never regenerated or overwritten, mirroring `executor.ts`'s
3490
+ * `ensureAuthCookieSecret` exactly. #211 round 4: `''`, `null` and
3491
+ * `undefined` all count as not set, the same "missing" a live Coolify
3492
+ * response can carry despite its declared `string` type
3493
+ * (`CoolifyPort.listEnvs`'s own doc comment) -- an entry present with one of
3494
+ * those values used to read as "already has this key set" and return `null`,
3495
+ * so a caller reading the value back afterwards (`provisionPlatformArchon`'s
3496
+ * own `AUTH_APP_KEY`) threw "lost its ... between two reads" on every single
3497
+ * apply instead of regenerating it.
3498
+ *
3499
+ * #211 round 5: `minLength`, when given, also counts a *present, non-empty*
3500
+ * value shorter than it as not set -- the broker's own `parseApps`
3501
+ * (`wtfalch/auth/broker/src/apps.ts`) refuses a `key` under 32 characters,
3502
+ * and this package minted a 32-character `AUTH_APP_KEY` from the start
3503
+ * (`provisionPlatformArchon`'s own comment on `randomBytes(32)`), so this
3504
+ * only ever changes behaviour for a key that reached the app some other way
3505
+ * -- a hand-set value, or one carried over from before that guarantee held.
3506
+ * Optional, and unused by every other call site (`ensureMasterkey`,
3507
+ * `ZITADEL_ADMIN_PASSWORD`, `AUTH_COOKIE_SECRET`, this file's recovery admin
3508
+ * password): none of those have a broker-enforced minimum, so widening this
3509
+ * check to them would reject a value that has always been fine.
3510
+ */
3511
+ async function ensureGeneratedEnv(coolify, appId, key, generate, minLength) {
3512
+ const envs = await coolify.listEnvs(appId);
3513
+ const entry = envs.find((e) => e.key === key && !e.is_preview);
3514
+ const already = entry !== undefined &&
3515
+ entry.value != null &&
3516
+ entry.value !== '' &&
3517
+ (minLength === undefined || entry.value.length >= minLength);
3518
+ if (already)
3519
+ return null;
3520
+ return generate();
3521
+ }
3522
+ /** The live value of `key` on `appId`, or `undefined` when unset -- `ensureGeneratedEnv`'s read half, for a caller that needs the value it already found. */
3523
+ async function currentEnvValue(coolify, appId, key) {
3524
+ const envs = await coolify.listEnvs(appId);
3525
+ return envs.find((e) => e.key === key && !e.is_preview)?.value;
3526
+ }
3527
+ /**
3528
+ * {@link ensureGeneratedEnv} for `ZITADEL_MASTERKEY`, which encrypts what
3529
+ * ZITADEL keeps in its database. A new key over a database ZITADEL has run
3530
+ * against (one that existed before this run and had its app deployed) makes
3531
+ * that data unreadable, so such a database never gets a generated one. The
3532
+ * env is always written before the first deploy, so a missing key on a
3533
+ * deployed app means someone removed it. This throws, and a person sets the key that database was
3534
+ * initialised with (or deletes an unused database) and applies again.
3535
+ */
3536
+ async function ensureMasterkey(coolify, appId, databaseInUse) {
3537
+ const envs = await coolify.listEnvs(appId);
3538
+ if (envs.some((e) => e.key === 'ZITADEL_MASTERKEY' && !e.is_preview))
3539
+ return null;
3540
+ if (databaseInUse) {
3541
+ throw new Error(`platform:auth: app ${appId} has no ZITADEL_MASTERKEY but its database is already in use; not generating a new one. Set ZITADEL_MASTERKEY on that app to the key the database was initialised with, or delete the database if ZITADEL never ran on it, then apply again`);
3542
+ }
3543
+ return randomBytes(24).toString('base64');
3544
+ }
3545
+ /** `docs/deploy.md`: "ZITADEL_ADMIN_PASSWORD: 8+, upper, lower, digit, symbol". */
3546
+ function generateAdminPassword() {
3547
+ return `${randomBytes(18).toString('base64')}Aa1!`;
3548
+ }
3549
+ /**
3550
+ * #171: how the host signs a files management request with the key this step
3551
+ * delivers under {@link FILES_MANAGEMENT_KEY_REF} (files'
3552
+ * `signManagementRequest`'s `SigningIdentity`): `issuer` is
3553
+ * {@link FILES_MANAGEMENT_ISSUER}, `keyId` is {@link FILES_MANAGEMENT_KEY_ID},
3554
+ * `audience` is `https://<platform.files.hostname>` (the files host's
3555
+ * `APP_URL`, which this step sets), and `privateKey` is the delivered PKCS#8
3556
+ * PEM. Neither constant is a secret. The files host pins the public half
3557
+ * through `MANAGEMENT_ISSUER`/`MANAGEMENT_PUBLIC_KEYS`, which this step sets.
3558
+ */
3559
+ export const FILES_MANAGEMENT_ISSUER = 'foundry';
3560
+ export const FILES_MANAGEMENT_KEY_ID = 'foundry-1';
3561
+ /** The ref the management private key (PKCS#8 PEM) is delivered under. */
3562
+ export const FILES_MANAGEMENT_KEY_REF = 'files:platform:managementKey';
3563
+ /** The ref the files host's introspection client secret is delivered under. */
3564
+ export const FILES_INTROSPECTION_SECRET_REF = 'zitadel:platform:files:introspection';
3565
+ /** https://github.com/wtfalch/files -- root Dockerfile with `migration` and `runner` targets. */
3566
+ const FILES_REPO = 'wtfalch/files';
3567
+ const PLATFORM_FILES_PROJECT = 'platform';
3568
+ const PLATFORM_FILES_DB_NAME = 'platform-files-db';
3569
+ /** The two Coolify apps files' `apps/host/README.md` ("Deploying on Coolify") names. */
3570
+ const FILES_MIGRATE_APP_NAME = 'files-migrate';
3571
+ const FILES_RUNNER_APP_NAME = 'files';
3572
+ /**
3573
+ * The runner's fixed `custom_internal_name`: the name another container on
3574
+ * the Coolify network reaches it by, and so the host part of
3575
+ * {@link PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY}'s recorded address.
3576
+ */
3577
+ const FILES_RUNNER_INTERNAL_NAME = 'platform-files';
3578
+ /** The runner's port (`Dockerfile`'s `runner` stage: `PORT=3000`, `EXPOSE 3000`). */
3579
+ const FILES_PORT = '3000';
3580
+ /** The Postgres database name; files' runtime role is `<database>_rt`. */
3581
+ const FILES_DB = 'files';
3582
+ const FILES_RUNTIME_ROLE = `${FILES_DB}_rt`;
3583
+ const FILES_ZITADEL_PROJECT_NAME = 'files';
3584
+ const FILES_INTROSPECTION_APP_NAME = 'files-introspection';
3585
+ const PLATFORM_FILES_PROJECT_SCOPE_KEY = 'coolify_project_platform_files';
3586
+ const PLATFORM_FILES_DB_SCOPE_KEY = 'coolify_platform_files_database';
3587
+ const PLATFORM_FILES_MIGRATE_SCOPE_KEY = 'coolify_platform_files_migrate_app';
3588
+ const PLATFORM_FILES_RUNNER_SCOPE_KEY = 'coolify_platform_files_runner_app';
3589
+ /**
3590
+ * #171: makes `coolify:app` keyed {@link PLATFORM_FILES_KEY} true -- the files
3591
+ * host from https://github.com/wtfalch/files, on Coolify's internal network
3592
+ * only, following its `apps/host/README.md` ("Deploying on Coolify"):
3593
+ *
3594
+ * 1. A Coolify Postgres (`files`), and two apps from the repo's root
3595
+ * Dockerfile: `files-migrate` (the `migration` target) and `files` (the
3596
+ * `runner` target, no domain, a fixed `custom_internal_name`).
3597
+ * 2. In the platform's own sign-in: the `files` ZITADEL project (its id is the
3598
+ * person-token audience, `AUTH_PERSON_TOKEN_AUDIENCE`) and an API app, the
3599
+ * host's introspection client (`AUTH_PERSON_TOKEN_INTROSPECTION_*`).
3600
+ * 3. Foundry's management key: an Ed25519 keypair minted here. The public
3601
+ * half is pinned on the runner (`MANAGEMENT_ISSUER`,
3602
+ * `MANAGEMENT_PUBLIC_KEYS`); the private half goes to the host as a held
3603
+ * key, which is what the org steps' `FilesOrganisationPort` signs with.
3604
+ * 4. Deploy `files-migrate`, wait for it to report healthy, stop it, then
3605
+ * deploy `files`.
3606
+ * 5. Record the runner's internal address and the ZITADEL project id.
3607
+ *
3608
+ * Both secrets are handed to {@link PlatformFilesDeps.deliverSecret} and
3609
+ * written to the runner's env, and nowhere else: never returned, recorded,
3610
+ * logged or put in a thrown error. Each is made only while its guard is
3611
+ * unrecorded, and follows `platform:auth`'s admin-token pattern: an
3612
+ * "undelivered" id is recorded and persisted the moment the secret is made,
3613
+ * and the guard only after delivery AND the env write. A run that fails in
3614
+ * between leaves an undelivered id with no guard; the planner reports that
3615
+ * and never mints again, and a person clears the recorded key (and the
3616
+ * ZITADEL client) to allow a new one.
3617
+ *
3618
+ * A re-run after a changed hostname or `gitRef` (recorded as applied scope
3619
+ * ids) updates `git_branch` and the runner env and redeploys, and mints
3620
+ * nothing.
3621
+ *
3622
+ * Known gaps, left for review:
3623
+ * - The README says to wait for `files-migrate: done` in the migrate app's
3624
+ * logs. `CoolifyPort` cannot read logs, so this waits for Coolify to report
3625
+ * the app `running:healthy` instead. The `migration` stage's HEALTHCHECK
3626
+ * (`test -f /tmp/files-migrated`) passes on the file touched right before
3627
+ * that line is logged, so the two mean the same thing. The migrate app is stopped
3628
+ * before its deploy, so the poll cannot pass on a container left up by a
3629
+ * failed run. Whether Coolify's status still shows the old container for a
3630
+ * moment right after the deploy is not checked against a live Coolify.
3631
+ * - The Dockerfile target is chosen by setting `dockerfile_target_build`
3632
+ * through `updateApplication`, since `createGithubAppApplication` has no
3633
+ * field for it. Not yet checked against a live Coolify.
3634
+ * - The runner is created without `domains`, like `platform:auth`'s apps.
3635
+ * Whether Coolify adds a generated domain anyway is not yet checked.
3636
+ * - Foundry sets only the env it owns: the two database URLs, `APP_URL`,
3637
+ * `AUTH_ISSUER`, `AUTH_PERSON_TOKEN_*`, `MANAGEMENT_*` and the two `KEYS_*`
3638
+ * values the host supplies through {@link PlatformFilesDeps.keys}, which
3639
+ * files-migrate needs to become healthy. The rest of files' `.env.example`
3640
+ * (`R2_*`, `KEYS_VERIFY_KEYS`, `JOBS_*`, `AUTHZ_BOOTSTRAP_OPERATOR`, ...)
3641
+ * is set by a person on the runner.
3642
+ * - The files project lives in the platform's `foundry` ZITADEL org. An
3643
+ * org's Agora requests it as an audience scope from its own ZITADEL org;
3644
+ * whether that needs a project grant per org is not decided here.
3645
+ * - The recorded address is reachable only from a container on the same
3646
+ * Coolify network, so the host must run there to use it for management
3647
+ * calls.
3648
+ */
3649
+ async function provisionPlatformFiles(context, deps) {
3650
+ const { platform } = context;
3651
+ const gitRef = platform.files.gitRef;
3652
+ const recorded = context.recordedIds();
3653
+ const runtime = deps.runtime ?? {};
3654
+ const sleep = runtime.sleep ?? defaultSleep;
3655
+ const attempts = runtime.deliverAttempts ?? 3;
3656
+ const keys = filesKeys(deps);
3657
+ if (!keys) {
3658
+ throw new Error('platform:files needs the keys worker URL and credential in its files dependencies (KEYS_WORKER_URL, KEYS_CREDENTIAL); files-migrate never becomes healthy without them');
3659
+ }
3660
+ // Never plan-and-throw: the planner reports a lost secret and plans
3661
+ // nothing; this is the same rule for a caller that reaches the step anyway.
3662
+ for (const [undeliveredKey, guardKey] of [
3663
+ [PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY],
3664
+ [PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY],
3665
+ ]) {
3666
+ const lost = recorded[undeliveredKey];
3667
+ if (lost && !recorded[guardKey]) {
3668
+ throw new Error(`platform:files: a secret (${lost}) was minted but never delivered; remove the recorded key ${undeliveredKey} to mint a new one`);
3669
+ }
3670
+ }
3671
+ const record = async (key, id) => {
3672
+ context.recordScopeId(key, id);
3673
+ await context.persist?.();
3674
+ };
3675
+ let projectUuid = recorded[PLATFORM_FILES_PROJECT_SCOPE_KEY];
3676
+ if (!projectUuid) {
3677
+ const found = await deps.coolify.findProjectByName(PLATFORM_FILES_PROJECT);
3678
+ projectUuid = found
3679
+ ? found.uuid
3680
+ : (await deps.coolify.createProject(PLATFORM_FILES_PROJECT)).uuid;
3681
+ await record(PLATFORM_FILES_PROJECT_SCOPE_KEY, projectUuid);
3682
+ }
3683
+ const { environment } = await deps.coolify.findOrCreateEnvironment(projectUuid, 'production');
3684
+ // ---- database ----------------------------------------------------------
3685
+ let dbId = recorded[PLATFORM_FILES_DB_SCOPE_KEY];
3686
+ if (!dbId) {
3687
+ // Found by name first, so a crash between create and record never makes
3688
+ // a second one (`coolifyJobs` does the same).
3689
+ const found = (await deps.coolify.listDatabases()).find((db) => db.name === PLATFORM_FILES_DB_NAME && db.environment_id === environment.id);
3690
+ if (found) {
3691
+ dbId = found.uuid;
3692
+ }
3693
+ else {
3694
+ const server = await resolvePlatformServer(deps.coolify);
3695
+ const created = await deps.coolify.createPostgresDatabase({
3696
+ project_uuid: projectUuid,
3697
+ server_uuid: server.uuid,
3698
+ environment_name: environment.name,
3699
+ environment_uuid: environment.uuid,
3700
+ name: PLATFORM_FILES_DB_NAME,
3701
+ postgres_db: FILES_DB,
3702
+ postgres_user: FILES_DB,
3703
+ });
3704
+ dbId = created.uuid;
3705
+ }
3706
+ await record(PLATFORM_FILES_DB_SCOPE_KEY, dbId);
3707
+ }
3708
+ const db = await deps.coolify.getDatabase(dbId);
3709
+ if (!db.internal_db_url) {
3710
+ throw new Error('coolify:app for the platform (files): the database has no internal_db_url yet -- retry this apply once Coolify reports one');
3711
+ }
3712
+ // ---- the two apps --------------------------------------------------------
3713
+ const createFilesApp = (name) => async () => {
3714
+ const server = await resolvePlatformServer(deps.coolify);
3715
+ const githubApp = await deps.coolify.requireGithubApp();
3716
+ return deps.coolify.createGithubAppApplication({
3717
+ project_uuid: projectUuid,
3718
+ server_uuid: server.uuid,
3719
+ environment_name: environment.name,
3720
+ environment_uuid: environment.uuid,
3721
+ github_app_uuid: githubApp.uuid,
3722
+ git_repository: FILES_REPO,
3723
+ git_branch: gitRef,
3724
+ build_pack: 'dockerfile',
3725
+ name,
3726
+ });
3727
+ };
3728
+ const migrateApp = await findOrCreatePlatformApp(deps.coolify, FILES_MIGRATE_APP_NAME, environment, createFilesApp(FILES_MIGRATE_APP_NAME));
3729
+ await record(PLATFORM_FILES_MIGRATE_SCOPE_KEY, migrateApp.appId);
3730
+ // Set on every run, like `platform:auth`'s `custom_internal_name`: a throw
3731
+ // right after a create must not leave the app on the wrong target.
3732
+ // `git_branch` too, so a changed `gitRef` reaches an app made earlier.
3733
+ await deps.coolify.updateApplication(migrateApp.appId, {
3734
+ dockerfile_target_build: 'migration',
3735
+ git_branch: gitRef,
3736
+ });
3737
+ const runnerApp = await findOrCreatePlatformApp(deps.coolify, FILES_RUNNER_APP_NAME, environment, createFilesApp(FILES_RUNNER_APP_NAME));
3738
+ await record(PLATFORM_FILES_RUNNER_SCOPE_KEY, runnerApp.appId);
3739
+ await deps.coolify.updateApplication(runnerApp.appId, {
3740
+ dockerfile_target_build: 'runner',
3741
+ custom_internal_name: FILES_RUNNER_INTERNAL_NAME,
3742
+ git_branch: gitRef,
3743
+ });
3744
+ // ---- database env ----------------------------------------------------------
3745
+ // The migrate app sets `files_rt`'s password from DATABASE_URL on every
3746
+ // boot (files' `scripts/db-runtime-role.sh`), and the runner connects with
3747
+ // the same URL, so both apps carry one value, minted once and never
3748
+ // rotated: an existing value on either app is reused.
3749
+ const runtimeUrl = await ensureFilesRuntimeUrl(deps.coolify, [migrateApp.appId, runnerApp.appId], db.internal_db_url);
3750
+ // The keys worker the migrate app needs to become healthy (files' README:
3751
+ // `DATABASE_URL_OWNER`, `DATABASE_URL`, `KEYS_WORKER_URL`, `KEYS_CREDENTIAL`).
3752
+ const keysEnvs = [
3753
+ { key: 'KEYS_WORKER_URL', value: keys.workerUrl },
3754
+ { key: 'KEYS_CREDENTIAL', value: keys.credential },
3755
+ ];
3756
+ await deps.coolify.bulkUpsertEnvs(migrateApp.appId, [
3757
+ { key: 'DATABASE_URL_OWNER', value: db.internal_db_url },
3758
+ { key: 'DATABASE_URL', value: runtimeUrl },
3759
+ ...keysEnvs,
3760
+ ]);
3761
+ // ---- the files ZITADEL project, in the platform's sign-in ----------------
3762
+ const issuer = `https://${platform.auth.hostname}`;
3763
+ const zitadel = deps.zitadelForInstance?.(issuer) ?? deps.zitadel;
3764
+ const { org } = await zitadel.findOrCreateOrg(PLATFORM_AUTH_ORG_NAME);
3765
+ const { project } = await zitadel.findOrCreateProject(org.id, FILES_ZITADEL_PROJECT_NAME);
3766
+ await record(PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, project.id);
3767
+ const runnerEnvs = [
3768
+ { key: 'DATABASE_URL', value: runtimeUrl },
3769
+ // The management verifier's audience, which must be an HTTPS origin. The
3770
+ // host has no public address, so this is a name, not a route.
3771
+ { key: 'APP_URL', value: `https://${platform.files.hostname}` },
3772
+ { key: 'AUTH_ISSUER', value: issuer },
3773
+ { key: 'AUTH_PERSON_TOKEN_AUDIENCE', value: project.id },
3774
+ ...keysEnvs,
3775
+ ];
3776
+ // Base env first, so a secret's own write below cannot be the only one
3777
+ // that lands, and a re-run with a changed hostname updates `APP_URL`.
3778
+ await deps.coolify.bulkUpsertEnvs(runnerApp.appId, runnerEnvs);
3779
+ // ---- the introspection client, once --------------------------------------
3780
+ if (!recorded[PLATFORM_FILES_INTROSPECTION_SCOPE_KEY]) {
3781
+ // An app by this name here has no recorded secret (no undelivered id, or
3782
+ // the throw above would have fired): a crash between the create and the
3783
+ // record. Nothing was delivered, so it is deleted and made again.
3784
+ const stale = (await zitadel.listApps(org.id, project.id)).filter((app) => app.name === FILES_INTROSPECTION_APP_NAME && app.state !== 'APP_STATE_REMOVED');
3785
+ for (const app of stale) {
3786
+ try {
3787
+ await zitadel.deleteApp(org.id, project.id, app.id);
3788
+ }
3789
+ catch (error) {
3790
+ if (!isNotFoundError(error))
3791
+ throw error;
3792
+ }
3793
+ }
3794
+ const client = await zitadel.createApiApp(org.id, project.id, {
3795
+ name: FILES_INTROSPECTION_APP_NAME,
3796
+ });
3797
+ // Recorded and persisted before the secret leaves this function: a failed
3798
+ // delivery or env write is then reported by the planner, not remade.
3799
+ await record(PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, client.appId);
3800
+ await deliverWithRetry(deps.deliverSecret, FILES_INTROSPECTION_SECRET_REF, client.clientSecret, attempts, sleep, `the introspection client secret (ZITADEL app ${client.appId})`);
3801
+ await deps.coolify.bulkUpsertEnvs(runnerApp.appId, [
3802
+ { key: 'AUTH_PERSON_TOKEN_INTROSPECTION_CLIENT_ID', value: client.clientId },
3803
+ { key: 'AUTH_PERSON_TOKEN_INTROSPECTION_CLIENT_SECRET', value: client.clientSecret },
3804
+ ]);
3805
+ await record(PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, client.appId);
3806
+ }
3807
+ // ---- foundry's management key, once --------------------------------------
3808
+ if (!recorded[PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY]) {
3809
+ const { publicKey, privateKey } = generateKeyPairSync('ed25519', {
3810
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
3811
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
3812
+ });
3813
+ await record(PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, FILES_MANAGEMENT_KEY_ID);
3814
+ await deliverWithRetry(deps.deliverSecret, FILES_MANAGEMENT_KEY_REF, privateKey, attempts, sleep, 'the files management key');
3815
+ await deps.coolify.bulkUpsertEnvs(runnerApp.appId, [
3816
+ { key: 'MANAGEMENT_ISSUER', value: FILES_MANAGEMENT_ISSUER },
3817
+ {
3818
+ key: 'MANAGEMENT_PUBLIC_KEYS',
3819
+ value: JSON.stringify({ [FILES_MANAGEMENT_KEY_ID]: publicKey }),
3820
+ },
3821
+ ]);
3822
+ await record(PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, FILES_MANAGEMENT_KEY_ID);
3823
+ }
3824
+ // ---- deploy: stop migrate, deploy it, wait, stop it, then the runner -----
3825
+ // A failed run can leave `files-migrate` up and healthy from the last
3826
+ // deploy. Stopped first, so the wait below cannot pass on the old
3827
+ // container before the new migration has run.
3828
+ await stopIfRunning(deps.coolify, migrateApp.appId);
3829
+ await deps.coolify.deploy(migrateApp.appId);
3830
+ await waitForMigration(deps.coolify, migrateApp.appId, runtime, sleep);
3831
+ await deps.coolify.raw('POST', `/applications/${migrateApp.appId}/stop`);
3832
+ await deps.coolify.deploy(runnerApp.appId);
3833
+ await record(PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, `http://${FILES_RUNNER_INTERNAL_NAME}:${FILES_PORT}`);
3834
+ // Last: a run that failed anywhere above is planned again.
3835
+ context.recordScopeId(PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, platform.files.hostname);
3836
+ await record(PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, gitRef);
3837
+ return { resourceId: runnerApp.appId };
3838
+ }
3839
+ function filesKeys(deps) {
3840
+ const keys = deps?.keys;
3841
+ return keys?.workerUrl && keys.credential ? keys : null;
3842
+ }
3843
+ /** Stops the app when Coolify reports it running; a stop on an app that is not running can fail. */
3844
+ async function stopIfRunning(coolify, appId) {
3845
+ const { status } = await coolify.getApplication(appId);
3846
+ if (status?.startsWith('running'))
3847
+ await coolify.raw('POST', `/applications/${appId}/stop`);
3848
+ }
3849
+ /**
3850
+ * Hands `secret` to the host, retrying. The thrown error names `what` only,
3851
+ * never the secret or the cause, which could echo it.
3852
+ */
3853
+ async function deliverWithRetry(deliverSecret, ref, secret, attempts, sleep, what) {
3854
+ for (let attempt = 1;; attempt += 1) {
3855
+ try {
3856
+ await deliverSecret(ref, secret);
3857
+ return;
3858
+ }
3859
+ catch {
3860
+ if (attempt >= attempts) {
3861
+ throw new Error(`platform:files: ${what} could not be delivered after ${attempts} tries; the next plan reports it as lost and mints no new one`);
3862
+ }
3863
+ await sleep(1000 * attempt);
3864
+ }
3865
+ }
3866
+ }
3867
+ /**
3868
+ * The runtime `DATABASE_URL` both files apps share: the first one already set
3869
+ * on either app, else a new `files_rt` credential on the database's host,
3870
+ * built the way `executor.ts`'s `ensureCmsRuntimeUrl` builds `cms_rt`'s. A
3871
+ * parse failure throws a message with no part of the URL in it, since the
3872
+ * URL carries the superuser's password.
3873
+ */
3874
+ async function ensureFilesRuntimeUrl(coolify, appIds, dbInternalUrl) {
3875
+ for (const appId of appIds) {
3876
+ const existing = (await coolify.listEnvs(appId)).find((e) => e.key === 'DATABASE_URL' && !e.is_preview && e.value);
3877
+ if (existing)
3878
+ return existing.value;
3879
+ }
3880
+ let host;
3881
+ let pathname;
3882
+ let search;
3883
+ try {
3884
+ ({ host, pathname, search } = new URL(dbInternalUrl));
3885
+ }
3886
+ catch {
3887
+ throw new Error("coolify:app for the platform (files): the database's internal_db_url could not be parsed to build the runtime DATABASE_URL");
3888
+ }
3889
+ const password = randomBytes(24).toString('base64url');
3890
+ return `postgres://${FILES_RUNTIME_ROLE}:${password}@${host}${pathname}${search}`;
3891
+ }
3892
+ /**
3893
+ * Polls the migrate app until Coolify reports it `running:healthy` after
3894
+ * first reporting anything else -- a healthy status before that is the old
3895
+ * container, since the stop and deploy calls can return before Coolify's
3896
+ * status catches up. The
3897
+ * `migration` stage's HEALTHCHECK passes only once the migration has
3898
+ * succeeded (see {@link provisionPlatformFiles}'s known gaps for why this
3899
+ * stands in for the log line). Throws once `migrateTimeoutMs` has passed; the
3900
+ * runner is then never deployed against an unmigrated database.
3901
+ */
3902
+ async function waitForMigration(coolify, appId, runtime, sleep) {
3903
+ const now = runtime.now ?? Date.now;
3904
+ const timeout = runtime.migrateTimeoutMs ?? 900_000;
3905
+ const interval = runtime.migrateIntervalMs ?? 10_000;
3906
+ const deadline = now() + timeout;
3907
+ let left = false;
3908
+ for (;;) {
3909
+ const { status } = await coolify.getApplication(appId);
3910
+ if (status === 'running:healthy' && left)
3911
+ return;
3912
+ if (status !== 'running:healthy')
3913
+ left = true;
3914
+ if (now() >= deadline) {
3915
+ throw new Error(`platform:files: files-migrate (${appId}) did not report healthy within ${Math.round(timeout / 1000)}s (last status: ${status ?? 'unknown'}); the files app was not deployed. Check its logs for "files-migrate: done" and retry this apply`);
3916
+ }
3917
+ await sleep(interval);
3918
+ }
3919
+ }
3920
+ export const INTEGRATIONS_KEYS_ENV_NAMES = [
3921
+ 'KEYS_WORKER_URL',
3922
+ 'KEYS_CREDENTIAL',
3923
+ 'KEYS_VERIFY_KEYS',
3924
+ 'KEYS_AUDIT_TOKEN',
3925
+ ];
3926
+ /** https://github.com/wtfalch/integrations -- one root Dockerfile, three targets. */
3927
+ const INTEGRATIONS_REPO = 'wtfalch/integrations';
3928
+ /**
3929
+ * The three Coolify apps `apps/host/README.md`'s "Deploy: migrate, web,
3930
+ * worker" names, each with the root Dockerfile target it builds.
3931
+ */
3932
+ const INTEGRATIONS_MIGRATE_APP = { name: 'integrations-migrate', target: 'migration' };
3933
+ const INTEGRATIONS_RUNNER_APP = { name: 'integrations', target: 'runner' };
3934
+ const INTEGRATIONS_WORKER_APP = { name: 'integrations-worker', target: 'worker' };
3935
+ /** The runner's `EXPOSE 3000`; readiness is its own `HEALTHCHECK` on `/api/health`. */
3936
+ const INTEGRATIONS_RUNNER_PORT = '3000';
3937
+ const INTEGRATIONS_DB_NAME = 'integrations-db';
3938
+ /**
3939
+ * The Postgres database and owner name. The host's runtime role is derived
3940
+ * from the database, never configured: `drizzle/0001_runtime_role.sql`
3941
+ * creates `current_database() || '_rt'`, so the runtime URL below must name
3942
+ * exactly {@link INTEGRATIONS_RUNTIME_ROLE}.
3943
+ */
3944
+ const INTEGRATIONS_POSTGRES_DB = 'integrations';
3945
+ const INTEGRATIONS_RUNTIME_ROLE = `${INTEGRATIONS_POSTGRES_DB}_rt`;
3946
+ /**
3947
+ * `.env.example`'s own default. Only ever set when absent: every stored
3948
+ * registration client secret is bound to this id, so foundry never
3949
+ * overwrites a value a person changed.
3950
+ */
3951
+ const INTEGRATIONS_OPERATOR_TENANT_ID = 'operator';
3952
+ /** `.env.example`'s own default for one worker pass; only ever set when absent. */
3953
+ const INTEGRATIONS_WORKER_BATCH = '50';
3954
+ const INTEGRATIONS_DATABASE_SCOPE_KEY = 'coolify_integrations_database';
3955
+ const INTEGRATIONS_MIGRATE_APP_SCOPE_KEY = 'coolify_integrations_migrate_app';
3956
+ const INTEGRATIONS_RUNNER_APP_SCOPE_KEY = 'coolify_integrations_app';
3957
+ const INTEGRATIONS_WORKER_APP_SCOPE_KEY = 'coolify_integrations_worker_app';
3958
+ /** Set (to the gitRef it migrated) once that ref's migration succeeded and the app was stopped. */
3959
+ const INTEGRATIONS_MIGRATED_SCOPE_KEY = 'integrations_migrated';
3960
+ /** The ref the management private key (PKCS#8 PEM) is delivered under. */
3961
+ export const INTEGRATIONS_MANAGEMENT_KEY_REF = 'integrations:management:privateKey';
3962
+ /** `MANAGEMENT_ISSUER` on the integrations host: whoever signs with the delivered key signs as this issuer. */
3963
+ export const INTEGRATIONS_MANAGEMENT_ISSUER = 'foundry';
3964
+ /**
3965
+ * The key id the public half is pinned under in `MANAGEMENT_PUBLIC_KEYS`
3966
+ * (the host's keyring allows `^[A-Za-z0-9_-]{1,100}$`). A signer names it in
3967
+ * its signed request; fixed, since there is one key until rotation is built.
3968
+ */
3969
+ export const INTEGRATIONS_MANAGEMENT_KEY_ID = 'foundry-1';
3970
+ /**
3971
+ * #174: makes `coolify:app` keyed {@link PLATFORM_INTEGRATIONS_KEY} true --
3972
+ * the integrations host (https://github.com/wtfalch/integrations) as its own
3973
+ * `apps/host/README.md` ("Deploy: migrate, web, worker") describes it, in the
3974
+ * shared `platform` Coolify project `coolify:jobs` uses:
3975
+ *
3976
+ * 1. A Postgres database (`integrations-db`), whose superuser URL is
3977
+ * `DATABASE_URL_OWNER` and whose `integrations_rt` runtime role gets a
3978
+ * password minted here once, as `DATABASE_URL` -- the same owner/runtime
3979
+ * split `executor.ts`'s `ensureCmsRuntimeUrl` builds for the cms app.
3980
+ * 2. Three apps from the one repo, each on its own Dockerfile target (set by
3981
+ * `updateApplication`, since Coolify's create endpoint refuses
3982
+ * `dockerfile_target_build`): `integrations-migrate` (`migration`),
3983
+ * `integrations` (`runner`, at `hostname`) and `integrations-worker`
3984
+ * (`worker`). The host's own `@wtfalch/keys` settings
3985
+ * ({@link PlatformIntegrationsDeps.keysEnv}) are written to them.
3986
+ * 3. The migrate app first, once per `gitRef`: stop it if running, deploy it,
3987
+ * wait until it reports healthy, stop it, and record the ref it migrated.
3988
+ * Only then are the runner and worker deployed.
3989
+ * 4. The management key, once: an Ed25519 pair minted here, its id recorded
3990
+ * before delivery, its private half handed to
3991
+ * {@link PlatformIntegrationsDeps.deliverSecret} and its public half pinned
3992
+ * on the runner as `MANAGEMENT_PUBLIC_KEYS`, with `MANAGEMENT_ISSUER`. The
3993
+ * guard is recorded after both.
3994
+ * 5. The applied `hostname` and `gitRef` are recorded last. A later change
3995
+ * plans the step again (`planIntegrations`), which patches `git_branch`
3996
+ * and the runner's `domains`, reruns the migration for a new ref, and
3997
+ * redeploys. It never mints the key again.
3998
+ *
3999
+ * Every part is safe to run again after a throw partway: each id is recorded
4000
+ * and persisted as soon as its resource exists, and what to deploy is read
4001
+ * from the live apps. Secrets -- the database URLs, the `KEYS_*` values and
4002
+ * the private key -- go to `bulkUpsertEnvs`/`deliverSecret` and nowhere else:
4003
+ * never returned, recorded, logged or put in an error.
4004
+ *
4005
+ * Known gaps, left for review:
4006
+ * - "Wait for `integrations-migrate: done` in its logs" is approximated by
4007
+ * polling the app's Coolify `status` for `running:healthy`: `CoolifyPort`
4008
+ * has no way to read container logs. The `migration` target's own
4009
+ * `HEALTHCHECK` passes only once the file it touches right before printing
4010
+ * that line exists, so healthy means the same thing. `exited` never counts
4011
+ * as done.
4012
+ * - The worker is created and deployed when created or changed, but nothing
4013
+ * schedules it. Its image runs one queue pass and exits, so Coolify's
4014
+ * scheduled tasks (which run a command in a *running* container) have no
4015
+ * container to run in, and `wtfalch/jobs` delivers a schedule as an HTTP
4016
+ * POST to a receiver route the integrations host does not have. Choosing
4017
+ * between such a route and a long-running worker image is a decision for
4018
+ * the integrations repo. Until then queued messages wait for a manual pass.
4019
+ * - Changing `gitRef` to a ref that has new commits under the same name is
4020
+ * not seen: only a different ref name re-plans the step.
4021
+ * - Like `coolify:jobs`, the runner is reached through its Coolify `domains`
4022
+ * binding; no Cloudflare tunnel or DNS record is created for `hostname`.
4023
+ */
4024
+ async function provisionPlatformIntegrations(action, context, deps) {
4025
+ const { coolify } = deps;
4026
+ const hostname = action.desired.hostname;
4027
+ const gitRef = action.desired.gitRef;
4028
+ const runtime = deps.runtime ?? {};
4029
+ const sleep = runtime.sleep ?? defaultSleep;
4030
+ const recorded = context.recordedIds();
4031
+ const record = async (key, id) => {
4032
+ context.recordScopeId(key, id);
4033
+ await context.persist?.();
4034
+ };
4035
+ // ---- project and environment: the one shared `platform` project ---------
4036
+ let projectUuid = recorded[JOBS_PROJECT_SCOPE_KEY];
4037
+ if (!projectUuid) {
4038
+ const found = await coolify.findProjectByName(PLATFORM_COOLIFY_PROJECT_NAME);
4039
+ projectUuid = found
4040
+ ? found.uuid
4041
+ : (await coolify.createProject(PLATFORM_COOLIFY_PROJECT_NAME)).uuid;
4042
+ await record(JOBS_PROJECT_SCOPE_KEY, projectUuid);
4043
+ }
4044
+ const { environment } = await coolify.findOrCreateEnvironment(projectUuid, 'production');
4045
+ // ---- database -------------------------------------------------------------
4046
+ let dbId = recorded[INTEGRATIONS_DATABASE_SCOPE_KEY];
4047
+ if (!dbId) {
4048
+ const found = (await coolify.listDatabases()).find((db) => db.name === INTEGRATIONS_DB_NAME && db.environment_id === environment.id);
4049
+ if (found) {
4050
+ dbId = found.uuid;
4051
+ }
4052
+ else {
4053
+ const server = await resolvePlatformServer(coolify);
4054
+ const created = await coolify.createPostgresDatabase({
4055
+ project_uuid: projectUuid,
4056
+ server_uuid: server.uuid,
4057
+ environment_name: environment.name,
4058
+ environment_uuid: environment.uuid,
4059
+ name: INTEGRATIONS_DB_NAME,
4060
+ postgres_db: INTEGRATIONS_POSTGRES_DB,
4061
+ postgres_user: INTEGRATIONS_POSTGRES_DB,
4062
+ });
4063
+ dbId = created.uuid;
4064
+ }
4065
+ await record(INTEGRATIONS_DATABASE_SCOPE_KEY, dbId);
4066
+ }
4067
+ const db = await coolify.getDatabase(dbId);
4068
+ if (!db.internal_db_url) {
4069
+ throw new Error(`platform:integrations: database ${dbId} has no internal_db_url yet; not deploying the integrations host without a database`);
4070
+ }
4071
+ const ownerUrl = db.internal_db_url;
4072
+ // ---- the three apps --------------------------------------------------------
4073
+ const createApp = (name, domains) => async () => {
4074
+ const server = await resolvePlatformServer(coolify);
4075
+ const githubApp = await coolify.requireGithubApp();
4076
+ return coolify.createGithubAppApplication({
4077
+ project_uuid: projectUuid,
4078
+ server_uuid: server.uuid,
4079
+ environment_name: environment.name,
4080
+ environment_uuid: environment.uuid,
4081
+ github_app_uuid: githubApp.uuid,
4082
+ git_repository: INTEGRATIONS_REPO,
4083
+ git_branch: gitRef,
4084
+ build_pack: 'dockerfile',
4085
+ name,
4086
+ ...(domains ? { domains } : {}),
4087
+ });
4088
+ };
4089
+ const migrateApp = await findOrCreatePlatformApp(coolify, INTEGRATIONS_MIGRATE_APP.name, environment, createApp(INTEGRATIONS_MIGRATE_APP.name));
4090
+ await record(INTEGRATIONS_MIGRATE_APP_SCOPE_KEY, migrateApp.appId);
4091
+ const runnerApp = await findOrCreatePlatformApp(coolify, INTEGRATIONS_RUNNER_APP.name, environment, createApp(INTEGRATIONS_RUNNER_APP.name, hostname));
4092
+ await record(INTEGRATIONS_RUNNER_APP_SCOPE_KEY, runnerApp.appId);
4093
+ const workerApp = await findOrCreatePlatformApp(coolify, INTEGRATIONS_WORKER_APP.name, environment, createApp(INTEGRATIONS_WORKER_APP.name));
4094
+ await record(INTEGRATIONS_WORKER_APP_SCOPE_KEY, workerApp.appId);
4095
+ // Set on every run, not only after a create: a throw right after a create
4096
+ // must not leave an app building the Dockerfile's last stage for good. The
4097
+ // branch and the runner's domain are set here too, so a changed `gitRef` or
4098
+ // `hostname` reaches an app that already exists.
4099
+ const configChanged = recorded[PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY] !== hostname ||
4100
+ recorded[PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY] !== gitRef;
4101
+ await coolify.updateApplication(migrateApp.appId, {
4102
+ dockerfile_target_build: INTEGRATIONS_MIGRATE_APP.target,
4103
+ git_branch: gitRef,
4104
+ });
4105
+ await coolify.updateApplication(runnerApp.appId, {
4106
+ dockerfile_target_build: INTEGRATIONS_RUNNER_APP.target,
4107
+ ports_exposes: INTEGRATIONS_RUNNER_PORT,
4108
+ git_branch: gitRef,
4109
+ domains: hostname,
4110
+ });
4111
+ await coolify.updateApplication(workerApp.appId, {
4112
+ dockerfile_target_build: INTEGRATIONS_WORKER_APP.target,
4113
+ git_branch: gitRef,
4114
+ });
4115
+ // ---- env ---------------------------------------------------------------------
4116
+ // One runtime URL for all three apps: the migrate job sets the role's
4117
+ // password from it, and the runner and worker connect with it.
4118
+ const runtimeUrl = await ensureIntegrationsRuntimeUrl(coolify, migrateApp.appId, ownerUrl);
4119
+ const keys = INTEGRATIONS_KEYS_ENV_NAMES.map((key) => ({
4120
+ key,
4121
+ value: deps.keysEnv[key] ?? '',
4122
+ }));
4123
+ if (keys.some((k) => !k.value)) {
4124
+ throw new Error('platform:integrations: the host supplies no value for the @wtfalch/keys settings; not deploying the integrations host');
4125
+ }
4126
+ const sharedEnvs = [
4127
+ { key: 'DATABASE_URL', value: runtimeUrl },
4128
+ { key: 'APP_URL', value: `https://${hostname}` },
4129
+ ...keys,
4130
+ ];
4131
+ const runnerEnvs = [
4132
+ ...sharedEnvs,
4133
+ ...(await envsIfAbsent(coolify, runnerApp.appId, [
4134
+ { key: 'INTEGRATIONS_OPERATOR_TENANT_ID', value: INTEGRATIONS_OPERATOR_TENANT_ID },
4135
+ ])),
4136
+ ];
4137
+ const workerEnvs = [
4138
+ ...sharedEnvs,
4139
+ ...(await envsIfAbsent(coolify, workerApp.appId, [
4140
+ { key: 'INTEGRATIONS_OPERATOR_TENANT_ID', value: INTEGRATIONS_OPERATOR_TENANT_ID },
4141
+ { key: 'INTEGRATIONS_WORKER_BATCH', value: INTEGRATIONS_WORKER_BATCH },
4142
+ ])),
4143
+ ];
4144
+ const migrateEnvs = [
4145
+ { key: 'DATABASE_URL_OWNER', value: ownerUrl },
4146
+ { key: 'DATABASE_URL', value: runtimeUrl },
4147
+ ...keys.filter((k) => k.key === 'KEYS_WORKER_URL' || k.key === 'KEYS_CREDENTIAL'),
4148
+ ];
4149
+ const runnerEnvsChanged = await envsDiffer(coolify, runnerApp.appId, runnerEnvs);
4150
+ const workerEnvsChanged = await envsDiffer(coolify, workerApp.appId, workerEnvs);
4151
+ await coolify.bulkUpsertEnvs(migrateApp.appId, migrateEnvs);
4152
+ await coolify.bulkUpsertEnvs(runnerApp.appId, runnerEnvs);
4153
+ await coolify.bulkUpsertEnvs(workerApp.appId, workerEnvs);
4154
+ // ---- migrate first, once per gitRef ---------------------------------------------
4155
+ if (context.recordedIds()[INTEGRATIONS_MIGRATED_SCOPE_KEY] !== gitRef) {
4156
+ // A running migrate app is stopped before the deploy is queued, so the old
4157
+ // container's healthy status is never read as the new deployment's.
4158
+ if (migrateApp.running)
4159
+ await coolify.raw('POST', `/applications/${migrateApp.appId}/stop`);
4160
+ await coolify.deploy(migrateApp.appId);
4161
+ await waitForIntegrationsMigration(coolify, migrateApp.appId, runtime, sleep);
4162
+ // It idles after migrating rather than exiting (the Dockerfile's own
4163
+ // comment), so it is stopped here; a person starts it again for
4164
+ // maintenance commands.
4165
+ await coolify.raw('POST', `/applications/${migrateApp.appId}/stop`);
4166
+ await record(INTEGRATIONS_MIGRATED_SCOPE_KEY, gitRef);
4167
+ }
4168
+ // ---- the management key, once --------------------------------------------------
4169
+ // The pair's id is recorded and persisted before the private half is
4170
+ // delivered. The guard is recorded only after delivery and the write of the
4171
+ // public half. A run that dies in between leaves the id without the guard:
4172
+ // the planner then reports the key and mints nothing, so a second pair can
4173
+ // never replace one the host may already hold.
4174
+ let keyJustSet = false;
4175
+ if (!context.recordedIds()[PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY]) {
4176
+ if (context.recordedIds()[PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY]) {
4177
+ throw new Error(`platform:integrations: management key ${INTEGRATIONS_MANAGEMENT_KEY_ID} was minted but not confirmed delivered and pinned; not minting another. 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`);
4178
+ }
4179
+ const { publicKey, privateKey } = generateKeyPairSync('ed25519', {
4180
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
4181
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
4182
+ });
4183
+ await record(PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, INTEGRATIONS_MANAGEMENT_KEY_ID);
4184
+ const attempts = runtime.deliverAttempts ?? 3;
4185
+ for (let attempt = 1;; attempt += 1) {
4186
+ try {
4187
+ await deps.deliverSecret(INTEGRATIONS_MANAGEMENT_KEY_REF, privateKey);
4188
+ break;
4189
+ }
4190
+ catch {
4191
+ // Retried; the error is dropped in case it echoes the key.
4192
+ if (attempt >= attempts) {
4193
+ throw new Error(`platform:integrations: management key ${INTEGRATIONS_MANAGEMENT_KEY_ID} was minted but could not be delivered after ${attempts} attempts`);
4194
+ }
4195
+ await sleep(1000 * attempt);
4196
+ }
4197
+ }
4198
+ await coolify.bulkUpsertEnvs(runnerApp.appId, [
4199
+ { key: 'MANAGEMENT_ISSUER', value: INTEGRATIONS_MANAGEMENT_ISSUER },
4200
+ {
4201
+ key: 'MANAGEMENT_PUBLIC_KEYS',
4202
+ value: JSON.stringify({ [INTEGRATIONS_MANAGEMENT_KEY_ID]: publicKey }),
4203
+ },
4204
+ ]);
4205
+ await record(PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, runnerApp.appId);
4206
+ keyJustSet = true;
4207
+ }
4208
+ // ---- then the runner and the worker ------------------------------------------------
4209
+ // Decided from the live state: an env change only reaches a container on its
4210
+ // next deploy, and a stopped runner is deployed so an earlier failed deploy
4211
+ // is retried. The worker exits after one pass by design, so "not running"
4212
+ // says nothing about it and it is deployed only when created or changed.
4213
+ if (runnerApp.justCreated ||
4214
+ !runnerApp.running ||
4215
+ runnerEnvsChanged ||
4216
+ keyJustSet ||
4217
+ configChanged) {
4218
+ await coolify.deploy(runnerApp.appId);
4219
+ }
4220
+ if (workerApp.justCreated || workerEnvsChanged || configChanged) {
4221
+ await coolify.deploy(workerApp.appId);
4222
+ }
4223
+ await record(PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, hostname);
4224
+ await record(PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, gitRef);
4225
+ return { resourceId: runnerApp.appId };
4226
+ }
4227
+ /**
4228
+ * The runtime `DATABASE_URL` already on the migrate app, or a new one:
4229
+ * {@link INTEGRATIONS_RUNTIME_ROLE} with a password minted once from a
4230
+ * URL-safe alphabet (the host's `db-runtime-role.sh` refuses anything but
4231
+ * `A-Za-z0-9_.~-`), on the owner URL's host, database and query string --
4232
+ * `executor.ts`'s `ensureCmsRuntimeUrl`, with the same rule: never rotated,
4233
+ * because the migrate job set the live role's password from the first one.
4234
+ * A parse failure throws a fixed message, never the URL (Node's own
4235
+ * `ERR_INVALID_URL` carries the whole input, password included).
4236
+ */
4237
+ async function ensureIntegrationsRuntimeUrl(coolify, migrateAppId, ownerUrl) {
4238
+ const existing = (await coolify.listEnvs(migrateAppId)).find((e) => e.key === 'DATABASE_URL' && !e.is_preview && e.value);
4239
+ if (existing)
4240
+ return existing.value;
4241
+ let host;
4242
+ let pathname;
4243
+ let search;
4244
+ try {
4245
+ ({ host, pathname, search } = new URL(ownerUrl));
4246
+ }
4247
+ catch {
4248
+ throw new Error('platform:integrations: the database internal_db_url could not be parsed; not deploying the integrations host');
4249
+ }
4250
+ const password = randomBytes(24).toString('base64url');
4251
+ return `postgres://${INTEGRATIONS_RUNTIME_ROLE}:${password}@${host}${pathname}${search}`;
4252
+ }
4253
+ /** The entries of `desired` whose key the app does not have yet -- never overwriting a value a person set. */
4254
+ async function envsIfAbsent(coolify, appId, desired) {
4255
+ const live = new Set((await coolify.listEnvs(appId)).filter((e) => !e.is_preview).map((e) => e.key));
4256
+ return desired.filter((e) => !live.has(e.key));
4257
+ }
4258
+ /**
4259
+ * Polls the migrate app until Coolify reports it `running:healthy` after
4260
+ * first reporting anything else -- a healthy status before that is the old
4261
+ * container, since the stop and deploy calls can return before Coolify's
4262
+ * status catches up. Its `HEALTHCHECK` passes only after
4263
+ * `integrations-migrate: done` (see
4264
+ * {@link provisionPlatformIntegrations}'s known gaps). Throws once
4265
+ * `migrateTimeoutMs` has passed. The runner and worker are never deployed
4266
+ * after a timeout.
4267
+ */
4268
+ async function waitForIntegrationsMigration(coolify, appId, runtime, sleep) {
4269
+ const now = runtime.now ?? Date.now;
4270
+ const timeout = runtime.migrateTimeoutMs ?? 20 * 60_000;
4271
+ const interval = runtime.migratePollMs ?? 10_000;
4272
+ const deadline = now() + timeout;
4273
+ let left = false;
4274
+ for (;;) {
4275
+ const { status } = await coolify.getApplication(appId);
4276
+ const healthy = status?.startsWith('running:healthy') === true;
4277
+ if (healthy && left)
4278
+ return;
4279
+ if (!healthy)
4280
+ left = true;
4281
+ if (now() >= deadline) {
4282
+ throw new Error(`platform:integrations: integrations-migrate (${appId}) did not report healthy within ${Math.round(timeout / 1000)}s (last status: ${status ?? 'unknown'}); check its logs, then apply again`);
4283
+ }
4284
+ await sleep(interval);
4285
+ }
4286
+ }
4287
+ /**
4288
+ * The real {@link PlatformCloudflareReader}: the platform's apex zone as
4289
+ * `planApexZone` expects to see it, and nothing else. The zone's DNS records
4290
+ * and tunnels belong to no declared platform step yet, so listing them would
4291
+ * only report them unmanaged.
4292
+ *
4293
+ * The zone is fetched by the id the last apply recorded when there is one,
4294
+ * because Cloudflare's zone list can lag a create; with no recorded id it is
4295
+ * found by apex.
4296
+ */
4297
+ export class ProvidersPlatformCloudflareReader {
4298
+ client;
4299
+ platformStore;
4300
+ constructor(client, platformStore) {
4301
+ this.client = client;
4302
+ this.platformStore = platformStore;
4303
+ }
4304
+ async listResources() {
4305
+ const platform = await this.platformStore.get();
4306
+ if (!platform)
4307
+ return [];
4308
+ const recordedId = (await this.platformStore.getServiceIds())[apexZoneKey(platform.apex)];
4309
+ const zone = (recordedId && (await recordedZone(this.client, recordedId, platform.apex))) ||
4310
+ (await exactZone(this.client, platform.apex));
4311
+ if (!zone)
4312
+ return [];
4313
+ return [
4314
+ {
4315
+ system: 'cloudflare',
4316
+ kind: 'zone',
4317
+ key: platform.apex,
4318
+ config: { id: zone.id, status: zone.status, name_servers: zone.name_servers },
4319
+ },
4320
+ ];
4321
+ }
4322
+ }
4323
+ /**
4324
+ * #173: the real {@link PlatformCoolifyReader} for the jobs scheduler app --
4325
+ * the recorded-id half of `ProvidersPlatformCloudflareReader`'s own strategy
4326
+ * above, with no list-by-name fallback: unlike a Cloudflare zone (which
4327
+ * exists before foundry ever touches it), the scheduler app has no identity
4328
+ * to adopt until this package creates it, so "nothing recorded yet" and
4329
+ * "nothing live yet" are the same state here. A recorded id Coolify no
4330
+ * longer has (deleted, or moved) reads as not-live, the same "stale id never
4331
+ * blocks planning for good" rule {@link recordedZone} follows.
4332
+ */
4333
+ export class ProvidersPlatformCoolifyReader {
4334
+ client;
4335
+ platformStore;
4336
+ constructor(client, platformStore) {
4337
+ this.client = client;
4338
+ this.platformStore = platformStore;
4339
+ }
4340
+ async listResources() {
4341
+ const recordedId = (await this.platformStore.getServiceIds())[JOBS_APP_KEY];
4342
+ if (!recordedId)
4343
+ return [];
4344
+ try {
4345
+ const app = await this.client.getApplication(recordedId);
4346
+ return [
4347
+ {
4348
+ system: 'coolify',
4349
+ kind: 'jobs',
4350
+ key: JOBS_SCHEDULER_KEY,
4351
+ config: { uuid: app.uuid, status: app.status },
4352
+ },
4353
+ ];
4354
+ }
4355
+ catch {
4356
+ return [];
4357
+ }
4358
+ }
4359
+ }
4360
+ /**
4361
+ * `findZoneByApex` returns the longest suffix match, so for a subdomain it
4362
+ * answers with the parent zone. The platform's zone is its apex exactly;
4363
+ * any other zone is never adopted or reported as the platform's.
4364
+ */
4365
+ async function exactZone(client, apex) {
4366
+ const zone = await client.findZoneByApex(apex);
4367
+ return zone && zone.name === apex ? zone : undefined;
4368
+ }
4369
+ /**
4370
+ * The zone the last apply recorded, or `undefined` when Cloudflare no longer
4371
+ * has it under that id (deleted, or moved to another account) or it is no
4372
+ * longer the apex. The caller then falls back to finding it by apex, so a
4373
+ * stale recorded id never blocks planning for good.
4374
+ */
4375
+ async function recordedZone(client, id, apex) {
4376
+ try {
4377
+ const zone = await client.getZone(id);
4378
+ return zone.name === apex ? zone : undefined;
4379
+ }
4380
+ catch {
4381
+ return undefined;
4382
+ }
4383
+ }
4384
+ /**
4385
+ * What Archon needs from each service before its step may run: the service's
4386
+ * own applied step (`coolify:app:<stepKey>`), scope ids its step recorded, and
4387
+ * held keys its step delivered. A missing one blocks Archon's plan. Archon
4388
+ * only reads these; it never mints them.
4389
+ */
4390
+ const ARCHON_SERVICE_INPUTS = {
4391
+ files: {
4392
+ stepKey: PLATFORM_FILES_KEY,
4393
+ scopeKeys: [PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY],
4394
+ refs: [FILES_MANAGEMENT_KEY_REF, FILES_INTROSPECTION_SECRET_REF],
4395
+ },
4396
+ ai: {
4397
+ stepKey: PLATFORM_AI_KEY,
4398
+ scopeKeys: [
4399
+ PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY,
4400
+ PLATFORM_AI_ORGANISATION_SCOPE_KEY,
4401
+ PLATFORM_AI_INTERNAL_URL_SCOPE_KEY,
4402
+ ],
4403
+ refs: [PLATFORM_AI_MANAGEMENT_KEY_REF],
4404
+ },
4405
+ integrations: {
4406
+ stepKey: PLATFORM_INTEGRATIONS_KEY,
4407
+ scopeKeys: [],
4408
+ refs: [INTEGRATIONS_MANAGEMENT_KEY_REF],
4409
+ },
4410
+ };