@wtfalch/foundry 0.5.0 → 0.7.1

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 (66) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +16 -1
  2. package/dist/bootstrap/bootstrap.js +40 -2
  3. package/dist/bootstrap/declare.d.ts +3 -1
  4. package/dist/bootstrap/declare.js +12 -3
  5. package/dist/bootstrap/versions.d.ts +17 -0
  6. package/dist/bootstrap/versions.js +12 -0
  7. package/dist/calls-sfu-deploy-support.d.ts +8 -0
  8. package/dist/calls-sfu-deploy-support.js +10 -1
  9. package/dist/calls-sfu.d.ts +36 -12
  10. package/dist/calls-sfu.js +42 -9
  11. package/dist/index.d.ts +10 -4
  12. package/dist/index.js +6 -3
  13. package/dist/migrations/0019_mail_owner_mailbox.sql +15 -0
  14. package/dist/migrations/0020_mail_apps.sql +7 -0
  15. package/dist/migrations/0021_mail_role_mailboxes.sql +6 -0
  16. package/dist/migrations/0022_chat_enabled.sql +6 -0
  17. package/dist/migrations/0023_hosting_app_env.sql +8 -0
  18. package/dist/migrations/0024_audit_events.sql +211 -0
  19. package/dist/migrations/0025_audit_display.sql +147 -0
  20. package/dist/migrations/0026_audit_erase_email_guard.sql +78 -0
  21. package/dist/migrations/0027_audit_chain.sql +170 -0
  22. package/dist/migrations/0028_audit_rls.sql +111 -0
  23. package/dist/org.d.ts +105 -0
  24. package/dist/org.js +101 -0
  25. package/dist/plan/agora.d.ts +21 -0
  26. package/dist/plan/agora.js +23 -0
  27. package/dist/plan/calls-matrix.d.ts +2 -2
  28. package/dist/plan/calls-matrix.js +3 -2
  29. package/dist/plan/cloudflared-connector.d.ts +3 -2
  30. package/dist/plan/cloudflared-connector.js +5 -3
  31. package/dist/plan/diff.d.ts +15 -2
  32. package/dist/plan/diff.js +399 -51
  33. package/dist/plan/executor.d.ts +307 -61
  34. package/dist/plan/executor.js +489 -129
  35. package/dist/plan/index.d.ts +9 -1
  36. package/dist/plan/index.js +5 -1
  37. package/dist/plan/login-host.d.ts +64 -0
  38. package/dist/plan/login-host.js +52 -0
  39. package/dist/plan/mail-copy.d.ts +125 -0
  40. package/dist/plan/mail-copy.js +166 -0
  41. package/dist/plan/mail-identity-allowlist.d.ts +50 -0
  42. package/dist/plan/mail-identity-allowlist.js +11 -0
  43. package/dist/plan/mail-identity.d.ts +68 -0
  44. package/dist/plan/mail-identity.js +17 -0
  45. package/dist/plan/plan-apply.d.ts +26 -8
  46. package/dist/plan/plan-apply.js +10 -9
  47. package/dist/plan/platform-diff.d.ts +207 -10
  48. package/dist/plan/platform-diff.js +403 -15
  49. package/dist/plan/platform-org.d.ts +3 -2
  50. package/dist/plan/platform-providers.d.ts +139 -25
  51. package/dist/plan/platform-providers.js +1531 -95
  52. package/dist/plan/platform-sfu.d.ts +54 -19
  53. package/dist/plan/platform-sfu.js +64 -22
  54. package/dist/plan/providers-readers.js +44 -1
  55. package/dist/plan/zitadel-machine-user.d.ts +60 -11
  56. package/dist/plan/zitadel-machine-user.js +182 -39
  57. package/dist/requested-changes.d.ts +17 -7
  58. package/dist/requested-changes.js +37 -7
  59. package/dist/store/audit.d.ts +40 -4
  60. package/dist/store/audit.js +114 -29
  61. package/dist/store/postgres.js +35 -3
  62. package/dist/store/schema.d.ts +109 -1
  63. package/dist/store/schema.js +20 -0
  64. package/dist/store.d.ts +7 -0
  65. package/package.json +5 -3
  66. package/versions.json +8 -0
@@ -1,9 +1,10 @@
1
- import { generateKeyPairSync, randomBytes } from 'node:crypto';
1
+ import { createHash, createPrivateKey, generateKeyPairSync, randomBytes, randomUUID, sign, } from 'node:crypto';
2
2
  import { isIPv4 } from 'node:net';
3
+ import { VERSIONS } from '../bootstrap/versions.js';
3
4
  import { redactSecrets } from '../calls-sfu-deploy-support.js';
4
5
  import { ensureCloudflaredConnector } from './cloudflared-connector.js';
5
6
  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_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_MINTER_BLOCKED, PLATFORM_AI_ORGANISATION_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_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_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, platformMailMxHost, } from './platform-diff.js';
7
+ 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
8
  import { applyCallsSfuPlatformStep } from './platform-sfu.js';
8
9
  /** The key `runPlatformSteps` records the apex zone's id under (`system:kind:key`). */
