@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
@@ -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.
@@ -144,6 +296,27 @@ export const PLATFORM_AI_INTERNAL_URL_SCOPE_KEY = 'ai_platform_internal_url';
144
296
  */
145
297
  export const PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY = 'ai_platform_management_key_id';
146
298
  export const PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY = 'ai_platform_management_key_undelivered';
299
+ /**
300
+ * #201: set when ai's `POST /v1/platform/keys` reports its `archon` key
301
+ * already exists but returns no secret -- a mint this record never learned
302
+ * about (most likely the crash window {@link PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY}'s
303
+ * own comment describes: the secret returned once and was never recorded).
304
+ * ai shows a credential's secret only at mint time, so it cannot be
305
+ * recovered; the planner reports this instead of minting under another
306
+ * name. A person revokes the live `archon` key in ai and removes this
307
+ * recorded key to let the step mint a fresh one.
308
+ */
309
+ export const PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY = 'ai_platform_management_key_conflict';
310
+ /**
311
+ * #201: the same two-key discipline as {@link PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY}
312
+ * and {@link PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY}, for the Ed25519 pair
313
+ * foundry mints to sign its calls to ai's `POST /v1/platform/keys`. The
314
+ * undelivered key is recorded the moment the pair is minted; the set key,
315
+ * only once the private half was delivered and the public half pinned on
316
+ * the runner as `MANAGEMENT_PUBLIC_KEYS`.
317
+ */
318
+ export const PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY = 'ai_platform_signing_key_undelivered';
319
+ export const PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY = 'ai_platform_signing_key_set';
147
320
  /**
148
321
  * #172: ai's platform (operator) tenant id, which the management key belongs
149
322
  * to -- what ai's own README calls `AI_ORGANISATION_ID`. Not a secret.
@@ -169,14 +342,6 @@ export const PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY = 'ai_platform_migrate_blocke
169
342
  * that throws) is repaired by the next one.
170
343
  */
171
344
  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
