@wtfalch/foundry 0.5.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +16 -1
  2. package/dist/bootstrap/bootstrap.js +40 -2
  3. package/dist/bootstrap/declare.d.ts +3 -1
  4. package/dist/bootstrap/declare.js +12 -3
  5. package/dist/bootstrap/versions.d.ts +17 -0
  6. package/dist/bootstrap/versions.js +12 -0
  7. package/dist/calls-sfu-deploy-support.d.ts +8 -0
  8. package/dist/calls-sfu-deploy-support.js +10 -1
  9. package/dist/calls-sfu.d.ts +36 -12
  10. package/dist/calls-sfu.js +42 -9
  11. package/dist/index.d.ts +10 -4
  12. package/dist/index.js +6 -3
  13. package/dist/migrations/0019_mail_owner_mailbox.sql +15 -0
  14. package/dist/migrations/0020_mail_apps.sql +7 -0
  15. package/dist/migrations/0021_mail_role_mailboxes.sql +6 -0
  16. package/dist/migrations/0022_chat_enabled.sql +6 -0
  17. package/dist/migrations/0023_hosting_app_env.sql +8 -0
  18. package/dist/migrations/0024_audit_events.sql +211 -0
  19. package/dist/migrations/0025_audit_display.sql +147 -0
  20. package/dist/migrations/0026_audit_erase_email_guard.sql +78 -0
  21. package/dist/migrations/0027_audit_chain.sql +170 -0
  22. package/dist/migrations/0028_audit_rls.sql +111 -0
  23. package/dist/org.d.ts +105 -0
  24. package/dist/org.js +101 -0
  25. package/dist/plan/agora.d.ts +21 -0
  26. package/dist/plan/agora.js +23 -0
  27. package/dist/plan/calls-matrix.d.ts +2 -2
  28. package/dist/plan/calls-matrix.js +3 -2
  29. package/dist/plan/cloudflared-connector.d.ts +3 -2
  30. package/dist/plan/cloudflared-connector.js +5 -3
  31. package/dist/plan/diff.d.ts +15 -2
  32. package/dist/plan/diff.js +399 -51
  33. package/dist/plan/executor.d.ts +307 -61
  34. package/dist/plan/executor.js +489 -129
  35. package/dist/plan/index.d.ts +9 -1
  36. package/dist/plan/index.js +5 -1
  37. package/dist/plan/login-host.d.ts +64 -0
  38. package/dist/plan/login-host.js +52 -0
  39. package/dist/plan/mail-copy.d.ts +125 -0
  40. package/dist/plan/mail-copy.js +166 -0
  41. package/dist/plan/mail-identity-allowlist.d.ts +50 -0
  42. package/dist/plan/mail-identity-allowlist.js +11 -0
  43. package/dist/plan/mail-identity.d.ts +68 -0
  44. package/dist/plan/mail-identity.js +17 -0
  45. package/dist/plan/plan-apply.d.ts +26 -8
  46. package/dist/plan/plan-apply.js +10 -9
  47. package/dist/plan/platform-diff.d.ts +207 -10
  48. package/dist/plan/platform-diff.js +403 -15
  49. package/dist/plan/platform-org.d.ts +3 -2
  50. package/dist/plan/platform-providers.d.ts +139 -25
  51. package/dist/plan/platform-providers.js +1531 -95
  52. package/dist/plan/platform-sfu.d.ts +54 -19
  53. package/dist/plan/platform-sfu.js +64 -22
  54. package/dist/plan/providers-readers.js +44 -1
  55. package/dist/plan/zitadel-machine-user.d.ts +60 -11
  56. package/dist/plan/zitadel-machine-user.js +182 -39
  57. package/dist/requested-changes.d.ts +17 -7
  58. package/dist/requested-changes.js +37 -7
  59. package/dist/store/audit.d.ts +40 -4
  60. package/dist/store/audit.js +114 -29
  61. package/dist/store/postgres.js +35 -3
  62. package/dist/store/schema.d.ts +109 -1
  63. package/dist/store/schema.js +20 -0
  64. package/dist/store.d.ts +7 -0
  65. package/package.json +5 -3
  66. package/versions.json +8 -0
@@ -47,6 +47,17 @@ export const PLATFORM_ARCHON_KEY = 'platform:archon';
47
47
  */
48
48
  export const PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY = 'archon_applied_hostname';
49
49
  export const PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY = 'archon_applied_git_ref';
