@wtfalch/foundry 0.5.0 → 0.6.0

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