@wtfalch/foundry 0.21.1 → 0.22.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.
package/dist/plan/diff.js CHANGED
@@ -7,10 +7,10 @@ import { planEntryAiKeys } from './entry-ai-key.js';
7
7
  import { planEdgeReservedLabels, planEntryEdgeKeys } from './entry-edge-key.js';
8
8
  import { planFilesKeys } from './entry-files-key.js';
9
9
  import { appHostname, entryHostnames, tunnelDnsHostnames } from './hostnames.js';
10
- import { LOGIN_BINDING_SCOPE_KEY, loginBindingState, loginHostClientIds, loginHostnameFor, pendingRemovalHosts, } from './login-host.js';
10
+ import { LOGIN_BINDING_SCOPE_KEY, companyLoginTunnelHost, 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';
@@ -102,10 +102,23 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
102
102
  if (loginHost.step)
103
103
  plannedSteps.push(loginHost.step);
104
104
  // Planned ahead of everything that needs the zone live (#238); its own step
105
- // is still pushed in its usual place below.
105
+ // is pushed just below, before mail.
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);
113
+ // #567: before the first step that reads the zone (mail, the tunnel, the
114
+ // rate limit), so a domain with no zone yet gets it provisioned first.
115
+ const zoneReason = zone.step ? edgeNeeded(platformReadiness, ['zones']) : null;
116
+ if (zone.step && zoneReason) {
117
+ dnsBlocked.push(edgeBlockedItem(org, 'Cloudflare zone', 'cloudflare:zone', zoneReason));
118
+ }
119
+ else if (zone.step) {
120
+ plannedSteps.push(zone.step);
121
+ }
109
122
  const mail = planMail(org, byKey, claimed, recordedIds, platformReadiness.mailHost, platformReadiness.platformApex, platformReadiness.platformLettermintProjectId);
110
123
  // #238: own-domain mail publishes DNS records into the zone, so it waits for
111
124
  // an active zone -- blocked, not planned (rule 2).
@@ -115,6 +128,10 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
115
128
  key: 'coolify:mail:zone',
116
129
  });
117
130
  }
131
+ else if (mail.step && edgeNeeded(platformReadiness, ['zones', 'dns'])) {
132
+ // #555: its zone lookup and DNS records go through edge.
133
+ dnsBlocked.push(edgeBlockedItem(org, 'mail', 'coolify:mail', edgeNeeded(platformReadiness, ['zones', 'dns'])));
134
+ }
118
135
  else if (mail.step) {
119
136
  plannedSteps.push(mail.step);
120
137
  }
@@ -166,8 +183,9 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
166
183
  // step's recorded client id, the same way `coolifyApp` runs after the
167
184
  // hosted app's own `zitadel:app` steps.
168
185
  const cmsClient = planCmsClient(org, byKey, claimed);
169
- if (cmsClient)
170
- plannedSteps.push(cmsClient);
186
+ const cmsGuarded = guardApp(cmsClient);
187
+ if (cmsGuarded)
188
+ plannedSteps.push(cmsGuarded);
171
189
  const site = planSite(org, byKey, claimed);
172
190
  if (site)
173
191
  plannedSteps.push(site);
@@ -182,11 +200,29 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
182
200
  const customers = planCustomers(org, live, claimed, recordedIds);
183
201
  if (customers)
184
202
  plannedSteps.push(customers);
185
- plannedSteps.push(...planIssuerApps(org, byKey, claimed));
186
- plannedSteps.push(...planEntryClients(org, byKey, claimed));
203
+ for (const step of [
204
+ ...planIssuerApps(org, byKey, claimed),
205
+ ...planEntryClients(org, byKey, claimed),
206
+ ]) {
207
+ const guarded = guardApp(step);
208
+ if (guarded)
209
+ plannedSteps.push(guarded);
210
+ }
187
211
  // The Access gate before the database, app, tunnel and zone (#84): on a
188
212
  // first apply the hostname is gated before anything answers on it.