50
+ /**
51
+ * #211 round 4: the broker app id Archon last registered its entry with.
52
+ * `provisionPlatformArchon` reads the broker to register with from
53
+ * {@link PLATFORM_AUTH_BROKER_SCOPE_KEY} alone, so a broker recreated under a
54
+ * new app id (the old one deleted by hand, or `platform:auth` minting a fresh
55
+ * one) leaves Archon "applied" against a broker that no longer holds its
56
+ * entry. `planArchon` plans the step again whenever this differs from the
57
+ * currently recorded {@link PLATFORM_AUTH_BROKER_SCOPE_KEY}, the same way a
58
+ * changed hostname or `gitRef` does.
59
+ */
60
+ export const PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY = 'archon_applied_broker_app';
50
61
  /**
51
62
  * #175: set to '1' before the step points the tunnel at `http_status:404`
52
63
  * (Access app rebuild), cleared to '' once the real ingress is written back.
@@ -56,6 +67,132 @@ export const PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY = 'archon_applied_git_ref
56
67
  export const PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY = 'archon_gate_closed';
57
68
  export const PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY = 'zitadel_platform_admin_user_id';
58
69
  export const PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY = 'zitadel_platform_admin_user_undelivered';
70
+ /**
71
+ * #216 (building on #209/#215): the `IAM_LOGIN_CLIENT` machine user minted
72
+ * for the login app and the broker, mirroring
73
+ * {@link PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY}/
74
+ * {@link PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY} in shape -- a recorded
75
+ * id once delivered, an undelivered id recorded first, the moment the user
76
+ * is minted.
77
+ *
78
+ * Unlike the admin pair, an undelivered login-client key never blocks
79
+ * {@link planAuth} from planning the step again -- #216 round 2: `planAuth`
80
+ * below does read it, but only to surface a non-blocking note (alongside a
81
+ * planned step, the way #211 round 3's broker-deploy block already is)
82
+ * rather than to hold the step back. Revoking a stale login-client PAT
83
+ * destroys nothing -- unlike the admin user's, whose loss the admin pair's
84
+ * own "delete that user by hand" message exists for because nothing else
85
+ * here re-derives the ZITADEL masterkey a lost admin PAT might have been the
86
+ * only record of -- so `mintLoginClientMachineUser` (`zitadel-machine-user.ts`)
87
+ * always lists and deletes every PAT the user already holds before minting a
88
+ * fresh one. That is what lets `provisionPlatformAuth` simply re-mint on the
89
+ * next run, with no manual step, which is #216's whole fix for the bug PR
90
+ * #215 shipped with: PR #215 mirrored the admin pair's forever-blocking
91
+ * behaviour for this key too, which meant one failed Coolify call lost the
92
+ * login-client token for good.
93
+ *
94
+ * #216 round 4: the value recorded here is JSON ({@link
95
+ * PlatformAuthLoginClientRecord}, parsed back by
96
+ * {@link parseLoginClientRecord}), not the bare user id round 2-3 wrote --
97
+ * the user id alone can never tell a healthy delivery from one a concurrent
98
+ * apply has since clobbered, because `mintLoginClientMachineUser` reuses the
99
+ * same user on every mint. The PAT id travels alongside it so {@link
100
+ * planAuth} can ask ZITADEL, on every plan, whether that exact PAT is still
101
+ * the sole live one.
102
+ *
103
+ * #216 round 5: that alone is still not enough -- a recorded PAT can be the
104
+ * sole live one in ZITADEL while Coolify holds a *different*, already-dead
105
+ * token, when two concurrent applies interleave their own writes and
106
+ * records in the right (wrong) order (see `authLoginClientDrifted`'s own
107
+ * doc comment in `platform-providers.ts` for the exact ordering). So the
108
+ * record also carries `tokenHash`, a sha256 of the exact token that was
109
+ * delivered -- never the token itself -- which `authLoginClientDrifted`
110
+ * compares against what is actually live in both apps' Coolify envs.
111
+ */
112
+ export const PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY = 'zitadel_platform_login_client_user_id';
113
+ export const PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY = 'zitadel_platform_login_client_user_undelivered';
114
+ /**
115
+ * Parses a {@link PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY} scope id back
116
+ * into a {@link PlatformAuthLoginClientRecord}. `null` for anything that is
117
+ * not one -- absent, the pre-round-4 plain user-id string, the round 2-4
118
+ * `{userId, patId}` shape with no `tokenHash`, or corrupt JSON -- the same
119
+ * "malformed reads as unapplied, never throws" rule
120
+ * {@link parseBrokerDeployRecord} follows and for the same reason: this is a
121
+ * planning read, not a validation boundary. A pre-round-5 record reading as
122
+ * unapplied is the intended migration: the next apply's fresh mint recomputes
123
+ * it in the new shape, the same way a pre-round-4 plain-string record already
124
+ * did.
125
+ */
126
+ export function parseLoginClientRecord(raw) {
127
+ if (!raw)
128
+ return null;
129
+ let parsed;
130
+ try {
131
+ parsed = JSON.parse(raw);
132
+ }
133
+ catch {
134
+ return null;
135
+ }
136
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
137
+ return null;
138
+ const candidate = parsed;
139
+ if (typeof candidate.userId !== 'string' || !candidate.userId)
140
+ return null;
141
+ if (typeof candidate.patId !== 'string' || !candidate.patId)
142
+ return null;
143
+ if (typeof candidate.tokenHash !== 'string' || !candidate.tokenHash)
144
+ return null;
145
+ return { userId: candidate.userId, patId: candidate.patId, tokenHash: candidate.tokenHash };
146
+ }
147
+ /**
148
+ * #204/#211: the sign-in broker app `provisionPlatformAuth` creates alongside
149
+ * ZITADEL and the login app. Defined here, not in `platform-providers.ts`
150
+ * (which imports it), because `planAuth` below needs it too -- a recorded
151
+ * `coolify:app:platform:auth` key with no recorded broker id is a platform
152
+ * provisioned before #204 shipped, and must still plan the step again so the
153
+ * broker gets created rather than being read as fully applied.
154
+ */
155
+ export const PLATFORM_AUTH_BROKER_SCOPE_KEY = 'coolify_platform_auth_broker_app';
156
+ /**
157
+ * #211: recorded once the broker has actually been deployed -- distinct from
158
+ * {@link PLATFORM_AUTH_BROKER_SCOPE_KEY} (the app exists) precisely because
159
+ * the app can exist for a while before it is ever deployed (see
160
+ * `missingBrokerInputs` in `platform-providers.ts`). Defined here, not there,
161
+ * for the same reason as {@link PLATFORM_AUTH_BROKER_SCOPE_KEY}: `planAuth`
162
+ * below needs it too, so a broker that is created but not yet deployed still
163
+ * plans the step again instead of reading as fully applied for good.
164
+ */
165
+ export const PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY = 'coolify_platform_auth_broker_deployed';
166
+ /**
167
+ * Parses a {@link PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY} scope id back into
168
+ * a {@link BrokerDeployRecord}. `null` for anything that is not one --
169
+ * absent, the pre-round-5 plain broker-id string, or corrupt JSON -- because
170
+ * this is a planning read, not a validation boundary: a malformed record
171
+ * must read as "not deployed" so the step plans (and overwrites it) again,
172
+ * never throw and take the rest of the plan down with it. Exported here
173
+ * rather than defined in `platform-providers.ts` for the same reason
174
+ * {@link PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY} itself was moved here in
175
+ * round 2 -- {@link planAuth} below needs it too, without a circular import.
176
+ */
177
+ export function parseBrokerDeployRecord(raw) {
178
+ if (!raw)
179
+ return null;
180
+ let parsed;
181
+ try {
182
+ parsed = JSON.parse(raw);
183
+ }
184
+ catch {
185
+ return null;
186
+ }
187
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
188
+ return null;
189
+ const candidate = parsed;
190
+ if (typeof candidate.brokerAppId !== 'string' || !candidate.brokerAppId)
191
+ return null;
192
+ if (typeof candidate.authAppsHash !== 'string' || !candidate.authAppsHash)
193
+ return null;
194
+ return { brokerAppId: candidate.brokerAppId, authAppsHash: candidate.authAppsHash };
195
+ }
59
196
  /**
60
197
  * #197: the `cloudflared` connector app `provisionPlatformAuth` runs for its
61
198
  * own tunnel. Consulted by `planAuth` below so that a tunnel recorded with no
@@ -68,6 +205,21 @@ export const PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY = 'coolify_platform_auth_conn
68
205
  export const PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY = 'coolify_platform_mail_connector_app';
69
206
  /** #197: the same connector scope id for the Archon step's own tunnel, read by `planArchon`. */