345
  /**
181
346
  * #171: the resource key `planFiles` plans under and `FoundryPlatformStepExecutor`
182
347
  * dispatches on, the {@link PLATFORM_AUTH_KEY} shape for the files host.
@@ -261,7 +426,7 @@ export function planPlatform(platform, live, options = {}) {
261
426
  const sfu = platform.sfu.enabled && sfuBlocked ? null : planSfu(platform);
262
427
  if (sfu)
263
428
  plannedSteps.push(sfu);
264
- const auth = planAuth(platform, recordedIds, blocked.auth);
429
+ const auth = planAuth(platform, recordedIds, blocked.auth, blocked.authExecutorMissing ?? false, drifted.auth ?? false, blocked.authOtherInputsMissing ?? []);
265
430
  if (auth.step)
266
431
  plannedSteps.push(auth.step);
267
432
  // #174: the integrations host. Planned after auth; neither step reads the
@@ -476,14 +641,123 @@ function planSfu(platform) {
476
641
  * -- `provisionPlatformAuth` re-runs idempotently against everything it
477
642
  * already built and only adds the connector, so this never mints or
478
643
  * redeploys what a prior run already finished.
644
+ *
645
+ * #211: the same holds for the broker ({@link PLATFORM_AUTH_BROKER_SCOPE_KEY}).
646
+ * A platform applied before #204 shipped the broker recorded the app and the
647
+ * connector and never the broker, so without this check it would read as
648
+ * fully applied and Archon would never find a broker to register with.
649
+ *
650
+ * #211 round 2: a recorded broker id is not the same as a *deployed* broker
651
+ * ({@link PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY}) -- `provisionPlatformAuth`
652
+ * creates and records the app before it has `ZITADEL_SERVICE_USER_TOKEN`/
653
+ * `RESEND_API_TOKEN` to deploy it with (#209, #210). So this still plans the
654
+ * step again while the broker sits undeployed: `executorBlocked` (from
655
+ * `authBrokerBlockedReason`) reports it blocked, by name, for as long as the
656
+ * inputs stay missing, and the moment they arrive the same replanned step
657
+ * deploys it -- no apply is left blocked for good once its inputs exist.
658
+ *
659
+ * #211 round 5: "deployed" ({@link PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY})
660
+ * is read back through {@link parseBrokerDeployRecord}, not `Boolean(...)`,
661
+ * and only counts while the record's `brokerAppId` matches the currently
662
+ * recorded {@link PLATFORM_AUTH_BROKER_SCOPE_KEY} -- a broker recreated under
663
+ * a new app id (the old one deleted by hand, or a fresh mint) leaves the old
664
+ * record pointing at an app that no longer exists, and a plain `Boolean`
665
+ * check (round 2-4) could never see that. This function has no live Coolify
666
+ * access, so it cannot also compare the record's `authAppsHash` against a
667
+ * live `AUTH_APPS` -- that half of the same rule (`brokerDeployNeeded` in
668
+ * `platform-providers.ts`) only runs inside the two `provision*` functions,
669
+ * which do have one. A malformed record parses to `null` and is treated the
670
+ * same as none recorded -- see {@link parseBrokerDeployRecord}'s own doc
671
+ * comment.
672
+ *
673
+ * #211 round 3: `executorBlocked` must not hold back the step itself once
674
+ * the broker exists (`brokerApplied`). `authBrokerBlockedReason` only ever
675
+ * returns non-null once a broker app id is recorded, so `executorBlocked &&
676
+ * brokerApplied` can only mean "broker created, not yet deployed" -- never
677
+ * "no executor". Blocking the whole step on that would deadlock: #209 mints
678
+ * the broker's own token as part of running this step, and a step that is
679
+ * never planned never runs. So this still plans the step -- surfacing
680
+ * `executorBlocked` in `blocked` for visibility, not to stop planning.
681
+ *
682
+ * #211 round 6: round 3's `!brokerApplied` guard assumed "no executor" and
683
+ * "broker not yet created" always coincide. They don't: a broker recorded on
684
+ * an earlier apply, then run again against a host built without its auth
685
+ * dependencies (`this.auth` undefined in `PlatformProvidersExecutor`), has
686
+ * `brokerApplied === true` and `executorBlocked` set to the no-deps message,
687
+ * so round 3's check let the step through and apply threw on the missing
688
+ * executor. `executorMissing` is that case's own signal
689
+ * ({@link PlanPlatformOptions.blocked}'s `authExecutorMissing`) and holds the
690
+ * step back unconditionally -- there is no executor to run it with, whatever
691
+ * `recordedIds` says.
692
+ *
693
+ * #216: a platform with every one of the above applied but no recorded
694
+ * {@link PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY} is not fully applied
695
+ * either -- the same "provisioned before this shipped" case
696
+ * {@link PLATFORM_AUTH_BROKER_SCOPE_KEY} already covers, here for a platform
697
+ * applied before #216's login-client mint existed at all.
698
+ * `provisionPlatformAuth` re-runs idempotently against everything it already
699
+ * built and only mints and delivers the login-client token, so this never
700
+ * redoes what a prior run already finished. Unlike the admin key, an
701
+ * undelivered login-client key never blocks this check -- see
702
+ * {@link PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY}'s own doc comment
703
+ * for why -- though it is still read, below, for a non-blocking note.
704
+ *
705
+ * #216 round 4: a *recorded* login-client key is also not enough on its own
706
+ * -- `authDrifted` (the host's own `driftedSteps`, reading ZITADEL's live
707
+ * PATs for the recorded user) means the PAT this platform's two apps were
708
+ * handed is no longer the sole live one: a concurrent apply's own mint
709
+ * revoked or replaced it, or minted an extra one alongside it. Round 2 and
710
+ * round 3 both tried to catch that from inside the same run that delivered
711
+ * the token (checking once after every write, then checking again before
712
+ * every write) and both left a window a slow concurrent write could still
713
+ * land in. This closes it from the other end instead: every plan re-checks
714
+ * the live state no matter which run last touched it, so whichever run's
715
+ * write actually won is caught on the very next plan, not raced against
716
+ * from inside the run that might lose.
717
+ *
718
+ * #216 round 5: `otherInputsMissing` keeps the note below honest when a
719
+ * re-mint is needed (undelivered or drifted) but cannot actually happen yet
720
+ * -- `provisionPlatformAuth` only mints and delivers once the broker's
721
+ * inputs *other than* `ZITADEL_SERVICE_USER_TOKEN` are all present
722
+ * (deferred, not blocked -- see that function's own doc comment), so a note
723
+ * promising "this apply re-mints" while one of those is still missing would
724
+ * be wrong the moment it is read.
479
725
  */
