@wtfalch/foundry 0.21.1 → 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
  /**
@@ -9,7 +9,7 @@ import { type AdoptCandidate, type AdoptInput, type AdoptPlan } from './adopt.js
9
9
  import { type AgoraHostPort } from './agora.js';
10
10
  import { type StepAction } from './diff.js';
11
11
  import { type LiveReaders, type LiveResource, type PlatformLiveReaders } from './live.js';
12
- import { type HeldKeyResolver } from './platform-org.js';
12
+ import { type EdgePortName, type HeldKeyResolver } from './platform-org.js';
13
13
  import type { CallsSfuConnectionPort } from './platform-sfu.js';
14
14
  /** Who did it, for `@wtfalch/audit`'s "who, when, the plan, the result" (#9, #19). */
15
15
  export interface AuditActor {
@@ -142,6 +142,8 @@ export interface StepExecutionContext {
142
142
  * scope key can never collide with the `system:kind:key` form.
143
143
  */
144
144
  recordScopeId(key: string, id: string): void;
145
+ /** Removes a recorded id (any key form). Optional: a context without it cannot forget one. */
146
+ forgetId?(key: string): void;
145
147
  /**
146
148
  * Writes every id recorded so far to the store now, not only at the end of
147
149
  * the run. For an id that must survive a crash mid-step: the ai key's id
@@ -161,6 +163,14 @@ export interface StepExecutor {
161
163
  execute(action: StepAction, context: StepExecutionContext): Promise<{
162
164
  resourceId?: string;
163
165
  } | undefined>;
166
+ /**
167
+ * #555: which of the edge-routed ports (`dns`, `zones`, `access`,
168
+ * `ratelimits`, `tunnels`) this executor was built with its own
169
+ * implementation of, which it uses before edge. The planner blocks a step
170
+ * that needs edge only if the port it needs is not listed here. Optional:
171
+ * an executor without it supplies none.
172
+ */
173
+ ownEdgePorts?(): Partial<Record<EdgePortName, boolean>>;
164
174
  }
165
175
  /**
166
176
  * #167: {@link StepExecutionContext}'s platform-scoped counterpart -- what
@@ -212,6 +222,10 @@ export interface PlatformStepExecutor {
212
222
  hc?: string;
213
223
  /** #540: see `PlanPlatformOptions.blocked.heartbeatsExecutorMissing` (`platform-diff.ts`). */
214
224
  heartbeatsExecutorMissing?: boolean;
225
+ /** See `PlanPlatformOptions.blocked.archonRunsInsideApp`. */
226
+ archonRunsInsideApp?: boolean;
227
+ /** See `PlanPlatformOptions.blocked.archonDeployRunning`. */
228
+ archonDeployRunning?: string;
215
229
  }>;
216
230
  /**
217
231
  * Steps whose live state differs from the declared one in a way the recorded
@@ -13,7 +13,7 @@ import { FILES_ROOT_UNDELIVERED_SCOPE_KEY, entryFilesUndeliveredScopeKey, } from
13
13
  import { readLivePlatformResources, readLiveResources, } from './live.js';
14
14
  import { lockedHostnames } from './locked-hostnames.js';
15
15
  import { PLATFORM_ARCHON_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_KEY, appStepKey, planPlatform as computePlatformPlanDetail, toPlatformPlan, } from './platform-diff.js';
16
- import { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './platform-org.js';
16
+ import { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, dnsBlockedReason, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './platform-org.js';
17
17
  import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
18
18
  import { liveWorkers, workerTraceScopeKeys } from './worker.js';
19
19
  /** #441: the lease scope that serializes every mutation of one company, and the one for the shared platform. */