189
- plannedSteps.push(...planAccess(org, recordedIds));
213
+ // An Access gate that cannot be planned must not leave the hostname it
214
+ // guards routed: the tunnel (and its DNS) that exposes it waits too.
215
+ let accessGateBlocked = false;
216
+ for (const step of planAccess(org, recordedIds)) {
217
+ const reason = edgeNeeded(platformReadiness, ['access']);
218
+ if (reason) {
219
+ accessGateBlocked = true;
220
+ dnsBlocked.push(edgeBlockedItem(org, `Access gate for ${step.action.key}`, 'cloudflare:access', reason));
221
+ }
222
+ else {
223
+ plannedSteps.push(step);
224
+ }
225
+ }
190
226
  // Database before app (hosting-wiring campaign): `plan-apply.ts`'s
191
227
  // `runSteps` executes `plannedSteps` in this array order, and the app's own
192
228
  // step (`executor.ts`'s `coolifyApp`) pushes the database's connection
@@ -214,10 +250,24 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
214
250
  const edgeReservedLabels = planEdgeReservedLabels(org, recordedIds, platformReadiness, options.platformHostnames ?? []);
215
251
  if (edgeReservedLabels.step)
216
252
  plannedSteps.push(edgeReservedLabels.step);
217
- const tunnel = planTunnel(org, byKey, claimed, recordedIds);
253
+ const tunnel = planTunnel(org, byKey, claimed, recordedIds, isPlatformOrg, platformReadiness.platformApex);
218
254
  // #238: a tunnel that is being provisioned waits for an active zone; parking
219
255
  // one never does.