70
207
  export const PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY = 'coolify_platform_archon_connector_app';
208
+ /**
209
+ * #213: the three held-key refs, under tenant `platform`, Archon's own
210
+ * onboarding reads before it can start (archon#101 round 3, archon#104).
211
+ * `planArchon`'s step puts each one, once, before Archon's app deploys; an
212
+ * entry already held is left alone, never overwritten
213
+ * (`putArchonMasterKeys` in `platform-providers.ts`). The Coolify URL and
214
+ * token are values this package already deploys with; the Cloudflare
215
+ * parent token ("API Tokens Write", so Archon can mint its own scoped
216
+ * child tokens from it) is a human-created credential the host supplies --
217
+ * neither Coolify nor Cloudflare lets either be minted over an API (#213
218
+ * "Human steps").
219
+ */
220
+ export const PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF = 'platform:master:coolify-url';
221
+ export const PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF = 'platform:master:coolify-token';
222
+ export const PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF = 'platform:master:cloudflare-token';
71
223
  /**
72
224
  * #169: `platform:<service>` for the platform's own mail server (Stalwart),
73
225
  * mirroring {@link PLATFORM_AUTH_KEY} exactly.
@@ -121,6 +273,41 @@ export function platformMailMxHost(apex) {
121
273
  */
122
274
  export const ORG_MAIL_APPLIED_APEX_SCOPE_KEY = 'mail_applied_apex';