@@ -773,6 +773,9 @@ export class FoundryPlanApply {
773
773
  recordScopeId: (key, id) => {
774
774
  serviceIds[key] = id;
775
775
  },
776
+ forgetId: (key) => {
777
+ delete serviceIds[key];
778
+ },
776
779
  persistScopeIds: async () => {
777
780
  await this.store.putAppliedState({
778
781
  orgId,
@@ -938,7 +941,13 @@ export class FoundryPlanApply {
938
941
  catch {
939
942
  // not held
940
943
  }
941
- return { ...readiness, mailKeyHeld, canHoldSecrets: typeof heldKeys?.put === 'function' };
944
+ return {
945
+ ...readiness,
946
+ mailKeyHeld,
947
+ canHoldSecrets: typeof heldKeys?.put === 'function',
948
+ dnsBlocked: dnsBlockedReason(platform, recorded),
949
+ ownEdgePorts: this.executor.ownEdgePorts?.(),
950
+ };
942
951
  }
943
952
  /**
944
953
  * #177: `AgoraHostValues` from the applied platform, the same
@@ -1171,7 +1180,7 @@ function destroyTargets(serviceIds, orgId) {
1171
1180
  continue;
1172
1181
  }
1173
1182
  for (const [resourceKey, id] of Object.entries(serviceIds)) {
1174
- if (!resourceKey.startsWith(prefix))
1183
+ if (!resourceKey.startsWith(prefix) || !id)
1175
1184
  continue;
1176
1185
  // Archon#271: foundry did not create an adopted app or database, so it never deletes it.
1177
1186
  if (adopted.has(resourceKey))
@@ -1224,7 +1233,7 @@ function destroyTargets(serviceIds, orgId) {
1224
1233
  targets.push({ system: 'edge', kind: 'key', key: entryKey, id });
1225
1234
  }
1226
1235
  for (const [resourceKey, id] of Object.entries(serviceIds)) {
1227
- if (!resourceKey.startsWith('zitadel:app:'))
1236
+ if (!resourceKey.startsWith('zitadel:app:') || !id)
1228
1237
  continue;
1229
1238
  targets.push({
1230
1239
  system: 'zitadel',
@@ -135,6 +135,15 @@ export declare const PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY = "archon
135
135
  * values until someone redeploys by hand (#467).
136
136
  */
137
137
  export declare const PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY = "archon_envs_deployed_hash";
138
+ /**
139
+ * The uuid of the deployment Archon's step triggered without waiting
140
+ * (`PlatformArchonDeps.runsInsideApp`), `pending` between the trigger and the
141
+ * moment its uuid is known. The next plan reads that deployment's status:
142
+ * running blocks the step, failed or unknown plans it again, finished lets the
143
+ * next run of the step confirm it (the integrations key-synced marker) and
144
+ * clear this.
145
+ */
146
+ export declare const PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY = "archon_deployment";
138
147
  /**
139
148
  * #211 round 4: the broker app id Archon last registered its entry with.
140
149
  * `provisionPlatformArchon` reads the broker to register with from
@@ -414,6 +423,16 @@ export declare function platformMailMxHost(apex: string): string;
414
423
  */
415
424
  export declare const ORG_MAIL_APPLIED_APEX_SCOPE_KEY = "mail_applied_apex";
416
425
  export declare const ORG_MAIL_APPLIED_HOST_SCOPE_KEY = "mail_applied_host";
426
+ /**
427
+ * #554: the mode and `usesDomain` the step last applied. No reader lists a
428
+ * live `coolify mail <org>` resource, so `planMail` decides "applied" from
429
+ * the record. Records written before these keys existed lack them; a
430
+ * missing mode vouches for `hosted` only, a missing `usesDomain` only when
431
+ * the declared domain equals the recorded `mail_domain`. A park records
432
+ * mode `off`.
433
+ */
434
+ export declare const ORG_MAIL_APPLIED_MODE_SCOPE_KEY = "mail_applied_mode";
435
+ export declare const ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY = "mail_applied_uses_domain";
417
436
  /**
418
437
  * The key of a hosting entry in a `coolify:app` or `cloudflare:access` step
419
438
  * and in the ids recorded for it: `<orgId>:<entry key>`, so a second entry
@@ -845,6 +864,15 @@ export interface PlanPlatformOptions {
845
864
  * steps" note in `platform-providers.ts`'s `FoundryPlatformStepExecutor.blockedSteps`.
846
865
  */
847
866
  heartbeatsApiKeyMissing?: boolean;
867
+ /**
868
+ * The host runs inside Archon's app (`PlatformArchonDeps.runsInsideApp`):
869
+ * Archon's step redeploys it, so it is ordered last in the plan, after
870
+ * every step that would otherwise be lost when the old container stops.
871
+ * Not a block.
872
+ */
873
+ archonRunsInsideApp?: boolean;
874
+ /** Why Archon's step must wait: the deployment it triggered is still running. Blocks even an applied step. */
875
+ archonDeployRunning?: string;
848
876
  };
849
877
  /**
850
878
  * Steps whose live state differs from the declared one in a way the recorded
@@ -156,6 +156,15 @@ export const PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY = 'archon_integra
156
156
  * values until someone redeploys by hand (#467).
157
157
  */
158
158
  export const PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY = 'archon_envs_deployed_hash';
159
+ /**
160
+ * The uuid of the deployment Archon's step triggered without waiting
161
+ * (`PlatformArchonDeps.runsInsideApp`), `pending` between the trigger and the
162
+ * moment its uuid is known. The next plan reads that deployment's status:
163
+ * running blocks the step, failed or unknown plans it again, finished lets the
164
+ * next run of the step confirm it (the integrations key-synced marker) and
165
+ * clear this.
166
+ */
167
+ export const PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY = 'archon_deployment';
159
168
  /**
160
169
  * #211 round 4: the broker app id Archon last registered its entry with.
161
170
  * `provisionPlatformArchon` reads the broker to register with from
@@ -451,6 +460,16 @@ export function platformMailMxHost(apex) {
451
460
  */
452
461
  export const ORG_MAIL_APPLIED_APEX_SCOPE_KEY = 'mail_applied_apex';
453
462
  export const ORG_MAIL_APPLIED_HOST_SCOPE_KEY = 'mail_applied_host';
463
+ /**
464
+ * #554: the mode and `usesDomain` the step last applied. No reader lists a
465
+ * live `coolify mail <org>` resource, so `planMail` decides "applied" from
466
+ * the record. Records written before these keys existed lack them; a
467
+ * missing mode vouches for `hosted` only, a missing `usesDomain` only when
468
+ * the declared domain equals the recorded `mail_domain`. A park records
469
+ * mode `off`.
470
+ */
471
+ export const ORG_MAIL_APPLIED_MODE_SCOPE_KEY = 'mail_applied_mode';
472
+ export const ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY = 'mail_applied_uses_domain';
454
473
  /**
455
474
  * The key of a hosting entry in a `coolify:app` or `cloudflare:access` step
456
475
  * and in the ids recorded for it: `<orgId>:<entry key>`, so a second entry
@@ -969,8 +988,10 @@ export function planPlatform(platform, live, options = {}) {
969
988
  // #175: last, on purpose. Archon's sign-in client lives in the sign-in
970
989
  // `planAuth` builds, and its env reads what every other service's step
971
990
  // records or delivers, so in one apply it runs after all of them.
972
- const archon = planArchon(platform, recordedIds, blocked.archon ?? dnsBlocked?.('Archon'), authReady, drifted.archon);
973
- if (archon.step)
991
+ const archon = planArchon(platform, recordedIds, blocked.archon ?? dnsBlocked?.('Archon'), authReady, drifted.archon, blocked.archonDeployRunning);
992
+ // The host runs inside this app: its step ends by redeploying the process
993
+ // that runs the plan, so it goes after everything else (pushed below).
994
+ if (archon.step && !blocked.archonRunsInsideApp)
974
995
  plannedSteps.push(archon.step);
975
996
  // #257: after every service above, so a database any of them just
976
997
  // recorded is already visible to `recordedIds` on this same plan.
@@ -982,6 +1003,8 @@ export function planPlatform(platform, live, options = {}) {
982
1003
  const heartbeats = planHeartbeats(platform, recordedIds, hcReady, blocked.heartbeatsExecutorMissing ?? false, blocked.heartbeatsApiKeyMissing ?? false);
983
1004
  if (heartbeats.step)
984
1005
  plannedSteps.push(heartbeats.step);
1006
+ if (archon.step && blocked.archonRunsInsideApp)
1007
+ plannedSteps.push(archon.step);
985
1008
  // #251: one line per service whose recorded applied ref no longer matches
986
1009
  // what the next apply would use (or, for zitadel, what is actually live).
987
1010
  // Never pushed into `plannedSteps` -- see `VersionChangeItem`'s own doc
@@ -2227,9 +2250,12 @@ function planAi(platform, recordedIds, executorBlocked) {
2227
2250
  * ({@link PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY}) plans the step again, as
2228
2251
  * {@link planAuth} does.
2229
2252
  */
2230
- function planArchon(platform, recordedIds, executorBlocked, authReady, drifted) {
2253
+ function planArchon(platform, recordedIds, executorBlocked, authReady, drifted, deployRunning) {
2231
2254
  if (!platform.archon.enabled)
2232
2255
  return { step: null, blocked: null };
2256
+ // A deploy the step triggered is still running: planning it again would deploy twice.
2257
+ if (deployRunning)
2258
+ return { step: null, blocked: deployRunning };
2233
2259
  const key = resourceKey('coolify', 'app', PLATFORM_ARCHON_KEY);
2234
2260
  const applied = Boolean(recordedIds[key]) &&
2235
2261
  Boolean(recordedIds[PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY]) &&
@@ -35,6 +35,8 @@ export declare const NO_DOMAIN_MAIL_APEX = "oikos.host";
35
35
  * names the same "positional arguments that shifted on every merge" problem
36
36
  * this avoids).
37
37
  */
38
+ /** The edge-routed ports an executor may be given its own implementation of. */
39
+ export type EdgePortName = 'dns' | 'zones' | 'access' | 'ratelimits' | 'tunnels';
38
40
  export interface OrgPlatformReadiness {
39
41
  /**
40
42
  * `null` until the platform's own mail server (#169) is enabled and
@@ -96,6 +98,22 @@ export interface OrgPlatformReadiness {
96
98
  * used to say there was none; N1 is no longer deployed by hand).
97
99
  */
98
100
  edgeReady: boolean;
101
+ /**
102
+ * #555: why a company step that writes through edge cannot run yet, `null`
103
+ * or absent when none is blocked. DNS records, zones, Access apps, rate
104
+ * limit rules and tunnels all go through edge unless the host supplies its
105
+ * own port ({@link ownEdgePorts}); while edge is not applied, `diff.ts`
106
+ * reports each such step blocked instead of planning one that would fail
107
+ * half way.
108
+ */
109
+ dnsBlocked?: string | null;
110
+ /**
111
+ * #555: the edge-routed ports the host's executor supplies itself
112
+ * (`FoundryStepExecutorPorts.dns` / `zones` / `access` / `ratelimits` /
113
+ * `tunnels`). The executor uses these before edge, so a step that needs
114
+ * only these is not blocked by {@link dnsBlocked}.
115
+ */
116
+ ownEdgePorts?: Partial<Record<EdgePortName, boolean>>;
99
117
  /** `https://<platform.edge.hostname>`, which an entry with `uses.edge` gets as `EDGE_URL`. `null` until `edgeReady`. */
100
118
  edgeUrl?: string | null;
101
119
  /**
@@ -149,6 +167,16 @@ export interface HeldKeyResolver {
149
167
  * (which could carry provider detail) goes nowhere.
150
168
  */
151
169
  export declare function heldPlatformKeyRefs(heldKeys: HeldKeyResolver): Promise<Set<string>>;
170
+ /**
171
+ * #555: the reason no company step may write DNS yet, `null` when one may.
172
+ * Every DNS record, zone, Access app, rate limit rule and tunnel call goes
173
+ * through edge unless the host supplies its own port
174
+ * ({@link OrgPlatformReadiness.ownEdgePorts}), so edge must be enabled and
175
+ * applied (the same check as `edgeReady`, without the host-port half). {@link FoundryPlanApply} sets
176
+ * {@link OrgPlatformReadiness.dnsBlocked} from this; the pure
177
+ * {@link orgPlatformReadinessFrom} leaves it unset (nothing blocked).
178
+ */
179
+ export declare function dnsBlockedReason(platform: DeclaredPlatform, platformRecordedIds: Readonly<Record<string, string>>): string | null;
152
180
  /**
153
181
  * `FoundryPlanApply.computeFreshPlan`'s own precedent for turning a
154
182
  * `PlatformStore`'s `get()`/`getServiceIds()` into what an org's plan needs.
@@ -63,6 +63,23 @@ export async function heldPlatformKeyRefs(heldKeys) {
63
63
  function coolifyAppApplied(platformRecordedIds, key) {
64
64
  return Boolean(platformRecordedIds[`coolify:app:${key}`]);
65
65
  }
66
+ /**
67
+ * #555: the reason no company step may write DNS yet, `null` when one may.
68
+ * Every DNS record, zone, Access app, rate limit rule and tunnel call goes
69
+ * through edge unless the host supplies its own port
70
+ * ({@link OrgPlatformReadiness.ownEdgePorts}), so edge must be enabled and
71
+ * applied (the same check as `edgeReady`, without the host-port half). {@link FoundryPlanApply} sets
72
+ * {@link OrgPlatformReadiness.dnsBlocked} from this; the pure
73
+ * {@link orgPlatformReadinessFrom} leaves it unset (nothing blocked).
74
+ */
75
+ export function dnsBlockedReason(platform, platformRecordedIds) {
76
+ if (!platform.edge.enabled) {
77
+ return 'every DNS record is written through edge, and the platform does not enable edge';
78
+ }
79
+ return coolifyAppApplied(platformRecordedIds, PLATFORM_EDGE_KEY)
80
+ ? null
81
+ : 'every DNS record is written through edge, and edge is not applied yet';
82
+ }
66
83
  /**
67
84
  * `FoundryPlanApply.computeFreshPlan`'s own precedent for turning a
68
85
  * `PlatformStore`'s `get()`/`getServiceIds()` into what an org's plan needs.
@@ -290,6 +290,8 @@ export declare class FoundryPlatformStepExecutor implements PlatformStepExecutor
290
290
  hc?: string;
291
291
  heartbeatsExecutorMissing?: boolean;
292
292
  heartbeatsApiKeyMissing?: boolean;
293
+ archonRunsInsideApp?: boolean;
294
+ archonDeployRunning?: string;
293
295
  }>;
294
296
  /**
295
297
  * Whether Archon's live Access policy lets through anyone but the declared
@@ -474,7 +476,7 @@ export interface PlatformMailRuntime {
474
476
  * {@link PlatformStepExecutionContext}, bundled like {@link PlatformAuthDeps}.
475
477
  */
476
478
  export interface PlatformArchonDeps {
477
- coolify: Pick<CoolifyPort, 'findProjectByName' | 'createProject' | 'findOrCreateEnvironment' | 'listServers' | 'findApplicationByName' | 'getApplication' | 'requireGithubApp' | 'createGithubAppApplication' | 'createDockerfileApplication' | 'listDatabases' | 'createPostgresDatabase' | 'getDatabase' | 'raw' | 'bulkUpsertEnvs' | 'listEnvs' | 'deleteEnv' | 'updateApplication' | 'deploy' | 'deployAndWait'>;
479
+ coolify: Pick<CoolifyPort, 'findProjectByName' | 'createProject' | 'findOrCreateEnvironment' | 'listServers' | 'findApplicationByName' | 'getApplication' | 'requireGithubApp' | 'createGithubAppApplication' | 'createDockerfileApplication' | 'listDatabases' | 'createPostgresDatabase' | 'getDatabase' | 'raw' | 'bulkUpsertEnvs' | 'listEnvs' | 'deleteEnv' | 'updateApplication' | 'deploy' | 'deployAndWait' | 'getDeployment'>;
478
480
  /** F4.dns: every DNS record call, through edge at its recorded address (see {@link PlatformDnsFactory}). */
479
481
  dns: PlatformDnsFactory;
480
482
  /** F4.tunnels: every tunnel call, through edge at its recorded address (see {@link PlatformTunnelsFactory}). */
@@ -493,6 +495,15 @@ export interface PlatformArchonDeps {
493
495
  * Access app nobody can pass.
494
496
  */
495
497
  accessEmails: readonly string[];
498
+ /**
499
+ * True when the host process runs inside the Coolify app this step
500
+ * redeploys (Archon's console is `platform:archon`). A deploy that waits
501
+ * would be killed with the old container, so the step then records every
502
+ * applied marker first, and triggers the deploy last, without waiting.
503
+ * The planner puts the step last in the plan. Next plan: the step runs
504
+ * again only if the app is not running (`archonRedeployFailed`).
505
+ */
506
+ runsInsideApp?: boolean;
496
507
  /**
497
508
  * Reads a secret an earlier step handed to the host through
498
509
  * `deliverSecret`, or `null` when none is held under `ref` yet -- the same
@@ -8,7 +8,7 @@ import { waitForDeploymentFinished, waitForHealthyVersion, waitForNewDeployment,
8
8
  import { ACCESS_POLICY_NAME, getDatabaseStarted, isNotFoundError, isRunningOrStarting, } from './executor.js';
9
9
  import { healthCheckSettings } from './health-check-settings.js';
10
10
  import { coolifyDomains, filesShareHostname } from './hostnames.js';
11
- import { AUTH_ZITADEL_RUN_OPTIONS, JOBS_DEPLOY_PENDING_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_OPERATOR_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_COOLIFY_READ_TOKEN_REF, PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, PLATFORM_AUTH_SMTP_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_EDGE_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY, PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_HC_API_KEY_REF, PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_HC_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_HC_KEY, PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_PREVIOUS_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_REQUESTED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_RUNNER_KEYRING_DEPLOYED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_UPTIME_HEARTBEAT_CHECK_NAME, archonBootstrapOperator, desiredHeartbeatCheckNames, heartbeatCheckUrlRef, parseBrokerDeployRecord, parseLoginClientRecord, platformBackupHeartbeatCheckName, platformMailMxHost, } from './platform-diff.js';
11
+ import { AUTH_ZITADEL_RUN_OPTIONS, JOBS_DEPLOY_PENDING_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_OPERATOR_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_COOLIFY_READ_TOKEN_REF, PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY, PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, PLATFORM_AUTH_SMTP_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_EDGE_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY, PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_HC_API_KEY_REF, PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_HC_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_HC_KEY, PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_PREVIOUS_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_REQUESTED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_RUNNER_KEYRING_DEPLOYED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_UPTIME_HEARTBEAT_CHECK_NAME, archonBootstrapOperator, desiredHeartbeatCheckNames, heartbeatCheckUrlRef, parseBrokerDeployRecord, parseLoginClientRecord, platformBackupHeartbeatCheckName, platformMailMxHost, } from './platform-diff.js';
12
12
  import { applyCallsSfuPlatformStep } from './platform-sfu.js';
13
13
  import { CreateInstanceError, createZitadelInstance, generateSystemApiKeypair, instanceWithoutBearerMessage, systemApiUsersEnv, } from './zitadel-system-api.js';
14
14
  /** The key `runPlatformSteps` records the apex zone's id under (`system:kind:key`). */
@@ -318,6 +318,18 @@ export class FoundryPlatformStepExecutor {
318
318
  if (!apiKey)
319
319
  blocked.heartbeatsApiKeyMissing = true;
320
320
  }
321
+ // Not a block: tells the planner to order Archon's step last (`PlanPlatformOptions.blocked.archonRunsInsideApp`).
322
+ if (this.archon?.runsInsideApp) {
323
+ blocked.archonRunsInsideApp = true;
324
+ // A deploy the step triggered and did not wait for: while it runs, a
325
+ // second deploy must not be planned.
326
+ const deployment = recordedIds[PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY];
327
+ if (platform.archon.enabled &&
328
+ deployment &&
329
+ (await archonDeploymentState(this.archon.coolify, deployment)) === 'running') {
330
+ blocked.archonDeployRunning = `Archon is blocked, not planned: its deployment ${deployment} is still running; plan again when it has finished`;
331
+ }
332
+ }
321
333
  return blocked;
322
334
  }
323
335
  /**
@@ -347,7 +359,18 @@ export class FoundryPlatformStepExecutor {
347
359
  !brokerDrifted &&
348
360
  !bootstrapOperatorDrifted &&
349
361
  (await archonRollingUpdateDrifted(this.archon.coolify, recordedIds[`coolify:app:${PLATFORM_ARCHON_KEY}`]));
350
- if (accessDrifted || brokerDrifted || bootstrapOperatorDrifted || rollingUpdateDrifted)
362
+ // The deployment the step triggered failed or Coolify does not know it:
363
+ // plan the step again, which deploys again. A finished one is confirmed
364
+ // by the next run of the step (keys-synced), see `provisionPlatformArchon`.
365
+ const deployment = recordedIds[PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY];
366
+ const redeployFailed = this.archon.runsInsideApp === true &&
367
+ Boolean(deployment) &&
368
+ (await archonDeploymentState(this.archon.coolify, deployment)) === 'failed';
369
+ if (accessDrifted ||
370
+ brokerDrifted ||
371
+ bootstrapOperatorDrifted ||
372
+ rollingUpdateDrifted ||
373
+ redeployFailed)
351
374
  result.archon = true;
352
375
  }
353
376
  if (platform.auth.enabled && this.auth) {
@@ -2600,6 +2623,7 @@ async function provisionPlatformArchon(context, deps) {
2600
2623
  const appliedGitRef = recorded[PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY];
2601
2624
  const runtime = deps.runtime ?? {};
2602
2625
  const sleep = runtime.sleep ?? defaultSleep;
2626
+ const selfRedeploy = deps.runsInsideApp === true;
2603
2627
  if (deps.accessEmails.length === 0) {
2604
2628
  throw new Error('platform:archon: no Access emails given; refusing to put Archon behind an Access app nobody can pass');
2605
2629
  }
@@ -3030,7 +3054,17 @@ async function provisionPlatformArchon(context, deps) {
3030
3054
  // patch above actually takes effect on the running container, not just on
3031
3055
  // Coolify's stored config.
3032
3056
  const rollingUpdateApplied = recorded[PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY] === '1';
3033
- const archonDeployedNow = keyUnsynced ||
3057
+ // The deployment an earlier run triggered without waiting: read before
3058
+ // deciding whether to deploy again.
3059
+ const priorDeployment = selfRedeploy ? recorded[PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY] : undefined;
3060
+ const deployState = priorDeployment
3061
+ ? await archonDeploymentState(deps.coolify, priorDeployment)
3062
+ : 'none';
3063
+ if (deployState === 'running' || deployState === 'unreadable') {
3064
+ throw new Error(`platform:archon: the deployment ${priorDeployment} cannot be confirmed yet (${deployState === 'running' ? 'still running' : 'status unreadable'}); plan again later`);
3065
+ }
3066
+ const archonDeployedNow = (keyUnsynced && deployState !== 'finished') ||
3067
+ deployState === 'failed' ||
3034
3068
  app.justCreated ||
3035
3069
  !app.running ||
3036
3070
  changed ||
@@ -3039,7 +3073,7 @@ async function provisionPlatformArchon(context, deps) {
3039
3073
  appliedGitRef !== gitRef ||
3040
3074
  !rollingUpdateApplied ||
3041
3075
  recorded[PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY] !== hostname;
3042
- if (archonDeployedNow) {
3076
+ if (archonDeployedNow && !selfRedeploy) {
3043
3077
  // #467: cleared before the deploy below is even attempted, not after --
3044
3078
  // a deploy that then fails, or a crash before `waitForHealthyVersion`
3045
3079
  // passes, must leave this run's attempt unconfirmed. Recording it only
@@ -3111,13 +3145,14 @@ async function provisionPlatformArchon(context, deps) {
3111
3145
  // Coolify's own deployment record finished, which is what actually closes
3112
3146
  // the loophole named in #252 (an old container staying healthy while the
3113
3147
  // deployment itself fails).
3114
- if (archonDeployedNow) {
3148
+ if (archonDeployedNow && !selfRedeploy) {
3115
3149
  await waitForHealthyVersion(appId, { kind: 'ready', url: `https://${hostname}/api/health` }, runtime, sleep, 'platform:archon', `archon stays at ${appliedGitRef ?? 'no recorded ref'} on ${recorded[PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY] ?? 'no recorded hostname'}`);
3116
3150
  }
3117
3151
  // #398: the deployment finished and answers health, so Archon now runs with
3118
3152
  // the key id its env carries. `provisionPlatformIntegrations` drops the
3119
3153
  // previous public key only on this marker.
3120
- if (keyUnsynced && integrationsKeyId) {
3154
+ // With `runsInsideApp` only once Coolify's deployment record says it finished.
3155
+ if (keyUnsynced && integrationsKeyId && (!selfRedeploy || deployState === 'finished')) {
3121
3156
  await record(PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY, integrationsKeyId);
3122
3157
  }
3123
3158
  // #475: confirmed only now, after the health check above has passed on a
@@ -3140,6 +3175,36 @@ async function provisionPlatformArchon(context, deps) {
3140
3175
  // it.
3141
3176
  await record(PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, brokerAppId);
3142
3177
  await record(PLATFORM_ARCHON_APPLIED_OPERATOR_SCOPE_KEY, bootstrapOperator ?? 'none');
3178
+ // The host runs inside this app: every marker is recorded above, so the
3179
+ // deploy goes last and is not waited for -- the old container stops during
3180
+ // it, and the response, the lease and the audit row must be done first.
3181
+ if (selfRedeploy && !archonDeployedNow && deployState === 'finished') {
3182
+ await record(PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY, ''); // confirmed above
3183
+ }
3184
+ if (selfRedeploy && archonDeployedNow) {
3185
+ await record(`coolify:app:${PLATFORM_ARCHON_KEY}`, appId);
3186
+ await record(PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY, 'pending');
3187
+ let deploymentUuid;
3188
+ try {
3189
+ // `force` takes the branch that always creates a real deployment record
3190
+ // (a skipped one answers with a uuid Coolify never stored).
3191
+ const started = (await deps.coolify.deploy(appId, true));
3192
+ const uuid = Array.isArray(started) ? started[0]?.deployment_uuid : undefined;
3193
+ if (typeof uuid !== 'string' || !uuid) {
3194
+ throw new Error('platform:archon: Coolify answered the deploy with no deployment_uuid');
3195
+ }
3196
+ deploymentUuid = uuid;
3197
+ }
3198
+ catch (error) {
3199
+ // Not triggered: forget the applied ref so the next plan runs the step again.
3200
+ await record(PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, '');
3201
+ await record(PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY, '');
3202
+ throw error;
3203
+ }
3204
+ // The next plan reads this deployment's status; a kill before this write
3205
+ // leaves `pending`, which that plan reads as unconfirmed and plans again.
3206
+ await record(PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY, deploymentUuid);
3207
+ }
3143
3208
  return { resourceId: appId };
3144
3209
  }
3145
3210
  // #211 round 6: mirrors `wtfalch/auth/broker/src/apps.ts`'s own `isValidFrom`
@@ -3560,6 +3625,28 @@ async function archonBootstrapOperatorDrifted(coolify, appId, recordedIds) {
3560
3625
  * version of this check never actually fired. `custom_network_aliases` and
3561
3626
  * `health_check_enabled` are both top-level there.
3562
3627
  */
3628
+ /**
3629
+ * The state of the deployment Archon's step triggered without waiting
3630
+ * (`PlatformArchonDeps.runsInsideApp`), from Coolify's own deployment record:
3631
+ * `running` (queued or in progress), `finished`, `failed` (failed, cancelled,
3632
+ * its uuid never recorded, or unknown to Coolify -- a 404), or `unreadable`
3633
+ * for any other error. Status and a served request cannot tell a failed build
3634
+ * from a success (the old container keeps serving), so this record is the
3635
+ * only honest answer.
3636
+ */
3637
+ async function archonDeploymentState(coolify, deploymentUuid) {
3638
+ if (deploymentUuid === 'pending')
3639
+ return 'failed';
3640
+ try {
3641
+ const { status } = await coolify.getDeployment(deploymentUuid);
3642
+ if (status === 'queued' || status === 'in_progress')
3643
+ return 'running';
3644
+ return status === 'finished' ? 'finished' : 'failed';
3645
+ }
3646
+ catch (error) {
3647
+ return isNotFoundError(error) ? 'failed' : 'unreadable';
3648
+ }
3649
+ }
3563
3650
  async function archonRollingUpdateDrifted(coolify, appId) {
3564
3651
  if (!appId)
3565
3652
  return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.21.1",
3
+ "version": "0.22.0",
4
4
  "description": "Foundry's SDK: the declared org's types, the plan and apply contracts, and the state store's interface.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -37,6 +37,7 @@
37
37
  "calls-sfu:deploy": "node scripts/calls-sfu-deploy.mjs"
38
38
  },
39
39
  "peerDependencies": {
40
+ "@wtfalch/providers": ">=0.7.4",
40
41
  "drizzle-orm": ">=0.45.2"
41
42
  },
42
43
  "dependencies": {
@@ -44,7 +45,7 @@
44
45
  "@wtfalch/auth": "0.16.0",
45
46
  "@wtfalch/edge": "0.9.0",
46
47
  "@wtfalch/email": "^0.17.0",
47
- "@wtfalch/providers": "0.7.3",
48
+ "@wtfalch/providers": "0.7.5",
48
49
  "ssh2": "1.17.0",
49
50
  "zod": "4.6.5"
50
51
  },