220
- if (tunnel && zoneWaiting && tunnel.action.desired.enabled !== false) {
256
+ // #555: provisioning writes the tunnel config, the zone lookup and DNS; a
257
+ // park only writes the tunnel config (`cloudflareTunnel`'s `enabled === false`
258
+ // branch), so it needs the tunnels port alone.
259
+ const tunnelEdgeReason = tunnel
260
+ ? edgeNeeded(platformReadiness, tunnel.action.desired.enabled === false ? ['tunnels'] : ['tunnels', 'zones', 'dns'])
261
+ : null;
262
+ // Parking closes a hostname, so only a provision waits for a blocked gate.
263
+ const tunnelReason = tunnelEdgeReason ??
264
+ (tunnel && accessGateBlocked && tunnel.action.desired.enabled !== false
265
+ ? 'its Access gate is blocked, and the tunnel and DNS would expose the hostname without it'
266
+ : null);
267
+ if (tunnel && tunnelReason) {
268
+ dnsBlocked.push(edgeBlockedItem(org, 'Cloudflare tunnel', 'cloudflare:tunnel', tunnelReason));
269
+ }
270
+ else if (tunnel && zoneWaiting && tunnel.action.desired.enabled !== false) {
221
271
  zoneBlocked.push({
222
272
  description: `${org.slug}'s Cloudflare tunnel is blocked, not planned: the zone for ${org.domain} is not active yet`,
223
273
  key: 'cloudflare:tunnel',
@@ -226,13 +276,15 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
226
276
  else if (tunnel) {
227
277
  plannedSteps.push(tunnel);
228
278
  }
229
- if (zone.step)
230
- plannedSteps.push(zone.step);
231
279
  // Right after the zone (#43): `cloudflareRateLimit` resolves the zone by
232
280
  // apex domain, so the zone step above must already have run (or the zone
233
281
  // must already exist live) by the time this one's action executes.
234
282
  const rateLimit = planRateLimit(org, recordedIds);
235
- if (rateLimit && zoneWaiting) {
283
+ const rateLimitReason = rateLimit ? edgeNeeded(platformReadiness, ['zones', 'ratelimits']) : null;
284
+ if (rateLimit && rateLimitReason) {
285
+ dnsBlocked.push(edgeBlockedItem(org, 'sign-in rate limit', 'cloudflare:ratelimit', rateLimitReason));
286
+ }
287
+ else if (rateLimit && zoneWaiting) {
236
288
  zoneBlocked.push({
237
289
  description: `${org.slug}'s sign-in rate limit is blocked, not planned: the zone for ${org.domain} is not active yet`,
238
290
  key: 'cloudflare:ratelimit',
@@ -246,8 +298,9 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
246
298
  // Its fixed ZITADEL client before the Coolify app it signs into:
247
299
  // `coolifyAgora` reads this step's recorded client id.
248
300
  const agoraClient = planAgoraClient(org, byKey, claimed);
249
- if (agoraClient)
250
- plannedSteps.push(agoraClient);
301
+ const agoraGuarded = guardApp(agoraClient);
302
+ if (agoraGuarded)
303
+ plannedSteps.push(agoraGuarded);
251
304
  const agora = planAgora(org, byKey, claimed, recordedIds, agoraHost);
252
305
  if (agora.step)
253
306
  plannedSteps.push(agora.step);
@@ -255,8 +308,9 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
255
308
  // shape as Agora and planned right after it. Its fixed ZITADEL client first,
256
309
  // for the same reason: `coolifyBoule` reads that step's recorded client id.
257
310
  const bouleClient = planBouleClient(org, byKey, claimed);
258
- if (bouleClient)
259
- plannedSteps.push(bouleClient);
311
+ const bouleGuarded = guardApp(bouleClient);
312
+ if (bouleGuarded)
313
+ plannedSteps.push(bouleGuarded);
260
314
  const boule = planBoule(org, byKey, claimed, recordedIds, authIssuer, options.otherSlugs ?? [], options.archonUrl ?? null);
261
315
  if (boule.step)
262
316
  plannedSteps.push(boule.step);
@@ -285,7 +339,12 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
285
339
  // retire, which needs no new zone) still waits for it.
286
340
  const edgeWorkers = planWorkers(org, recordedIds, options.edgeWorkers ?? false, options.platformHostnames ?? []);
287
341
  for (const step of edgeWorkers.steps) {
288
- if (zoneWaiting && step.action.desired.mode === 'apply') {
342
+ // A retire never looks up the zone (`cloudflareWorker`), so only a deploy needs edge.
343
+ const workerReason = step.action.desired.mode === 'apply' ? edgeNeeded(platformReadiness, ['zones']) : null;
344
+ if (workerReason) {
345
+ dnsBlocked.push(edgeBlockedItem(org, `${step.action.key.slice(org.id.length + 1)} Worker`, step.action.key, workerReason));
346
+ }
347
+ else if (zoneWaiting && step.action.desired.mode === 'apply') {
289
348
  zoneBlocked.push({
290
349
  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
350
  key: step.action.key,
@@ -361,6 +420,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
361
420
  if (zone.blocked)
362
421
  unmanaged.push({ description: zone.blocked, key: 'cloudflare:zone' });
363
422
  unmanaged.push(...zoneBlocked);
423
+ unmanaged.push(...dnsBlocked);
364
424
  // #243: an appEnv source nothing will record is reported, not silently skipped.
365
425
  unmanaged.push(...app.blocked);
366
426
  unmanaged.push(...brokerEntries.blocked);
@@ -372,6 +432,52 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
372
432
  versionChanges,
373
433
  };
374
434
  }
435
+ /**
436
+ * #553: a plain ZITADEL app provision (not a `recreate`) is planned only when
437
+ * the live listing lacks the app by name. If an app id is recorded for it,
438
+ * the listing may simply have missed a live app, and nothing can read an app
439
+ * by id, so applying silently could replace a live sign-in client. The way
440
+ * out is the destroying `recreate` path: the step is planned as a
441
+ * replacement that asks for the typed confirmation, so a person who has
442
+ * checked the app is gone (deleted by hand in ZITADEL) says so, and apply
443
+ * then deletes the recorded id (a 404 is fine) and creates a new app.
444
+ * (A failed recreate clears the recorded id, so it is not caught here.)
445
+ */
446
+ function guardAppProvision(step, org, recordedIds) {
447
+ if (!step || step.action.system !== 'zitadel' || step.action.kind !== 'app')
448
+ return step;
449
+ if (step.action.desired.recreate === true)
450
+ return step;
451
+ const recorded = recordedIds[`zitadel:app:${step.action.key}`];
452
+ if (!recorded)
453
+ return step;
454
+ return {
455
+ action: { ...step.action, desired: { ...step.action.desired, recreate: true } },
456
+ step: {
457
+ 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)`,
458
+ destroys: true,
459
+ migrates: false,
460
+ },
461
+ };
462
+ }
463
+ /**
464
+ * #555: why a step that needs these edge-routed ports cannot run, or `null`.
465
+ * `readiness.dnsBlocked` is set while edge is not applied; a port the host
466
+ * supplies itself (`readiness.ownEdgePorts`, mirroring the executor's
467
+ * `ports.dns` / `zones` / `access` / `ratelimits` / `tunnels`, which win over
468
+ * edge) needs no edge, so only a step that would really reach edge is blocked.
469
+ */
470
+ function edgeNeeded(readiness, needs) {
471
+ if (!readiness.dnsBlocked)
472
+ return null;
473
+ return needs.every((port) => readiness.ownEdgePorts?.[port]) ? null : readiness.dnsBlocked;
474
+ }
475
+ function edgeBlockedItem(org, what, key, reason) {
476
+ return {
477
+ description: `${org.slug}'s ${what} is blocked, not planned: ${reason ?? ''} (#555)`,
478
+ key,
479
+ };
480
+ }
375
481
  /**
376
482
  * The org's own ZITADEL org (issuer), a per-org singleton like every other
377
483
  * planner in this file, with one extra wrinkle: unlike mail/chat/site/app,
@@ -637,7 +743,7 @@ function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired
637
743
  // nothing else here would replan the step to retry it.
638
744
  if (recordedIds[key] === host &&
639
745
  recordedIds[LOGIN_BINDING_SCOPE_KEY] ===
640
- loginBindingState(host, loginHostClientIds(recordedIds)) &&
746
+ loginBindingState(host, loginHostClientIds(org, recordedIds)) &&
641
747
  pendingRemovalHosts(recordedIds).length === 0) {
642
748
  return none;
643
749
  }
@@ -776,7 +882,24 @@ platformApex, platformLettermintProjectId) {
776
882
  const roleNote = rolesOwed > 0 ? `create ${rolesOwed} role mailbox(es) for ${org.slug}` : '';
777
883
  const mailboxWork = [ownerNote, roleNote].filter(Boolean).join(' and ');
778
884
  const mailboxNote = mailboxWork ? ` and ${mailboxWork}` : '';
779
- if (!live) {
885
+ // #554: no reader lists a live `coolify mail <org>` resource, so "applied"
886
+ // comes from the record: the domain, mode and `usesDomain` the step last
887
+ // wrote (`coolifyMail`). A changed declaration differs from it and plans.
888
+ const recordedDomain = recordedIds[MAIL_DOMAIN_SCOPE_KEY];
889
+ const recordedMode = recordedIds[ORG_MAIL_APPLIED_MODE_SCOPE_KEY];
890
+ const recordedUsesDomain = recordedIds[ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY];
891
+ const domainMatches = Boolean(recordedDomain && org.domain && recordedDomain.toLowerCase() === org.domain.toLowerCase());
892
+ const appliedFromRecord = !live &&
893
+ Boolean(recordedDomain) &&
894
+ // Before `mail_applied_mode` existed, only a received or hosted apply
895
+ // recorded `mail_domain` (`off` returns before it); production's record is
896
+ // hosted. An unrecorded mode vouches for `hosted` only.
897
+ (recordedMode === undefined ? desired.mode === 'hosted' : recordedMode === desired.mode) &&
898
+ (recordedUsesDomain === undefined
899
+ ? desired.usesDomain && domainMatches
900
+ : recordedUsesDomain === String(desired.usesDomain) &&
901
+ (!desired.usesDomain || domainMatches));
902
+ if (!live && !appliedFromRecord) {
780
903
  return {
781
904
  step: {
782
905
  action: { system: 'coolify', kind: 'mail', key: org.id, desired },
@@ -790,8 +913,8 @@ platformApex, platformLettermintProjectId) {
790
913
  mailboxBlocked,
791
914
  };
792
915
  }
793
- const liveMode = live.config.mode;
794
- const liveUsesDomain = Boolean(live.config.usesDomain);
916
+ const liveMode = live ? live.config.mode : desired.mode;
917
+ const liveUsesDomain = live ? Boolean(live.config.usesDomain) : desired.usesDomain;
795
918
  if (liveMode === desired.mode && liveUsesDomain === desired.usesDomain) {
796
919
  // #177: the platform's apex or mail host moved since this step last wrote
797
920
  // them (or it never recorded them). Re-applying is idempotent.
@@ -1476,7 +1599,12 @@ function planCmsClient(org, byKey, claimed) {
1476
1599
  if (live.config.appType === 'web')
1477
1600
  return null;
1478
1601
  return {
1479
- action: { system: 'zitadel', kind: 'app', key: 'cms', desired },
1602
+ action: {
1603
+ system: 'zitadel',
1604
+ kind: 'app',
1605
+ key: 'cms',
1606
+ desired: { ...desired, recreate: true },
1607
+ },
1480
1608
  step: {
1481
1609
  description: `recreate ZITADEL app 'cms' for ${org.slug} as web (was ${String(live.config.appType)})`,
1482
1610
  destroys: true,
@@ -1580,7 +1708,12 @@ function planAgoraClient(org, byKey, claimed) {
1580
1708
  if (live.config.appType === 'web')
1581
1709
  return null;
1582
1710
  return {
1583
- action: { system: 'zitadel', kind: 'app', key: 'agora', desired },
1711
+ action: {
1712
+ system: 'zitadel',
1713
+ kind: 'app',
1714
+ key: 'agora',
1715
+ desired: { ...desired, recreate: true },
1716
+ },
1584
1717
  step: {
1585
1718
  description: `recreate ZITADEL app 'agora' for ${org.slug} as web (was ${String(live.config.appType)})`,
1586
1719
  destroys: true,
@@ -1718,7 +1851,12 @@ function planBouleClient(org, byKey, claimed) {
1718
1851
  if (live.config.appType === 'web')
1719
1852
  return null;
1720
1853
  return {
1721
- action: { system: 'zitadel', kind: 'app', key: 'boule', desired },
1854
+ action: {
1855
+ system: 'zitadel',
1856
+ kind: 'app',
1857
+ key: 'boule',
1858
+ desired: { ...desired, recreate: true },
1859
+ },
1722
1860
  step: {
1723
1861
  description: `recreate ZITADEL app 'boule' for ${org.slug} as web (was ${String(live.config.appType)})`,
1724
1862
  destroys: true,
@@ -2168,6 +2306,19 @@ function planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer, re
2168
2306
  platformLettermintProjectId: readiness.platformLettermintProjectId,
2169
2307
  };
2170
2308
  }
2309
+ // #555: the `app` entry's step publishes the mail domain's DNS records
2310
+ // (`ensureAppLettermintMail`) once one is recorded, and those go through
2311
+ // edge: blocked, not planned, while edge is not applied.
2312
+ const appReason = edgeNeeded(readiness, ['zones', 'dns']);
2313
+ if (appReason &&
2314
+ key === APP_ENTRY_KEY &&
2315
+ app.step &&
2316
+ app.step.action.desired.enabled !== false &&
2317
+ recordedIds[MAIL_DOMAIN_SCOPE_KEY]) {
2318
+ blocked.push(edgeBlockedItem(org, 'app', 'coolify:app', appReason));
2319
+ blocked.push(...app.blocked);
2320
+ continue;
2321
+ }
2171
2322
  if (app.step)
2172
2323
  steps.push(app.step);
2173
2324
  blocked.push(...app.blocked);
@@ -2283,7 +2434,7 @@ function sortedNames(names) {
2283
2434
  * domain) plans no per-org tunnel for Agora: `<slug>-agora.oikos.host` rides
2284
2435
  * the shared `*.oikos.host` wildcard tunnel (`scripts/shared-ingress.mjs`).
2285
2436
  */
2286
- function planTunnel(org, byKey, claimed, recordedIds) {
2437
+ function planTunnel(org, byKey, claimed, recordedIds, isPlatformOrg, platformApex) {
2287
2438
  const desired = (org.hosting.tunnel && org.hosting.usesDomain && Boolean(org.domain)) ||
2288
2439
  (org.site.enabled && Boolean(org.domain)) ||
2289
2440
  (org.calls.enabled && org.calls.usesDomain && Boolean(org.domain)) ||
@@ -2300,6 +2451,16 @@ function planTunnel(org, byKey, claimed, recordedIds) {
2300
2451
  return live.config.enabled !== false;
2301
2452
  return desired ? status !== 'down' : status === 'healthy' || status === 'degraded';
2302
2453
  });
2454
+ // #568: the company's own login host is routed by the step, so a first
2455
+ // provision carries it too; a park does not. Without a known platform apex
2456
+ // the platform's own org cannot be told apart, so none is planned.
2457
+ const loginHost = platformApex ? companyLoginTunnelHost(org, isPlatformOrg) : null;
2458
+ if (toggled && toggled.action.desired.enabled !== false && loginHost) {
2459
+ return {
2460
+ ...toggled,
2461
+ action: { ...toggled.action, desired: { ...toggled.action.desired, loginHost } },
2462
+ };
2463
+ }
2303
2464
  if (toggled || !desired)
2304
2465
  return toggled;
2305
2466
  // A live, enabled tunnel re-plans the same step again when either:
@@ -2323,12 +2484,18 @@ function planTunnel(org, byKey, claimed, recordedIds) {
2323
2484
  const published = recordedIds[CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY];
2324
2485
  const edgeChanged = published !== undefined &&
2325
2486
  org.domain !== null &&
2326
- sortedNames(published.split(',')) !== sortedNames(tunnelDnsHostnames(org, org.domain));
2487
+ sortedNames(published.split(',')) !==
2488
+ sortedNames(tunnelDnsHostnames(org, org.domain, loginHost));
2327
2489
  const dnsStale = recordedIds[CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY] !== org.domain || edgeChanged;
2328
2490
  if (!connectorMissing && !dnsStale)
2329
2491
  return null;
2330
2492
  return {
2331
- action: { system: 'cloudflare', kind: 'tunnel', key: org.id, desired: { enabled: true } },
2493
+ action: {
2494
+ system: 'cloudflare',
2495
+ kind: 'tunnel',
2496
+ key: org.id,
2497
+ desired: loginHost ? { enabled: true, loginHost } : { enabled: true },
2498
+ },
2332
2499
  step: {
2333
2500
  description: connectorMissing
2334
2501
  ? `run a cloudflared connector for ${org.slug}'s Cloudflare tunnel`
@@ -2458,7 +2625,12 @@ function planIssuerApps(org, byKey, claimed) {
2458
2625
  if (live.config.appType === app.kind)
2459
2626
  continue;
2460
2627
  steps.push({
2461
- action: { system: 'zitadel', kind: 'app', key: app.name, desired },
2628
+ action: {
2629
+ system: 'zitadel',
2630
+ kind: 'app',
2631
+ key: app.name,
2632
+ desired: { ...desired, recreate: true },
2633
+ },
2462
2634
  step: {
2463
2635
  description: `recreate ZITADEL app '${app.name}' for ${org.slug} as ${app.kind} (was ${String(live.config.appType)})`,
2464
2636
  destroys: true,
@@ -12,27 +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
- /**
16
- * The real {@link StepExecutor} (#20 "provision Company Factory end to
17
- * end"): makes each {@link StepAction} `diff.ts` plans true against a real
18
- * Coolify, Cloudflare and ZITADEL, plus per-org organisations, spend limits
19
- * and (for AI) offers on Files and AI (#80). Scope and design are exactly
20
- * what `.claude/decisions-2026-09-24-executor.md`'s "Executor decisions"
21
- * section settled, referenced by name at each dispatch branch below.
22
- *
23
- * Built entirely from narrow ports (interfaces), never a concrete
24
- * `@wtfalch/providers` client: this package holds no credential and never
25
- * will (executor decisions: "the executor is constructed with narrow ports
26
- * ... a host [archon] supplies real clients. How the host gets provider
27
- * tokens from the keys Worker is the host's job, not this campaign's").
28
- * Every port method here is named and shaped to match a real method on
29
- * `@wtfalch/providers`' `CoolifyClient`/`CloudflareClient`/`ZitadelClient`
30
- * (checked against `providers` `origin/main`, 2026-09-24), or on
31
- * `@wtfalch/files`'/`@wtfalch/ai`'s own SDK clients (#80, checked against
32
- * each repo's own `main`, 2026-09-25), so a real client satisfies the port
33
- * structurally, with no adapter needed — see each port's own doc comment for
34
- * the one real method it does not yet have.
35
- */
15
+ import type { EdgePortName } from './platform-org.js';
36
16
  /**
37
17
  * The Coolify calls this executor makes, shaped to match
38
18
  * `@wtfalch/providers/coolify`'s `CoolifyClient` method-for-method — a real
@@ -53,10 +33,19 @@ export interface CoolifyPort {
53
33
  findProjectByName(name: string): Promise<{
54
34
  uuid: string;
55
35
  name: string;
36
+ description?: string | null;
56
37
  } | null>;
57
38
  createProject(name: string, description?: string): Promise<{
58
39
  uuid: string;
59
40
  }>;
41
+ /**
42
+ * #569: what a project holds (`CoolifyClient.listResources`: its
43
+ * environments, applications and databases). Optional: without it an
44
+ * unmarked project cannot be shown empty, so it is refused.
45
+ */
46
+ listResources?(projectUuid: string): Promise<Array<{
47
+ kind: string;
48
+ }>>;
60
49
  findOrCreateEnvironment(projectUuid: string, name: string): Promise<{
61
50
  environment: {
62
51
  id: number;
@@ -421,6 +410,16 @@ export interface CoolifyPort {
421
410
  deploymentUuid: string;
422
411
  status: string;
423
412
  }>;
413
+ /**
414
+ * One deployment's status by its uuid (`queued`, `in_progress`, `finished`,
415
+ * `failed`, `cancelled-by-user`); throws a 404 for a uuid Coolify does not
416
+ * know. Matches `CoolifyClient.getDeployment` method-for-method. The
417
+ * platform's Archon step reads it to confirm a deploy it triggered and did
418
+ * not wait for.
419
+ */
420
+ getDeployment(deploymentUuid: string): Promise<{
421
+ status: string;
422
+ }>;
424
423
  }
425
424
  /**
426
425
  * The Cloudflare zone calls the platform steps make directly, shaped to match
@@ -799,6 +798,8 @@ export interface ZitadelPort {
799
798
  appType: 'web' | 'native' | 'api';
800
799
  state: string;
801
800
  loginBaseUri?: string | null;
801
+ /** Present only when the host's listing reads it; `zitadelApp` adopts with it, else with the recorded one. */
802
+ clientId?: string;
802
803
  }>;
803
804
  complete: boolean;
804
805
  }>;
@@ -987,10 +988,10 @@ export interface ZitadelPort {
987
988
  * listApps` -- a plain array, with no `loginBaseUri` or completeness signal
988
989
  * -- into {@link ZitadelPort.listApps}'s own shape, so a host wiring that
989
990
  * 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`
991
+ * itself to build the wrapper by hand. `complete: true` on every call:
992
+ * since providers 0.7.4 the real client pages `_search` to the end. A host
993
+ * that supplies its own `listApps` keeps whatever `complete` it reports.
994
+ * Each app's `clientId` (providers 0.7.5) passes through. `loginBaseUri` comes back `undefined`
994
995
  * for every app for the identical reason -- the real client does not read
995
996
  * it back at all yet, not "read it back as absent" -- which is exactly the
996
997
  * state `zitadelLoginHost`'s fail-closed rule exists for: every pending
@@ -1011,6 +1012,8 @@ export interface ZitadelListAppsRawApp {
1011
1012
  name: string;
1012
1013
  appType: 'web' | 'native' | 'api';
1013
1014
  state: string;
1015
+ /** providers 0.7.5: the app's client id. Passed through to `ZitadelPort.listApps`. */
1016
+ clientId?: string;
1014
1017
  }
1015
1018
  /**
1016
1019
  * Provisions one org's organisation on Files (#80), matching
@@ -1555,6 +1558,8 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1555
1558
  private edgeZones;
1556
1559
  private edgeWriteSlot;
1557
1560
  constructor(ports: FoundryStepExecutorPorts);
1561
+ /** #555: the edge-routed ports this executor was built with itself (they win over edge). */
1562
+ ownEdgePorts(): Partial<Record<EdgePortName, boolean>>;
1558
1563
  execute(action: StepAction, context: StepExecutionContext): Promise<{
1559
1564
  resourceId?: string;
1560
1565
  } | undefined>;
@@ -1784,9 +1789,9 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1784
1789
  * container itself still writing nowhere). `wasAlreadyRunning` tells this
1785
1790
  * method whether `appId` names an app `coolifyApp` found already there
1786
1791
  * (restart it once the volume is created) or one it just created fresh
1787
- * (already about to get its first deploy/start from
1788
- * `createGithubAppApplication`'s own instant-deploy path, so a restart
1789
- * here would only be a redundant extra one).
1792
+ * (`coolifyApp` deploys it after this call, #566, and that first start
1793
+ * carries the storage, so a restart here would only be a redundant extra
1794
+ * one).
1790
1795
  */
1791
1796
  private ensureLogVolume;
1792
1797
  private coolifyDatabase;