123
275
  export const ORG_MAIL_APPLIED_HOST_SCOPE_KEY = 'mail_applied_host';
276
+ /**
277
+ * #243: the org's `hosting.appEnv` values the last `coolify:app` apply pushed,
278
+ * recorded as one JSON object (env name to value; ids and a domain, never a
279
+ * secret). `planApp` plans the step again when {@link resolveAppEnv} differs
280
+ * from it, so a changed map reaches an app that is already live.
281
+ */
282
+ export const APP_ENV_APPLIED_SCOPE_KEY = 'app_env_applied';
283
+ /**
284
+ * #239: set to '1' once an app has been restarted after its database URLs
285
+ * were pushed. Not a URL. Without it a crash between the push and the restart
286
+ * would leave a stale container that no plan notices.
287
+ */
288
+ export const DB_ENV_APPLIED_SCOPE_KEY = 'db_env_applied';
289
+ /**
290
+ * The `hosting.appEnv` entries whose source has a recorded value, in env-name
291
+ * order, and the env names whose source has none yet. Shared by `planApp` and
292
+ * the executor's `pushAppEnv` so both read the same recorded ids.
293
+ */
294
+ export function resolveAppEnv(org, recorded) {
295
+ const sources = {
296
+ filesOrganisationId: recorded[`files:organisation:${org.id}`],
297
+ aiOrganisationId: recorded[`ai:organisation:${org.id}`],
298
+ mailDomain: recorded.mail_domain,
299
+ };
300
+ const values = {};
301
+ const unresolved = [];
302
+ for (const [name, source] of Object.entries(org.hosting.appEnv).sort(([a], [b]) => a < b ? -1 : 1)) {
303
+ const value = sources[source];
304
+ if (value)
305
+ values[name] = value;
306
+ else
307
+ unresolved.push([name, source]);
308
+ }
309
+ return { values, unresolved };
310
+ }
124
311
  /**
125
312
  * #172: the resource key `planAi` plans under and `FoundryPlatformStepExecutor`
126
313
  * dispatches on, the same `platform:<service>` shape as
@@ -144,6 +331,27 @@ export const PLATFORM_AI_INTERNAL_URL_SCOPE_KEY = 'ai_platform_internal_url';
144
331
  */
145
332
  export const PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY = 'ai_platform_management_key_id';
146
333
  export const PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY = 'ai_platform_management_key_undelivered';
334
+ /**
335
+ * #201: set when ai's `POST /v1/platform/keys` reports its `archon` key
336
+ * already exists but returns no secret -- a mint this record never learned
337
+ * about (most likely the crash window {@link PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY}'s
338
+ * own comment describes: the secret returned once and was never recorded).
339
+ * ai shows a credential's secret only at mint time, so it cannot be
340
+ * recovered; the planner reports this instead of minting under another
341
+ * name. A person revokes the live `archon` key in ai and removes this
342
+ * recorded key to let the step mint a fresh one.
343
+ */
344
+ export const PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY = 'ai_platform_management_key_conflict';
345
+ /**
346
+ * #201: the same two-key discipline as {@link PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY}
347
+ * and {@link PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY}, for the Ed25519 pair
348
+ * foundry mints to sign its calls to ai's `POST /v1/platform/keys`. The
349
+ * undelivered key is recorded the moment the pair is minted; the set key,
350
+ * only once the private half was delivered and the public half pinned on
351
+ * the runner as `MANAGEMENT_PUBLIC_KEYS`.
352
+ */
353
+ export const PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY = 'ai_platform_signing_key_undelivered';
354
+ export const PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY = 'ai_platform_signing_key_set';
147
355
  /**
148
356
  * #172: ai's platform (operator) tenant id, which the management key belongs
149
357
  * to -- what ai's own README calls `AI_ORGANISATION_ID`. Not a secret.
@@ -169,14 +377,6 @@ export const PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY = 'ai_platform_migrate_blocke
169
377
  * that throws) is repaired by the next one.
170
378
  */
171
379
  export const PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY = 'ai_platform_deploy_pending';
