@wtfalch/foundry 0.21.0 → 0.22.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.
package/dist/plan/diff.js CHANGED
@@ -9,8 +9,8 @@ import { planFilesKeys } from './entry-files-key.js';
9
9
  import { appHostname, entryHostnames, tunnelDnsHostnames } from './hostnames.js';
10
10
  import { LOGIN_BINDING_SCOPE_KEY, loginBindingState, loginHostClientIds, loginHostnameFor, pendingRemovalHosts, } from './login-host.js';
11
11
  import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, } from './mail-copy.js';
12
- import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, appEnvAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, versionNotAppliedLine, } from './platform-diff.js';
13
- import { DEFAULT_ORG_PLATFORM_READINESS } from './platform-org.js';
12
+ import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, ORG_MAIL_APPLIED_MODE_SCOPE_KEY, ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY, appEnvAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, versionNotAppliedLine, } from './platform-diff.js';
13
+ import { DEFAULT_ORG_PLATFORM_READINESS, } from './platform-org.js';
14
14
  import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
15
15
  import { appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec } from './proxy-labels.js';
16
16
  import { withStepSystem } from './step-system.js';
@@ -106,6 +106,10 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
106
106
  const zone = planZone(org, byKey, claimed);
107
107
  const zoneWaiting = zone.blocked !== null;
108
108
  const zoneBlocked = [];
109
+ // #555: steps that write DNS records, which go through edge.
110
+ const dnsBlocked = [];
111
+ // #553: a ZITADEL app provision whose app id is recorded needs the typed confirmation.
112
+ const guardApp = (step) => guardAppProvision(step, org, recordedIds);
109
113
  const mail = planMail(org, byKey, claimed, recordedIds, platformReadiness.mailHost, platformReadiness.platformApex, platformReadiness.platformLettermintProjectId);
110
114
  // #238: own-domain mail publishes DNS records into the zone, so it waits for
111
115
  // an active zone -- blocked, not planned (rule 2).
@@ -115,6 +119,10 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
115
119
  key: 'coolify:mail:zone',
116
120
  });
117
121
  }
122
+ else if (mail.step && edgeNeeded(platformReadiness, ['zones', 'dns'])) {
123
+ // #555: its zone lookup and DNS records go through edge.
124
+ dnsBlocked.push(edgeBlockedItem(org, 'mail', 'coolify:mail', edgeNeeded(platformReadiness, ['zones', 'dns'])));
125
+ }
118
126
  else if (mail.step) {
119
127
  plannedSteps.push(mail.step);
120
128
  }
@@ -166,8 +174,9 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
166
174
  // step's recorded client id, the same way `coolifyApp` runs after the
167
175
  // hosted app's own `zitadel:app` steps.
168
176
  const cmsClient = planCmsClient(org, byKey, claimed);
169
- if (cmsClient)
170
- plannedSteps.push(cmsClient);
177
+ const cmsGuarded = guardApp(cmsClient);
178
+ if (cmsGuarded)
179
+ plannedSteps.push(cmsGuarded);
171
180
  const site = planSite(org, byKey, claimed);
172
181
  if (site)
173
182
  plannedSteps.push(site);
@@ -182,11 +191,29 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
182
191
  const customers = planCustomers(org, live, claimed, recordedIds);
183
192
  if (customers)
184
193
  plannedSteps.push(customers);
185
- plannedSteps.push(...planIssuerApps(org, byKey, claimed));
186
- plannedSteps.push(...planEntryClients(org, byKey, claimed));
194
+ for (const step of [
195
+ ...planIssuerApps(org, byKey, claimed),
196
+ ...planEntryClients(org, byKey, claimed),
197
+ ]) {
198
+ const guarded = guardApp(step);
199
+ if (guarded)
200
+ plannedSteps.push(guarded);
201
+ }
187
202
  // The Access gate before the database, app, tunnel and zone (#84): on a
188
203
  // first apply the hostname is gated before anything answers on it.
189
- plannedSteps.push(...planAccess(org, recordedIds));
204
+ // An Access gate that cannot be planned must not leave the hostname it
205
+ // guards routed: the tunnel (and its DNS) that exposes it waits too.
206
+ let accessGateBlocked = false;
207
+ for (const step of planAccess(org, recordedIds)) {
208
+ const reason = edgeNeeded(platformReadiness, ['access']);
209
+ if (reason) {
210
+ accessGateBlocked = true;
211
+ dnsBlocked.push(edgeBlockedItem(org, `Access gate for ${step.action.key}`, 'cloudflare:access', reason));
212
+ }
213
+ else {
214
+ plannedSteps.push(step);
215
+ }
216
+ }
190
217
  // Database before app (hosting-wiring campaign): `plan-apply.ts`'s
191
218
  // `runSteps` executes `plannedSteps` in this array order, and the app's own
192
219
  // step (`executor.ts`'s `coolifyApp`) pushes the database's connection
@@ -217,7 +244,21 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
217
244
  const tunnel = planTunnel(org, byKey, claimed, recordedIds);
218
245
  // #238: a tunnel that is being provisioned waits for an active zone; parking
219
246
  // one never does.