9
10
  function apexZoneKey(apex) {
@@ -67,6 +68,9 @@ const JOBS_SCHEDULER_REPO = 'wtfalch/jobs';
67
68
  * optional: an existing host that constructs this executor for the zone step
68
69
  * alone keeps compiling, and a plan that includes an `sfu:livekit` step
69
70
  * without it configured refuses clearly rather than silently doing nothing.
71
+ * #152: the step mints one LiveKit key per org with calls enabled rather
72
+ * than one shared pair, so it also needs `declaredOrgStore.list()` -- the
73
+ * same store this class already takes for `findOrgIdForZone`.
70
74
  *
71
75
  * #168 adds `coolify:app` keyed {@link PLATFORM_AUTH_KEY}, the platform's own
72
76
  * sign-in ({@link provisionPlatformAuth}), once a host passes
@@ -120,6 +124,18 @@ export class FoundryPlatformStepExecutor {
120
124
  if (platform.auth.enabled && !this.auth) {
121
125
  blocked.auth =
122
126
  "the platform's sign-in is blocked, not planned: the host built the platform executor without its auth dependencies";
127
+ // #211 round 6: its own signal, distinct from `auth` -- `planAuth` must
128
+ // withhold the step for this case whatever the recorded broker state
129
+ // says, unlike "broker created, deploy still blocked" (below).
130
+ blocked.authExecutorMissing = true;
131
+ }
132
+ else if (platform.auth.enabled && this.auth) {
133
+ const { reason: authBlocked, otherInputsMissing } = await authBrokerBlockedReason(this.auth, recordedIds);
134
+ if (authBlocked)
135
+ blocked.auth = authBlocked;
136
+ // #216 round 5: see `PlanPlatformOptions.blocked.authOtherInputsMissing`.
137
+ if (otherInputsMissing.length > 0)
138
+ blocked.authOtherInputsMissing = otherInputsMissing;
123
139
  }
124
140
  if (platform.mail.enabled) {
125
141
  const mailBlocked = await mailBlockedReason(platform, this.mail, recordedIds);
@@ -150,8 +166,14 @@ export class FoundryPlatformStepExecutor {
150
166
  }
151
167
  else if (platform.ai.enabled &&
152
168
  !this.ai?.managementKeyMinter &&
169
+ !this.ai?.heldKeys &&
170
+ recordedIds[PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY] &&
153
171
  !recordedIds[PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY]) {
154
- blocked.ai = PLATFORM_AI_MINTER_BLOCKED;
172
+ // The signing pair was pinned on an earlier apply; minting the
173
+ // management key needs its private half read back, and no minter was
174
+ // injected to skip that (#201).
175
+ blocked.ai =
176
+ "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)";
155
177
  }
156
178
  if (platform.archon.enabled) {
157
179
  if (!this.archon) {
@@ -164,20 +186,43 @@ export class FoundryPlatformStepExecutor {
164
186
  }
165
187
  else {
166
188
  const inputs = await collectArchonServiceInputs(platform, this.archon, recordedIds);
167
- if ('blocked' in inputs)
189
+ if ('blocked' in inputs) {
168
190
  blocked.archon = inputs.blocked;
191
+ }
192
+ else if (!this.archon.masterKeys || !this.archon.heldKeys?.put) {
193
+ // #213: Archon's own onboarding reads these back as held keys
194
+ // before it can start; a host that cannot put them blocks Archon,
195
+ // the same rule a missing service ref follows above.
196
+ blocked.archon =
197
+ '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)';
198
+ }
169
199
  }
170
200
  }
171
201
  return blocked;
172
202
  }
173
- /** Whether Archon's live Access policy lets through anyone but the declared emails. */
203
+ /**
204
+ * Whether Archon's live Access policy lets through anyone but the declared
205
+ * emails, or (#211 round 4) the broker it last registered with no longer
206
+ * carries its entry; or (#216 round 4) the platform's recorded
207
+ * login-client token is no longer the sole live PAT on its ZITADEL machine
208
+ * user.
209
+ */
174
210
  async driftedSteps(platform, recordedIds = {}) {
175
- if (!platform.archon.enabled || !this.archon)
176
- return {};
177
- if (!recordedIds[`coolify:app:${PLATFORM_ARCHON_KEY}`])
178
- return {};
179
- const drifted = await archonAccessDrifted(this.archon.cloudflare, platform.archon.hostname, this.archon.accessEmails);
180
- return drifted ? { archon: true } : {};
211
+ const result = {};
212
+ if (platform.archon.enabled &&
213
+ this.archon &&
214
+ recordedIds[`coolify:app:${PLATFORM_ARCHON_KEY}`]) {
215
+ const accessDrifted = await archonAccessDrifted(this.archon.cloudflare, platform.archon.hostname, this.archon.accessEmails);
216
+ const brokerDrifted = !accessDrifted && (await archonBrokerRegistrationDrifted(this.archon.coolify, recordedIds));
217
+ if (accessDrifted || brokerDrifted)
218
+ result.archon = true;
219
+ }
220
+ if (platform.auth.enabled && this.auth) {
221
+ const loginClientDrifted = await authLoginClientDrifted(platform, this.auth, recordedIds);
222
+ if (loginClientDrifted)
223
+ result.auth = true;
224
+ }
225
+ return result;
181
226
  }
182
227
  async execute(action, context) {
183
228
  if (action.system === 'coolify' && action.kind === 'app' && action.key === PLATFORM_AUTH_KEY) {
@@ -244,11 +289,25 @@ export class FoundryPlatformStepExecutor {
244
289
  const created = await this.cloudflare.createZone(domain);
245
290
  return { resourceId: created.id };
246
291
  }
292
+ /**
293
+ * #152: the platform `sfu` step owns the shared LiveKit config, so it
294
+ * lists every org with calls enabled itself -- `DeclaredOrgStore.list()`,
295
+ * filtered the same way an org's own `calls.enabled` flag gates its
296
+ * `coolify:calls-service` step (`executor.ts`'s `coolifyCallsService`) --
297
+ * rather than taking the list as an argument nothing else would supply.
298
+ * Sorted by slug so a re-run with the same set of enabled orgs is a no-op
299
+ * even before `applyCallsSfuPlatformStep`'s own sort (its doc comment).
300
+ */
247
301
  async executeSfu() {
248
302
  if (!this.sfu) {
249
303
  throw new Error('platform step sfu:livekit is not built yet (#170: pass `sfu` to FoundryPlatformStepExecutor)');
250
304
  }
251
- await applyCallsSfuPlatformStep(this.sfu);
305
+ const orgs = await this.declaredOrgStore.list();
306
+ const orgSlugs = orgs
307
+ .filter((org) => org.calls.enabled)
308
+ .map((org) => org.slug)
309
+ .sort();
310
+ await applyCallsSfuPlatformStep(this.sfu, orgSlugs);
252
311
  return undefined;
253
312
  }
254
313
  async coolifyJobs(action, context) {
@@ -367,6 +426,7 @@ const PLATFORM_AUTH_PROJECT = 'platform';
367
426
  const PLATFORM_AUTH_DB_NAME = 'platform-auth-db';
368
427
  const PLATFORM_AUTH_ZITADEL_APP_NAME = 'platform-auth-zitadel';
369
428
  const PLATFORM_AUTH_LOGIN_APP_NAME = 'platform-auth-login';
429
+ const PLATFORM_AUTH_BROKER_APP_NAME = 'platform-auth-broker';
370
430
  /**
371
431
  * Fixed `custom_internal_name`s (`diff.ts`'s `coolifyCalls` sets the same
372
432
  * kind of name on Synapse/MAS) so the Cloudflare tunnel below can address
@@ -376,15 +436,18 @@ const PLATFORM_AUTH_LOGIN_APP_NAME = 'platform-auth-login';
376
436
  */
377
437
  const PLATFORM_AUTH_ZITADEL_INTERNAL_NAME = 'platform-auth-zitadel';
378
438
  const PLATFORM_AUTH_LOGIN_INTERNAL_NAME = 'platform-auth-login';
439
+ const PLATFORM_AUTH_BROKER_INTERNAL_NAME = 'platform-auth-broker';
379
440
  const PLATFORM_AUTH_TUNNEL_NAME = 'platform-auth-tunnel';
380
441
  /** #197: the one Coolify app that runs `cloudflared` for {@link PLATFORM_AUTH_TUNNEL_NAME}. */
381
442
  const PLATFORM_AUTH_CLOUDFLARED_APP_NAME = 'platform-auth-cloudflared';
382
- /** `wtfalch/auth`'s `docker-compose.yml`: `image: ghcr.io/zitadel/zitadel:${ZITADEL_VERSION:-v4.17.3}`. */
443
+ /** `wtfalch/auth`'s `docker-compose.yml`: `image: ghcr.io/zitadel/zitadel:${ZITADEL_VERSION:-v4.17.3}`. Pinned in `versions.json` (#247). */
383
444
  const AUTH_ZITADEL_IMAGE = 'ghcr.io/zitadel/zitadel';
384
- const AUTH_ZITADEL_IMAGE_TAG = 'v4.17.3';
445
+ const AUTH_ZITADEL_IMAGE_TAG = VERSIONS.zitadel;
385
446
  /** `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". */
386
447
  const AUTH_REPO = 'wtfalch/auth';
387
448
  const AUTH_LOGIN_DOCKERFILE = 'login/Dockerfile';
449
+ /** `wtfalch/auth`'s own `broker/Dockerfile` -- built from the repo root so the workspace resolves (#204). */
450
+ const AUTH_BROKER_DOCKERFILE = 'broker/Dockerfile';
388
451
  /**
389
452
  * `wtfalch/auth`'s login app answers on 3000 (its `Dockerfile`'s Next.js
390
453
  * default); ZITADEL answers on 8080 -- both fixed by `docker-compose.yml`'s
@@ -392,6 +455,8 @@ const AUTH_LOGIN_DOCKERFILE = 'login/Dockerfile';
392
455
  */
393
456
  const AUTH_ZITADEL_PORT = '8080';
394
457
  const AUTH_LOGIN_PORT = '3000';
458
+ /** `broker/Dockerfile`'s own `ENV PORT=3000` / `EXPOSE 3000`. */
459
+ const AUTH_BROKER_PORT = '3000';
395
460
  /**
396
461
  * Every ZITADEL OIDC/management/console/asset path -- `deploy/proxy/Caddyfile`'s
397
462
  * own explicit list, restated here as one regex since the tunnel routes by a
@@ -400,6 +465,14 @@ const AUTH_LOGIN_PORT = '3000';
400
465
  * everything else, including the root).
401
466
  */
402
467
  const AUTH_ZITADEL_PATH_PATTERN = '^/(oidc|oauth|saml|management|admin|system|auth|assets|ui/console|\\.well-known)(/|$)';
468
+ /**
469
+ * `@wtfalch/auth`'s own `brokerUrl` default, `<issuer>/api`
470
+ * (`packages/auth/src/config.ts`) -- `deploy/proxy/Caddyfile`'s own
471
+ * `handle /api/*`. Checked before {@link AUTH_ZITADEL_PATH_PATTERN}'s
472
+ * catch-all-else login rule below, so it must be its own ingress rule rather
473
+ * than folded into it.
474
+ */
475
+ const AUTH_BROKER_PATH_PATTERN = '^/api(/|$)';
403
476
  const PLATFORM_AUTH_PROJECT_SCOPE_KEY = 'coolify_project_platform_auth';
404
477
  const PLATFORM_AUTH_DB_SCOPE_KEY = 'coolify_platform_auth_database';
405
478
  const PLATFORM_AUTH_ZITADEL_SCOPE_KEY = 'coolify_platform_auth_zitadel_app';
@@ -408,43 +481,252 @@ const PLATFORM_AUTH_TUNNEL_SCOPE_KEY = 'cloudflare_platform_auth_tunnel';
408
481
  /** Recorded once the ZITADEL app has been deployed, so ZITADEL may have written to its database. */
409
482
  const PLATFORM_AUTH_ZITADEL_DEPLOYED_SCOPE_KEY = 'zitadel_platform_auth_deployed';
410
483
  const PLATFORM_AUTH_ADMIN_USER_NAME = 'foundry-admin';
484
+ /** #216 (building on #209/#215): the narrower `IAM_LOGIN_CLIENT` machine user for the login app and the broker. */
485
+ const PLATFORM_AUTH_LOGIN_CLIENT_USER_NAME = 'foundry-login-client';
411
486
  const PLATFORM_AUTH_ORG_NAME = 'foundry';
487
+ /**
488
+ * Retries `action` up to `attempts` times, sleeping increasingly between
489
+ * tries -- the same shape the admin machine user's own delivery retry (just
490
+ * below) already uses inline, pulled out here because #216's login-client
491
+ * block needs it four times over (both apps' envs, both apps' deploys)
492
+ * rather than once. Rethrows the *last* failure unwrapped: callers that must
493
+ * never echo the underlying error (a Coolify validation error can include
494
+ * the rejected value -- here, the token itself, #216 follow-up review item
495
+ * 1) catch this function's own rejection and build a safe message instead of
496
+ * reading `.message` off what it throws.
497
+ */
498
+ async function retryPlatformAuthCall(attempts, sleep, action) {
499
+ for (let attempt = 1;; attempt += 1) {
500
+ try {
501
+ return await action();
502
+ }
503
+ catch (e) {
504
+ if (attempt >= attempts)
505
+ throw e;
506
+ await sleep(1000 * attempt);
507
+ }
508
+ }
509
+ }
510
+ /**
511
+ * #216 round 5: sha256 of the exact login-client token value -- never the
512
+ * token itself, only its hash, the same `hashAuthApps` rule for the same
513
+ * reason: a recorded scope id must never leak a secret.
514
+ */
515
+ function hashLoginClientToken(token) {
516
+ return createHash('sha256').update(token).digest('hex');
517
+ }
518
+ /**
519
+ * #216 round 4/5: encodes the {@link PlatformAuthLoginClientRecord} a
520
+ * successful login-client delivery writes under
521
+ * {@link PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY}, replacing the plain
522
+ * user-id string round 2-3 wrote there. Paired with
523
+ * {@link parseLoginClientRecord} (`platform-diff.ts`), which `planAuth`
524
+ * reads back to answer whether the delivered PAT is still live, and with
525
+ * {@link authLoginClientDrifted} below, which (round 5) also reads `token`'s
526
+ * hash back to answer whether what was actually delivered is still live.
527
+ */
528
+ function loginClientRecordValue(userId, patId, token) {
529
+ const record = {
530
+ userId,
531
+ patId,
532
+ tokenHash: hashLoginClientToken(token),
533
+ };
534
+ return JSON.stringify(record);
535
+ }
536
+ /**
537
+ * #216 round 4: whether the login-client PAT recorded for this platform
538
+ * (under {@link PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY}) is still, right
539
+ * now in ZITADEL, the one and only live PAT on that machine user. `false`
540
+ * when nothing is recorded yet -- that is "unapplied," which `planAuth`
541
+ * already plans for without this function's help, not drift.
542
+ *
543
+ * Every real mint revokes every other PAT on the user first
544
+ * (`zitadel-machine-user.ts`), so "exactly one live PAT, and it is the
545
+ * recorded one" is never a false negative: a concurrent apply's own mint
546
+ * landing at any point -- before this platform's last delivery, after it, or
547
+ * anywhere in between -- always leaves the recorded PAT either gone or
548
+ * joined by one it isn't. This is what lets `provisionPlatformAuth` stop
549
+ * trying to catch that interleaving from inside the run that delivers the
550
+ * token (round 2's check-after, round 3's check-before-every-write, each
551
+ * confirmed by review to still leave a window): whichever run's write
552
+ * actually won, the next plan's call here sees it and replans.
553
+ *
554
+ * #216 round 5: that ZITADEL-only check is itself not enough. Ordering that
555
+ * breaks it (confirmed by review): run A mints PAT a and is about to
556
+ * deliver; run B's own mint revokes a and mints PAT b first, delivers b to
557
+ * both apps, and records {b}; A's slower delivery of a then lands *last*,
558
+ * overwriting both apps' envs with the now-dead a, and A records {a} after
559
+ * B. Final state: ZITADEL's only live PAT is b, which is NOT what either
560
+ * record ends up naming last (a) -- so a check against ZITADEL's live PATs
561
+ * alone, compared only to whichever record happens to be the one read, can
562
+ * land on either side of this by accident. The fix is to also compare what
563
+ * is actually live in Coolify: `record.tokenHash` is a hash of the exact
564
+ * token this run itself delivered, and if what is live in Coolify right now
565
+ * does not hash to it, the record does not describe reality, whatever
566
+ * ZITADEL's PAT list says. Read fresh, every plan: a corrupted Coolify env
567
+ * is not something minting again fixes by itself, so if this is wrong, a
568
+ * later apply's own mint-and-redeliver is what actually repairs it, and the
569
+ * only way to know to run one is to keep checking.
570
+ *
571
+ * #216 round 5: reads the ZITADEL org id back from
572
+ * {@link PLATFORM_ZITADEL_ORG_SCOPE_KEY} rather than calling
573
+ * `zitadel.findOrCreateOrg` -- a plan must not create anything, and
574
+ * `findOrCreateOrg`'s name says it might. `provisionPlatformAuth` records
575
+ * that key itself now (mirroring `provisionPlatformArchon`), so it is always
576
+ * present by the time a login-client record exists to check. Any of the
577
+ * three ids this needs (`org`, the login app, the broker) missing is not a
578
+ * state this function can verify against, so it reads as drifted -- the
579
+ * next apply's own mint-and-deliver backfills all three.
580
+ *
581
+ * #216 round 5: wrapped in `try`/`catch` -- ZITADEL or Coolify being
582
+ * unreachable must read as "unknown, not drifted," the same rule
583
+ * `archonAccessDrifted`/`archonBrokerRegistrationDrifted` already follow and
584
+ * for the same reason (a plan must not fail outright over a transient read),
585
+ * but logged here: silently treating a real outage as healthy is a worse
586
+ * miss for a credential-liveness check than for Archon's own Access policy.
587
+ */
588
+ async function authLoginClientDrifted(platform, auth, recordedIds) {
589
+ const record = parseLoginClientRecord(recordedIds[PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY]);
590
+ if (!record)
591
+ return false;
592
+ const orgId = recordedIds[PLATFORM_ZITADEL_ORG_SCOPE_KEY];
593
+ const loginAppId = recordedIds[PLATFORM_AUTH_LOGIN_SCOPE_KEY];
594
+ const brokerAppId = recordedIds[PLATFORM_AUTH_BROKER_SCOPE_KEY];
595
+ if (!orgId || !loginAppId || !brokerAppId)
596
+ return true;
597
+ try {
598
+ const zitadel = auth.zitadelForInstance?.(`https://${platform.auth.hostname}`) ?? auth.zitadel;
599
+ const livePats = await zitadel.listLoginClientPats(orgId, record.userId);
600
+ if (livePats.length !== 1 || livePats[0] !== record.patId)
601
+ return true;
602
+ const [loginHash, brokerHash] = await Promise.all([
603
+ liveLoginClientTokenHash(auth.coolify, loginAppId),
604
+ liveLoginClientTokenHash(auth.coolify, brokerAppId),
605
+ ]);
606
+ // #216 (4th PR review): compare each side on its own. Either side can
607
+ // come back `undefined` -- hidden, with nothing to check it against, see
608
+ // `liveLoginClientTokenHash`'s own doc comment -- and that side alone
609
+ // reads as "unknown," the same as the outer `catch` below. But returning
610
+ // "not drifted" as soon as *either* side was unreadable (round 3's
611
+ // behavior) hid a stale token on the *other*, readable app whenever this
612
+ // app's own read happened to fail or come back hidden -- a readable and
613
+ // wrong hash must still drift even when its sibling is unknown.
614
+ for (const hash of [loginHash, brokerHash]) {
615
+ if (hash !== undefined && hash !== record.tokenHash)
616
+ return true;
617
+ }
618
+ return false;
619
+ }
620
+ catch (e) {
621
+ 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)}`);
622
+ return false;
623
+ }
624
+ }
625
+ /**
626
+ * #216 round 5: `ZITADEL_SERVICE_USER_TOKEN`'s live value on one app, hashed
627
+ * -- see {@link authLoginClientDrifted}. `undefined` means "could not be
628
+ * read as a trustworthy value, treat as unknown" -- never a reason by itself
629
+ * to call the token missing.
630
+ *
631
+ * #216 (second PR review, round 2): Coolify's own `GET
632
+ * /applications/{uuid}/envs` response carries both `value` and `real_value`
633
+ * per row (its documented schema for `list-envs-by-application-uuid`), but
634
+ * `CoolifyPort.listEnvs`'s narrower return type (`executor.ts`, this
635
+ * package's own) names only `value` and `is_preview`. The real client
636
+ * (`@wtfalch/providers`' `CoolifyClient.listEnvs`, which does `await
637
+ * r.json()` and returns it as-is with no field-stripping, `coolify.ts:219-
638
+ * 253`) returns Coolify's full JSON row regardless of what this package's
639
+ * own narrower type declares, so `real_value` is present on the object at
640
+ * runtime to read even when absent from the declared type.
641
+ *
642
+ * #216 (4th PR review): "could not be read" has one real cause, confirmed
643
+ * against Coolify's own source rather than guessed -- there is no masked
644
+ * placeholder string anywhere in it. `routes/api.php` maps `GET
645
+ * /applications/{uuid}/envs` to `ApplicationsController::envs()`, which maps
646
+ * each row through `removeSensitiveData()`. That method calls
647
+ * `$env->makeVisible(['value', 'real_value'])` only when the request's
648
+ * `can_read_sensitive` attribute is true (set from the API token's
649
+ * `read:sensitive`/`root` ability), and separately calls
650
+ * `$env->makeHidden(['value', 'real_value'])` whenever the env var's own
651
+ * `is_shown_once` flag is set ("the saved value is hidden in the UI and API
652
+ * responses," per that flag's own API doc comment), regardless of ability.
653
+ * Laravel omits a `makeHidden` attribute from the JSON row entirely -- it is
654
+ * not present as a key, let alone populated with a placeholder string. So
655
+ * unreadable here never looks like `value: '••••••••'`; it looks like
656
+ * `found.value` and `found.real_value` both being `undefined`.
657
+ * `@wtfalch/providers`' own `CoolifyEnvVar` type documents a `real_value` of
658
+ * `null` for a genuinely empty variable (`coolify.ts:84`, "Empty variables
659
+ * come back as `\"\"` here and `null` in `real_value`"), which is a
660
+ * different, readable state -- a real, verified value (of the empty
661
+ * string), not a hidden one -- so only `undefined` on both counts as hidden.
662
+ *
663
+ * A wrong guess in the unsafe direction is costly here specifically: reading
664
+ * a hidden field as the empty string would never hash to what was recorded,
665
+ * so every single plan afterward would see "drift" and re-mint, revoking the
666
+ * one live PAT and redeploying both apps, taking sign-in down on every apply
667
+ * rather than the one apply a real drift deserves. So this reads as unknown
668
+ * (logged once, the same as `authLoginClientDrifted`'s own outer
669
+ * `try`/`catch` already treats a transient read failure) whenever the row
670
+ * exists but carries neither key.
671
+ */
672
+ async function liveLoginClientTokenHash(coolify, appId) {
673
+ const envs = await coolify.listEnvs(appId);
674
+ const found = envs.find((e) => e.key === 'ZITADEL_SERVICE_USER_TOKEN' && !e.is_preview);
675
+ if (found && found.value === undefined && found.real_value === undefined) {
676
+ 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`);
677
+ return undefined;
678
+ }
679
+ const value = found?.real_value ?? found?.value;
680
+ return hashLoginClientToken(value ?? '');
681
+ }
412
682
  /**
413
683
  * #168: makes `coolify:app` keyed {@link PLATFORM_AUTH_KEY} true -- a Coolify
414
- * Postgres, the ZITADEL app itself and the zitadel-login app, reached through
415
- * one Cloudflare tunnel and DNS record at `platform.auth.hostname`, then (only
416
- * the first time) foundry's own admin machine user, handed to
417
- * {@link PlatformAuthDeps.deliverSecret}. Env values and instance settings
418
- * are `wtfalch/auth`'s own `docker-compose.yml` (checked 2026-09-29), with
419
- * `ZITADEL_DOMAIN`/`wtfalch.dev` replaced by this platform's own
420
- * `apex`/`auth.hostname` wherever the compose named one -- every other
421
- * setting it leaves open (login v2 required, external port 443, TLS
422
- * terminated at Cloudflare, `PASSWORDCHANGEREQUIRED` false) is kept exactly
423
- * as that compose has it (#168's "Decide").
684
+ * Postgres, the ZITADEL app itself, the zitadel-login app and (#204) the
685
+ * sign-in broker, reached through one Cloudflare tunnel and DNS record at
686
+ * `platform.auth.hostname`, then (only the first time) foundry's own admin
687
+ * machine user, handed to {@link PlatformAuthDeps.deliverSecret}. Env values
688
+ * and instance settings are `wtfalch/auth`'s own `docker-compose.yml`
689
+ * (checked 2026-09-29), with `ZITADEL_DOMAIN`/`wtfalch.dev` replaced by this
690
+ * platform's own `apex`/`auth.hostname` wherever the compose named one --
691
+ * every other setting it leaves open (login v2 required, external port 443,
692
+ * TLS terminated at Cloudflare, `PASSWORDCHANGEREQUIRED` false) is kept
693
+ * exactly as that compose has it (#168's "Decide"). The estate's old, shared
694
+ * sign-in host carries nothing once this runs (#204, `WTFALCH-DEV-EXIT.md`
695
+ * row A4).
424
696
  *
425
697
  * Every part is safe to run again after a throw partway: each id is recorded
426
698
  * (and persisted) as soon as its resource exists, each app is named and
427
699
  * deployed on every run that needs it, and the admin key is delivered before
428
700
  * the guard that skips the mint is recorded.
429
701
  *
430
- * Two apps, one public hostname, no third `proxy` app: `wtfalch/auth` splits
431
- * ZITADEL's API from its login UI with a built Caddy proxy container inside
432
- * one docker-compose stack; foundry instead gives each app a fixed
433
- * `custom_internal_name` and lets the Cloudflare tunnel itself path-route
434
- * between them (`AUTH_ZITADEL_PATH_PATTERN`), skipping Coolify's own Traefik
435
- * (which can only dispatch by `Host`, not by path) rather than provisioning a
436
- * fourth Coolify resource to do the same job. Neither app is created with a
437
- * Coolify `domains` binding for the same reason -- the tunnel reaches each
438
- * one directly by its `custom_internal_name`, never through Traefik.
702
+ * Three apps, one public hostname, no separate `proxy` app: `wtfalch/auth`
703
+ * splits ZITADEL's API, its login UI and its broker with a built Caddy proxy
704
+ * container inside one docker-compose stack; foundry instead gives each app a
705
+ * fixed `custom_internal_name` and lets the Cloudflare tunnel itself
706
+ * path-route between them (`AUTH_ZITADEL_PATH_PATTERN`,
707
+ * `AUTH_BROKER_PATH_PATTERN`), skipping Coolify's own Traefik (which can only
708
+ * dispatch by `Host`, not by path) rather than provisioning a fourth Coolify
709
+ * resource to do the same job. No app is created with a Coolify `domains`
710
+ * binding for the same reason -- the tunnel reaches each one directly by its
711
+ * `custom_internal_name`, never through Traefik.
439
712
  *
440
713
  * Known gaps, left for review (#168's own "the PR stays open for William's
441
714
  * review"):
442
- * - The login app never receives `ZITADEL_SERVICE_USER_TOKEN`. `wtfalch/auth`
443
- * reads it from a bootstrap file two containers share inside one compose
444
- * stack; two Coolify apps share none. The minted admin token must not go
445
- * there (it is `IAM_OWNER`); a dedicated `IAM_LOGIN_CLIENT` machine user
446
- * would need its own mint, delivery and redeploy. Sign-in through the login
447
- * app is not proven working until that is wired.
715
+ * - ~~Neither the login app nor the broker ever receives
716
+ * `ZITADEL_SERVICE_USER_TOKEN`.~~ Closed by #216 (building on #209/#215):
717
+ * this function's own login-client block below mints a dedicated
718
+ * `IAM_LOGIN_CLIENT` machine user (never the admin one, which is
719
+ * `IAM_OWNER`) and delivers its token to both apps, once the broker's
720
+ * other required inputs are already met.
721
+ * - The broker never receives `RESEND_API_TOKEN` either, so it will not start
722
+ * on `AUTH_APPS` alone. `wtfalch/auth#138` moves the broker's account mail
723
+ * onto held keys and `@wtfalch/mailer`; until that lands there is no held
724
+ * key to read here, so this stays a plain gap rather than a wired value.
725
+ * - `AUTH_APPS` starts unset: the broker has nothing to serve until a
726
+ * platform app registers with it ({@link provisionPlatformArchon} is the
727
+ * first), so it will not start (`parseApps` throws on an empty list) until
728
+ * then either. Safe to run again: the next apply that registers an app
729
+ * redeploys the broker with a non-empty list.
448
730
  * - Whoever supplies `deps.zitadel` (or `zitadelForInstance`) must hold a
449
731
  * bearer token for the new instance, which foundry cannot read from it.
450
732
  *
@@ -549,6 +831,28 @@ async function provisionPlatformAuth(context, deps) {
549
831
  await deps.coolify.updateApplication(loginAppId, {
550
832
  custom_internal_name: PLATFORM_AUTH_LOGIN_INTERNAL_NAME,
551
833
  });
834
+ // ---- the broker app (#204) -------------------------------------------------
835
+ const brokerApp = await findOrCreatePlatformApp(deps.coolify, PLATFORM_AUTH_BROKER_APP_NAME, environment, async () => {
836
+ const server = await resolvePlatformServer(deps.coolify);
837
+ const githubApp = await deps.coolify.requireGithubApp();
838
+ return deps.coolify.createGithubAppApplication({
839
+ project_uuid: projectUuid,
840
+ server_uuid: server.uuid,
841
+ environment_name: environment.name,
842
+ environment_uuid: environment.uuid,
843
+ github_app_uuid: githubApp.uuid,
844
+ git_repository: AUTH_REPO,
845
+ git_branch: gitRef,
846
+ build_pack: 'dockerfile',
847
+ name: PLATFORM_AUTH_BROKER_APP_NAME,
848
+ dockerfile_location: AUTH_BROKER_DOCKERFILE,
849
+ });
850
+ });
851
+ const brokerAppId = brokerApp.appId;
852
+ await record(PLATFORM_AUTH_BROKER_SCOPE_KEY, brokerAppId);
853
+ await deps.coolify.updateApplication(brokerAppId, {
854
+ custom_internal_name: PLATFORM_AUTH_BROKER_INTERNAL_NAME,
855
+ });
552
856
  // ---- env -----------------------------------------------------------------
553
857
  // Generated once, handed straight to `bulkUpsertEnvs` and nowhere else --
554
858
  // never returned, recorded, logged or interpolated into a thrown error, the
@@ -588,13 +892,24 @@ async function provisionPlatformAuth(context, deps) {
588
892
  { key: 'OTEL_NODE_RESOURCE_DETECTORS', value: 'none' },
589
893
  { key: 'API_CACHE_CONFIG', value: '{"defaultMinutes":2,"longMinutes":5}' },
590
894
  ];
895
+ // `AUTH_APPS`, `ZITADEL_SERVICE_USER_TOKEN`/`_FILE` and `RESEND_API_TOKEN`
896
+ // are not pushed here -- this function's own doc comment, "Known gaps".
897
+ const brokerEnvs = [
898
+ {
899
+ key: 'ZITADEL_API_URL',
900
+ value: `http://${PLATFORM_AUTH_ZITADEL_INTERNAL_NAME}:${AUTH_ZITADEL_PORT}`,
901
+ },
902
+ { key: 'ZITADEL_DOMAIN', value: hostname },
903
+ ];
591
904
  // Compared before the write: an env change only reaches a running
592
905
  // container on its next deploy (`diff.ts`'s `coolifyCalls` follows the
593
906
  // identical rule).
594
907
  const zitadelEnvsChanged = await envsDiffer(deps.coolify, zitadelAppId, zitadelEnvs);
595
908
  const loginEnvsChanged = await envsDiffer(deps.coolify, loginAppId, loginEnvs);
909
+ const brokerEnvsChanged = await envsDiffer(deps.coolify, brokerAppId, brokerEnvs);
596
910
  await deps.coolify.bulkUpsertEnvs(zitadelAppId, zitadelEnvs);
597
911
  await deps.coolify.bulkUpsertEnvs(loginAppId, loginEnvs);
912
+ await deps.coolify.bulkUpsertEnvs(brokerAppId, brokerEnvs);
598
913
  // Deploy an app that was just created, is not running (a throw after the
599
914
  // create left it never deployed, or it stopped), or whose envs changed.
600
915
  if (zitadelApp.justCreated || !zitadelApp.running || zitadelEnvsChanged) {
@@ -604,6 +919,31 @@ async function provisionPlatformAuth(context, deps) {
604
919
  if (loginApp.justCreated || !loginApp.running || loginEnvsChanged) {
605
920
  await deps.coolify.deploy(loginAppId);
606
921
  }
922
+ // #211: the broker's own `server.ts` refuses to start without
923
+ // `ZITADEL_SERVICE_USER_TOKEN`/`_FILE` and `RESEND_API_TOKEN` -- deploying
924
+ // it without them only produces a container that crash-loops behind
925
+ // `/api`. The app is still created and recorded above either way, so #209
926
+ // and #210 (or a human, directly) have something to push the env onto;
927
+ // only the deploy waits.
928
+ // #211 round 5: `brokerDeployNeeded` is its own reason to deploy (not only
929
+ // alongside justCreated/running/envsChanged, none of which this function's
930
+ // own `brokerEnvs` write above ever touches `AUTH_APPS`): it is what
931
+ // catches a broker recreated under a new app id, or a crash between some
932
+ // other step writing `AUTH_APPS` and deploying, cases none of the other
933
+ // three conditions see. This function never writes `AUTH_APPS` itself
934
+ // (this function's own doc comment, "Known gaps"), so whatever is live now
935
+ // (`authAppsRaw`) is the value this deploy, if it runs, puts into effect.
936
+ const brokerMissingInputs = await missingBrokerInputs(deps.coolify, brokerAppId);
937
+ const authAppsRaw = await currentEnvValue(deps.coolify, brokerAppId, 'AUTH_APPS');
938
+ const recordedBrokerDeploy = parseBrokerDeployRecord(recorded[PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY]);
939
+ if (brokerMissingInputs.length === 0 &&
940
+ (brokerApp.justCreated ||
941
+ !brokerApp.running ||
942
+ brokerEnvsChanged ||
943
+ brokerDeployNeeded(brokerMissingInputs, brokerAppId, authAppsRaw, recordedBrokerDeploy))) {
944
+ await deps.coolify.deploy(brokerAppId);
945
+ await record(PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, brokerDeployRecordValue(brokerAppId, authAppsRaw));
946
+ }
607
947
  // ---- tunnel and DNS ------------------------------------------------------
608
948
  const existingTunnel = await deps.cloudflare.findTunnelByName(PLATFORM_AUTH_TUNNEL_NAME);
609
949
  const tunnel = existingTunnel ?? (await deps.cloudflare.createTunnel(PLATFORM_AUTH_TUNNEL_NAME));
@@ -615,6 +955,11 @@ async function provisionPlatformAuth(context, deps) {
615
955
  path: AUTH_ZITADEL_PATH_PATTERN,
616
956
  service: `http://${PLATFORM_AUTH_ZITADEL_INTERNAL_NAME}:${AUTH_ZITADEL_PORT}`,
617
957
  },
958
+ {
959
+ hostname,
960
+ path: AUTH_BROKER_PATH_PATTERN,
961
+ service: `http://${PLATFORM_AUTH_BROKER_INTERNAL_NAME}:${AUTH_BROKER_PORT}`,
962
+ },
618
963
  { hostname, service: `http://${PLATFORM_AUTH_LOGIN_INTERNAL_NAME}:${AUTH_LOGIN_PORT}` },
619
964
  { service: 'http_status:404' },
620
965
  ],
@@ -671,6 +1016,12 @@ async function provisionPlatformAuth(context, deps) {
671
1016
  await waitForInstance(`https://${hostname}/.well-known/openid-configuration`, runtime, sleep);
672
1017
  const zitadel = deps.zitadelForInstance?.(`https://${hostname}`) ?? deps.zitadel;
673
1018
  const { org } = await zitadel.findOrCreateOrg(PLATFORM_AUTH_ORG_NAME);
1019
+ // #216 round 5: recorded here too (mirroring `provisionPlatformArchon`),
1020
+ // so `authLoginClientDrifted` (below) never needs its own ZITADEL call
1021
+ // to resolve the org -- see that function's own doc comment.
1022
+ if (recorded[PLATFORM_ZITADEL_ORG_SCOPE_KEY] !== org.id) {
1023
+ await record(PLATFORM_ZITADEL_ORG_SCOPE_KEY, org.id);
1024
+ }
674
1025
  const { userId, token } = await zitadel.mintAdminMachineUser(org.id, {
675
1026
  userName: PLATFORM_AUTH_ADMIN_USER_NAME,
676
1027
  name: 'Foundry (platform admin)',
@@ -698,6 +1049,132 @@ async function provisionPlatformAuth(context, deps) {
698
1049
  }
699
1050
  await record(PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, userId);
700
1051
  }
1052
+ // ---- foundry's own login-client machine user, once (#216, building on #209/#215) --
1053
+ // The login app and the broker both refuse to start without
1054
+ // `ZITADEL_SERVICE_USER_TOKEN`/`_FILE` (this function's own doc comment,
1055
+ // "Known gaps") -- the admin token above must never go there (it is
1056
+ // `IAM_OWNER`), so this mints a second, narrower machine user
1057
+ // (`IAM_LOGIN_CLIENT` only) and delivers its token into both apps' own
1058
+ // envs, then redeploys both.
1059
+ //
1060
+ // Deferred, not blocked, while the broker's other required inputs
1061
+ // (`RESEND_API_TOKEN`, `AUTH_APPS`) are still missing (composing with
1062
+ // #211's own gating): minting and delivering here would only redeploy a
1063
+ // broker that still refuses to start on the other missing input, spending
1064
+ // a mint and two deploys on nothing this apply can fix. `blockedSteps`'s
1065
+ // own `missingBrokerInputs` read already names what is still missing; once
1066
+ // the other inputs land (#210, or Archon's own registration for
1067
+ // `AUTH_APPS`), a later apply's fresh read of `brokerMissingInputs` clears
1068
+ // this gate and this block runs.
1069
+ //
1070
+ // Unlike the admin key above, an undelivered login-client key never blocks
1071
+ // planning or throws a "delete this user by hand" error -- #216's own fix
1072
+ // for the bug PR #215 shipped with, which mirrored the admin pair's
1073
+ // forever-blocking behaviour here too. Revoking a stale login-client PAT
1074
+ // destroys nothing (unlike ZITADEL's own masterkey, nothing else was ever
1075
+ // encrypted with it), so `mintLoginClientMachineUser`
1076
+ // (`zitadel-machine-user.ts`) always lists and deletes every PAT the user
1077
+ // already holds before minting a fresh one. A crash between mint and
1078
+ // record, or every delivery retry failing, both leave this `if` still true
1079
+ // on the next run, which re-mints (revoking the stale PAT first) and
1080
+ // retries delivery, with no manual step.
1081
+ //
1082
+ // #216 round 2 and round 3 each tried to catch a concurrent apply's own
1083
+ // mint (idempotent revoke-then-create on the same `userName`) racing this
1084
+ // one -- round 2 checked liveness once, after every write; round 3 checked
1085
+ // again before every write, inside each retry. Review confirmed each still
1086
+ // left a window: a write that lands between the last check this run makes
1087
+ // and the very next network call is never caught by either. #216 round 4
1088
+ // drops both: this block no longer tries to detect the race from inside
1089
+ // the run that might lose it. It mints, delivers with ordinary retries,
1090
+ // and records whatever it actually delivered -- the user id *and* the
1091
+ // exact PAT id (`loginClientRecordValue`). `planAuth` (`platform-diff.ts`)
1092
+ // re-checks that recorded PAT against ZITADEL's live state on *every*
1093
+ // plan, not only this run's: whichever run's write actually won is caught
1094
+ // on the very next plan and replanned, however the writes interleaved.
1095
+ // See `authLoginClientDrifted`'s own doc comment for why that check can
1096
+ // never read a healthy delivery as drifted.
1097
+ //
1098
+ // Read fresh here, immediately before deciding whether to mint -- not
1099
+ // `recorded`, the snapshot this function took at the top, several hundred
1100
+ // lines above. A concurrent apply's own mint-deliver-record can land in
1101
+ // that gap; deciding off a stale snapshot would mint and redeliver on top
1102
+ // of a token another run just finished setting up.
1103
+ //
1104
+ // A record present is still not enough on its own: `planAuth` may have
1105
+ // replanned this step because `authLoginClientDrifted` (the same check,
1106
+ // reused here) found the recorded PAT no longer live -- this run has to
1107
+ // make the same call, not just trust that a record exists, or a drifted
1108
+ // platform would be planned again forever without ever actually healing.
1109
+ const loginClientOtherInputsMissing = brokerMissingInputs.filter((m) => m !== 'ZITADEL_SERVICE_USER_TOKEN');
1110
+ const freshRecordedIds = context.recordedIds();
1111
+ const freshLoginClientRecord = parseLoginClientRecord(freshRecordedIds[PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY]);
1112
+ const loginClientNeedsMint = !freshLoginClientRecord || (await authLoginClientDrifted(platform, deps, freshRecordedIds));
1113
+ if (loginClientNeedsMint && loginClientOtherInputsMissing.length === 0) {
1114
+ const loginClientZitadel = deps.zitadelForInstance?.(`https://${hostname}`) ?? deps.zitadel;
1115
+ const { org: loginClientOrg } = await loginClientZitadel.findOrCreateOrg(PLATFORM_AUTH_ORG_NAME);
1116
+ // #216 round 5: backfills the org id for a platform whose admin block
1117
+ // (above) ran before this recording existed and has not run since --
1118
+ // see `authLoginClientDrifted`'s own doc comment.
1119
+ if (freshRecordedIds[PLATFORM_ZITADEL_ORG_SCOPE_KEY] !== loginClientOrg.id) {
1120
+ await record(PLATFORM_ZITADEL_ORG_SCOPE_KEY, loginClientOrg.id);
1121
+ }
1122
+ const { userId: loginClientUserId, token: loginClientToken, patId: loginClientPatId, revokedPriorPat, } = await loginClientZitadel.mintLoginClientMachineUser(loginClientOrg.id, {
1123
+ userName: PLATFORM_AUTH_LOGIN_CLIENT_USER_NAME,
1124
+ name: 'Foundry (platform login client)',
1125
+ });
1126
+ // Recorded and persisted before the token leaves this function -- the
1127
+ // same "one mint" rule the admin block above follows. Never read back to
1128
+ // block planning; `planAuth` (`platform-diff.ts`) reads it only to
1129
+ // surface a non-blocking note, for the reason this block's own doc
1130
+ // comment above gives.
1131
+ await record(PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, loginClientUserId);
1132
+ const deliverAttempts = runtime.deliverAttempts ?? 3;
1133
+ try {
1134
+ await retryPlatformAuthCall(deliverAttempts, sleep, () => deps.coolify.bulkUpsertEnvs(loginAppId, [
1135
+ { key: 'ZITADEL_SERVICE_USER_TOKEN', value: loginClientToken },
1136
+ ]));
1137
+ await retryPlatformAuthCall(deliverAttempts, sleep, () => deps.coolify.bulkUpsertEnvs(brokerAppId, [
1138
+ { key: 'ZITADEL_SERVICE_USER_TOKEN', value: loginClientToken },
1139
+ ]));
1140
+ // Forced: an unforced deploy answers 200 "skipped" when one for the
1141
+ // same commit is already queued -- likely here, since this function's
1142
+ // own deploys above may just have queued one for either app. An
1143
+ // ignored "skipped" would let this function record delivery as
1144
+ // succeeded while the running containers never actually see the token
1145
+ // (#216 follow-up review, item 2).
1146
+ await retryPlatformAuthCall(deliverAttempts, sleep, () => deps.coolify.deploy(loginAppId, true));
1147
+ await retryPlatformAuthCall(deliverAttempts, sleep, () => deps.coolify.deploy(brokerAppId, true));
1148
+ }
1149
+ catch {
1150
+ // Never echo the underlying error: Coolify's validation error can
1151
+ // include the rejected env value -- the token itself -- in its
1152
+ // message (#216 follow-up review, item 1). Name the user id only,
1153
+ // never the token or the cause. Nothing records delivered, so the
1154
+ // next apply re-mints (revoking whatever is live) and re-delivers.
1155
+ //
1156
+ // #216 (second PR review, round 2; third PR review, nit): on a
1157
+ // re-mint, `mintLoginClientMachineUser` already revoked this user's
1158
+ // prior PAT before minting the one that just failed to deliver -- see
1159
+ // this file's own doc comment above and `zitadel-machine-user.ts`'s
1160
+ // revoke-before-mint sweep. So on that path the login app and broker
1161
+ // are not merely stale, they are down: neither has any live token
1162
+ // until a later apply mints and delivers a fresh one. `revokedPriorPat`
1163
+ // is false on a first-ever mint, where nothing was live to revoke --
1164
+ // saying "its prior token was already revoked" there would describe a
1165
+ // revoke that never happened, so the message only says so when it did.
1166
+ throw new Error(revokedPriorPat
1167
+ ? `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`
1168
+ : `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`);
1169
+ }
1170
+ // #216 round 4/5: records what was actually delivered -- the user id,
1171
+ // the exact PAT id, and (round 5) a hash of the exact token -- so the
1172
+ // next plan's `authLoginClientDrifted` check can tell a healthy delivery
1173
+ // from one a concurrent apply has since clobbered, including the
1174
+ // ordering round 4's own check alone could not catch. See this block's
1175
+ // own doc comment, and `authLoginClientDrifted`'s, for why.
1176
+ await record(PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, loginClientRecordValue(loginClientUserId, loginClientPatId, loginClientToken));
1177
+ }
701
1178
  return { resourceId: zitadelAppId };
702
1179
  }
703
1180
  // ---------------------------------------------------------------------------
@@ -818,13 +1295,15 @@ export const PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION = 'foundry:platform:admin';
818
1295
  * access-token `aud` check; whoever supplies
819
1296
  * {@link PlatformMailDeps.oidcAudience} must have registered one by hand
820
1297
  * until that is built.
821
- * - **The org step `coolify:mail`'s `MAIL_APEX`/`MAIL_HOST` constants
822
- * (`executor.ts`) still point at the hand-built `wtfalch.dev` instance,
823
- * not this one**, and the Lettermint team token it reads still comes from
824
- * wherever the host already reads it, not held keys. Both need
1298
+ * - **The Lettermint team token the org app step reads still comes from
1299
+ * wherever the host already reads it, not held keys.** Needs
825
1300
  * `StepExecutionContext` to carry the platform, which `plan-apply.ts`'s
826
1301
  * own doc comment already scoped out of #167 as more than a small change
827
- * -- a follow-up issue, not this one.
1302
+ * -- a follow-up issue, not this one. (#205 removed the org step
1303
+ * `coolify:mail`'s own fallback to the hand-built `wtfalch.dev`
1304
+ * instance -- `diff.ts`'s `planMail` already blocks the step until the
1305
+ * platform's own mail is applied, so the fallback only ever served a
1306
+ * plan recorded before that, and now fails loudly instead.)
828
1307
  */
829
1308
  async function provisionPlatformMail(context, deps) {
830
1309
  const { platform } = context;
@@ -1098,6 +1577,69 @@ async function mailBlockedReason(platform, mail, recordedIds) {
1098
1577
  return "the platform's mail server is blocked, not planned: no usable Coolify server could be resolved for it";
1099
1578
  }
1100
1579
  }
1580
+ /**
1581
+ * #211: the broker cannot start without `ZITADEL_SERVICE_USER_TOKEN`/`_FILE`,
1582
+ * `RESEND_API_TOKEN` and `AUTH_APPS`. `provisionPlatformAuth` already skips
1583
+ * deploying the broker while any is missing ({@link missingBrokerInputs});
1584
+ * this is the same check surfaced through `blockedSteps`, so a plan says
1585
+ * why, by name, rather than the broker simply sitting undeployed with no
1586
+ * explanation. `null` while there is no broker app id recorded yet (nothing
1587
+ * to check) or every input is present.
1588
+ *
1589
+ * #211 round 3: wraps the live read the way {@link mailBlockedReason} wraps
1590
+ * its own Coolify call -- a transient `listEnvs` failure must report a
1591
+ * blocked reason naming the broker app id, never throw out of
1592
+ * `blockedSteps` and take the rest of the plan down with it.
1593
+ *
1594
+ * #211 round 5 addendum: when `AUTH_APPS` is the reason, names which problem
1595
+ * it actually is ({@link authAppsProblem}) instead of always saying "it has
1596
+ * no ... AUTH_APPS yet" -- that wording is only true when nothing has been
1597
+ * set; a present-but-invalid value is a different failure (a person needs to
1598
+ * go fix an entry, not merely wait for one of #209/#210/Archon's own
1599
+ * registration to supply one), and reporting it identically hid which one
1600
+ * was true.
1601
+ */
1602
+ async function authBrokerBlockedReason(auth, recordedIds) {
1603
+ const brokerAppId = recordedIds[PLATFORM_AUTH_BROKER_SCOPE_KEY];
1604
+ if (!brokerAppId)
1605
+ return { reason: null, otherInputsMissing: [] };
1606
+ let missing;
1607
+ let authAppsRaw;
1608
+ try {
1609
+ missing = await missingBrokerInputs(auth.coolify, brokerAppId);
1610
+ authAppsRaw = await currentEnvValue(auth.coolify, brokerAppId, 'AUTH_APPS');
1611
+ }
1612
+ catch {
1613
+ return {
1614
+ reason: `the platform's sign-in broker ${brokerAppId} is blocked, not deployed: its live env could not be read`,
1615
+ // #216 round 5: unreadable, not "known missing" -- `planAuth` must not
1616
+ // assume the login-client mint is blocked by anything in particular.
1617
+ otherInputsMissing: [],
1618
+ };
1619
+ }
1620
+ // #216 round 5: what `provisionPlatformAuth`'s own `loginClientOtherInputsMissing`
1621
+ // gate is -- see `planAuth`'s own doc comment for why it needs this too.
1622
+ const otherInputsMissing = missing.filter((m) => m !== 'ZITADEL_SERVICE_USER_TOKEN');
1623
+ if (missing.length === 0)
1624
+ return { reason: null, otherInputsMissing };
1625
+ if (authAppsProblem(authAppsRaw, brokerAppId) === 'malformed') {
1626
+ const otherMissing = missing.filter((m) => m !== 'AUTH_APPS');
1627
+ const otherSuffix = otherMissing.length > 0
1628
+ ? `, and it has no ${otherMissing.join(' and no ')} yet (#209, #210)`
1629
+ : '';
1630
+ return {
1631
+ reason: `the platform's sign-in broker ${brokerAppId} is blocked, not deployed: its AUTH_APPS is malformed${otherSuffix}`,
1632
+ otherInputsMissing,
1633
+ };
1634
+ }
1635
+ // AUTH_APPS isn't gated on #209/#210 the way the two tokens are; only cite
1636
+ // those issues when one of them is actually among what's missing.
1637
+ const suffix = missing.some((m) => m !== 'AUTH_APPS') ? ' (#209, #210)' : '';
1638
+ return {
1639
+ reason: `the platform's sign-in broker is created but not deployed, blocked: it has no ${missing.join(' and no ')} yet${suffix}`,
1640
+ otherInputsMissing,
1641
+ };
1642
+ }
1101
1643
  /** Polls until Coolify reports the mail app stopped, so a healthy answer read afterwards is the new deployment's. */
1102
1644
  async function waitForMailStopped(coolify, appId, runtime, sleep) {
1103
1645
  const now = runtime.now ?? Date.now;
@@ -1222,15 +1764,25 @@ export const PLATFORM_ARCHON_CLIENT_ID_SCOPE_KEY = 'zitadel_platform_archon_clie
1222
1764
  * PKCE, no client secret: `createOidcApp` never returns one, see
1223
1765
  * `executor.ts`'s `zitadelApp`), which is what Archon's `@wtfalch/auth`
1224
1766
  * sign-in reads -- it takes `AUTH_CLIENT_ID` and no client secret. So there
1225
- * is no one-time secret to hand to `deliverSecret` here. The two values this
1226
- * step does generate (cookie secret, runtime-role password) go straight into
1227
- * the app's env and nowhere else: never returned, recorded, logged or put in
1228
- * an error.
1767
+ * is no one-time secret to hand to `deliverSecret` here. The three values this
1768
+ * step does generate (cookie secret, runtime-role password, `AUTH_APP_KEY`)
1769
+ * go straight into the app's env and nowhere else: never returned, recorded,
1770
+ * logged or put in an error.
1771
+ *
1772
+ * `AUTH_APP_KEY` (#204): minted once, the same `ensureGeneratedEnv` way as
1773
+ * the cookie secret, and registered with the broker by rewriting its
1774
+ * `AUTH_APPS` env to include Archon's entry -- `deliverSecret` is not the
1775
+ * right shape here, since the destination is another app's env this same
1776
+ * function already writes to, not a human or a secret store. Requires
1777
+ * {@link PLATFORM_AUTH_BROKER_SCOPE_KEY} to be recorded, which
1778
+ * `provisionPlatformAuth` guarantees by the time this step is reachable (its
1779
+ * own resourceId records only once the broker app exists too). The broker
1780
+ * will not actually be able to serve a request with this key until
1781
+ * `provisionPlatformAuth`'s own "Known gaps" (`ZITADEL_SERVICE_USER_TOKEN`,
1782
+ * `RESEND_API_TOKEN`) are closed; wiring the key here is still correct and
1783
+ * needed so the two apps agree the moment the broker can run.
1229
1784
  *
1230
1785
  * Known gaps, left for review:
1231
- * - `AUTH_APP_KEY`: the `auth.wtfalch.dev` broker's per-organisation
1232
- * registry key. The platform's new sign-in has no broker registration API,
1233
- * the same gap `executor.ts`'s `pushAppEnv` names for an org's app.
1234
1786
  * - `AUTHZ_BOOTSTRAP_OPERATOR`: the first operator's ZITADEL subject id. A
1235
1787
  * person has to exist in the new instance first; not something foundry
1236
1788
  * mints.
@@ -1280,6 +1832,13 @@ async function provisionPlatformArchon(context, deps) {
1280
1832
  const inputs = await collectArchonServiceInputs(platform, deps, recorded);
1281
1833
  if ('blocked' in inputs)
1282
1834
  throw new Error(`platform:archon: ${inputs.blocked}`);
1835
+ // #213: Archon's own onboarding reads these back as held keys before it
1836
+ // can start. Put before anything below touches Coolify, so a host that
1837
+ // cannot put them never gets a half-built app.
1838
+ if (!deps.masterKeys || !deps.heldKeys?.put) {
1839
+ 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)");
1840
+ }
1841
+ await putArchonMasterKeys(deps.heldKeys, deps.masterKeys);
1283
1842
  // ---- Coolify project and database -----------------------------------------
1284
1843
  let projectUuid = recorded[PLATFORM_ARCHON_PROJECT_SCOPE_KEY];
1285
1844
  if (!projectUuid) {
@@ -1290,19 +1849,32 @@ async function provisionPlatformArchon(context, deps) {
1290
1849
  await record(PLATFORM_ARCHON_PROJECT_SCOPE_KEY, projectUuid);
1291
1850
  }
1292
1851
  const { environment } = await deps.coolify.findOrCreateEnvironment(projectUuid, 'production');
1852
+ // #265: a database already named `platform-archon-db` (the hand-deployed
1853
+ // one) is adopted, never built a second time. One of that name in another
1854
+ // environment is refused: adopting or duplicating it would split the data.
1293
1855
  let dbId = recorded[PLATFORM_ARCHON_DB_SCOPE_KEY];
1294
1856
  if (!dbId) {
1295
- const server = await resolvePlatformServer(deps.coolify);
1296
- const created = await deps.coolify.createPostgresDatabase({
1297
- project_uuid: projectUuid,
1298
- server_uuid: server.uuid,
1299
- environment_name: environment.name,
1300
- environment_uuid: environment.uuid,
1301
- name: PLATFORM_ARCHON_DB_NAME,
1302
- postgres_db: ARCHON_POSTGRES_DB,
1303
- postgres_user: ARCHON_POSTGRES_DB,
1304
- });
1305
- dbId = created.uuid;
1857
+ const named = (await deps.coolify.listDatabases()).filter((d) => d.name === PLATFORM_ARCHON_DB_NAME);
1858
+ const found = named.find((d) => d.environment_id === environment.id);
1859
+ if (!found && named.length > 0) {
1860
+ throw new Error(`platform:archon: a database named ${PLATFORM_ARCHON_DB_NAME} exists outside the ${PLATFORM_ARCHON_PROJECT} project's production environment; refusing to adopt or duplicate it. Move or rename it in Coolify and apply again`);
1861
+ }
1862
+ if (found) {
1863
+ dbId = found.uuid;
1864
+ }
1865
+ else {
1866
+ const server = await resolvePlatformServer(deps.coolify);
1867
+ const created = await deps.coolify.createPostgresDatabase({
1868
+ project_uuid: projectUuid,
1869
+ server_uuid: server.uuid,
1870
+ environment_name: environment.name,
1871
+ environment_uuid: environment.uuid,
1872
+ name: PLATFORM_ARCHON_DB_NAME,
1873
+ postgres_db: ARCHON_POSTGRES_DB,
1874
+ postgres_user: ARCHON_POSTGRES_DB,
1875
+ });
1876
+ dbId = created.uuid;
1877
+ }
1306
1878
  await record(PLATFORM_ARCHON_DB_SCOPE_KEY, dbId);
1307
1879
  }
1308
1880
  const db = await deps.coolify.getDatabase(dbId);
@@ -1311,6 +1883,12 @@ async function provisionPlatformArchon(context, deps) {
1311
1883
  }
1312
1884
  // ---- the app ----------------------------------------------------------------
1313
1885
  const app = await findOrCreatePlatformApp(deps.coolify, PLATFORM_ARCHON_APP_NAME, environment, async () => {
1886
+ // #265: the lookup above is scoped to this environment, so a same-named
1887
+ // app elsewhere is invisible to it. Making another one would give two
1888
+ // `platform-archon` apps; refuse instead.
1889
+ if (await deps.coolify.findApplicationByName(PLATFORM_ARCHON_APP_NAME)) {
1890
+ throw new Error(`platform:archon: an application named ${PLATFORM_ARCHON_APP_NAME} exists outside the ${PLATFORM_ARCHON_PROJECT} project's production environment; refusing to create a second one. Move or rename it in Coolify and apply again`);
1891
+ }
1314
1892
  const server = await resolvePlatformServer(deps.coolify);
1315
1893
  const githubApp = await deps.coolify.requireGithubApp();
1316
1894
  return deps.coolify.createGithubAppApplication({
@@ -1386,18 +1964,130 @@ async function provisionPlatformArchon(context, deps) {
1386
1964
  { key: 'AUTH_ISSUER', value: issuer },
1387
1965
  { key: 'AUTH_ORGANIZATION_ID', value: org.id },
1388
1966
  { key: 'AUTH_CLIENT_ID', value: clientId },
1389
- { key: 'DATABASE_URL_OWNER', value: db.internal_db_url },
1390
- { key: 'REPORTING_SITE', value: 'archon' },
1391
1967
  ];
1968
+ // #265: an owner URL or reporting site already on the app is kept as it is.
1969
+ // The owner URL must name the adopted database's host, or the app's two
1970
+ // database URLs would point at different databases.
1971
+ const liveOwnerUrl = await currentEnvValue(deps.coolify, appId, 'DATABASE_URL_OWNER');
1972
+ if (liveOwnerUrl) {
1973
+ assertSameDatabaseHost('DATABASE_URL_OWNER', liveOwnerUrl, db.internal_db_url);
1974
+ }
1975
+ else {
1976
+ envs.push({ key: 'DATABASE_URL_OWNER', value: db.internal_db_url });
1977
+ }
1978
+ if (!(await currentEnvValue(deps.coolify, appId, 'REPORTING_SITE'))) {
1979
+ envs.push({ key: 'REPORTING_SITE', value: 'archon' });
1980
+ }
1392
1981
  const cookieSecret = await ensureGeneratedEnv(deps.coolify, appId, 'AUTH_COOKIE_SECRET', () => randomBytes(32).toString('base64'));
1393
1982
  if (cookieSecret)
1394
1983
  envs.push({ key: 'AUTH_COOKIE_SECRET', value: cookieSecret });
1395
1984
  const runtimeUrl = await ensureArchonRuntimeUrl(deps.coolify, appId, db.internal_db_url);
1396
1985
  if (runtimeUrl)
1397
1986
  envs.push({ key: 'DATABASE_URL', value: runtimeUrl });
1987
+ // `AUTH_APP_KEY` (#204): minted once like the cookie secret above, but read
1988
+ // back (never regenerated) rather than skipped when already set, since its
1989
+ // value is needed below to keep the broker's own registration in sync even
1990
+ // on a run that mints nothing new. `randomBytes(32)` (#211), the same
1991
+ // random half `wtfalch/auth/broker/src/apps.ts`'s own `mintKey` gives an
1992
+ // app key -- `parseApps` there rejects a key under 32 *characters*, and
1993
+ // `randomBytes(24)` base64url-encodes to exactly 32, landing right on that
1994
+ // edge with no margin for a different encoding or a trim.
1995
+ // #211 round 5: `minLength: 32` on this call only -- a live value shorter
1996
+ // than the broker itself will accept is exactly as unusable as one that is
1997
+ // empty, and `readBrokerApps`/`validateBrokerAppEntry` would refuse it the
1998
+ // moment this step tried to register it. See `ensureGeneratedEnv`'s own
1999
+ // doc comment for why no other call site gets this argument.
2000
+ const appKey = (await ensureGeneratedEnv(deps.coolify, appId, 'AUTH_APP_KEY', () => randomBytes(32).toString('base64url'), 32)) ?? (await currentEnvValue(deps.coolify, appId, 'AUTH_APP_KEY'));
2001
+ if (!appKey) {
2002
+ throw new Error(`platform:archon: app ${appId} lost its AUTH_APP_KEY between two reads`);
2003
+ }
2004
+ envs.push({ key: 'AUTH_APP_KEY', value: appKey });
1398
2005
  envs.push(...inputs.envs);
1399
2006
  const changed = await envsDiffer(deps.coolify, appId, envs);
1400
2007
  await deps.coolify.bulkUpsertEnvs(appId, envs);
2008
+ // ---- register with the broker (#204) ---------------------------------------
2009
+ // After Archon's own `AUTH_APP_KEY` is durably written above, so a throw
2010
+ // earlier in this function (a bad database URL, a Cloudflare failure) never
2011
+ // touches the broker. `provisionPlatformAuth`'s own resourceId records only
2012
+ // once the broker app exists too (it is created before that function
2013
+ // returns), and this step is unreachable until that resourceId is recorded
2014
+ // (the guard at the top of this function) -- so a missing id here means a
2015
+ // caller reached this step some other way.
2016
+ const brokerAppId = recorded[PLATFORM_AUTH_BROKER_SCOPE_KEY];
2017
+ if (!brokerAppId) {
2018
+ throw new Error('platform:archon: platform:auth has not recorded its broker app id yet; apply platform:auth again');
2019
+ }
2020
+ const brokerApps = await readBrokerApps(deps.coolify, brokerAppId);
2021
+ // #211 round 2: merged into the matching entry, not replacing it -- the
2022
+ // broker's own `App` (`broker/src/apps.ts`) carries fields this step never
2023
+ // sets (`registration`, `locale`, `productName`, `emailLink`), and a
2024
+ // previous `filter`-then-append dropped every one of them, plus every
2025
+ // *other* app this organisation may have registered directly through the
2026
+ // broker's own registration API. Matched on `organizationId` and `name`
2027
+ // together: Archon is one app of possibly several this organisation runs,
2028
+ // and only Archon's own entry is this step's to touch.
2029
+ // #211 round 3: every entry matching Archon's org and name, not just the
2030
+ // first -- a previous bug, or a hand-edited AUTH_APPS, can leave more than
2031
+ // one. The first is kept (and updated below); the rest are dropped, so no
2032
+ // duplicate carries a stale key a broker client could still present.
2033
+ const archonMatchIndices = brokerApps
2034
+ .map((a, i) => (a.organizationId === org.id && a.name === PLATFORM_ARCHON_ZITADEL_APP ? i : -1))
2035
+ .filter((i) => i >= 0);
2036
+ const archonEntryIndex = archonMatchIndices[0] ?? -1;
2037
+ const duplicateIndices = new Set(archonMatchIndices.slice(1));
2038
+ const archonEntry = {
2039
+ ...(archonEntryIndex >= 0 ? brokerApps[archonEntryIndex] : {}),
2040
+ organizationId: org.id,
2041
+ key: appKey,
2042
+ name: PLATFORM_ARCHON_ZITADEL_APP,
2043
+ clientIds: [clientId],
2044
+ origins: [`https://${hostname}`],
2045
+ // #211: `wtfalch/auth`'s broker (`broker/src/apps.ts`'s `parseAppFields`)
2046
+ // falls back to `wtfalch <noreply@wtfalch.dev>` for an entry with no
2047
+ // `from`, and (`wtfalch/auth#179`) an entry with no `from` is dropped
2048
+ // entirely -- either way, unset here means Archon's mail comes from
2049
+ // the wrong platform or not at all.
2050
+ from: `Company Included <noreply@${platform.apex}>`,
2051
+ };
2052
+ const desiredBrokerApps = archonEntryIndex >= 0
2053
+ ? brokerApps
2054
+ .map((a, i) => (i === archonEntryIndex ? archonEntry : a))
2055
+ .filter((_, i) => !duplicateIndices.has(i))
2056
+ : [...brokerApps, archonEntry];
2057
+ const desiredAuthApps = [{ key: 'AUTH_APPS', value: JSON.stringify(desiredBrokerApps) }];
2058
+ // Rebuilt and compared every run (not gated on a freshly minted key), so a
2059
+ // clientId or hostname change reaches the broker too, and so a previous
2060
+ // run that wrote Archon's own key but failed before reaching the broker is
2061
+ // repaired by this one.
2062
+ const authAppsChanged = await envsDiffer(deps.coolify, brokerAppId, desiredAuthApps);
2063
+ if (authAppsChanged) {
2064
+ await deps.coolify.bulkUpsertEnvs(brokerAppId, desiredAuthApps);
2065
+ }
2066
+ // #211 round 2: deploying the broker before its own `server.ts` can start
2067
+ // (missing `ZITADEL_SERVICE_USER_TOKEN`/`_FILE` or `RESEND_API_TOKEN`, #209,
2068
+ // #210) only produces a container that crash-loops behind `/api` -- the
2069
+ // same rule `provisionPlatformAuth` follows for its own deploy of this
2070
+ // app. The env is still written either way, so the broker serves the new
2071
+ // registration the moment it is next deployed, by either step.
2072
+ // #211 round 5: the deploy decision is {@link brokerDeployNeeded}, the same
2073
+ // rule `provisionPlatformAuth` uses -- it is what replaces the old
2074
+ // `!brokerAlreadyDeployed` boolean, and with it catches both a broker
2075
+ // recreated under a new app id (this step's own last-registered-broker
2076
+ // check, `archonBrokerRegistrationDrifted`, is a different, already-correct
2077
+ // mechanism for *replanning*; this is what stops the *executor* from
2078
+ // reading a stale record as already deployed) and a crash between this
2079
+ // run's own `AUTH_APPS` write above and its deploy -- a rerun recomputes
2080
+ // the identical `desiredAuthApps` value, sees no recorded deploy naming
2081
+ // that hash, and deploys again. `desiredAuthApps[0].value` (not a re-read)
2082
+ // is the exact string now live on the app either way: written just above
2083
+ // when `authAppsChanged`, already matching when it was not.
2084
+ const brokerMissingInputs = await missingBrokerInputs(deps.coolify, brokerAppId);
2085
+ const recordedBrokerDeploy = parseBrokerDeployRecord(recorded[PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY]);
2086
+ const desiredAuthAppsRaw = desiredAuthApps[0]?.value;
2087
+ if (brokerDeployNeeded(brokerMissingInputs, brokerAppId, desiredAuthAppsRaw, recordedBrokerDeploy)) {
2088
+ await deps.coolify.deploy(brokerAppId);
2089
+ await record(PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, brokerDeployRecordValue(brokerAppId, desiredAuthAppsRaw));
2090
+ }
1401
2091
  // ---- Cloudflare Access, before the hostname resolves ----------------------
1402
2092
  let gateClosed = Boolean(recorded[PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY]);
1403
2093
  const accessAppId = await ensureArchonAccess(deps.cloudflare, hostname, deps.accessEmails, async () => {
@@ -1477,8 +2167,280 @@ async function provisionPlatformArchon(context, deps) {
1477
2167
  // halfway plans again.
1478
2168
  await record(PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, hostname);
1479
2169
  await record(PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, gitRef);
2170
+ // #211 round 4: the broker this run actually registered Archon's entry
2171
+ // with, so `planArchon` can tell a broker recreated under a new app id
2172
+ // (whose AUTH_APPS never carried this registration) from one still holding
2173
+ // it.
2174
+ await record(PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, brokerAppId);
1480
2175
  return { resourceId: appId };
1481
2176
  }
2177
+ // #211 round 6: mirrors `wtfalch/auth/broker/src/apps.ts`'s own `isValidFrom`
2178
+ // (and the `isSafeText`/control-character checks it shares with
2179
+ // `parseCommonAppFields`) exactly, character class for character class.
2180
+ // That module is `@wtfalch/auth-broker`, `private: true`, and exports
2181
+ // neither function, so there is nothing to import -- this has to stay a
2182
+ // faithful copy. Round 5's version only checked that an address sat inside
2183
+ // `<...>`, not the display name in front of it, so `bad<name> <a@b.co>` and
2184
+ // a name carrying a right-to-left override both passed this step's gate and
2185
+ // were then silently dropped by the broker's own parser at boot (`AUTH_APPS
2186
+ // has no usable entries` if it was the only one). Kept in lockstep by hand;
2187
+ // if the broker's rule ever changes, this one has to change with it.
2188
+ const FROM_MAX_LENGTH = 320;
2189
+ const LOCAL_PART = '[A-Za-z0-9._%+-]+';
2190
+ const DOMAIN_LABEL = '[A-Za-z0-9-]+';
2191
+ const SENDER_ADDRESS_RE = new RegExp(`^${LOCAL_PART}@${DOMAIN_LABEL}(?:\\.${DOMAIN_LABEL})*$`);
2192
+ const SENDER_NAME_CHAR = "[\\p{L}\\p{M}\\d .,'&()!\\u2019\\u2013-]";
2193
+ const SENDER_NAME_RE = new RegExp(`^${SENDER_NAME_CHAR}+$`, 'u');
2194
+ const NAMED_FROM_RE = /^(.*)<([^<>]+)>$/;
2195
+ // Every control character (`\p{Cc}`), every format character (`\p{Cf}`, how
2196
+ // an RTL override hides a reordering attack), both Unicode line/paragraph
2197
+ // separators, and the two blanks-in-disguise `trim()` cannot be trusted to
2198
+ // strip first (`\u00a0` NBSP, `\ufeff` BOM).
2199
+ const OTHER_UNSAFE_RE = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\u00a0\ufeff]/u;
2200
+ // #218 round 2: `\p{Cf}` also matches U+200C (ZWNJ) and U+200D (ZWJ), which
2201
+ // a real name can carry -- ZWNJ joins letters into one word in
2202
+ // Persian/Arabic script, ZWJ joins separate emoji into one glyph. Rejecting
2203
+ // every `\p{Cf}` character missed that the broker's own `hasUnsafeText`
2204
+ // exempts both, but only immediately between two letters or two emoji: a
2205
+ // loose one at either end, or beside anything else, is exactly the kind of
2206
+ // invisible character this check exists to catch, same as an RTL override.
2207
+ // Hand-copied from `wtfalch/auth/broker/src/apps.ts`'s own `hasUnsafeText`,
2208
+ // character class for character class, for the same reason the rest of
2209
+ // this block is a copy: that module is `@wtfalch/auth-broker`, `private:
2210
+ // true`.
2211
+ const JOINER_CHARS = new Set(['\u200c', '\u200d']);
2212
+ const LETTER_OR_EMOJI_RE = /^[\p{L}\p{Extended_Pictographic}]$/u;
2213
+ // A real joined sequence is rarely letter-immediately-next-to-joiner on
2214
+ // both sides. Devanagari's explicit half-form writes a virama (a combining
2215
+ // mark, `\p{M}`) before the ZWJ; a multi-part emoji chains a ZWJ onto a
2216
+ // variation selector (U+FE0F) or a skin-tone modifier
2217
+ // (`\p{Emoji_Modifier}`) rather than straight onto the base character. All
2218
+ // of these sit *before* the joiner, never after it, so only the "previous
2219
+ // character" side is widened.
2220
+ const JOINER_PREV_RE = /^(?:[\p{L}\p{M}\p{Extended_Pictographic}]|\u{FE0F}|\p{Emoji_Modifier})$/u;
2221
+ function hasUnsafeText(value) {
2222
+ const chars = Array.from(value);
2223
+ for (let i = 0; i < chars.length; i++) {
2224
+ const ch = chars[i]; // i < chars.length, so this index always exists
2225
+ if (JOINER_CHARS.has(ch)) {
2226
+ const prev = chars[i - 1];
2227
+ const next = chars[i + 1];
2228
+ if (!prev || !next || !JOINER_PREV_RE.test(prev) || !LETTER_OR_EMOJI_RE.test(next)) {
2229
+ return true;
2230
+ }
2231
+ continue;
2232
+ }
2233
+ if (OTHER_UNSAFE_RE.test(ch))
2234
+ return true;
2235
+ }
2236
+ return false;
2237
+ }
2238
+ function isSafeText(value, max) {
2239
+ return value.trim().length > 0 && value.length <= max && !hasUnsafeText(value);
2240
+ }
2241
+ function isValidSenderDisplayName(value) {
2242
+ return value.trim().length > 0 && SENDER_NAME_RE.test(value);
2243
+ }
2244
+ /**
2245
+ * A broker app entry's `from` -- not an RFC 5322 parser, just enough to
2246
+ * catch what the broker itself would refuse or silently drop. Accepts the
2247
+ * two shapes this package itself ever writes (`provisionPlatformArchon`'s
2248
+ * own `archonEntry.from`, `` `Company Included <noreply@${platform.apex}>` ``)
2249
+ * or reads back from a hand-edited `AUTH_APPS`: `Display Name <email@domain>`
2250
+ * (quoted or not) and a bare `email@domain` address.
2251
+ */
2252
+ function isValidSenderFrom(from) {
2253
+ if (from.length === 0 || from.length > FROM_MAX_LENGTH)
2254
+ return false;
2255
+ if (SENDER_ADDRESS_RE.test(from))
2256
+ return true;
2257
+ const named = NAMED_FROM_RE.exec(from);
2258
+ if (!named)
2259
+ return false;
2260
+ const rawName = named[1] ?? '';
2261
+ const address = named[2] ?? '';
2262
+ if (!SENDER_ADDRESS_RE.test(address))
2263
+ return false;
2264
+ if (!isSafeText(rawName, FROM_MAX_LENGTH))
2265
+ return false;
2266
+ const name = rawName.trim();
2267
+ if (name.startsWith('"') && name.endsWith('"') && name.length >= 2) {
2268
+ return isValidSenderDisplayName(name.slice(1, -1));
2269
+ }
2270
+ return isValidSenderDisplayName(name);
2271
+ }
2272
+ /** #211 round 5: the two locales `@wtfalch/mailer`'s `Locale` type restricts a broker app entry's optional `locale` to. */
2273
+ const VALID_BROKER_APP_LOCALES = new Set(['en', 'nb']);
2274
+ /** #211 round 6: matches the broker's own `CLIENT_ID_MAX_LENGTH`. */
2275
+ const BROKER_CLIENT_ID_MAX_LENGTH = 200;
2276
+ /**
2277
+ * #218: matches the broker's own `PRODUCT_NAME_MAX_LENGTH`
2278
+ * (`broker/src/apps.ts`), which it uses as the cap for both `name` and
2279
+ * `productName` -- `parseCommonAppFields`'s own `isSafeText(app.name,
2280
+ * PRODUCT_NAME_MAX_LENGTH)` and `isValidProductName`'s
2281
+ * `isSafeText(value, PRODUCT_NAME_MAX_LENGTH)`.
2282
+ */
2283
+ const BROKER_NAME_MAX_LENGTH = 100;
2284
+ /**
2285
+ * #211 round 5: whether `value` is already in the canonical origin form
2286
+ * `wtfalch/auth/broker/src/apps.ts`'s own `parseApps` requires for an entry's
2287
+ * `origins` -- http(s), and `new URL(value).origin === value` exactly, so no
2288
+ * path, query, trailing slash or differently-cased host survives. `new URL`
2289
+ * throws on an arbitrary string, so that failure is itself "not a URL".
2290
+ */
2291
+ function isCanonicalOrigin(value) {
2292
+ try {
2293
+ const parsed = new URL(value);
2294
+ return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && parsed.origin === value;
2295
+ }
2296
+ catch {
2297
+ return false;
2298
+ }
2299
+ }
2300
+ /**
2301
+ * One `AUTH_APPS[index]` entry, checked the way
2302
+ * `wtfalch/auth/broker/src/apps.ts`'s own `parseApps`/`parseAppFields`
2303
+ * (https://github.com/wtfalch/auth/blob/main/broker/src/apps.ts) check it at
2304
+ * boot, in the same order: a `key` of at least 32 characters and a non-empty
2305
+ * `organizationId`; a `name` that passes the broker's own `isSafeText` rule
2306
+ * (#218); a `productName`, when present, that is a string and also passes it
2307
+ * (#218); a `from` that passes {@link isValidSenderFrom}; a `locale` of
2308
+ * `'en'` or `'nb'` when present; non-blank `clientIds`; and `origins` each in
2309
+ * {@link isCanonicalOrigin} form. #211 round 2: on a bad
2310
+ * entry this throws naming the broker app id and the entry's index, never
2311
+ * the value -- `key` may hold another organisation's secret, and even
2312
+ * `organizationId`/`name` are not this step's to print in an error only an
2313
+ * operator reads. `entry` may be anything at all (`null`, a string, an
2314
+ * array): every check below is a `typeof`/`Array.isArray` guard first, so a
2315
+ * malformed entry never reaches a property read on a non-object and throws a
2316
+ * `TypeError` instead of this named error.
2317
+ */
2318
+ function validateBrokerAppEntry(entry, brokerAppId, index) {
2319
+ const label = `platform:archon: broker app ${brokerAppId}'s AUTH_APPS[${index}]`;
2320
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
2321
+ throw new Error(`${label} is not an object`);
2322
+ }
2323
+ const app = entry;
2324
+ if (typeof app.organizationId !== 'string' || !app.organizationId) {
2325
+ throw new Error(`${label} has no organizationId`);
2326
+ }
2327
+ if (typeof app.key !== 'string' || !app.key) {
2328
+ throw new Error(`${label} has no key`);
2329
+ }
2330
+ if (app.key.length < 32) {
2331
+ throw new Error(`${label} has too short a key`);
2332
+ }
2333
+ if (typeof app.name !== 'string' || !app.name) {
2334
+ throw new Error(`${label} has no name`);
2335
+ }
2336
+ // #218: the broker's own `parseCommonAppFields` runs `name` through
2337
+ // `isSafeText(app.name, PRODUCT_NAME_MAX_LENGTH)` too (`name` stands in for
2338
+ // `productName` in the mail subject whenever an entry declares no
2339
+ // `productName` of its own, so it needs the same safety check) -- this
2340
+ // step's own truthy check above missed a NUL, an RTL override, a line
2341
+ // separator, or a name over 100 characters, all of which this gate reads
2342
+ // back and keeps, and all of which the broker then throws on at boot.
2343
+ if (!isSafeText(app.name, BROKER_NAME_MAX_LENGTH)) {
2344
+ throw new Error(`${label} has an invalid name`);
2345
+ }
2346
+ if (app.productName !== undefined) {
2347
+ // #218: `productName` is optional on the broker's own `App`, but when
2348
+ // present it runs through `isValidProductName`
2349
+ // (`isSafeText(value, PRODUCT_NAME_MAX_LENGTH)`) exactly like `name`
2350
+ // above -- a non-string, a NUL, or one over 100 characters all throw at
2351
+ // the broker's boot the same way.
2352
+ if (typeof app.productName !== 'string') {
2353
+ throw new Error(`${label} has a non-string productName`);
2354
+ }
2355
+ if (!isSafeText(app.productName, BROKER_NAME_MAX_LENGTH)) {
2356
+ throw new Error(`${label} has an invalid productName`);
2357
+ }
2358
+ }
2359
+ if (typeof app.from !== 'string' || !app.from) {
2360
+ throw new Error(`${label} has no from`);
2361
+ }
2362
+ if (!isValidSenderFrom(app.from)) {
2363
+ throw new Error(`${label} has an invalid from`);
2364
+ }
2365
+ if (app.locale !== undefined && !VALID_BROKER_APP_LOCALES.has(app.locale)) {
2366
+ throw new Error(`${label} has an invalid locale`);
2367
+ }
2368
+ if (!Array.isArray(app.clientIds) || app.clientIds.length === 0) {
2369
+ throw new Error(`${label} has no clientIds`);
2370
+ }
2371
+ if (app.clientIds.some((id) => typeof id !== 'string' || id.trim() === '')) {
2372
+ throw new Error(`${label} has a blank clientIds entry`);
2373
+ }
2374
+ // #211 round 6: `clientIds:["a\u0000b"]` passed this gate before -- a
2375
+ // control character is not blank, so the check above missed it, and the
2376
+ // broker's own `parseCommonAppFields` throws on it at boot
2377
+ // (`isSafeText(candidate, CLIENT_ID_MAX_LENGTH)`).
2378
+ if (app.clientIds.some((id) => typeof id === 'string' && !isSafeText(id, BROKER_CLIENT_ID_MAX_LENGTH))) {
2379
+ throw new Error(`${label} has an invalid clientIds entry`);
2380
+ }
2381
+ if (!Array.isArray(app.origins) || app.origins.length === 0) {
2382
+ throw new Error(`${label} has no origins`);
2383
+ }
2384
+ if (app.origins.some((origin) => typeof origin !== 'string' || !isCanonicalOrigin(origin))) {
2385
+ throw new Error(`${label} has an invalid origins entry`);
2386
+ }
2387
+ return app;
2388
+ }
2389
+ /**
2390
+ * The broker's current `AUTH_APPS` (#204), parsed back so an entry can be
2391
+ * merged without losing whichever other organisations -- or other apps of
2392
+ * this same organisation -- have registered. Empty only for a broker that
2393
+ * has never had one set. #211: an *unparseable* value (bad JSON, or JSON
2394
+ * that is not an array) is never treated as empty -- the write that follows
2395
+ * replaces the whole env, so silently reading it as `[]` would drop every
2396
+ * other organisation's entry the moment this run cannot parse what is
2397
+ * there. Throws instead, naming the broker app id (the app holding the
2398
+ * unreadable value), never the value itself -- it may hold another
2399
+ * organisation's key. #211 round 2: each entry is validated the same way
2400
+ * the broker itself validates `AUTH_APPS` at boot ({@link validateBrokerAppEntry}),
2401
+ * so a bad entry is caught here, by this step, rather than only discovered
2402
+ * once the broker refuses to start on it.
2403
+ */
2404
+ async function readBrokerApps(coolify, brokerAppId) {
2405
+ const envs = await coolify.listEnvs(brokerAppId);
2406
+ const raw = envs.find((e) => e.key === 'AUTH_APPS' && !e.is_preview)?.value;
2407
+ if (!raw)
2408
+ return [];
2409
+ let parsed;
2410
+ try {
2411
+ parsed = JSON.parse(raw);
2412
+ }
2413
+ catch {
2414
+ throw new Error(`platform:archon: broker app ${brokerAppId} has an AUTH_APPS that is not JSON`);
2415
+ }
2416
+ if (!Array.isArray(parsed)) {
2417
+ throw new Error(`platform:archon: broker app ${brokerAppId} has an AUTH_APPS that is not a JSON array`);
2418
+ }
2419
+ return parsed.map((entry, index) => validateBrokerAppEntry(entry, brokerAppId, index));
2420
+ }
2421
+ /**
2422
+ * #213: puts {@link PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF},
2423
+ * {@link PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF} and
2424
+ * {@link PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF} as held keys, under
2425
+ * tenant `platform`, so Archon's own onboarding can read them back once its
2426
+ * app is live. Each ref is resolved first; `put` runs only when that comes
2427
+ * back empty, so an entry already held (put by an earlier apply, or by hand)
2428
+ * is left alone and never overwritten. Values never reach a log, a record or
2429
+ * an error: `heldKeys.put`'s own contract, mirroring `deliverSecret`'s.
2430
+ */
2431
+ async function putArchonMasterKeys(heldKeys, masterKeys) {
2432
+ const entries = [
2433
+ [PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, masterKeys.coolifyUrl],
2434
+ [PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, masterKeys.coolifyToken],
2435
+ [PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, masterKeys.cloudflareParentToken],
2436
+ ];
2437
+ for (const [ref, value] of entries) {
2438
+ const existing = await heldKeys.resolve(ref);
2439
+ if (existing)
2440
+ continue;
2441
+ await heldKeys.put?.(ref, value);
2442
+ }
2443
+ }
1482
2444
  /**
1483
2445
  * `executor.ts`'s `cloudflareAccess`, for the platform's Archon: reuses only
1484
2446
  * an Access app named {@link PLATFORM_ARCHON_ACCESS_APP_NAME}, refuses to
@@ -1558,6 +2520,33 @@ async function archonAccessDrifted(cf, domain, emails) {
1558
2520
  return false;
1559
2521
  }
1560
2522
  }
2523
+ /**
2524
+ * #211 round 4: whether the broker Archon last registered with (its
2525
+ * `AUTH_APPS`) still carries an entry for Archon's organisation.
2526
+ * `planArchon`'s own recorded-id check
2527
+ * ({@link PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY} against
2528
+ * {@link PLATFORM_AUTH_BROKER_SCOPE_KEY}) catches the broker itself being
2529
+ * recreated under a new app id; this catches the same broker losing the
2530
+ * entry some other way -- a hand edit of `AUTH_APPS`, a previous write that
2531
+ * silently failed. A failed read is not drift, the same rule
2532
+ * {@link archonAccessDrifted} follows: the next plan reads again. Missing
2533
+ * `recordedIds` (no broker recorded yet, or Archon's own ZITADEL org not yet
2534
+ * recorded) is not drift either -- `planArchon`'s other checks already cover
2535
+ * a step that has never applied.
2536
+ */
2537
+ async function archonBrokerRegistrationDrifted(coolify, recordedIds) {
2538
+ const brokerAppId = recordedIds[PLATFORM_AUTH_BROKER_SCOPE_KEY];
2539
+ const orgId = recordedIds[PLATFORM_ZITADEL_ORG_SCOPE_KEY];
2540
+ if (!brokerAppId || !orgId)
2541
+ return false;
2542
+ try {
2543
+ const apps = await readBrokerApps(coolify, brokerAppId);
2544
+ return !apps.some((a) => a.organizationId === orgId && a.name === PLATFORM_ARCHON_ZITADEL_APP);
2545
+ }
2546
+ catch {
2547
+ return false;
2548
+ }
2549
+ }
1561
2550
  /**
1562
2551
  * What Archon reads from each enabled service, gathered before anything is
1563
2552
  * built. Either everything is there and the env values come back, or the first
@@ -1633,10 +2622,34 @@ async function collectArchonServiceInputs(platform, deps, recorded) {
1633
2622
  * `base64url` fits `db-runtime-role.sh`'s password alphabet
1634
2623
  * (`A-Za-z0-9_.~-`).
1635
2624
  */
2625
+ /**
2626
+ * #265: throws when `liveUrl` points at another host than the adopted
2627
+ * database. Names both hosts and nothing else: a URL carries a credential, and
2628
+ * Node's parse error echoes its whole input.
2629
+ */
2630
+ function assertSameDatabaseHost(key, liveUrl, dbInternalUrl) {
2631
+ const hostOf = (url) => {
2632
+ try {
2633
+ return new URL(url).host;
2634
+ }
2635
+ catch {
2636
+ return null;
2637
+ }
2638
+ };
2639
+ const liveHost = hostOf(liveUrl);
2640
+ const dbHost = hostOf(dbInternalUrl);
2641
+ if (liveHost !== dbHost) {
2642
+ throw new Error(`platform:archon: ${key} on the app points at host ${liveHost ?? '(unparseable)'}, but the adopted database ${PLATFORM_ARCHON_DB_NAME} is at host ${dbHost ?? '(unparseable)'}; refusing to leave the app's database URLs on different databases`);
2643
+ }
2644
+ }
1636
2645
  async function ensureArchonRuntimeUrl(coolify, appId, dbInternalUrl) {
1637
2646
  const envs = await coolify.listEnvs(appId);
1638
- if (envs.some((e) => e.key === 'DATABASE_URL' && !e.is_preview))
2647
+ const live = envs.find((e) => e.key === 'DATABASE_URL' && !e.is_preview);
2648
+ if (live) {
2649
+ // #265: kept as it is, but it must name the adopted database's host.
2650
+ assertSameDatabaseHost('DATABASE_URL', live.value, dbInternalUrl);
1639
2651
  return null;
2652
+ }
1640
2653
  let host;
1641
2654
  let pathname;
1642
2655
  let search;
@@ -1651,22 +2664,213 @@ async function ensureArchonRuntimeUrl(coolify, appId, dbInternalUrl) {
1651
2664
  const password = randomBytes(24).toString('base64url');
1652
2665
  return `postgres://${ARCHON_RUNTIME_ROLE}:${password}@${host}${pathname}${search}`;
1653
2666
  }
2667
+ /**
2668
+ * Thrown by {@link createAiManagementKeyMinter} (never by a test's own
2669
+ * injected minter, unless it chooses to) when ai's `POST /v1/platform/keys`
2670
+ * answers 200: a live `archon` key already exists and no secret comes back.
2671
+ * See {@link PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY}.
2672
+ */
2673
+ export class AiManagementKeyExistsError extends Error {
2674
+ credentialId;
2675
+ organisationId;
2676
+ constructor(credentialId, organisationId) {
2677
+ super(`ai already holds a management key named 'archon' (${credentialId}) with no secret to show`);
2678
+ this.credentialId = credentialId;
2679
+ this.organisationId = organisationId;
2680
+ }
2681
+ }
2682
+ /**
2683
+ * Thrown by {@link createAiManagementKeyMinter} on a 409: a live `archon` key
2684
+ * already exists with different permissions or expiry than this request
2685
+ * asked for. ai never overwrites it, so this is never retried under another
2686
+ * name -- an operator revokes the live key in ai first.
2687
+ */
2688
+ export class AiManagementKeyConflictError extends Error {
2689
+ constructor() {
2690
+ super("ai already holds a live 'archon' key minted with different permissions or expiry; revoke it in ai, then apply again");
2691
+ }
2692
+ }
2693
+ /**
2694
+ * Thrown by {@link createAiManagementKeyMinter} on a 401 or 403: ai does not
2695
+ * trust foundry's signing key yet for this request. Most likely the runner
2696
+ * has not redeployed since {@link AI_SIGNING_KEY_REF}'s public half was
2697
+ * pinned as `MANAGEMENT_PUBLIC_KEYS`/`MANAGEMENT_ISSUER` -- a Coolify env
2698
+ * change reaches the container only on its next deploy.
2699
+ */
2700
+ export class AiManagementKeyUntrustedError extends Error {
2701
+ status;
2702
+ constructor(status) {
2703
+ 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`);
2704
+ this.status = status;
2705
+ }
2706
+ }
2707
+ /**
2708
+ * Both response bodies {@link createAiManagementKeyMinter} reads are parsed
2709
+ * rather than cast: a malformed body (an empty 201, a 200 with no
2710
+ * `credentialId`) must fail the mint outright rather than record an
2711
+ * `undefined` id or hand `deliverSecret` an `undefined` "secret". Neither
2712
+ * error message repeats the body, which could hold the very secret this
2713
+ * function must not leak.
2714
+ */
2715
+ function parseAiKeyExistsBody(body) {
2716
+ const record = body;
2717
+ if (typeof record !== 'object' ||
2718
+ record === null ||
2719
+ typeof record.credentialId !== 'string' ||
2720
+ typeof record.organisationId !== 'string') {
2721
+ throw new Error("ai's platform/keys route answered 200 with a body missing credentialId/organisationId");
2722
+ }
2723
+ return { credentialId: record.credentialId, organisationId: record.organisationId };
2724
+ }
2725
+ function parseAiKeyMintedBody(body) {
2726
+ const record = body;
2727
+ if (typeof record !== 'object' ||
2728
+ record === null ||
2729
+ typeof record.credentialId !== 'string' ||
2730
+ typeof record.organisationId !== 'string' ||
2731
+ typeof record.secret !== 'string') {
2732
+ throw new Error("ai's platform/keys route answered 201 with a body missing credentialId/organisationId/secret");
2733
+ }
2734
+ return {
2735
+ credentialId: record.credentialId,
2736
+ organisationId: record.organisationId,
2737
+ secret: record.secret,
2738
+ };
2739
+ }
1654
2740
  /** The ref the AI management key is delivered under; the host's `AiOrganisationPort` adapter reads it back. */
1655
2741
  export const PLATFORM_AI_MANAGEMENT_KEY_REF = 'ai:platform:managementKey';
1656
2742
  /**
1657
- * What foundry's management key may do: exactly the grants `ai`'s README
1658
- * ("Archon's first key", by-hand form) gives archon's key, which are the
1659
- * ones `ai:organisation`, `ai:budget`, `ai:key` and the org destroy call.
2743
+ * What Archon's management key may do in ai: exactly `ai`'s own
2744
+ * `ARCHON_AI_PERMISSIONS` (`apps/host/scripts/authz/bootstrap-archon.ts`) --
2745
+ * configuration only (offerings, provisioning, offers, budgets, usage, cost)
2746
+ * plus deleting (archiving) an organisation, never connections, runs or
2747
+ * webhooks. ai's `/v1/platform/keys` route refuses any permission outside
2748
+ * this same list, so a request for more than this is a 403, not a wider key.
1660
2749
  */
1661
2750
  export const PLATFORM_AI_MANAGEMENT_PERMISSIONS = [
2751
+ 'ai.offerings:read',
2752
+ 'ai.offerings:create',
2753
+ 'ai.offerings:update',
2754
+ 'ai.offerings:disable',
1662
2755
  'ai.organisations:provision',
1663
2756
  'ai.organisations:offer',
1664
2757
  'ai.organisations:delete',
1665
2758
  'ai.keys:issue',
2759
+ 'ai.budgets:read',
1666
2760
  'ai.budgets:update',
2761
+ 'ai.usage:read',
2762
+ 'ai.cost:read',
1667
2763
  ];
1668
- /** The message {@link provisionPlatformAi} throws when no {@link AiManagementKeyMinter} was supplied. */
1669
- export const PLATFORM_AI_MINTER_MISSING = "platform:ai: the AI host is deployed but foundry's AI management key cannot be minted: no AiManagementKeyMinter was supplied (PlatformAiDeps.managementKeyMinter). ai mints its first platform credential only through its keys:issue-archon script run against the database (apps/host/scripts/authz/bootstrap-archon.ts); it has no HTTP route for that, and CoolifyPort cannot run a command inside a container (#172)";
2764
+ /** 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. */
2765
+ export const AI_SIGNING_KEY_REF = 'ai:platform:signingPrivateKey';
2766
+ /** `MANAGEMENT_ISSUER` on the ai runner: whoever signs with the delivered key signs as this issuer. */
2767
+ export const AI_MANAGEMENT_ISSUER = 'foundry';
2768
+ /** 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. */
2769
+ export const AI_MANAGEMENT_KEY_ID = 'foundry-1';
2770
+ /**
2771
+ * ai's fixed application/platform identifier
2772
+ * (`apps/host/src/lib/authz/policy-catalogue.ts`'s `PLATFORM_ID`), bound
2773
+ * into every signed assertion's `platform` claim. foundry does not depend on
2774
+ * that app, so the value is mirrored here rather than imported; a mismatch
2775
+ * makes ai's `verifyManagementRequest` refuse every request.
2776
+ */
2777
+ const AI_MANAGEMENT_PLATFORM_ID = 'wtfalch';
2778
+ /** The header ai's `service-assertion.ts` (`MANAGEMENT_HEADER`) reads the signed assertion from. */
2779
+ const AI_MANAGEMENT_HEADER = 'x-wtfalch-management';
2780
+ /** `service-assertion.ts`'s own signed-message prefix; the signature covers exactly `PREFIX + message`. */
2781
+ const AI_MANAGEMENT_ASSERTION_PREFIX = 'wtfalch.service-request.v1\n';
2782
+ /** `service-assertion.ts`'s own token TTL, in seconds. */
2783
+ const AI_MANAGEMENT_ASSERTION_TTL_SECONDS = 30;
2784
+ /**
2785
+ * ai's signed-assertion `organisation` claim is required and validated as a
2786
+ * uuid, but unused for tenant selection on the platform/keys route
2787
+ * (`platform-keys-endpoint.ts`'s own comment: "callers may sign it as the
2788
+ * nil uuid"). There is no organisation to name before one exists.
2789
+ */
2790
+ const AI_MANAGEMENT_NIL_ORGANISATION = '00000000-0000-0000-0000-000000000000';
2791
+ /** ai's platform/keys route, relative to its internal address. */
2792
+ const AI_PLATFORM_KEYS_PATH = '/v1/platform/keys';
2793
+ /**
2794
+ * Signs one request to ai's `POST /v1/platform/keys` the way ai's own
2795
+ * `service-assertion.ts` (`signManagementRequest`) does. foundry does not
2796
+ * depend on that app's code, so the wire format -- header, prefix, claim
2797
+ * shape, digest -- is reproduced here exactly, rather than imported, so
2798
+ * ai's `verifyManagementRequest` accepts it unmodified.
2799
+ */
2800
+ function signAiManagementRequest(input) {
2801
+ const digest = createHash('sha256').update(input.body).digest('hex');
2802
+ const issued = Math.floor(input.now() / 1000);
2803
+ const claims = {
2804
+ version: 1,
2805
+ issuer: input.issuer,
2806
+ audience: input.audience,
2807
+ platform: AI_MANAGEMENT_PLATFORM_ID,
2808
+ subject: input.issuer,
2809
+ organisation: AI_MANAGEMENT_NIL_ORGANISATION,
2810
+ method: 'POST',
2811
+ target: input.target,
2812
+ contentType: input.contentType,
2813
+ digest,
2814
+ issued,
2815
+ expires: issued + AI_MANAGEMENT_ASSERTION_TTL_SECONDS,
2816
+ nonce: randomUUID(),
2817
+ };
2818
+ const payload = Buffer.from(JSON.stringify(claims)).toString('base64url');
2819
+ const message = `${input.keyId}.${payload}`;
2820
+ const signature = sign(null, Buffer.from(AI_MANAGEMENT_ASSERTION_PREFIX + message), input.privateKey).toString('base64url');
2821
+ return `${message}.${signature}`;
2822
+ }
2823
+ /**
2824
+ * The real {@link AiManagementKeyMinter}: signs a management assertion with
2825
+ * `privateKeyPem` (the PKCS#8 PEM {@link provisionPlatformAi} delivers under
2826
+ * {@link AI_SIGNING_KEY_REF} and pins on the runner under `keyId`) and calls
2827
+ * ai's `POST /v1/platform/keys`. `audience` must be the exact origin ai's own
2828
+ * `APP_URL` resolves to (`https://<platform.ai.hostname>`), since ai checks
2829
+ * the claim against that env var, not against the internal address the
2830
+ * request itself is sent to.
2831
+ */
2832
+ export function createAiManagementKeyMinter(options) {
2833
+ const fetchImpl = options.fetchImpl ?? fetch;
2834
+ const now = options.now ?? Date.now;
2835
+ const privateKey = createPrivateKey(options.privateKeyPem);
2836
+ return {
2837
+ async mintManagementKey({ internalUrl, name, expiresDays, permissions }) {
2838
+ const contentType = 'application/json';
2839
+ const body = JSON.stringify({ name, expiresDays, permissions });
2840
+ const token = signAiManagementRequest({
2841
+ target: AI_PLATFORM_KEYS_PATH,
2842
+ contentType,
2843
+ body,
2844
+ issuer: options.issuer,
2845
+ audience: options.audience,
2846
+ keyId: options.keyId,
2847
+ privateKey,
2848
+ now,
2849
+ });
2850
+ const response = await fetchImpl(`${internalUrl}${AI_PLATFORM_KEYS_PATH}`, {
2851
+ method: 'POST',
2852
+ headers: { 'content-type': contentType, [AI_MANAGEMENT_HEADER]: token },
2853
+ body,
2854
+ // A hung ai must not hold the apply open.
2855
+ signal: AbortSignal.timeout(30_000),
2856
+ });
2857
+ if (response.status === 200) {
2858
+ const existing = parseAiKeyExistsBody(await response.json());
2859
+ throw new AiManagementKeyExistsError(existing.credentialId, existing.organisationId);
2860
+ }
2861
+ if (response.status === 409) {
2862
+ throw new AiManagementKeyConflictError();
2863
+ }
2864
+ if (response.status === 401 || response.status === 403) {
2865
+ throw new AiManagementKeyUntrustedError(response.status);
2866
+ }
2867
+ if (response.status !== 201) {
2868
+ throw new Error(`ai's platform/keys route answered ${response.status}`);
2869
+ }
2870
+ return parseAiKeyMintedBody(await response.json());
2871
+ },
2872
+ };
2873
+ }
1670
2874
  const PLATFORM_AI_PROJECT = 'platform';
1671
2875
  const PLATFORM_AI_REPO = 'wtfalch/ai';
1672
2876
  const PLATFORM_AI_DB_NAME = 'ai-db';
@@ -1761,22 +2965,22 @@ const PLATFORM_AI_WORKER_DEFAULTS = [
1761
2965
  * put in an error. The step's resource id is returned only after that, so
1762
2966
  * the planner keeps planning the step until the key is delivered.
1763
2967
  *
2968
+ * #201 built the real minter: ai's `POST /v1/platform/keys`
2969
+ * (github.com/wtfalch/ai/issues/141) mints the `archon` key over HTTP, signed
2970
+ * with an Ed25519 pair this function mints and pins on the runner once
2971
+ * ({@link ensureAiSigningKey}), the same two-key discipline as the
2972
+ * management key itself. A test may still inject a different
2973
+ * {@link AiManagementKeyMinter} in {@link PlatformAiDeps.managementKeyMinter},
2974
+ * which skips the signing-key machinery entirely.
2975
+ *
1764
2976
  * Known gaps, left for review (#172 stays open for review):
1765
- * - No minter ships, so the step is blocked in the planner (nothing is
1766
- * deployed) until a host supplies an {@link AiManagementKeyMinter}. It
1767
- * cannot be built over ai's admin API: ai issues its first platform-tenant
1768
- * credential only from `apps/host/scripts/authz/bootstrap-archon.ts`
1769
- * (`keys:issue-archon`), a script that writes to the database directly and
1770
- * needs `AUTHZ_BOOTSTRAP_OPERATOR` and the `KEYS_*` settings. Its HTTP
1771
- * routes cannot bootstrap one: `POST /v1/organisations/{id}/keys` needs an
1772
- * existing `ai.keys:issue` key and refuses the platform tenant, and a
1773
- * signed management proof needs a human who already holds authority there.
1774
- * `CoolifyPort` has no call that runs a command in a container.
1775
2977
  * - Crash window: the credential id is recorded only after the minter
1776
2978
  * returns, so a crash between the mint and that record loses a key nobody
1777
- * knows the id of. ai has no name or idempotency key on that path to find
1778
- * it again by; {@link AiManagementKeyMinter} therefore requires a minter to
1779
- * look up an existing key named `foundry` and return it before issuing one.
2979
+ * knows the id of. ai is idempotent by name, though, so the next apply's
2980
+ * mint call reaches the same live `archon` key rather than a second one --
2981
+ * it just cannot get the secret back, since ai shows it once. That is
2982
+ * reported as {@link PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY}, never
2983
+ * minted under another name.
1780
2984
  * - Coolify's `dockerfile_target_build` and `is_auto_deploy_enabled` are set
1781
2985
  * through the generic application PATCH; neither has been checked against
1782
2986
  * a live instance from this package.
@@ -1890,12 +3094,19 @@ async function provisionPlatformAi(context, deps) {
1890
3094
  // deployed with. Marked and persisted before the first write, and cleared
1891
3095
  // only after the deploys below, so a run that fails halfway (the migrate
1892
3096
  // wait throwing, for one) leaves the mark and the next run deploys again.
3097
+ // #201: pin foundry's signing key before the deploy decision below, so the
3098
+ // runner starts with MANAGEMENT_ISSUER/MANAGEMENT_PUBLIC_KEYS: Coolify env
3099
+ // changes reach a container only on a redeploy. Only the real minter needs
3100
+ // it; an injected one answers for its own authentication.
3101
+ const signingKeyPem = !recorded[PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY] && !deps.managementKeyMinter
3102
+ ? await ensureAiSigningKey(context, deps, runner.appId, runtime, sleep, record)
3103
+ : undefined;
1893
3104
  const migrateChanged = await envsDiffer(coolify, migrate.appId, migrateEnvs);
1894
3105
  const runnerChanged = await envsDiffer(coolify, runner.appId, runnerEnvs);
1895
3106
  const workerChanged = await envsDiffer(coolify, worker.appId, workerEnvs);
1896
3107
  const declaredChanged = recorded[PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY] !== hostname ||
1897
3108
  recorded[PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY] !== gitRef;
1898
- const pending = Boolean(recorded[PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY]) ||
3109
+ const pending = Boolean(context.recordedIds()[PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY]) ||
1899
3110
  migrateChanged ||
1900
3111
  runnerChanged ||
1901
3112
  workerChanged ||
@@ -1945,28 +3156,50 @@ async function provisionPlatformAi(context, deps) {
1945
3156
  await record(PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, gitRef);
1946
3157
  const internalUrl = `http://${PLATFORM_AI_RUNNER.internalName}:${PLATFORM_AI_PORT}`;
1947
3158
  await record(PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, internalUrl);
1948
- // ---- foundry's AI management key, once -----------------------------------
3159
+ // ---- Archon's AI management key, once ------------------------------------
1949
3160
  const now = context.recordedIds();
1950
3161
  if (!now[PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY]) {
1951
3162
  const lost = now[PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY];
1952
3163
  if (lost) {
1953
3164
  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`);
1954
3165
  }
1955
- const minter = deps.managementKeyMinter;
1956
- // The planner blocks the step while there is no minter; this is for a
1957
- // caller that reaches the step anyway.
1958
- if (!minter)
1959
- throw new Error(PLATFORM_AI_MINTER_MISSING);
3166
+ // #201: a test injects its own minter; without one, build the real one
3167
+ // over ai's own POST /v1/platform/keys, signed with the key pinned above.
3168
+ const minter = deps.managementKeyMinter ??
3169
+ createAiManagementKeyMinter({
3170
+ privateKeyPem: signingKeyPem,
3171
+ keyId: AI_MANAGEMENT_KEY_ID,
3172
+ issuer: AI_MANAGEMENT_ISSUER,
3173
+ audience: `https://${hostname}`,
3174
+ fetchImpl: deps.fetchImpl,
3175
+ });
1960
3176
  let minted;
1961
3177
  try {
1962
3178
  minted = await minter.mintManagementKey({
1963
3179
  internalUrl,
1964
- name: 'foundry',
3180
+ name: 'archon',
1965
3181
  expiresDays: 90,
1966
3182
  permissions: PLATFORM_AI_MANAGEMENT_PERMISSIONS,
1967
3183
  });
1968
3184
  }
1969
- catch {
3185
+ catch (error) {
3186
+ if (error instanceof AiManagementKeyExistsError) {
3187
+ // ai already holds an 'archon' key this record never learned about
3188
+ // (most likely the crash window below's own comment describes,
3189
+ // happening one mint earlier): its secret cannot be shown again, so
3190
+ // this is reported and never retried under another name.
3191
+ await record(PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, error.credentialId);
3192
+ 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`);
3193
+ }
3194
+ // Both messages below are fixed strings with no interpolated body, so
3195
+ // safe to surface directly -- unlike the generic case, neither can
3196
+ // ever echo a credential the minter read off the wire.
3197
+ if (error instanceof AiManagementKeyConflictError) {
3198
+ throw new Error(`platform:ai: ${error.message}`);
3199
+ }
3200
+ if (error instanceof AiManagementKeyUntrustedError) {
3201
+ throw new Error(`platform:ai: ${error.message}`);
3202
+ }
1970
3203
  // The cause is dropped in case it echoes a credential.
1971
3204
  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');
1972
3205
  }
@@ -1991,6 +3224,64 @@ async function provisionPlatformAi(context, deps) {
1991
3224
  }
1992
3225
  return { resourceId: runner.appId };
1993
3226
  }
3227
+ /**
3228
+ * #201: ensures foundry's Ed25519 signing pair for ai's `POST
3229
+ * /v1/platform/keys` exists, and returns the private half (PKCS#8 PEM)
3230
+ * needed to sign the mint call that follows -- freshly minted this run, or
3231
+ * read back via `deps.heldKeys` when an earlier run already delivered and
3232
+ * pinned it but crashed before the management key itself was minted and
3233
+ * delivered. Follows `platform:integrations`'s own two-key discipline
3234
+ * exactly ({@link INTEGRATIONS_MANAGEMENT_KEY_REF}/`_ISSUER`/`_KEY_ID`): the
3235
+ * id is recorded and persisted before the private half is delivered, the
3236
+ * guard only once delivery and the pin on the runner both succeeded, so a
3237
+ * crash in between never mints a second pair.
3238
+ */
3239
+ async function ensureAiSigningKey(context, deps, runnerAppId, runtime, sleep, record) {
3240
+ if (context.recordedIds()[PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY]) {
3241
+ if (!deps.heldKeys) {
3242
+ 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)');
3243
+ }
3244
+ const held = await deps.heldKeys.resolve(AI_SIGNING_KEY_REF);
3245
+ if (!held) {
3246
+ 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})`);
3247
+ }
3248
+ return held;
3249
+ }
3250
+ if (context.recordedIds()[PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY]) {
3251
+ 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`);
3252
+ }
3253
+ const { publicKey, privateKey } = generateKeyPairSync('ed25519', {
3254
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
3255
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
3256
+ });
3257
+ await record(PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, AI_MANAGEMENT_KEY_ID);
3258
+ const attempts = runtime.deliverAttempts ?? 3;
3259
+ for (let attempt = 1;; attempt += 1) {
3260
+ try {
3261
+ await deps.deliverSecret(AI_SIGNING_KEY_REF, privateKey);
3262
+ break;
3263
+ }
3264
+ catch {
3265
+ // The cause is dropped in case it echoes the key.
3266
+ if (attempt >= attempts) {
3267
+ throw new Error(`platform:ai: signing key ${AI_MANAGEMENT_KEY_ID} was minted but could not be delivered after ${attempts} attempts`);
3268
+ }
3269
+ await sleep(1000 * attempt);
3270
+ }
3271
+ }
3272
+ // The runner reads these only after a redeploy: mark it pending first, so
3273
+ // a crash between pinning and deploying still redeploys on the next apply.
3274
+ await record(PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, '1');
3275
+ await deps.coolify.bulkUpsertEnvs(runnerAppId, [
3276
+ { key: 'MANAGEMENT_ISSUER', value: AI_MANAGEMENT_ISSUER },
3277
+ {
3278
+ key: 'MANAGEMENT_PUBLIC_KEYS',
3279
+ value: JSON.stringify({ [AI_MANAGEMENT_KEY_ID]: publicKey }),
3280
+ },
3281
+ ]);
3282
+ await record(PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, runnerAppId);
3283
+ return privateKey;
3284
+ }
1994
3285
  /** The migrate job did not turn healthy in time; carries its last status, which holds no secret. */
1995
3286
  class MigrationNotHealthyError extends Error {
1996
3287
  lastStatus;
@@ -2126,6 +3417,123 @@ async function findOrCreatePlatformApp(coolify, name, environment, create) {
2126
3417
  const created = await create();
2127
3418
  return { appId: created.uuid, justCreated: true, running: false };
2128
3419
  }
3420
+ /**
3421
+ * #211: names of the envs the platform's sign-in broker (`wtfalch/auth`'s
3422
+ * `broker/server.ts`) refuses to start without, that `provisionPlatformAuth`
3423
+ * itself has no way to supply yet -- `ZITADEL_SERVICE_USER_TOKEN`/`_FILE`
3424
+ * (#209 mints and pushes this once it lands) and `RESEND_API_TOKEN` (#210,
3425
+ * blocked on `wtfalch/auth#138`). Read from the broker's own live env, not a
3426
+ * recorded scope id: whichever of #209/#210 lands first (or a human, setting
3427
+ * either directly on the app) is enough to clear that one input, in either
3428
+ * order, with no coupling to this step's own execution.
3429
+ *
3430
+ * #211 round 3: `AUTH_APPS` is a third required input -- the broker's own
3431
+ * `parseApps` refuses to start with no `AUTH_APPS` set or an empty array
3432
+ * (`wtfalch/auth/broker/src/apps.ts`), and this check used to name only the
3433
+ * other two, so a deploy could go ahead with a broker that still would not
3434
+ * come up. Also treat `null`/`undefined` as missing, not only `''`: the
3435
+ * `CoolifyPort.listEnvs` doc comment already warns a live value does not
3436
+ * always match its declared `string` type.
3437
+ */
3438
+ async function missingBrokerInputs(coolify, brokerAppId) {
3439
+ const envs = await coolify.listEnvs(brokerAppId);
3440
+ const value = (key) => envs.find((e) => e.key === key && !e.is_preview && e.value != null && e.value !== '')?.value;
3441
+ const has = (key) => value(key) !== undefined;
3442
+ const missing = [];
3443
+ if (!has('ZITADEL_SERVICE_USER_TOKEN') && !has('ZITADEL_SERVICE_USER_TOKEN_FILE')) {
3444
+ missing.push('ZITADEL_SERVICE_USER_TOKEN');
3445
+ }
3446
+ if (!has('RESEND_API_TOKEN'))
3447
+ missing.push('RESEND_API_TOKEN');
3448
+ if (authAppsProblem(value('AUTH_APPS'), brokerAppId) !== null)
3449
+ missing.push('AUTH_APPS');
3450
+ return missing;
3451
+ }
3452
+ /**
3453
+ * #211 round 5 addendum: why a live `AUTH_APPS` value would not pass the
3454
+ * broker's own `parseApps` at boot, distinguishing `'missing'` (nothing set,
3455
+ * or an empty array -- nobody has registered anything yet) from
3456
+ * `'malformed'` (something is set and it is wrong: bad JSON, not an array, or
3457
+ * an entry {@link validateBrokerAppEntry} itself refuses). `hasNonEmptyAuthApps`
3458
+ * (#211 round 4) collapsed both into one `false`, so `missingBrokerInputs`
3459
+ * (and, through it, `authBrokerBlockedReason`) always reported "it has no
3460
+ * ... AUTH_APPS yet" even when `AUTH_APPS` held garbage a person needs to go
3461
+ * fix, rather than an input nobody has supplied yet -- `authBrokerBlockedReason`
3462
+ * below is what turns this distinction into different wording. `null` when
3463
+ * the value is valid. A bad entry's own error is discarded here too: this
3464
+ * function only classifies the problem, and the named error still surfaces
3465
+ * wherever `readBrokerApps` is actually called.
3466
+ */
3467
+ function authAppsProblem(raw, brokerAppId) {
3468
+ if (raw === undefined || raw === '')
3469
+ return 'missing';
3470
+ let parsed;
3471
+ try {
3472
+ parsed = JSON.parse(raw);
3473
+ }
3474
+ catch {
3475
+ return 'malformed';
3476
+ }
3477
+ if (!Array.isArray(parsed))
3478
+ return 'malformed';
3479
+ if (parsed.length === 0)
3480
+ return 'missing';
3481
+ try {
3482
+ parsed.forEach((entry, index) => validateBrokerAppEntry(entry, brokerAppId, index));
3483
+ }
3484
+ catch {
3485
+ return 'malformed';
3486
+ }
3487
+ return null;
3488
+ }
3489
+ /**
3490
+ * #211 round 5: sha256 of the exact `AUTH_APPS` string a {@link BrokerDeployRecord}
3491
+ * names -- never the value itself, only its hash, so a recorded scope id can
3492
+ * never leak another organisation's broker key. `undefined` (no `AUTH_APPS`
3493
+ * set) hashes the same as `''`, both meaning "nothing deployed yet".
3494
+ */
3495
+ function hashAuthApps(raw) {
3496
+ return createHash('sha256')
3497
+ .update(raw ?? '')
3498
+ .digest('hex');
3499
+ }
3500
+ /**
3501
+ * #211 round 5: the one rule `provisionPlatformAuth` and
3502
+ * `provisionPlatformArchon` both call to decide whether the broker needs a
3503
+ * (re)deploy -- replacing the two ad hoc "was it ever ostensibly deployed"
3504
+ * booleans each function used to keep, which is what let a recreated broker,
3505
+ * and a crash between writing `AUTH_APPS` and deploying, both go unnoticed
3506
+ * (the round 5 issue text's own diagnosis: "they share one cause"). `false`
3507
+ * while any required input is still missing -- deploying a broker that
3508
+ * cannot start is pointless regardless of what changed. Otherwise `true`
3509
+ * when nothing has ever been recorded, when the broker that was deployed is
3510
+ * a different app (recreated under a new id), or when the `AUTH_APPS` that
3511
+ * was deployed is not the `AUTH_APPS` that is live/about-to-be-live now.
3512
+ * `authAppsRaw` is that exact string -- read live by `provisionPlatformAuth`,
3513
+ * which never writes `AUTH_APPS` itself, or supplied by
3514
+ * `provisionPlatformArchon` as the string it already knows it just wrote (or
3515
+ * left unchanged), so neither caller needs a second read to agree with
3516
+ * itself about what is now true of the app.
3517
+ */
3518
+ function brokerDeployNeeded(missingInputs, brokerAppId, authAppsRaw, recordedDeploy) {
3519
+ if (missingInputs.length > 0)
3520
+ return false;
3521
+ if (recordedDeploy === null)
3522
+ return true;
3523
+ if (recordedDeploy.brokerAppId !== brokerAppId)
3524
+ return true;
3525
+ return recordedDeploy.authAppsHash !== hashAuthApps(authAppsRaw);
3526
+ }
3527
+ /**
3528
+ * #211 round 5: the {@link BrokerDeployRecord} JSON a successful deploy
3529
+ * writes under {@link PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY} (via each
3530
+ * caller's own `record()`), replacing the plain broker-id string round 2-4
3531
+ * wrote there. Paired with {@link brokerDeployNeeded}, which reads it back.
3532
+ */
3533
+ function brokerDeployRecordValue(brokerAppId, authAppsRaw) {
3534
+ const record = { brokerAppId, authAppsHash: hashAuthApps(authAppsRaw) };
3535
+ return JSON.stringify(record);
3536
+ }
2129
3537
  /** Whether any desired env is missing from the app, or set to another value. */
2130
3538
  async function envsDiffer(coolify, appId, desired) {
2131
3539
  const live = new Map((await coolify.listEnvs(appId)).filter((e) => !e.is_preview).map((e) => [e.key, e.value]));
@@ -2134,15 +3542,43 @@ async function envsDiffer(coolify, appId, desired) {
2134
3542
  /**
2135
3543
  * `null` once the app already has this key set -- generated only the first
2136
3544
  * time, never regenerated or overwritten, mirroring `executor.ts`'s
2137
- * `ensureAuthCookieSecret` exactly.
3545
+ * `ensureAuthCookieSecret` exactly. #211 round 4: `''`, `null` and
3546
+ * `undefined` all count as not set, the same "missing" a live Coolify
3547
+ * response can carry despite its declared `string` type
3548
+ * (`CoolifyPort.listEnvs`'s own doc comment) -- an entry present with one of
3549
+ * those values used to read as "already has this key set" and return `null`,
3550
+ * so a caller reading the value back afterwards (`provisionPlatformArchon`'s
3551
+ * own `AUTH_APP_KEY`) threw "lost its ... between two reads" on every single
3552
+ * apply instead of regenerating it.
3553
+ *
3554
+ * #211 round 5: `minLength`, when given, also counts a *present, non-empty*
3555
+ * value shorter than it as not set -- the broker's own `parseApps`
3556
+ * (`wtfalch/auth/broker/src/apps.ts`) refuses a `key` under 32 characters,
3557
+ * and this package minted a 32-character `AUTH_APP_KEY` from the start
3558
+ * (`provisionPlatformArchon`'s own comment on `randomBytes(32)`), so this
3559
+ * only ever changes behaviour for a key that reached the app some other way
3560
+ * -- a hand-set value, or one carried over from before that guarantee held.
3561
+ * Optional, and unused by every other call site (`ensureMasterkey`,
3562
+ * `ZITADEL_ADMIN_PASSWORD`, `AUTH_COOKIE_SECRET`, this file's recovery admin
3563
+ * password): none of those have a broker-enforced minimum, so widening this
3564
+ * check to them would reject a value that has always been fine.
2138
3565
  */
2139
- async function ensureGeneratedEnv(coolify, appId, key, generate) {
3566
+ async function ensureGeneratedEnv(coolify, appId, key, generate, minLength) {
2140
3567
  const envs = await coolify.listEnvs(appId);
2141
- const already = envs.some((e) => e.key === key && !e.is_preview);
3568
+ const entry = envs.find((e) => e.key === key && !e.is_preview);
3569
+ const already = entry !== undefined &&
3570
+ entry.value != null &&
3571
+ entry.value !== '' &&
3572
+ (minLength === undefined || entry.value.length >= minLength);
2142
3573
  if (already)
2143
3574
  return null;
2144
3575
  return generate();
2145
3576
  }
3577
+ /** 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. */
3578
+ async function currentEnvValue(coolify, appId, key) {
3579
+ const envs = await coolify.listEnvs(appId);
3580
+ return envs.find((e) => e.key === key && !e.is_preview)?.value;
3581
+ }
2146
3582
  /**
2147
3583
  * {@link ensureGeneratedEnv} for `ZITADEL_MASTERKEY`, which encrypts what
2148
3584
  * ZITADEL keeps in its database. A new key over a database ZITADEL has run