172
- /**
173
- * #172: what the planner says while no AI management key minter is supplied.
174
- * A fixed text: it is part of the stale-plan comparison. ai issues its first
175
- * platform credential only through its `keys:issue-archon` script, run
176
- * against its database with its keys worker credential; it has no HTTP route
177
- * for that, so foundry cannot build a minter over ai's admin API yet.
178
- */
179
- export const PLATFORM_AI_MINTER_BLOCKED = "the platform's AI host is blocked, not planned: no AI management key minter was supplied. ai mints its first platform key only through its keys:issue-archon script against its database, so the host must supply PlatformAiDeps.managementKeyMinter";
180
380
  /**
181
381
  * #171: the resource key `planFiles` plans under and `FoundryPlatformStepExecutor`
182
382
  * dispatches on, the {@link PLATFORM_AUTH_KEY} shape for the files host.
@@ -261,7 +461,7 @@ export function planPlatform(platform, live, options = {}) {
261
461
  const sfu = platform.sfu.enabled && sfuBlocked ? null : planSfu(platform);
262
462
  if (sfu)
263
463
  plannedSteps.push(sfu);
264
- const auth = planAuth(platform, recordedIds, blocked.auth);
464
+ const auth = planAuth(platform, recordedIds, blocked.auth, blocked.authExecutorMissing ?? false, drifted.auth ?? false, blocked.authOtherInputsMissing ?? []);
265
465
  if (auth.step)
266
466
  plannedSteps.push(auth.step);
267
467
  // #174: the integrations host. Planned after auth; neither step reads the
@@ -476,14 +676,123 @@ function planSfu(platform) {
476
676
  * -- `provisionPlatformAuth` re-runs idempotently against everything it
477
677
  * already built and only adds the connector, so this never mints or
478
678
  * redeploys what a prior run already finished.
679
+ *
680
+ * #211: the same holds for the broker ({@link PLATFORM_AUTH_BROKER_SCOPE_KEY}).
681
+ * A platform applied before #204 shipped the broker recorded the app and the
682
+ * connector and never the broker, so without this check it would read as
683
+ * fully applied and Archon would never find a broker to register with.
684
+ *
685
+ * #211 round 2: a recorded broker id is not the same as a *deployed* broker
686
+ * ({@link PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY}) -- `provisionPlatformAuth`
687
+ * creates and records the app before it has `ZITADEL_SERVICE_USER_TOKEN`/
688
+ * `RESEND_API_TOKEN` to deploy it with (#209, #210). So this still plans the
689
+ * step again while the broker sits undeployed: `executorBlocked` (from
690
+ * `authBrokerBlockedReason`) reports it blocked, by name, for as long as the
691
+ * inputs stay missing, and the moment they arrive the same replanned step
692
+ * deploys it -- no apply is left blocked for good once its inputs exist.
693
+ *
694
+ * #211 round 5: "deployed" ({@link PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY})
695
+ * is read back through {@link parseBrokerDeployRecord}, not `Boolean(...)`,
696
+ * and only counts while the record's `brokerAppId` matches the currently
697
+ * recorded {@link PLATFORM_AUTH_BROKER_SCOPE_KEY} -- a broker recreated under
698
+ * a new app id (the old one deleted by hand, or a fresh mint) leaves the old
699
+ * record pointing at an app that no longer exists, and a plain `Boolean`
700
+ * check (round 2-4) could never see that. This function has no live Coolify
701
+ * access, so it cannot also compare the record's `authAppsHash` against a
702
+ * live `AUTH_APPS` -- that half of the same rule (`brokerDeployNeeded` in
703
+ * `platform-providers.ts`) only runs inside the two `provision*` functions,
704
+ * which do have one. A malformed record parses to `null` and is treated the
705
+ * same as none recorded -- see {@link parseBrokerDeployRecord}'s own doc
706
+ * comment.
707
+ *
708
+ * #211 round 3: `executorBlocked` must not hold back the step itself once
709
+ * the broker exists (`brokerApplied`). `authBrokerBlockedReason` only ever
710
+ * returns non-null once a broker app id is recorded, so `executorBlocked &&
711
+ * brokerApplied` can only mean "broker created, not yet deployed" -- never
712
+ * "no executor". Blocking the whole step on that would deadlock: #209 mints
713
+ * the broker's own token as part of running this step, and a step that is
714
+ * never planned never runs. So this still plans the step -- surfacing
715
+ * `executorBlocked` in `blocked` for visibility, not to stop planning.
716
+ *
717
+ * #211 round 6: round 3's `!brokerApplied` guard assumed "no executor" and
718
+ * "broker not yet created" always coincide. They don't: a broker recorded on
719
+ * an earlier apply, then run again against a host built without its auth
720
+ * dependencies (`this.auth` undefined in `PlatformProvidersExecutor`), has
721
+ * `brokerApplied === true` and `executorBlocked` set to the no-deps message,
722
+ * so round 3's check let the step through and apply threw on the missing
723
+ * executor. `executorMissing` is that case's own signal
724
+ * ({@link PlanPlatformOptions.blocked}'s `authExecutorMissing`) and holds the
725
+ * step back unconditionally -- there is no executor to run it with, whatever
726
+ * `recordedIds` says.
727
+ *
728
+ * #216: a platform with every one of the above applied but no recorded
729
+ * {@link PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY} is not fully applied
730
+ * either -- the same "provisioned before this shipped" case
731
+ * {@link PLATFORM_AUTH_BROKER_SCOPE_KEY} already covers, here for a platform
732
+ * applied before #216's login-client mint existed at all.
733
+ * `provisionPlatformAuth` re-runs idempotently against everything it already
734
+ * built and only mints and delivers the login-client token, so this never
735
+ * redoes what a prior run already finished. Unlike the admin key, an
736
+ * undelivered login-client key never blocks this check -- see
737
+ * {@link PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY}'s own doc comment
738
+ * for why -- though it is still read, below, for a non-blocking note.
739
+ *
740
+ * #216 round 4: a *recorded* login-client key is also not enough on its own
741
+ * -- `authDrifted` (the host's own `driftedSteps`, reading ZITADEL's live
742
+ * PATs for the recorded user) means the PAT this platform's two apps were
743
+ * handed is no longer the sole live one: a concurrent apply's own mint
744
+ * revoked or replaced it, or minted an extra one alongside it. Round 2 and
745
+ * round 3 both tried to catch that from inside the same run that delivered
746
+ * the token (checking once after every write, then checking again before
747
+ * every write) and both left a window a slow concurrent write could still
748
+ * land in. This closes it from the other end instead: every plan re-checks
749
+ * the live state no matter which run last touched it, so whichever run's
750
+ * write actually won is caught on the very next plan, not raced against
751
+ * from inside the run that might lose.
752
+ *
753
+ * #216 round 5: `otherInputsMissing` keeps the note below honest when a
754
+ * re-mint is needed (undelivered or drifted) but cannot actually happen yet
755
+ * -- `provisionPlatformAuth` only mints and delivers once the broker's
756
+ * inputs *other than* `ZITADEL_SERVICE_USER_TOKEN` are all present
757
+ * (deferred, not blocked -- see that function's own doc comment), so a note
758
+ * promising "this apply re-mints" while one of those is still missing would
759
+ * be wrong the moment it is read.
479
760
  */