480
- function planAuth(platform, recordedIds, executorBlocked) {
726
+ function planAuth(platform, recordedIds, executorBlocked, executorMissing, authDrifted, otherInputsMissing = []) {
481
727
  if (!platform.auth.enabled)
482
728
  return { step: null, blocked: null };
483
729
  const key = resourceKey('coolify', 'app', PLATFORM_AUTH_KEY);
484
730
  const connectorApplied = Boolean(recordedIds[PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY]);
485
- if (recordedIds[key] && connectorApplied)
731
+ // #211: a recorded app and connector with no recorded broker id is a
732
+ // platform provisioned before #204 shipped the broker, or a run that threw
733
+ // before the broker's own id was recorded -- still not fully applied, the
734
+ // same rule `connectorApplied` already follows for #197's connector.
735
+ const brokerApplied = Boolean(recordedIds[PLATFORM_AUTH_BROKER_SCOPE_KEY]);
736
+ // #211 round 2: also require the broker to be deployed, not merely created.
737
+ // #211 round 5: and only while the deploy record still names the broker
738
+ // that is currently recorded -- see this function's own doc comment.
739
+ const brokerDeployRecord = parseBrokerDeployRecord(recordedIds[PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY]);
740
+ const brokerDeployed = brokerDeployRecord !== null &&
741
+ brokerDeployRecord.brokerAppId === recordedIds[PLATFORM_AUTH_BROKER_SCOPE_KEY];
742
+ // #216 round 4: parsed back, not just `Boolean(...)` -- see this
743
+ // function's own doc comment and {@link parseLoginClientRecord}.
744
+ const loginClientRecord = parseLoginClientRecord(recordedIds[PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY]);
745
+ // #216 round 4: a recorded record whose PAT has since drifted (revoked,
746
+ // replaced, or joined by an extra live PAT) reads as unapplied, same as
747
+ // none recorded at all -- see this function's own doc comment.
748
+ const loginClientApplied = loginClientRecord !== null && !authDrifted;
749
+ // #216 round 2: read for a non-blocking note only -- see
750
+ // {@link PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY}'s own doc
751
+ // comment for why this never holds the step back the way the admin key's
752
+ // own `undelivered` check below does.
753
+ const loginClientUndelivered = recordedIds[PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY];
754
+ if (recordedIds[key] &&
755
+ connectorApplied &&
756
+ brokerApplied &&
757
+ brokerDeployed &&
758
+ loginClientApplied) {
486
759
  return { step: null, blocked: null };
760
+ }
487
761
  // The admin key was minted but never reached the host. ZITADEL shows a
488
762
  // token once, so it is lost: minting another would hide that, and skipping
489
763
  // would leave foundry without its admin key. Report it, plan nothing.
@@ -494,7 +768,16 @@ function planAuth(platform, recordedIds, executorBlocked) {
494
768
  blocked: `the platform's sign-in is blocked, not planned: the admin key of ZITADEL machine user ${undelivered} was minted but never delivered and cannot be shown again; delete that user in ZITADEL and remove the recorded key ${PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY} to mint a new one`,
495
769
  };
496
770
  }
497
- if (executorBlocked)
771
+ // #211 round 6: the executor itself is unusable on this host -- nothing to
772
+ // plan, whatever the broker's recorded state says.
773
+ if (executorMissing)
774
+ return { step: null, blocked: executorBlocked ?? null };
775
+ // #211 round 3: a blocked reason with no broker yet created also means the
776
+ // executor is unusable (no auth dependencies) -- nothing to plan.
777
+ // A blocked reason with the broker already created means only its deploy
778
+ // is held back; plan the step anyway so it can keep retrying and, once
779
+ // #209/#210 land, deploy on the very next apply.
780
+ if (executorBlocked && !brokerApplied)
498
781
  return { step: null, blocked: executorBlocked };
499
782
  return {
500
783
  step: {
@@ -505,14 +788,48 @@ function planAuth(platform, recordedIds, executorBlocked) {
505
788
  desired: { hostname: platform.auth.hostname, gitRef: platform.auth.gitRef },
506
789
  },
507
790
  step: {
508
- description: `provision the platform's own sign-in (ZITADEL) at ${platform.auth.hostname}`,
791
+ description: `provision the platform's own sign-in (ZITADEL, the login app and the sign-in broker) at ${platform.auth.hostname}`,
509
792
  destroys: false,
510
793
  migrates: false,
511
794
  },
512
795
  },
513
- blocked: null,
796
+ // #211 round 3: still surface a broker-deploy block for visibility even
797
+ // though the step above is planned -- see the doc comment above. #216
798
+ // round 2: when nothing executor-side is blocked, surface an in-flight
799
+ // (or abandoned) login-client mint the same way -- informational only,
800
+ // never a reason to skip planning. #216 round 4: same treatment for a
801
+ // drifted (already-delivered, now stale) login-client token. #216 round
802
+ // 5: and only claim "this apply re-mints" when `otherInputsMissing` is
803
+ // actually empty -- see this function's own doc comment.
804
+ blocked: executorBlocked ??
805
+ loginClientNeedsMintNote(loginClientUndelivered, loginClientApplied, authDrifted, loginClientRecord, otherInputsMissing),
514
806
  };
515
807
  }