220
- if (tunnel && zoneWaiting && tunnel.action.desired.enabled !== false) {
247
+ // #555: provisioning writes the tunnel config, the zone lookup and DNS; a
248
+ // park only writes the tunnel config (`cloudflareTunnel`'s `enabled === false`
249
+ // branch), so it needs the tunnels port alone.
250
+ const tunnelEdgeReason = tunnel
251
+ ? edgeNeeded(platformReadiness, tunnel.action.desired.enabled === false ? ['tunnels'] : ['tunnels', 'zones', 'dns'])
252
+ : null;
253
+ // Parking closes a hostname, so only a provision waits for a blocked gate.
254
+ const tunnelReason = tunnelEdgeReason ??
255
+ (tunnel && accessGateBlocked && tunnel.action.desired.enabled !== false
256
+ ? 'its Access gate is blocked, and the tunnel and DNS would expose the hostname without it'
257
+ : null);
258
+ if (tunnel && tunnelReason) {
259
+ dnsBlocked.push(edgeBlockedItem(org, 'Cloudflare tunnel', 'cloudflare:tunnel', tunnelReason));
260
+ }
261
+ else if (tunnel && zoneWaiting && tunnel.action.desired.enabled !== false) {
221
262
  zoneBlocked.push({
222
263
  description: `${org.slug}'s Cloudflare tunnel is blocked, not planned: the zone for ${org.domain} is not active yet`,
223
264
  key: 'cloudflare:tunnel',
@@ -226,13 +267,22 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
226
267
  else if (tunnel) {
227
268
  plannedSteps.push(tunnel);
228
269
  }
229
- if (zone.step)
270
+ const zoneReason = zone.step ? edgeNeeded(platformReadiness, ['zones']) : null;
271
+ if (zone.step && zoneReason) {
272
+ dnsBlocked.push(edgeBlockedItem(org, 'Cloudflare zone', 'cloudflare:zone', zoneReason));
273
+ }
274
+ else if (zone.step) {
230
275
  plannedSteps.push(zone.step);
276
+ }
231
277
  // Right after the zone (#43): `cloudflareRateLimit` resolves the zone by
232
278
  // apex domain, so the zone step above must already have run (or the zone
233
279
  // must already exist live) by the time this one's action executes.
234
280
  const rateLimit = planRateLimit(org, recordedIds);
235
- if (rateLimit && zoneWaiting) {
281
+ const rateLimitReason = rateLimit ? edgeNeeded(platformReadiness, ['zones', 'ratelimits']) : null;
282
+ if (rateLimit && rateLimitReason) {
283
+ dnsBlocked.push(edgeBlockedItem(org, 'sign-in rate limit', 'cloudflare:ratelimit', rateLimitReason));
284
+ }
285
+ else if (rateLimit && zoneWaiting) {
236
286
  zoneBlocked.push({
237
287
  description: `${org.slug}'s sign-in rate limit is blocked, not planned: the zone for ${org.domain} is not active yet`,
238
288
  key: 'cloudflare:ratelimit',
@@ -246,8 +296,9 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
246
296
  // Its fixed ZITADEL client before the Coolify app it signs into:
247
297
  // `coolifyAgora` reads this step's recorded client id.
248
298
  const agoraClient = planAgoraClient(org, byKey, claimed);
249
- if (agoraClient)
250
- plannedSteps.push(agoraClient);
299
+ const agoraGuarded = guardApp(agoraClient);
300
+ if (agoraGuarded)
301
+ plannedSteps.push(agoraGuarded);
251
302
  const agora = planAgora(org, byKey, claimed, recordedIds, agoraHost);
252
303
  if (agora.step)
253
304
  plannedSteps.push(agora.step);
@@ -255,8 +306,9 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
255
306
  // shape as Agora and planned right after it. Its fixed ZITADEL client first,
256
307
  // for the same reason: `coolifyBoule` reads that step's recorded client id.
257
308
  const bouleClient = planBouleClient(org, byKey, claimed);
258
- if (bouleClient)
259
- plannedSteps.push(bouleClient);
309
+ const bouleGuarded = guardApp(bouleClient);
310
+ if (bouleGuarded)
311
+ plannedSteps.push(bouleGuarded);
260
312
  const boule = planBoule(org, byKey, claimed, recordedIds, authIssuer, options.otherSlugs ?? [], options.archonUrl ?? null);
261
313
  if (boule.step)
262
314
  plannedSteps.push(boule.step);
@@ -285,7 +337,12 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
285
337
  // retire, which needs no new zone) still waits for it.
286
338
  const edgeWorkers = planWorkers(org, recordedIds, options.edgeWorkers ?? false, options.platformHostnames ?? []);
287
339
  for (const step of edgeWorkers.steps) {
288
- if (zoneWaiting && step.action.desired.mode === 'apply') {
340
+ // A retire never looks up the zone (`cloudflareWorker`), so only a deploy needs edge.
341
+ const workerReason = step.action.desired.mode === 'apply' ? edgeNeeded(platformReadiness, ['zones']) : null;
342
+ if (workerReason) {
343
+ dnsBlocked.push(edgeBlockedItem(org, `${step.action.key.slice(org.id.length + 1)} Worker`, step.action.key, workerReason));
344
+ }
345
+ else if (zoneWaiting && step.action.desired.mode === 'apply') {
289
346
  zoneBlocked.push({
290
347
  description: `${org.slug}'s ${step.action.key.slice(org.id.length + 1)} Worker is blocked, not planned: the zone for ${org.domain} is not active yet`,
291
348
  key: step.action.key,
@@ -361,6 +418,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
361
418
  if (zone.blocked)
362
419
  unmanaged.push({ description: zone.blocked, key: 'cloudflare:zone' });
363
420
  unmanaged.push(...zoneBlocked);
421
+ unmanaged.push(...dnsBlocked);
364
422
  // #243: an appEnv source nothing will record is reported, not silently skipped.
365
423
  unmanaged.push(...app.blocked);
366
424
  unmanaged.push(...brokerEntries.blocked);
@@ -372,6 +430,52 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
372
430
  versionChanges,
373
431
  };
374
432
  }
433
+ /**
434
+ * #553: a plain ZITADEL app provision (not a `recreate`) is planned only when
435
+ * the live listing lacks the app by name. If an app id is recorded for it,
436
+ * the listing may simply have missed a live app, and nothing can read an app
437
+ * by id, so applying silently could replace a live sign-in client. The way
438
+ * out is the destroying `recreate` path: the step is planned as a
439
+ * replacement that asks for the typed confirmation, so a person who has
440
+ * checked the app is gone (deleted by hand in ZITADEL) says so, and apply
441
+ * then deletes the recorded id (a 404 is fine) and creates a new app.
442
+ * (A failed recreate clears the recorded id, so it is not caught here.)
443
+ */
444
+ function guardAppProvision(step, org, recordedIds) {
445
+ if (!step || step.action.system !== 'zitadel' || step.action.kind !== 'app')
446
+ return step;
447
+ if (step.action.desired.recreate === true)
448
+ return step;
449
+ const recorded = recordedIds[`zitadel:app:${step.action.key}`];
450
+ if (!recorded)
451
+ return step;
452
+ return {
453
+ action: { ...step.action, desired: { ...step.action.desired, recreate: true } },
454
+ step: {
455
+ description: `replace ZITADEL app '${step.action.key}' for ${org.slug}: recorded app ${recorded} is not in ZITADEL's listing (a listing can miss a live app; if it is live, do not confirm -- apply deletes it and creates a new client id)`,
456
+ destroys: true,
457
+ migrates: false,
458
+ },
459
+ };
460
+ }
461
+ /**
462
+ * #555: why a step that needs these edge-routed ports cannot run, or `null`.
463
+ * `readiness.dnsBlocked` is set while edge is not applied; a port the host
464
+ * supplies itself (`readiness.ownEdgePorts`, mirroring the executor's
465
+ * `ports.dns` / `zones` / `access` / `ratelimits` / `tunnels`, which win over
466
+ * edge) needs no edge, so only a step that would really reach edge is blocked.
467
+ */
468
+ function edgeNeeded(readiness, needs) {
469
+ if (!readiness.dnsBlocked)
470
+ return null;
471
+ return needs.every((port) => readiness.ownEdgePorts?.[port]) ? null : readiness.dnsBlocked;
472
+ }
473
+ function edgeBlockedItem(org, what, key, reason) {
474
+ return {
475
+ description: `${org.slug}'s ${what} is blocked, not planned: ${reason ?? ''} (#555)`,
476
+ key,
477
+ };
478
+ }
375
479
  /**
376
480
  * The org's own ZITADEL org (issuer), a per-org singleton like every other
377
481
  * planner in this file, with one extra wrinkle: unlike mail/chat/site/app,
@@ -776,7 +880,24 @@ platformApex, platformLettermintProjectId) {
776
880
  const roleNote = rolesOwed > 0 ? `create ${rolesOwed} role mailbox(es) for ${org.slug}` : '';
777
881
  const mailboxWork = [ownerNote, roleNote].filter(Boolean).join(' and ');
778
882
  const mailboxNote = mailboxWork ? ` and ${mailboxWork}` : '';
779
- if (!live) {
883
+ // #554: no reader lists a live `coolify mail <org>` resource, so "applied"
884
+ // comes from the record: the domain, mode and `usesDomain` the step last
885
+ // wrote (`coolifyMail`). A changed declaration differs from it and plans.
886
+ const recordedDomain = recordedIds[MAIL_DOMAIN_SCOPE_KEY];
887
+ const recordedMode = recordedIds[ORG_MAIL_APPLIED_MODE_SCOPE_KEY];
888
+ const recordedUsesDomain = recordedIds[ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY];
889
+ const domainMatches = Boolean(recordedDomain && org.domain && recordedDomain.toLowerCase() === org.domain.toLowerCase());
890
+ const appliedFromRecord = !live &&
891
+ Boolean(recordedDomain) &&
892
+ // Before `mail_applied_mode` existed, only a received or hosted apply
893
+ // recorded `mail_domain` (`off` returns before it); production's record is
894
+ // hosted. An unrecorded mode vouches for `hosted` only.
895
+ (recordedMode === undefined ? desired.mode === 'hosted' : recordedMode === desired.mode) &&
896
+ (recordedUsesDomain === undefined
897
+ ? desired.usesDomain && domainMatches
898
+ : recordedUsesDomain === String(desired.usesDomain) &&
899
+ (!desired.usesDomain || domainMatches));
900
+ if (!live && !appliedFromRecord) {
780
901
  return {
781
902
  step: {
782
903
  action: { system: 'coolify', kind: 'mail', key: org.id, desired },
@@ -790,8 +911,8 @@ platformApex, platformLettermintProjectId) {
790
911
  mailboxBlocked,
791
912
  };
792
913
  }
793
- const liveMode = live.config.mode;
794
- const liveUsesDomain = Boolean(live.config.usesDomain);
914
+ const liveMode = live ? live.config.mode : desired.mode;
915
+ const liveUsesDomain = live ? Boolean(live.config.usesDomain) : desired.usesDomain;
795
916
  if (liveMode === desired.mode && liveUsesDomain === desired.usesDomain) {
796
917
  // #177: the platform's apex or mail host moved since this step last wrote
797
918
  // them (or it never recorded them). Re-applying is idempotent.
@@ -1476,7 +1597,12 @@ function planCmsClient(org, byKey, claimed) {
1476
1597
  if (live.config.appType === 'web')
1477
1598
  return null;
1478
1599
  return {
1479
- action: { system: 'zitadel', kind: 'app', key: 'cms', desired },
1600
+ action: {
1601
+ system: 'zitadel',
1602
+ kind: 'app',
1603
+ key: 'cms',
1604
+ desired: { ...desired, recreate: true },
1605
+ },
1480
1606
  step: {
1481
1607
  description: `recreate ZITADEL app 'cms' for ${org.slug} as web (was ${String(live.config.appType)})`,
1482
1608
  destroys: true,
@@ -1580,7 +1706,12 @@ function planAgoraClient(org, byKey, claimed) {
1580
1706
  if (live.config.appType === 'web')
1581
1707
  return null;
1582
1708
  return {
1583
- action: { system: 'zitadel', kind: 'app', key: 'agora', desired },
1709
+ action: {
1710
+ system: 'zitadel',
1711
+ kind: 'app',
1712
+ key: 'agora',
1713
+ desired: { ...desired, recreate: true },
1714
+ },
1584
1715
  step: {
1585
1716
  description: `recreate ZITADEL app 'agora' for ${org.slug} as web (was ${String(live.config.appType)})`,
1586
1717
  destroys: true,
@@ -1718,7 +1849,12 @@ function planBouleClient(org, byKey, claimed) {
1718
1849
  if (live.config.appType === 'web')
1719
1850
  return null;
1720
1851
  return {
1721
- action: { system: 'zitadel', kind: 'app', key: 'boule', desired },
1852
+ action: {
1853
+ system: 'zitadel',
1854
+ kind: 'app',
1855
+ key: 'boule',
1856
+ desired: { ...desired, recreate: true },
1857
+ },
1722
1858
  step: {
1723
1859
  description: `recreate ZITADEL app 'boule' for ${org.slug} as web (was ${String(live.config.appType)})`,
1724
1860
  destroys: true,
@@ -2168,6 +2304,19 @@ function planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer, re
2168
2304
  platformLettermintProjectId: readiness.platformLettermintProjectId,
2169
2305
  };
2170
2306
  }
2307
+ // #555: the `app` entry's step publishes the mail domain's DNS records
2308
+ // (`ensureAppLettermintMail`) once one is recorded, and those go through
2309
+ // edge: blocked, not planned, while edge is not applied.
2310
+ const appReason = edgeNeeded(readiness, ['zones', 'dns']);
2311
+ if (appReason &&
2312
+ key === APP_ENTRY_KEY &&
2313
+ app.step &&
2314
+ app.step.action.desired.enabled !== false &&
2315
+ recordedIds[MAIL_DOMAIN_SCOPE_KEY]) {
2316
+ blocked.push(edgeBlockedItem(org, 'app', 'coolify:app', appReason));
2317
+ blocked.push(...app.blocked);
2318
+ continue;
2319
+ }
2171
2320
  if (app.step)
2172
2321
  steps.push(app.step);
2173
2322
  blocked.push(...app.blocked);
@@ -2458,7 +2607,12 @@ function planIssuerApps(org, byKey, claimed) {
2458
2607
  if (live.config.appType === app.kind)
2459
2608
  continue;
2460
2609
  steps.push({
2461
- action: { system: 'zitadel', kind: 'app', key: app.name, desired },
2610
+ action: {
2611
+ system: 'zitadel',
2612
+ kind: 'app',
2613
+ key: app.name,
2614
+ desired: { ...desired, recreate: true },
2615
+ },
2462
2616
  step: {
2463
2617
  description: `recreate ZITADEL app '${app.name}' for ${org.slug} as ${app.kind} (was ${String(live.config.appType)})`,
2464
2618
  destroys: true,
@@ -12,6 +12,7 @@ import { type EdgePlatformZones, type ZonePort } from './edge-zones.js';
12
12
  import { type LoginHostBindingsPort } from './login-host.js';
13
13
  import { type MailCopyPort } from './mail-copy.js';
14
14
  import type { DestroyTarget, StepDestroyer, StepExecutionContext, StepExecutor } from './plan-apply.js';
15
+ import type { EdgePortName } from './platform-org.js';
15
16
  /**
16
17
  * The real {@link StepExecutor} (#20 "provision Company Factory end to
17
18
  * end"): makes each {@link StepAction} `diff.ts` plans true against a real
@@ -421,6 +422,16 @@ export interface CoolifyPort {
421
422
  deploymentUuid: string;
422
423
  status: string;
423
424
  }>;
425
+ /**
426
+ * One deployment's status by its uuid (`queued`, `in_progress`, `finished`,
427
+ * `failed`, `cancelled-by-user`); throws a 404 for a uuid Coolify does not
428
+ * know. Matches `CoolifyClient.getDeployment` method-for-method. The
429
+ * platform's Archon step reads it to confirm a deploy it triggered and did
430
+ * not wait for.
431
+ */
432
+ getDeployment(deploymentUuid: string): Promise<{
433
+ status: string;
434
+ }>;
424
435
  }
425
436
  /**
426
437
  * The Cloudflare zone calls the platform steps make directly, shaped to match
@@ -799,6 +810,8 @@ export interface ZitadelPort {
799
810
  appType: 'web' | 'native' | 'api';
800
811
  state: string;
801
812
  loginBaseUri?: string | null;
813
+ /** Present only when the host's listing reads it; `zitadelApp` adopts with it, else with the recorded one. */
814
+ clientId?: string;
802
815
  }>;
803
816
  complete: boolean;
804
817
  }>;
@@ -987,10 +1000,10 @@ export interface ZitadelPort {
987
1000
  * listApps` -- a plain array, with no `loginBaseUri` or completeness signal
988
1001
  * -- into {@link ZitadelPort.listApps}'s own shape, so a host wiring that
989
1002
  * real client in satisfies this port without reaching into `executor.ts`
990
- * itself to build the wrapper by hand. `complete: false` on every call:
991
- * the real client's own `_search` request has no pagination on it yet
992
- * (https://github.com/wtfalch/providers/issues/29), so this adapter cannot
993
- * promise it read more than one page. `loginBaseUri` comes back `undefined`
1003
+ * itself to build the wrapper by hand. `complete: true` on every call:
1004
+ * since providers 0.7.4 the real client pages `_search` to the end. A host
1005
+ * that supplies its own `listApps` keeps whatever `complete` it reports.
1006
+ * Each app's `clientId` (providers 0.7.5) passes through. `loginBaseUri` comes back `undefined`
994
1007
  * for every app for the identical reason -- the real client does not read
995
1008
  * it back at all yet, not "read it back as absent" -- which is exactly the
996
1009
  * state `zitadelLoginHost`'s fail-closed rule exists for: every pending
@@ -1011,6 +1024,8 @@ export interface ZitadelListAppsRawApp {
1011
1024
  name: string;
1012
1025
  appType: 'web' | 'native' | 'api';
1013
1026
  state: string;
1027
+ /** providers 0.7.5: the app's client id. Passed through to `ZitadelPort.listApps`. */
1028
+ clientId?: string;
1014
1029
  }
1015
1030
  /**
1016
1031
  * Provisions one org's organisation on Files (#80), matching
@@ -1555,6 +1570,8 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1555
1570
  private edgeZones;
1556
1571
  private edgeWriteSlot;
1557
1572
  constructor(ports: FoundryStepExecutorPorts);
1573
+ /** #555: the edge-routed ports this executor was built with itself (they win over edge). */
1574
+ ownEdgePorts(): Partial<Record<EdgePortName, boolean>>;
1558
1575
  execute(action: StepAction, context: StepExecutionContext): Promise<{
1559
1576
  resourceId?: string;
1560
1577
  } | undefined>;
@@ -23,7 +23,7 @@ import { healthCheckSettings } from './health-check-settings.js';
23
23
  import { appHostname, coolifyDomains, entryHostnames, tunnelDnsHostnames, tunnelIngressHostnames, } from './hostnames.js';
24
24
  import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, LOGIN_HOST_SHARED_SCOPE_KEY, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
25
25
  import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
26
- import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, appDomainsAppliedScopeKey, appEnvAppliedScopeKey, appNameFor, appSettingsAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, } from './platform-diff.js';
26
+ import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, ORG_MAIL_APPLIED_MODE_SCOPE_KEY, ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY, appDomainsAppliedScopeKey, appEnvAppliedScopeKey, appNameFor, appSettingsAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, } from './platform-diff.js';
27
27
  import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
28
28
  import { appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec, entryProxyLabels, sameLabelSets, } from './proxy-labels.js';
29
29
  import { entryWorkerAppliedScopeKey, entryWorkerExclusionsScopeKey, entryWorkerRetiredScopeKey, entryWorkerRouteIdScopeKey, entryWorkerRoutePatternScopeKey, entryWorkerRoutePendingScopeKey, entryWorkerRouteZoneScopeKey, entryWorkerUploadedScopeKey, } from './worker.js';
@@ -32,10 +32,10 @@ import { entryWorkerAppliedScopeKey, entryWorkerExclusionsScopeKey, entryWorkerR
32
32
  * listApps` -- a plain array, with no `loginBaseUri` or completeness signal
33
33
  * -- into {@link ZitadelPort.listApps}'s own shape, so a host wiring that
34
34
  * real client in satisfies this port without reaching into `executor.ts`
35
- * itself to build the wrapper by hand. `complete: false` on every call:
36
- * the real client's own `_search` request has no pagination on it yet
37
- * (https://github.com/wtfalch/providers/issues/29), so this adapter cannot
38
- * promise it read more than one page. `loginBaseUri` comes back `undefined`
35
+ * itself to build the wrapper by hand. `complete: true` on every call:
36
+ * since providers 0.7.4 the real client pages `_search` to the end. A host
37
+ * that supplies its own `listApps` keeps whatever `complete` it reports.
38
+ * Each app's `clientId` (providers 0.7.5) passes through. `loginBaseUri` comes back `undefined`
39
39
  * for every app for the identical reason -- the real client does not read
40
40
  * it back at all yet, not "read it back as absent" -- which is exactly the
41
41
  * state `zitadelLoginHost`'s fail-closed rule exists for: every pending
@@ -45,7 +45,9 @@ import { entryWorkerAppliedScopeKey, entryWorkerExclusionsScopeKey, entryWorkerR
45
45
  export function adaptZitadelListApps(client) {
46
46
  return async (orgId, projectId) => ({
47
47
  apps: await client.listApps(orgId, projectId),
48
- complete: false,
48
+ // Sound because package.json requires providers >=0.7.4 as a peer: from
49
+ // there `listApps` pages `_search` to the end, so this read every app.
50
+ complete: true,
49
51
  });
50
52
  }
51
53
  // ---------------------------------------------------------------------------
@@ -731,6 +733,16 @@ export class FoundryStepExecutor {
731
733
  constructor(ports) {
732
734
  this.ports = ports;
733
735
  }
736
+ /** #555: the edge-routed ports this executor was built with itself (they win over edge). */
737
+ ownEdgePorts() {
738
+ return {
739
+ dns: Boolean(this.ports.dns),
740
+ zones: Boolean(this.ports.zones),
741
+ access: Boolean(this.ports.access),
742
+ ratelimits: Boolean(this.ports.ratelimits),
743
+ tunnels: Boolean(this.ports.tunnels),
744
+ };
745
+ }
734
746
  async execute(action, context) {
735
747
  const { org } = context;
736
748
  switch (`${action.system}:${action.kind}`) {
@@ -1459,14 +1471,69 @@ export class FoundryStepExecutor {
1459
1471
  throw new Error(`zitadel:app '${action.key}' for ${org.slug}: no ZITADEL org recorded yet -- the issuer must be provisioned first`);
1460
1472
  }
1461
1473
  const projectId = await this.ensureZitadelProject(org, zitadelOrgId, context);
1462
- // A kind change deletes the recorded app then recreates it (task
1463
- // brief); the first-ever create for this key has nothing recorded, so
1464
- // this is a no-op then. A 404 here (already gone) is tolerated the same
1465
- // way `destroy` tolerates one -- a retry after the delete half already
1466
- // succeeded but the recreate half failed must find an already-deleted
1467
- // app a no-op, not a new error that wedges the apply forever.
1474
+ const isCmsClient = action.key === 'cms' && org.site.enabled;
1475
+ const isAgoraClient = action.key === 'agora';
1476
+ const isBouleClient = action.key === 'boule';
1477
+ const authEntry = org.hosting.entries.find((e) => e.key === action.key && e.uses?.auth);
1478
+ const clientIdScopeKey = isCmsClient
1479
+ ? CMS_CLIENT_ID_SCOPE_KEY
1480
+ : isAgoraClient
1481
+ ? AGORA_CLIENT_ID_SCOPE_KEY
1482
+ : isBouleClient
1483
+ ? BOULE_CLIENT_ID_SCOPE_KEY
1484
+ : authEntry
1485
+ ? entryClientIdScopeKey(action.key)
1486
+ : APP_CLIENT_ID_SCOPE_KEY;
1468
1487
  const priorAppId = recorded[`zitadel:app:${action.key}`];
1469
- if (priorAppId) {
1488
+ // Only a step the planner flagged as destroying (`recreate`, set with
1489
+ // `destroys: true` so plan-apply asks for the typed confirmation) may
1490
+ // delete an app. A plain provision adopts a live app instead: one with
1491
+ // the recorded id, or one of the same name in the project (an app an
1492
+ // earlier apply created whose response was lost). ZITADEL's listing can
1493
+ // miss a live app (it once dropped both company sign-in clients on
1494
+ // production), and there is no read by app id, so a recorded app the
1495
+ // listing does not show is never replaced: the planner reports it
1496
+ // blocked, and this throws if a stale plan reaches it.
1497
+ if (action.desired.recreate !== true) {
1498
+ const listing = await this.ports.zitadel.listApps(zitadelOrgId, projectId);
1499
+ const liveApps = listing.apps.filter((app) => app.state !== 'APP_STATE_REMOVED');
1500
+ const byId = priorAppId ? liveApps.find((app) => app.id === priorAppId) : undefined;
1501
+ const adopted = byId ?? liveApps.find((app) => app.name === action.key && app.appType === appType);
1502
+ if (adopted) {
1503
+ // Not adopted silently: a wrong type is a recreate (destroying, typed
1504
+ // confirmation) and an inactive app is a person's decision. Neither
1505
+ // can be known from the plan's name-keyed live read, so this throws.
1506
+ if (adopted.appType !== appType || adopted.state !== 'APP_STATE_ACTIVE') {
1507
+ throw new Error(`zitadel:app '${action.key}' for ${org.slug}: app ${adopted.id} is live as a ${adopted.appType} app in state ${adopted.state}, not an active ${appType} app -- nothing was created or deleted`);
1508
+ }
1509
+ if (appType === 'api') {
1510
+ // The secret was minted once, at creation; an app matched only by
1511
+ // name has no recorded delivery, and nothing can recover it.
1512
+ if (adopted.id !== priorAppId) {
1513
+ throw new Error(`zitadel:app '${action.key}' for ${org.slug}: an api app ${adopted.id} of that name exists but was never recorded, so its secret cannot be recovered -- nothing was created or deleted; delete that app in ZITADEL, then apply again to mint a new one`);
1514
+ }
1515
+ }
1516
+ else {
1517
+ // A client id recorded for another app is never carried over.
1518
+ const clientId = adopted.clientId ??
1519
+ (adopted.id === priorAppId ? recorded[clientIdScopeKey] : undefined);
1520
+ if (!clientId) {
1521
+ throw new Error(`zitadel:app '${action.key}' for ${org.slug}: app ${adopted.id} is live but the listing gives no client id for it -- nothing was created or deleted`);
1522
+ }
1523
+ context.recordScopeId(clientIdScopeKey, clientId);
1524
+ }
1525
+ return { resourceId: adopted.id };
1526
+ }
1527
+ if (priorAppId) {
1528
+ throw new Error(`zitadel:app '${action.key}' for ${org.slug}: recorded app ${priorAppId} is not in the listing, and a listing can miss a live app -- nothing was created or deleted; plan again and confirm the replacement step if the app is really gone`);
1529
+ }
1530
+ }
1531
+ // A kind change (`recreate`) deletes the recorded app then recreates it
1532
+ // (task brief); a 404 (already gone) is tolerated the same way `destroy`
1533
+ // tolerates one -- a retry after the delete half already succeeded but
1534
+ // the recreate half failed must find an already-deleted app a no-op, not
1535
+ // a new error that wedges the apply forever.
1536
+ if (action.desired.recreate === true && priorAppId) {
1470
1537
  try {
1471
1538
  await this.ports.zitadel.deleteApp(zitadelOrgId, projectId, priorAppId);
1472
1539
  }
@@ -1474,16 +1541,22 @@ export class FoundryStepExecutor {
1474
1541
  if (!isNotFoundError(error))
1475
1542
  throw error;
1476
1543
  }
1544
+ // Gone now: a retry after a failed create must not find this id and be
1545
+ // blocked as "recorded but missing" (`diff.ts`'s `guardAppProvision`).
1546
+ // Removed (not blanked) and persisted now: a death before the create
1547
+ // must not leave the stale id behind, nor an empty one for destroy.
1548
+ if (context.forgetId)
1549
+ context.forgetId(`zitadel:app:${action.key}`);
1550
+ else
1551
+ context.recordScopeId(`zitadel:app:${action.key}`, '');
1552
+ await context.persistScopeIds();
1477
1553
  // The recreate half is retried on its own failure (the delete above
1478
1554
  // already tolerates the resulting 404), so a retry can also land here
1479
1555
  // after an earlier attempt's create call actually succeeded against
1480
- // ZITADEL but whose response this executor never saw (a dropped
1481
- // connection, a killed process). That app is deleted, not reused: the
1482
- // lost response also lost its clientSecret and clientId, which
1483
- // `listApps` cannot return, so reusing it would leave the secret
1484
- // undelivered and the clientId unrecorded forever. The create below
1485
- // then runs every side effect. A first-ever create (no `priorAppId`)
1486
- // has nothing to find here and never pays this read.
1556
+ // ZITADEL but whose response this executor never saw. That app is
1557
+ // deleted, not reused: the lost response also lost its clientSecret
1558
+ // and clientId, which `listApps` cannot return, so reusing it would
1559
+ // leave the secret undelivered and the clientId unrecorded forever.
1487
1560
  const orphan = await this.findRecreatedApp(zitadelOrgId, projectId, action.key, appType);
1488
1561
  if (orphan) {
1489
1562
  try {
@@ -1512,16 +1585,13 @@ export class FoundryStepExecutor {
1512
1585
  // doc comment relies on. #164: the fixed `agora` client (`diff.ts`'s
1513
1586
  // `planAgoraClient`) is the same shape, always -- there is no
1514
1587
  // `org.site.enabled`-style guard to check, since Agora is always on.
1515
- const isCmsClient = action.key === 'cms' && org.site.enabled;
1516
1588
  // The Agora client stays `appType: 'web'`: providers' `createOidcApp`
1517
1589
  // makes every non-native app a public client (authorization code + PKCE,
1518
1590
  // `OIDC_AUTH_METHOD_TYPE_NONE`, no secret), which is what Agora's browser
1519
1591
  // SPA needs. Providers cannot create ZITADEL's `USER_AGENT` app kind.
1520
- const isAgoraClient = action.key === 'agora';
1521
1592
  // #161: the fixed `boule` client signs people into the org's Boule at its
1522
1593
  // own hostname, on the standard `/auth/callback` path (Boule is built on
1523
1594
  // `@wtfalch/auth` like every other web app here).
1524
- const isBouleClient = action.key === 'boule';
1525
1595
  const hostname = isCmsClient
1526
1596
  ? cmsHostnameFor(org)
1527
1597
  : isAgoraClient
@@ -1542,7 +1612,6 @@ export class FoundryStepExecutor {
1542
1612
  : `https://${hostname}/auth/callback`;
1543
1613
  // An entry with `uses.auth` signs in at its own exact hostnames, one pair
1544
1614
  // of addresses each; a `*` adds none (`entryHostnames`).
1545
- const authEntry = org.hosting.entries.find((e) => e.key === action.key && e.uses?.auth);
1546
1615
  const hosts = authEntry ? entryHostnames(org, authEntry) : null;
1547
1616
  const created = await this.ports.zitadel.createOidcApp(zitadelOrgId, projectId, {
1548
1617
  name: action.key,
@@ -1567,15 +1636,7 @@ export class FoundryStepExecutor {
1567
1636
  // plain scope id, not `deliverSecret`. A native app signs in via
1568
1637
  // device code with no cookie session of its own, so its clientId is
1569
1638
  // never something an app's env needs.
1570
- context.recordScopeId(isCmsClient
1571
- ? CMS_CLIENT_ID_SCOPE_KEY
1572
- : isAgoraClient
1573
- ? AGORA_CLIENT_ID_SCOPE_KEY
1574
- : isBouleClient
1575
- ? BOULE_CLIENT_ID_SCOPE_KEY
1576
- : authEntry
1577
- ? entryClientIdScopeKey(action.key)
1578
- : APP_CLIENT_ID_SCOPE_KEY, created.clientId);
1639
+ context.recordScopeId(clientIdScopeKey, created.clientId);
1579
1640
  }
1580
1641
  return { resourceId: created.appId };
1581
1642
  }
@@ -4715,6 +4776,9 @@ export class FoundryStepExecutor {
4715
4776
  const parkedMailOrg = mailOrgFor(org, usesDomain, 'hosted');
4716
4777
  await this.ports.mail.parkOrgMail(parkedMailOrg, options);
4717
4778
  await this.parkLettermintMail(org, parkedMailOrg, context, platformApex, platformLettermintProjectId);
4779
+ // #554: so a later declaration of hosted or received is not mistaken for applied.
4780
+ context.recordScopeId(ORG_MAIL_APPLIED_MODE_SCOPE_KEY, mode);
4781
+ context.recordScopeId(ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY, String(usesDomain));
4718
4782
  return undefined;
4719
4783
  }
4720
4784
  const mailOrg = mailOrgFor(org, usesDomain, mode);
@@ -4722,12 +4786,16 @@ export class FoundryStepExecutor {
4722
4786
  await this.publishMailDnsRecords(org, result);
4723
4787
  await this.deleteStaleLettermintDomain(context, result.domain, platformApex);
4724
4788
  context.recordScopeId(MAIL_DOMAIN_SCOPE_KEY, result.domain);
4725
- // #177: what this step wrote from the platform, so `planMail` can see a later change.
4726
- context.recordScopeId(ORG_MAIL_APPLIED_APEX_SCOPE_KEY, options.apex);
4727
- context.recordScopeId(ORG_MAIL_APPLIED_HOST_SCOPE_KEY, options.mailHost);
4728
4789
  await this.ensureOwnerMailbox(org, mailOrg, options, context);
4729
4790
  await this.ensureOrgLettermintRelay(org, mailOrg, result.domain, context, platformApex, platformLettermintProjectId);
4730
4791
  await this.ensureRoleMailboxes(org, mailOrg, result.domain, options, context);
4792
+ // #177, #554: what this step wrote, so `planMail` can see a later change
4793
+ // and knows mail is applied. Last on purpose: a crash in the relay or a
4794
+ // mailbox leaves these unrecorded, so the next plan still has the step.
4795
+ context.recordScopeId(ORG_MAIL_APPLIED_APEX_SCOPE_KEY, options.apex);
4796
+ context.recordScopeId(ORG_MAIL_APPLIED_HOST_SCOPE_KEY, options.mailHost);
4797
+ context.recordScopeId(ORG_MAIL_APPLIED_MODE_SCOPE_KEY, mode);
4798
+ context.recordScopeId(ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY, String(usesDomain));
4731
4799
  return { resourceId: result.domain };
4732
4800
  }
4733
4801
  /**