480
- function planAuth(platform, recordedIds, executorBlocked) {
761
+ function planAuth(platform, recordedIds, executorBlocked, executorMissing, authDrifted, otherInputsMissing = []) {
481
762
  if (!platform.auth.enabled)
482
763
  return { step: null, blocked: null };
483
764
  const key = resourceKey('coolify', 'app', PLATFORM_AUTH_KEY);
484
765
  const connectorApplied = Boolean(recordedIds[PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY]);
485
- if (recordedIds[key] && connectorApplied)
766
+ // #211: a recorded app and connector with no recorded broker id is a
767
+ // platform provisioned before #204 shipped the broker, or a run that threw
768
+ // before the broker's own id was recorded -- still not fully applied, the
769
+ // same rule `connectorApplied` already follows for #197's connector.
770
+ const brokerApplied = Boolean(recordedIds[PLATFORM_AUTH_BROKER_SCOPE_KEY]);
771
+ // #211 round 2: also require the broker to be deployed, not merely created.
772
+ // #211 round 5: and only while the deploy record still names the broker
773
+ // that is currently recorded -- see this function's own doc comment.
774
+ const brokerDeployRecord = parseBrokerDeployRecord(recordedIds[PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY]);
775
+ const brokerDeployed = brokerDeployRecord !== null &&
776
+ brokerDeployRecord.brokerAppId === recordedIds[PLATFORM_AUTH_BROKER_SCOPE_KEY];
777
+ // #216 round 4: parsed back, not just `Boolean(...)` -- see this
778
+ // function's own doc comment and {@link parseLoginClientRecord}.
779
+ const loginClientRecord = parseLoginClientRecord(recordedIds[PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY]);
780
+ // #216 round 4: a recorded record whose PAT has since drifted (revoked,
781
+ // replaced, or joined by an extra live PAT) reads as unapplied, same as
782
+ // none recorded at all -- see this function's own doc comment.
783
+ const loginClientApplied = loginClientRecord !== null && !authDrifted;
784
+ // #216 round 2: read for a non-blocking note only -- see
785
+ // {@link PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY}'s own doc
786
+ // comment for why this never holds the step back the way the admin key's
787
+ // own `undelivered` check below does.
788
+ const loginClientUndelivered = recordedIds[PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY];
789
+ if (recordedIds[key] &&
790
+ connectorApplied &&
791
+ brokerApplied &&
792
+ brokerDeployed &&
793
+ loginClientApplied) {
486
794
  return { step: null, blocked: null };
795
+ }
487
796
  // The admin key was minted but never reached the host. ZITADEL shows a
488
797
  // token once, so it is lost: minting another would hide that, and skipping
489
798
  // would leave foundry without its admin key. Report it, plan nothing.
@@ -494,7 +803,16 @@ function planAuth(platform, recordedIds, executorBlocked) {
494
803
  blocked: `the platform's sign-in is blocked, not planned: the admin key of ZITADEL machine user ${undelivered} was minted but never delivered and cannot be shown again; delete that user in ZITADEL and remove the recorded key ${PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY} to mint a new one`,
495
804
  };
496
805
  }
497
- if (executorBlocked)
806
+ // #211 round 6: the executor itself is unusable on this host -- nothing to
807
+ // plan, whatever the broker's recorded state says.
808
+ if (executorMissing)
809
+ return { step: null, blocked: executorBlocked ?? null };
810
+ // #211 round 3: a blocked reason with no broker yet created also means the
811
+ // executor is unusable (no auth dependencies) -- nothing to plan.
812
+ // A blocked reason with the broker already created means only its deploy
813
+ // is held back; plan the step anyway so it can keep retrying and, once
814
+ // #209/#210 land, deploy on the very next apply.
815
+ if (executorBlocked && !brokerApplied)
498
816
  return { step: null, blocked: executorBlocked };
499
817
  return {
500
818
  step: {
@@ -505,14 +823,48 @@ function planAuth(platform, recordedIds, executorBlocked) {
505
823
  desired: { hostname: platform.auth.hostname, gitRef: platform.auth.gitRef },
506
824
  },
507
825
  step: {
508
- description: `provision the platform's own sign-in (ZITADEL) at ${platform.auth.hostname}`,
826
+ description: `provision the platform's own sign-in (ZITADEL, the login app and the sign-in broker) at ${platform.auth.hostname}`,
509
827
  destroys: false,
510
828
  migrates: false,
511
829
  },
512
830
  },
513
- blocked: null,
831
+ // #211 round 3: still surface a broker-deploy block for visibility even
832
+ // though the step above is planned -- see the doc comment above. #216
833
+ // round 2: when nothing executor-side is blocked, surface an in-flight
834
+ // (or abandoned) login-client mint the same way -- informational only,
835
+ // never a reason to skip planning. #216 round 4: same treatment for a
836
+ // drifted (already-delivered, now stale) login-client token. #216 round
837
+ // 5: and only claim "this apply re-mints" when `otherInputsMissing` is
838
+ // actually empty -- see this function's own doc comment.
839
+ blocked: executorBlocked ??
840
+ loginClientNeedsMintNote(loginClientUndelivered, loginClientApplied, authDrifted, loginClientRecord, otherInputsMissing),
514
841
  };
515
842
  }