808
+ /**
809
+ * #216 round 5: the note {@link planAuth} surfaces when the login-client key
810
+ * needs a re-mint it cannot yet perform, split out of `planAuth`'s own return
811
+ * statement so the "needs minting" question and the "can it actually mint
812
+ * right now" question are each answered in one place.
813
+ */
814
+ function loginClientNeedsMintNote(loginClientUndelivered, loginClientApplied, authDrifted, loginClientRecord, otherInputsMissing) {
815
+ const undeliveredNeedsMint = Boolean(loginClientUndelivered) && !loginClientApplied;
816
+ let subject = null;
817
+ if (undeliveredNeedsMint) {
818
+ subject = `the login-client key of ZITADEL machine user ${loginClientUndelivered} was minted but not yet confirmed delivered`;
819
+ }
820
+ else if (authDrifted && loginClientRecord) {
821
+ subject = `the login-client token delivered for ZITADEL machine user ${loginClientRecord.userId} is no longer the sole live token on that user, likely revoked or replaced by a concurrent apply`;
822
+ }
823
+ if (subject === null)
824
+ return null;
825
+ if (otherInputsMissing.length > 0) {
826
+ return `${subject}; this apply cannot re-mint and redeliver it yet -- the platform's sign-in broker still needs ${otherInputsMissing.join(' and ')} first`;
827
+ }
828
+ const action = undeliveredNeedsMint
829
+ ? 'this apply re-mints (revoking it) and retries'
830
+ : 'this apply re-mints and redelivers';
831
+ return `${subject}; ${action} -- no manual step needed`;
832
+ }
516
833
  /**
517
834
  * #169: the platform's own mail server (Stalwart), built fresh -- Stalwart on
518
835
  * Coolify with its own storage, reached at `platform.mail.hostname` for
@@ -763,6 +1080,18 @@ function planAi(platform, recordedIds, executorBlocked) {
763
1080
  recordedIds[PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY] !== platform.ai.gitRef;
764
1081
  if (applied && !drifted)
765
1082
  return { step: null, blocked: null };
1083
+ // #201: the signing pair foundry mints to authenticate its own calls to
1084
+ // ai's platform/keys route. Minted but not confirmed delivered and pinned:
1085
+ // the private half cannot be recovered, so report it rather than mint
1086
+ // another (the same reason the management key check below never mints
1087
+ // twice).
1088
+ const signingUndelivered = recordedIds[PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY];
1089
+ if (signingUndelivered && !recordedIds[PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY]) {
1090
+ return {
1091
+ step: null,
1092
+ blocked: `the platform's AI host is blocked, not planned: its signing key ${signingUndelivered} was minted but not confirmed delivered and pinned, and the private half cannot be recovered; check the held key and the runner's MANAGEMENT_PUBLIC_KEYS, then remove the recorded key ${PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY} to mint a new one`,
1093
+ };
1094
+ }
766
1095
  // Minted but never delivered: ai shows a key's secret once, so it is lost.
767
1096
  // Minting another would hide that; report it and plan nothing.
768
1097
  const undelivered = recordedIds[PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY];
@@ -772,6 +1101,16 @@ function planAi(platform, recordedIds, executorBlocked) {
772
1101
  blocked: `the platform's AI host is blocked, not planned: AI management key ${undelivered} was minted but never delivered and cannot be shown again; revoke it in ai (keys:revoke --id ${undelivered}) and remove the recorded key ${PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY} to mint a new one`,
773
1102
  };
774
1103
  }
1104
+ // #201: ai reported its 'archon' key already exists with no secret to
1105
+ // show -- a mint this record never learned about. Never minted under
1106
+ // another name; a person must revoke the live key first.
1107
+ const conflict = recordedIds[PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY];
1108
+ if (conflict) {
1109
+ return {
1110
+ step: null,
1111
+ blocked: `the platform's AI host is blocked, not planned: ai already holds a management key named 'archon' (${conflict}) that was never recorded delivered, so its secret cannot be shown again; revoke it in ai and remove the recorded key ${PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY} to mint a new one`,
1112
+ };
1113
+ }
775
1114
  const migrateBlocked = recordedIds[PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY];
776
1115
  if (migrateBlocked) {
777
1116
  return {
@@ -819,6 +1158,11 @@ function planAi(platform, recordedIds, executorBlocked) {
819
1158
  * planned in this same plan: Archon's sign-in client is created inside it, so
820
1159
  * the step could only throw.
821
1160
  *
1161
+ * #213: also puts {@link PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF},
1162
+ * {@link PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF} and
1163
+ * {@link PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF} as held keys, before
1164
+ * the app deploys -- `putArchonMasterKeys` in `platform-providers.ts`.
1165
+ *
822
1166
  * #197: a recorded app with no recorded `cloudflared` connector
823
1167
  * ({@link PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY}) plans the step again, as
824
1168
  * {@link planAuth} does.
@@ -831,6 +1175,13 @@ function planArchon(platform, recordedIds, executorBlocked, authReady, drifted)
831
1175
  Boolean(recordedIds[PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY]) &&
832
1176
  recordedIds[PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY] === platform.archon.hostname &&
833
1177
  recordedIds[PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY] === platform.archon.gitRef &&
1178
+ // #211 round 4: a broker recreated under a new app id (the old one
1179
+ // deleted by hand, or `platform:auth` minting a fresh one) leaves
1180
+ // Archon's last registration on a broker that no longer exists, and
1181
+ // nothing else here would ever plan the step again to redo it.
1182
+ Boolean(recordedIds[PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY]) &&
1183
+ recordedIds[PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY] ===
1184
+ recordedIds[PLATFORM_AUTH_BROKER_SCOPE_KEY] &&
834
1185
  !recordedIds[PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY];
835
1186
  if (applied && !drifted)
836
1187
  return { step: null, blocked: null };
@@ -857,7 +1208,9 @@ function planArchon(platform, recordedIds, executorBlocked, authReady, drifted)
857
1208
  desired: { hostname: platform.archon.hostname, gitRef: platform.archon.gitRef },
858
1209
  },
859
1210
  step: {
860
- description: `provision Archon at ${platform.archon.hostname}`,
1211
+ description: `provision Archon at ${platform.archon.hostname}, putting its master keys ` +
1212
+ `(${PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF}, ${PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF}, ` +
1213
+ `${PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF}) as held keys if they are not held yet`,
861
1214
  destroys: false,
862
1215
  migrates: false,
863
1216
  },
@@ -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