843
+ /**
844
+ * #216 round 5: the note {@link planAuth} surfaces when the login-client key
845
+ * needs a re-mint it cannot yet perform, split out of `planAuth`'s own return
846
+ * statement so the "needs minting" question and the "can it actually mint
847
+ * right now" question are each answered in one place.
848
+ */
849
+ function loginClientNeedsMintNote(loginClientUndelivered, loginClientApplied, authDrifted, loginClientRecord, otherInputsMissing) {
850
+ const undeliveredNeedsMint = Boolean(loginClientUndelivered) && !loginClientApplied;
851
+ let subject = null;
852
+ if (undeliveredNeedsMint) {
853
+ subject = `the login-client key of ZITADEL machine user ${loginClientUndelivered} was minted but not yet confirmed delivered`;
854
+ }
855
+ else if (authDrifted && loginClientRecord) {
856
+ subject = `the login-client token delivered for ZITADEL machine user ${loginClientRecord.userId} is no longer the sole live token on that user, likely revoked or replaced by a concurrent apply`;
857
+ }
858
+ if (subject === null)
859
+ return null;
860
+ if (otherInputsMissing.length > 0) {
861
+ return `${subject}; this apply cannot re-mint and redeliver it yet -- the platform's sign-in broker still needs ${otherInputsMissing.join(' and ')} first`;
862
+ }
863
+ const action = undeliveredNeedsMint
864
+ ? 'this apply re-mints (revoking it) and retries'
865
+ : 'this apply re-mints and redelivers';
866
+ return `${subject}; ${action} -- no manual step needed`;
867
+ }
516
868
  /**
517
869
  * #169: the platform's own mail server (Stalwart), built fresh -- Stalwart on
518
870
  * Coolify with its own storage, reached at `platform.mail.hostname` for
@@ -763,6 +1115,18 @@ function planAi(platform, recordedIds, executorBlocked) {
763
1115
  recordedIds[PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY] !== platform.ai.gitRef;
764
1116
  if (applied && !drifted)
765
1117
  return { step: null, blocked: null };
1118
+ // #201: the signing pair foundry mints to authenticate its own calls to
1119
+ // ai's platform/keys route. Minted but not confirmed delivered and pinned:
1120
+ // the private half cannot be recovered, so report it rather than mint
1121
+ // another (the same reason the management key check below never mints
1122
+ // twice).
1123
+ const signingUndelivered = recordedIds[PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY];
1124
+ if (signingUndelivered && !recordedIds[PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY]) {
1125
+ return {
1126
+ step: null,
1127
+ blocked: `the platform's AI host is blocked, not planned: its signing key ${signingUndelivered} was minted but not confirmed delivered and pinned, and the private half cannot be recovered; check the held key and the runner's MANAGEMENT_PUBLIC_KEYS, then remove the recorded key ${PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY} to mint a new one`,
1128
+ };
1129
+ }
766
1130
  // Minted but never delivered: ai shows a key's secret once, so it is lost.
767
1131
  // Minting another would hide that; report it and plan nothing.
768
1132
  const undelivered = recordedIds[PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY];
@@ -772,6 +1136,16 @@ function planAi(platform, recordedIds, executorBlocked) {
772
1136
  blocked: `the platform's AI host is blocked, not planned: AI management key ${undelivered} was minted but never delivered and cannot be shown again; revoke it in ai (keys:revoke --id ${undelivered}) and remove the recorded key ${PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY} to mint a new one`,
773
1137
  };
774
1138
  }
1139
+ // #201: ai reported its 'archon' key already exists with no secret to
1140
+ // show -- a mint this record never learned about. Never minted under
1141
+ // another name; a person must revoke the live key first.
1142
+ const conflict = recordedIds[PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY];
1143
+ if (conflict) {
1144
+ return {
1145
+ step: null,
1146
+ blocked: `the platform's AI host is blocked, not planned: ai already holds a management key named 'archon' (${conflict}) that was never recorded delivered, so its secret cannot be shown again; revoke it in ai and remove the recorded key ${PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY} to mint a new one`,
1147
+ };
1148
+ }
775
1149
  const migrateBlocked = recordedIds[PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY];
776
1150
  if (migrateBlocked) {
777
1151
  return {
@@ -819,6 +1193,11 @@ function planAi(platform, recordedIds, executorBlocked) {
819
1193
  * planned in this same plan: Archon's sign-in client is created inside it, so
820
1194
  * the step could only throw.
821
1195
  *
1196
+ * #213: also puts {@link PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF},
1197
+ * {@link PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF} and
1198
+ * {@link PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF} as held keys, before
1199
+ * the app deploys -- `putArchonMasterKeys` in `platform-providers.ts`.
1200
+ *
822
1201
  * #197: a recorded app with no recorded `cloudflared` connector
823
1202
  * ({@link PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY}) plans the step again, as
824
1203
  * {@link planAuth} does.
@@ -831,6 +1210,13 @@ function planArchon(platform, recordedIds, executorBlocked, authReady, drifted)
831
1210
  Boolean(recordedIds[PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY]) &&
832
1211
  recordedIds[PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY] === platform.archon.hostname &&
833
1212
  recordedIds[PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY] === platform.archon.gitRef &&
1213
+ // #211 round 4: a broker recreated under a new app id (the old one
1214
+ // deleted by hand, or `platform:auth` minting a fresh one) leaves
1215
+ // Archon's last registration on a broker that no longer exists, and
1216
+ // nothing else here would ever plan the step again to redo it.
1217
+ Boolean(recordedIds[PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY]) &&
1218
+ recordedIds[PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY] ===
1219
+ recordedIds[PLATFORM_AUTH_BROKER_SCOPE_KEY] &&
834
1220
  !recordedIds[PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY];
835
1221
  if (applied && !drifted)
836
1222
  return { step: null, blocked: null };
@@ -857,7 +1243,9 @@ function planArchon(platform, recordedIds, executorBlocked, authReady, drifted)
857
1243
  desired: { hostname: platform.archon.hostname, gitRef: platform.archon.gitRef },
858
1244
  },
859
1245
  step: {
860
- description: `provision Archon at ${platform.archon.hostname}`,
1246
+ description: `provision Archon at ${platform.archon.hostname}, putting its master keys ` +
1247
+ `(${PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF}, ${PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF}, ` +
1248
+ `${PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF}) as held keys if they are not held yet`,
861
1249
  destroys: false,
862
1250
  migrates: false,
863
1251
  },
@@ -4,8 +4,9 @@ import type { AgoraHostValues } from './agora.js';
4
4
  * https://github.com/wtfalch/foundry/issues/177: what an org step needs to
5
5
  * know about the platform it runs on -- whether each shared service it
6
6
  * depends on is enabled *and* applied (not just declared), and the one
7
- * address `coolify:mail`'s own step used to read as a literal
8
- * (`executor.ts`'s old `MAIL_APEX`/`MAIL_HOST` constants). Built by
7
+ * address `coolify:mail`'s own step used to read off two fixed, hand-built
8
+ * constants in `executor.ts` (removed for #205; `mailHost` below is what
9
+ * replaced them). Built by
9
10
  * {@link orgPlatformReadinessFrom} from the very `DeclaredPlatform` + applied
10
11
  * service ids `planPlatform` (`platform-diff.ts`) already reasons over -- the
11
12
  * same "declared vs applied" distinction every platform step already makes