@wtfalch/foundry 0.18.0 → 0.19.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.
@@ -37,8 +37,17 @@ export interface BootstrapOwner {
37
37
  */
38
38
  export declare function ownerIdentity(owner: BootstrapOwner): OwnerIdentity;
39
39
  /**
40
- * Company Included's declared org: the platform's owner, running no app or
41
- * mail of its own. Chat is on, like every org's.
40
+ * Company Included's declared org: the platform's owner, tier 3 on the
41
+ * platform's own apex from the start (#512) -- it runs on that domain with
42
+ * hosted mail, not a level-1 org whose domain gets set by hand afterward.
43
+ * `issuer`/`calls`/`site` go on the domain too, the same tier-3 defaults
44
+ * archon#388 gives every company declared there. `calls` and `site` stay
45
+ * disabled (calls is not in v1; no site is declared), so `usesDomain` is
46
+ * inert on both until one is switched on. Chat stays off the domain: its
47
+ * re-key onto a per-org homeserver is not built yet (#463). The org's own
48
+ * sign-in still never collides with the shared ZITADEL instance's host --
49
+ * `loginHostnameFor` (#488) falls back to `<slug>-auth.oikos.host` whenever
50
+ * `auth.<domain>` would equal the platform's own auth hostname.
42
51
  */
43
52
  export declare function declaredCompanyOrg(company: BootstrapCompany, owner: BootstrapOwner): DeclaredOrg;
44
53
  /**
@@ -55,25 +55,34 @@ export function ownerIdentity(owner) {
55
55
  return identity;
56
56
  }
57
57
  /**
58
- * Company Included's declared org: the platform's owner, running no app or
59
- * mail of its own. Chat is on, like every org's.
58
+ * Company Included's declared org: the platform's owner, tier 3 on the
59
+ * platform's own apex from the start (#512) -- it runs on that domain with
60
+ * hosted mail, not a level-1 org whose domain gets set by hand afterward.
61
+ * `issuer`/`calls`/`site` go on the domain too, the same tier-3 defaults
62
+ * archon#388 gives every company declared there. `calls` and `site` stay
63
+ * disabled (calls is not in v1; no site is declared), so `usesDomain` is
64
+ * inert on both until one is switched on. Chat stays off the domain: its
65
+ * re-key onto a per-org homeserver is not built yet (#463). The org's own
66
+ * sign-in still never collides with the shared ZITADEL instance's host --
67
+ * `loginHostnameFor` (#488) falls back to `<slug>-auth.oikos.host` whenever
68
+ * `auth.<domain>` would equal the platform's own auth hostname.
60
69
  */
61
70
  export function declaredCompanyOrg(company, owner) {
62
71
  return {
63
72
  id: COMPANY_INCLUDED_ORG_ID,
64
73
  slug: company.slug,
65
74
  displayName: company.name,
66
- domain: null,
75
+ domain: company.apex,
67
76
  mail: {
68
- mode: 'off',
69
- usesDomain: false,
77
+ mode: 'hosted',
78
+ usesDomain: true,
70
79
  ownerMailboxAddress: null,
71
80
  noReplyLocalPart: 'no-reply',
72
81
  },
73
82
  chat: { enabled: true, usesDomain: false },
74
- calls: { enabled: false, usesDomain: false },
75
- issuer: { usesDomain: false },
76
- site: { enabled: false, usesDomain: false },
83
+ calls: { enabled: false, usesDomain: true },
84
+ issuer: { usesDomain: true },
85
+ site: { enabled: false, usesDomain: true },
77
86
  hosting: {
78
87
  enabled: false,
79
88
  usesDomain: false,
package/dist/index.d.ts CHANGED
@@ -28,6 +28,8 @@ export { PlatformOrgBlockedError, PlatformOrgPorts } from './plan/index.js';
28
28
  export type { CoolifyPort, CloudflarePort, ZitadelPort, CallsSfuPort, JobsSchedulerPort, FilesOrganisationPort, AiOrganisationPort, FoundryStepExecutorPorts, PlatformOrgPortSource, AgoraHostPort, AgoraHostValues, LoginHostBinding, LoginHostBindingsPort, } from './plan/index.js';
29
29
  export { FoundryStepExecutor, NotBuiltError, healthUrls, loginHostnameFor } from './plan/index.js';
30
30
  export { HttpJobsSchedulerClient } from './plan/index.js';
31
+ export type { ScopeResolver, RecordedIds } from './plan/index.js';
32
+ export { ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './plan/index.js';
31
33
  export type { ZitadelListAppsRawApp } from './plan/index.js';
32
34
  export { adaptZitadelListApps } from './plan/index.js';
33
35
  export type { MailIdentityApp, MailIdentityAudienceCheck } from './plan/index.js';
package/dist/index.js CHANGED
@@ -17,6 +17,7 @@ export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesF
17
17
  export { PlatformOrgBlockedError, PlatformOrgPorts } from './plan/index.js';
18
18
  export { FoundryStepExecutor, NotBuiltError, healthUrls, loginHostnameFor } from './plan/index.js';
19
19
  export { HttpJobsSchedulerClient } from './plan/index.js';
20
+ export { ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './plan/index.js';
20
21
  export { adaptZitadelListApps } from './plan/index.js';
21
22
  export { checkMailIdentityAudience } from './plan/index.js';
22
23
  export { checkMailAppsAllowlist } from './plan/index.js';
@@ -110,6 +110,18 @@ export declare function agoraHostMissing(host: AgoraHostValues): string[];
110
110
  export declare function agoraHostnameFor(org: DeclaredOrg): string;
111
111
  /** The port `calls/packages/service/Dockerfile` documents (`EXPOSE 8080`); `config.mjs`'s `PORT` has no default, so the executor pushes it. */
112
112
  export declare const CALLS_SERVICE_PORT = "8080";
113
+ /**
114
+ * The port `wtfalch/agora`'s `apps/agora/Dockerfile` serves Caddy on
115
+ * (`EXPOSE 8080`). Coolify's github-app build never reads a Dockerfile's
116
+ * `EXPOSE` on its own, so without this passed as `ports_exposes` Traefik is
117
+ * left routing to a port nothing listens on and Agora answers 502 behind the
118
+ * tunnel (#506, seen live on Company Included, 2026-10-05). `executor.ts`'s
119
+ * `coolifyAgora` sets it at create time and fixes it on a found app whose
120
+ * live value differs; `diff.ts`'s `planAgora` plans that fix the same way it
121
+ * plans an env difference, by comparing this constant against the live
122
+ * app's own `ports_exposes` (`providers-readers.ts`'s `withAgoraPortConfig`).
123
+ */
124
+ export declare const AGORA_APP_PORTS_EXPOSES = "8080";
113
125
  /**
114
126
  * Recorded by the executor once the calls service app carries its fixed
115
127
  * internal name (`callsServiceInternalName`). `desiredAgoraEnv` waits for it,
@@ -196,6 +208,19 @@ export declare function agoraEnvDrift(desired: Readonly<Record<string, string>>,
196
208
  * against (#442's own probe: "one env write, zero restarts, zero deploys").
197
209
  */
198
210
  export declare const AGORA_ACTIVATED_ENV_SCOPE_KEY = "coolify_agora_activated_env";
211
+ /**
212
+ * #504: recorded once `coolifyAgora` has actually called `deploy` on this
213
+ * org's Agora app -- `createGithubAppApplication` never sets
214
+ * `instant_deploy`, and Coolify defaults it to false, so a freshly created
215
+ * app (or one found by name that a previous apply created but crashed
216
+ * before deploying) sat at `exited:unhealthy` with zero deployments
217
+ * forever: nothing ever deployed it, and `restart` cannot start a container
218
+ * that was never built. Absent means "never deployed", regardless of
219
+ * whether the app is found live this run or created fresh -- unlike
220
+ * {@link AGORA_ACTIVATED_ENV_SCOPE_KEY}, this never changes value once set,
221
+ * so a later env change restarts rather than deploys again.
222
+ */
223
+ export declare const AGORA_DEPLOYED_SCOPE_KEY = "coolify_agora_deployed";
199
224
  /**
200
225
  * A stable digest of `env`, for comparison against what was last recorded
201
226
  * under {@link AGORA_ACTIVATED_ENV_SCOPE_KEY}. `AGORA_CONFIG` is compared by
@@ -118,6 +118,18 @@ export function agoraHostnameFor(org) {
118
118
  }
119
119
  /** The port `calls/packages/service/Dockerfile` documents (`EXPOSE 8080`); `config.mjs`'s `PORT` has no default, so the executor pushes it. */
120
120
  export const CALLS_SERVICE_PORT = '8080';
121
+ /**
122
+ * The port `wtfalch/agora`'s `apps/agora/Dockerfile` serves Caddy on
123
+ * (`EXPOSE 8080`). Coolify's github-app build never reads a Dockerfile's
124
+ * `EXPOSE` on its own, so without this passed as `ports_exposes` Traefik is
125
+ * left routing to a port nothing listens on and Agora answers 502 behind the
126
+ * tunnel (#506, seen live on Company Included, 2026-10-05). `executor.ts`'s
127
+ * `coolifyAgora` sets it at create time and fixes it on a found app whose
128
+ * live value differs; `diff.ts`'s `planAgora` plans that fix the same way it
129
+ * plans an env difference, by comparing this constant against the live
130
+ * app's own `ports_exposes` (`providers-readers.ts`'s `withAgoraPortConfig`).
131
+ */
132
+ export const AGORA_APP_PORTS_EXPOSES = '8080';
121
133
  /**
122
134
  * Recorded by the executor once the calls service app carries its fixed
123
135
  * internal name (`callsServiceInternalName`). `desiredAgoraEnv` waits for it,
@@ -305,6 +317,19 @@ export function agoraEnvDrift(desired, live) {
305
317
  * against (#442's own probe: "one env write, zero restarts, zero deploys").
306
318
  */
307
319
  export const AGORA_ACTIVATED_ENV_SCOPE_KEY = 'coolify_agora_activated_env';
320
+ /**
321
+ * #504: recorded once `coolifyAgora` has actually called `deploy` on this
322
+ * org's Agora app -- `createGithubAppApplication` never sets
323
+ * `instant_deploy`, and Coolify defaults it to false, so a freshly created
324
+ * app (or one found by name that a previous apply created but crashed
325
+ * before deploying) sat at `exited:unhealthy` with zero deployments
326
+ * forever: nothing ever deployed it, and `restart` cannot start a container
327
+ * that was never built. Absent means "never deployed", regardless of
328
+ * whether the app is found live this run or created fresh -- unlike
329
+ * {@link AGORA_ACTIVATED_ENV_SCOPE_KEY}, this never changes value once set,
330
+ * so a later env change restarts rather than deploys again.
331
+ */
332
+ export const AGORA_DEPLOYED_SCOPE_KEY = 'coolify_agora_deployed';
308
333
  /**
309
334
  * A stable digest of `env`, for comparison against what was last recorded
310
335
  * under {@link AGORA_ACTIVATED_ENV_SCOPE_KEY}. `AGORA_CONFIG` is compared by
@@ -20,6 +20,14 @@ export declare const BOULE_CLIENT_ID_SCOPE_KEY = "boule_client_id";
20
20
  * scope id, like the cms and calls databases.
21
21
  */
22
22
  export declare const BOULE_DATABASE_SCOPE_KEY = "coolify_boule_database";
23
+ /**
24
+ * #504: the same marker `agora.ts`'s `AGORA_DEPLOYED_SCOPE_KEY` is, for
25
+ * Boule -- recorded once `coolifyBoule` has actually called `deploy` on this
26
+ * org's Boule app. See that constant's own doc comment for why: Coolify
27
+ * defaults `instant_deploy` to false, so without this a freshly created app
28
+ * sat built but never started, and `restart` cannot fix that.
29
+ */
30
+ export declare const BOULE_DEPLOYED_SCOPE_KEY = "coolify_boule_deployed";
23
31
  /**
24
32
  * `boule.<domain>` when the org has a domain, `<slug>-boule.oikos.host`
25
33
  * otherwise (the tier rule of boule#128 and foundry#147; `../slug.ts`'s
@@ -19,6 +19,14 @@ export const BOULE_CLIENT_ID_SCOPE_KEY = 'boule_client_id';
19
19
  * scope id, like the cms and calls databases.
20
20
  */
21
21
  export const BOULE_DATABASE_SCOPE_KEY = 'coolify_boule_database';
22
+ /**
23
+ * #504: the same marker `agora.ts`'s `AGORA_DEPLOYED_SCOPE_KEY` is, for
24
+ * Boule -- recorded once `coolifyBoule` has actually called `deploy` on this
25
+ * org's Boule app. See that constant's own doc comment for why: Coolify
26
+ * defaults `instant_deploy` to false, so without this a freshly created app
27
+ * sat built but never started, and `restart` cannot fix that.
28
+ */
29
+ export const BOULE_DEPLOYED_SCOPE_KEY = 'coolify_boule_deployed';
22
30
  /**
23
31
  * `boule.<domain>` when the org has a domain, `<slug>-boule.oikos.host`
24
32
  * otherwise (the tier rule of boule#128 and foundry#147; `../slug.ts`'s
package/dist/plan/diff.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
2
2
  import { APP_ENTRY_KEY, ownerMailboxRecorded, ownerMailboxes } from '../org.js';
3
- import { AGORA_ACTIVATED_ENV_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostMissing, agoraHostnameFor, callsServiceHasName, callsServiceHasTokenEnv, callsTokenEnvMovedMessage, callsTokenHostFrom, desiredAgoraEnv, withRecordedMessagingProject, } from './agora.js';
3
+ import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostMissing, agoraHostnameFor, callsServiceHasName, callsServiceHasTokenEnv, callsTokenEnvMovedMessage, callsTokenHostFrom, desiredAgoraEnv, withRecordedMessagingProject, } from './agora.js';
4
4
  import { BOULE_BROKER_ENTRY_KEY, bouleEnvDrift, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
5
5
  import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, } from './broker-entries.js';
6
6
  import { planEntryAiKeys } from './entry-ai-key.js';
@@ -82,7 +82,11 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
82
82
  const byKey = new Map(live.map((r) => [resourceKey(r.system, r.kind, r.key), r]));
83
83
  const claimed = new Set();
84
84
  const plannedSteps = [];
85
- const issuer = planIssuer(org, byKey, claimed, platformReadiness.authReady);
85
+ // The platform's own org: its domain is the platform apex (the same fact
86
+ // #491 reads for mail). It signs in on the shared instance's own host, so
87
+ // `issuer.usesDomain` never moves it.
88
+ const isPlatformOrg = isPlatformOwnOrg(org, platformReadiness.platformApex);
89
+ const issuer = planIssuer(org, byKey, claimed, platformReadiness.authReady, isPlatformOrg);
86
90
  if (issuer.step)
87
91
  plannedSteps.push(issuer.step);
88
92
  plannedSteps.push(...planOwners(org, recordedIds, platformReadiness.authReady, 'error' in agoraHost ? null : agoraHost.issuer));
@@ -94,7 +98,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
94
98
  // URL (`https://<platform.auth.hostname>`), the same value every org's
95
99
  // sign-in already reads through `planOwners` above -- no separate port
96
100
  // or plumbing needed to know the platform's own auth hostname.
97
- platformAuthHostnameFrom('error' in agoraHost ? null : agoraHost.issuer));
101
+ platformAuthHostnameFrom('error' in agoraHost ? null : agoraHost.issuer), isPlatformOrg);
98
102
  if (loginHost.step)
99
103
  plannedSteps.push(loginHost.step);
100
104
  // Planned ahead of everything that needs the zone live (#238); its own step
@@ -391,7 +395,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
391
395
  * already exists live is unaffected: its own domain-move logic runs
392
396
  * regardless, since that never re-provisions the ZITADEL org itself.
393
397
  */
394
- function planIssuer(org, byKey, claimed, authReady) {
398
+ function planIssuer(org, byKey, claimed, authReady, isPlatformOrg = false) {
395
399
  const key = resourceKey('zitadel', 'issuer', org.id);
396
400
  const live = byKey.get(key);
397
401
  const desiredUsesDomain = org.issuer.usesDomain;
@@ -420,9 +424,14 @@ function planIssuer(org, byKey, claimed, authReady) {
420
424
  };
421
425
  }
422
426
  claimed.add(key);
423
- const liveUsesDomain = Boolean(live.config.usesDomain);
424
- if (liveUsesDomain === desiredUsesDomain)
427
+ // The platform's own org already signs in on the shared host: no move. A
428
+ // live issuer that does not say (the reader never does: foundry v1 never
429
+ // moves one) is not read as a different one either.
430
+ if (isPlatformOrg ||
431
+ live.config.usesDomain === undefined ||
432
+ Boolean(live.config.usesDomain) === desiredUsesDomain) {
425
433
  return { step: null, blocked: null };
434
+ }
426
435
  const domain = org.domain ?? 'the org domain';
427
436
  return {
428
437
  step: {
@@ -519,6 +528,9 @@ function planOwners(org, recordedIds, authReady, authIssuer) {
519
528
  * computed `auth.<domain>` against, so the platform's own org never plans
520
529
  * its login host equal to the shared ZITADEL instance's own.
521
530
  */
531
+ function isPlatformOwnOrg(org, platformApex) {
532
+ return Boolean(org.domain && platformApex && org.domain.toLowerCase() === platformApex.toLowerCase());
533
+ }
522
534
  function platformAuthHostnameFrom(issuer) {
523
535
  if (!issuer)
524
536
  return null;
@@ -565,16 +577,58 @@ function platformAuthHostnameFrom(issuer) {
565
577
  * platform's own domain, never plans the shared ZITADEL instance's own login
566
578
  * host as its own -- `auth.companyincluded.com` colliding with itself.
567
579
  */
568
- function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired, platformAuthHostname) {
580
+ function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired, platformAuthHostname, isPlatformOrg) {
569
581
  const none = { step: null, blocked: null };
570
582
  const key = resourceKey('zitadel', 'login-host', org.id);
571
- const host = loginHostnameFor(org, platformAuthHostname);
583
+ // The platform's own org signs in on the shared instance's host whatever
584
+ // `issuer.usesDomain` says (turning it on would move the issuer).
585
+ // Before that host is known, plan nothing: a guessed host would delete the
586
+ // working one, and a normal step would trust a domain `destroy` later deletes.
587
+ if (isPlatformOrg && platformAuthHostname === null) {
588
+ return {
589
+ step: null,
590
+ blocked: `${org.slug}'s login host is waiting for the platform auth host`,
591
+ };
592
+ }
593
+ const sharedInstanceHost = isPlatformOrg && platformAuthHostname !== null;
594
+ const host = sharedInstanceHost ? platformAuthHostname : loginHostnameFor(org);
572
595
  // #488: the guard in `loginHostnameFor` fired -- the org's own domain host
573
596
  // would have been the platform's shared ZITADEL instance host.
574
- const sharedInstanceHost = Boolean(org.domain) &&
575
- org.issuer.usesDomain &&
576
- `auth.${org.domain}` === platformAuthHostname &&
577
- host !== `auth.${org.domain}`;
597
+ if (sharedInstanceHost) {
598
+ // The host is the shared instance's own: nothing to trust or bind, and an
599
+ // org with no recorded host already signs in there. Only an org recorded
600
+ // on another host (the old `<slug>-auth.oikos.host`) plans one step, to
601
+ // move its apps over and remove the old host; then it plans nothing.
602
+ const recordedHost = recordedIds[key];
603
+ const moving = recordedHost !== undefined && recordedHost !== host;
604
+ const removalOwed = pendingRemovalHosts(recordedIds).length > 0;
605
+ if (!authReady || !(moving || removalOwed))
606
+ return none;
607
+ // The same gates as the normal branch: the executor's shared step also
608
+ // needs the binding port and the ZITADEL org.
609
+ const waiting = loginHostWaiting(org, host, recordedIds, bindingsWired, issuerPlanned);
610
+ return waiting ?? stepFor(true);
611
+ }
612
+ function stepFor(shared) {
613
+ return {
614
+ step: {
615
+ action: {
616
+ system: 'zitadel',
617
+ kind: 'login-host',
618
+ key: org.id,
619
+ desired: shared ? { host, shared: true } : { host },
620
+ },
621
+ step: {
622
+ description: shared
623
+ ? `move ${org.slug}'s apps onto the platform's own sign-in host ${host}`
624
+ : `set ${org.slug}'s login host to ${host}`,
625
+ destroys: false,
626
+ migrates: false,
627
+ },
628
+ },
629
+ blocked: null,
630
+ };
631
+ }
578
632
  // Already applied -- a re-run plans nothing unless the host changed, an app
579
633
  // client id appeared since the binding was last written (#267, rule 4), or
580
634
  // an earlier host change still has a removal owed (#274 fix round 2):
@@ -595,6 +649,10 @@ function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired
595
649
  // Isolation between orgs rests on zitadel-login's binding, and repointing
596
650
  // an app at a host with no binding breaks its sign-in: trust nothing and
597
651
  // repoint nothing until the port that writes the binding is wired.
652
+ return loginHostWaiting(org, host, recordedIds, bindingsWired, issuerPlanned) ?? stepFor(false);
653
+ }
654
+ /** Why a login-host step cannot run yet, or `null` when nothing is missing. */
655
+ function loginHostWaiting(org, host, recordedIds, bindingsWired, issuerPlanned) {
598
656
  if (!bindingsWired) {
599
657
  return {
600
658
  step: null,
@@ -609,24 +667,7 @@ function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired
609
667
  blocked: `${org.slug}'s login host ${host} waits for its ZITADEL org, which is not recorded yet`,
610
668
  };
611
669
  }
612
- return {
613
- step: {
614
- action: {
615
- system: 'zitadel',
616
- kind: 'login-host',
617
- key: org.id,
618
- desired: { host },
619
- },
620
- step: {
621
- description: sharedInstanceHost
622
- ? `set ${org.slug}'s login host to ${host}: its domain host (auth.${org.domain}) is the platform's own shared ZITADEL instance host, so it stays on oikos.host`
623
- : `set ${org.slug}'s login host to ${host}`,
624
- destroys: false,
625
- migrates: false,
626
- },
627
- },
628
- blocked: null,
629
- };
670
+ return null;
630
671
  }
631
672
  /** #230: restated from `executor.ts`'s `MAIL_ROLE_MAILBOXES_SCOPE_KEY`, same rule: comma-joined local parts already created. */
632
673
  const MAIL_ROLE_MAILBOXES_SCOPE_KEY = 'mail_role_mailboxes';
@@ -1594,6 +1635,13 @@ function planAgora(org, byKey, claimed, recordedIds, reading) {
1594
1635
  if (liveEnv.AGORA_CONFIG === undefined && !drift.includes('AGORA_CONFIG')) {
1595
1636
  drift.push('AGORA_CONFIG');
1596
1637
  }
1638
+ // #506: an app Coolify never got `ports_exposes` on (or got the wrong one)
1639
+ // answers 502 behind the tunnel even though its env already matches --
1640
+ // exactly the Company Included incident this issue names. Compared
1641
+ // separately from `drift` above: it is a Coolify application setting, not
1642
+ // an env var, and `providers-readers.ts`'s `withAgoraEnvConfig` reads it
1643
+ // from `getApplication`, never `listEnvs`.
1644
+ const portDrift = live.config.ports_exposes !== AGORA_APP_PORTS_EXPOSES;
1597
1645
  // #442: `drift` alone, once this step has ever run, is comparing `env`
1598
1646
  // against Coolify's own env store -- which the executor's `bulkUpsertEnvs`
1599
1647
  // leaves matching `env` the moment the write succeeds, restarted or not.
@@ -1604,14 +1652,18 @@ function planAgora(org, byKey, claimed, recordedIds, reading) {
1604
1652
  // actually serving this exact `env`; while it disagrees, the step stays
1605
1653
  // planned no matter what Coolify's env store already shows.
1606
1654
  const activated = recordedIds[AGORA_ACTIVATED_ENV_SCOPE_KEY] === agoraEnvRevision(env);
1607
- if (drift.length === 0 && activated)
1655
+ if (drift.length === 0 && activated && !portDrift)
1608
1656
  return { step: null, blocked: null };
1657
+ const reasons = [
1658
+ ...(drift.length > 0 ? [`${drift.join(', ')} missing or different`] : []),
1659
+ ...(portDrift ? ["ports_exposes wrong -- Traefik can't reach it"] : []),
1660
+ ];
1609
1661
  return {
1610
1662
  step: {
1611
1663
  action,
1612
1664
  step: {
1613
- description: drift.length > 0
1614
- ? `update ${org.slug}'s Agora config (${drift.join(', ')} missing or different)`
1665
+ description: reasons.length > 0
1666
+ ? `update ${org.slug}'s Agora config (${reasons.join('; ')})`
1615
1667
  : `activate ${org.slug}'s Agora config -- written but not yet confirmed restarted`,
1616
1668
  destroys: false,
1617
1669
  migrates: false,
@@ -1735,13 +1787,13 @@ function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonU
1735
1787
  * park-not-delete rule (#7 corrections 1) -- turning it off never deletes
1736
1788
  * the org's own app, database or tunnel configuration, it parks it.
1737
1789
  */
1738
- function planHostingToggle(system, kind, desiredEnabled, org, byKey, claimed, describeProvision, describePark, stepKey = org.id) {
1790
+ function planHostingToggle(system, kind, desiredEnabled, org, byKey, claimed, describeProvision, describePark, stepKey = org.id, isOn = (live) => live.config.enabled !== false) {
1739
1791
  const key = resourceKey(system, kind, stepKey);
1740
1792
  const live = byKey.get(key);
1741
1793
  // A live resource is on unless it says it is parked. Providers' own
1742
1794
  // configs carry no `enabled` field at all, so only a park step's
1743
1795
  // explicit `enabled: false` reads as off.
1744
- const liveEnabled = live !== undefined && live.config.enabled !== false;
1796
+ const liveEnabled = live !== undefined && isOn(live);
1745
1797
  if (!desiredEnabled) {
1746
1798
  // Nothing declared and nothing live: there is nothing to park.
1747
1799
  if (!live)
@@ -2192,7 +2244,18 @@ function planTunnel(org, byKey, claimed, recordedIds) {
2192
2244
  (org.site.enabled && Boolean(org.domain)) ||
2193
2245
  (org.calls.enabled && org.calls.usesDomain && Boolean(org.domain)) ||
2194
2246
  Boolean(org.domain);
2195
- const toggled = planHostingToggle('cloudflare', 'tunnel', desired, org, byKey, claimed, () => `provision a Cloudflare tunnel for ${org.slug}'s app`, () => `park ${org.slug}'s Cloudflare tunnel (configuration is retained, not deleted -- #7 corrections 1)`);
2247
+ const toggled = planHostingToggle('cloudflare', 'tunnel', desired, org, byKey, claimed, () => `provision a Cloudflare tunnel for ${org.slug}'s app`, () => `park ${org.slug}'s Cloudflare tunnel (configuration is retained, not deleted -- #7 corrections 1)`, org.id,
2248
+ // Cloudflare's status, read against what is declared: a park cannot change
2249
+ // it, so judging on/off by status alone would never let the plan empty.
2250
+ // Wanted on: only `down` needs a repair (`inactive` is a fresh tunnel
2251
+ // whose connector has not dialled in yet). Wanted off: only a connected
2252
+ // tunnel needs parking; `inactive` and `down` are already parked.
2253
+ (live) => {
2254
+ const status = live.config.status;
2255
+ if (typeof status !== 'string')
2256
+ return live.config.enabled !== false;
2257
+ return desired ? status !== 'down' : status === 'healthy' || status === 'degraded';
2258
+ });
2196
2259
  if (toggled || !desired)
2197
2260
  return toggled;
2198
2261
  // A live, enabled tunnel re-plans the same step again when either:
@@ -89,6 +89,10 @@ export interface CoolifyPort {
89
89
  * `settings`, not at the top level -- confirmed against live Coolify
90
90
  * 4.3.23 (PR #482 review); `custom_network_aliases` and
91
91
  * `health_check_enabled` are both top-level there.
92
+ *
93
+ * #506: also the port Traefik routes to, so `coolifyAgora` can tell an app
94
+ * that predates passing `ports_exposes` at create apart from one already
95
+ * fixed.
92
96
  */
93
97
  getApplication(uuid: string): Promise<{
94
98
  uuid: string;
@@ -99,6 +103,7 @@ export interface CoolifyPort {
99
103
  };
100
104
  custom_network_aliases?: string | null;
101
105
  health_check_enabled?: boolean;
106
+ ports_exposes?: string;
102
107
  }>;
103
108
  createGithubAppApplication(input: {
104
109
  project_uuid: string;
@@ -1410,6 +1415,8 @@ export interface PlatformOrgPortSource {
1410
1415
  files(): Promise<FilesOrganisationPort>;
1411
1416
  ai(): Promise<AiOrganisationPort>;
1412
1417
  jobs(): Promise<JobsSchedulerPort>;
1418
+ /** The platform's shared sign-in host, or `null` when none is declared. */
1419
+ authHostname?(): Promise<string | null>;
1413
1420
  }
1414
1421
  export interface FoundryStepExecutorPorts {
1415
1422
  coolify: CoolifyPort;
@@ -2331,11 +2338,25 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2331
2338
  * actually differs, or whenever the last restart this step confirmed
2332
2339
  * ({@link AGORA_ACTIVATED_ENV_SCOPE_KEY}) is not this exact env (an org
2333
2340
  * applied before this existed, whose live env already matched desired
2334
- * with nothing ever confirmed running it). A brand new app has nothing
2335
- * running yet to restart. The activation marker is recorded last, after
2336
- * the restart (if any) returns without throwing, so a failed restart
2337
- * leaves it stale and the next plan replans this step even though
2338
- * Coolify's own env store already matches desired.
2341
+ * with nothing ever confirmed running it). The activation marker is
2342
+ * recorded last, after the restart (if any) returns without throwing, so a
2343
+ * failed restart leaves it stale and the next plan replans this step even
2344
+ * though Coolify's own env store already matches desired.
2345
+ *
2346
+ * #504: a brand new app is never restarted -- but it is deployed, once
2347
+ * ({@link AGORA_DEPLOYED_SCOPE_KEY}). `createGithubAppApplication` never
2348
+ * sets `instant_deploy`, and Coolify defaults it to false, so without an
2349
+ * explicit deploy the app sat created but never built or started.
2350
+ *
2351
+ * #506: `ports_exposes: AGORA_APP_PORTS_EXPOSES` goes on the create call
2352
+ * itself, so Traefik has a port to route to from the moment the app
2353
+ * exists. An app `findOrCreateCallsApp` instead *found* may predate this
2354
+ * fix and carry no `ports_exposes` at all (or the wrong one), so its live
2355
+ * value is read and, when it differs, patched -- and, since the container
2356
+ * listens on `ports_exposes` only at its next deploy (the same rule
2357
+ * `coolifyApp`'s own `settingsNeedDeploy` follows for a port change
2358
+ * there), redeployed even on a run that already has its one #504 deploy
2359
+ * behind it.
2339
2360
  */
2340
2361
  private coolifyAgora;
2341
2362
  /**
@@ -2360,6 +2381,10 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2360
2381
  *
2361
2382
  * `AUTH_APP_KEY` is minted once here; its broker entry is the next step
2362
2383
  * (`bouleBrokerEntry`).
2384
+ *
2385
+ * #504: a brand new app is never restarted -- but it is deployed, once
2386
+ * ({@link BOULE_DEPLOYED_SCOPE_KEY}), the same reason and the same marker
2387
+ * shape {@link coolifyAgora}'s own doc comment explains.
2363
2388
  */
2364
2389
  private coolifyBoule;
2365
2390
  /**
@@ -2501,6 +2526,15 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2501
2526
  * shared-hostname collision this ticket exists to refuse. An org re-running
2502
2527
  * its own already-recorded zone is unaffected: the store then names this
2503
2528
  * same org as the owner.
2529
+ *
2530
+ * #510: also records the zone's own id under the flat `cloudflare_zone`
2531
+ * scope key, alongside the `cloudflare:zone:<domain>` resource id `runSteps`
2532
+ * already records from this method's return value. Without it, nothing
2533
+ * ever gives `ProvidersCloudflareReader` a zone id to resolve this org's
2534
+ * scope with (`providers-readers.ts`'s `storeScopeResolver` doc comment),
2535
+ * so that reader's `listResources` always returned `[]` and `diff.ts`'s
2536
+ * `planZone` re-proposed "provision a Cloudflare zone" on every apply, even
2537
+ * once the zone was live.
2504
2538
  */
2505
2539
  private cloudflareZone;
2506
2540
  /**
@@ -2,8 +2,8 @@ import { randomBytes } from 'node:crypto';
2
2
  import { URL } from 'node:url';
3
3
  import { CALLS_SFU_PRIVATE_URL, CALLS_SFU_PUBLIC_URL } from '../calls-sfu.js';
4
4
  import { APP_ENTRY_KEY, appEntry, filesCollectionEnvName, ownerMailboxRecorded, ownerMailboxes, sharedSecretRef, } from '../org.js';
5
- import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_CLIENT_ID_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_NAME_SCOPE_KEY, CALLS_SERVICE_PORT, CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY, CHAT_SERVICE_URL_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, ZITADEL_MESSAGING_PROJECT_SCOPE_KEY, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostnameFor, callsIntrospectionAppName, callsServiceHasName, callsServiceInternalName, callsTokenEnvMarker, callsTokenEnvMovedMessage, desiredAgoraEnv, entryClientIdScopeKey, } from './agora.js';
6
- import { BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_DATABASE_SCOPE_KEY, bouleEnvDrift, bouleHostnameFor, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
5
+ import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, AGORA_CLIENT_ID_SCOPE_KEY, AGORA_DEPLOYED_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_NAME_SCOPE_KEY, CALLS_SERVICE_PORT, CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY, CHAT_SERVICE_URL_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, ZITADEL_MESSAGING_PROJECT_SCOPE_KEY, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostnameFor, callsIntrospectionAppName, callsServiceHasName, callsServiceInternalName, callsTokenEnvMarker, callsTokenEnvMovedMessage, desiredAgoraEnv, entryClientIdScopeKey, } from './agora.js';
6
+ import { BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_DATABASE_SCOPE_KEY, BOULE_DEPLOYED_SCOPE_KEY, bouleEnvDrift, bouleHostnameFor, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
7
7
  import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
8
8
  import { MAS_IMAGE, MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
9
9
  import { CHAT_REPO, CHAT_SERVICE_DOCKERFILE, CHAT_SERVICE_PORT, CHAT_SYNAPSE_DOCKERFILE, CHAT_SYNAPSE_PORT, chatInternalNames, chatMasConfigYaml, chatServiceEnvs, chatSynapseHomeserverYaml, chatUpstreams, } from './chat-matrix.js';
@@ -14,7 +14,7 @@ import { AI_ENTRY_RENEW_WINDOW_DAYS, entryAiAppliedScopeKey, entryAiClampedScope
14
14
  import { EDGE_ENTRY_KEY_DAYS, EDGE_RESERVED_LABELS_APPLIED_SCOPE_KEY, ENTRY_EDGE_KEY_DELIVERY_ATTEMPTS, entryEdgeMintedAtScopeKey, entryEdgeRetiredScopeKey, entryEdgeUndeliveredScopeKey, } from './entry-edge-key.js';
15
15
  import { FILES_AGORA_KEY_SCOPE_KEY, FILES_AGORA_UNDELIVERED_SCOPE_KEY, FILES_KEY_DAYS, FILES_KEY_RENEW_WINDOW_DAYS, FILES_ORG_OWNER_SCOPE_KEY, FILES_ROOT_EXPIRES_SCOPE_KEY, FILES_ROOT_PREVIOUS_SCOPE_KEY, FILES_ROOT_UNDELIVERED_SCOPE_KEY, FILES_ROOT_UNREVOKED_SCOPE_KEY, entryFilesAppliedScopeKey, entryFilesExpiresScopeKey, entryFilesMinterScopeKey, entryFilesPendingExpiresScopeKey, entryFilesRetiredScopeKey, entryFilesUndeliveredScopeKey, entryFilesUnrevokedScopeKey, entryFilesWrittenScopeKey, filesOwnerUserId, parseIdList as parseFilesIdList, } from './entry-files-key.js';
16
16
  import { appHostname, coolifyDomains, entryHostnames, tunnelDnsHostnames, tunnelIngressHostnames, } from './hostnames.js';
17
- import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
17
+ import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, LOGIN_HOST_SHARED_SCOPE_KEY, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
18
18
  import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
19
19
  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';
20
20
  import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
@@ -240,10 +240,14 @@ const CMS_SITE_APP_DOCKERFILE = '/apps/site/Dockerfile';
240
240
  * followed rather than reopened (#164 "Decide" 1). The agora repo's own
241
241
  * Dockerfile (`apps/agora/Dockerfile`) already `EXPOSE`s 8080 and serves
242
242
  * from a build built at the repository root, the same one-repo shape
243
- * `CMS_REPO`'s own two apps use -- no `ports_exposes`/`PORT` env is needed
244
- * the way `CALLS_SERVICE_PORT` is for the calls service's own Node process,
245
- * since Coolify's github-app build already reads the exposed port the same
246
- * way it does for `cmsSite`'s two apps above.
243
+ * `CMS_REPO`'s own two apps use. This comment used to claim that needed no
244
+ * `ports_exposes` of its own, on the theory that Coolify's github-app build
245
+ * reads a Dockerfile's `EXPOSE` the same way it does for `cmsSite`'s two
246
+ * apps above -- wrong (#506): Coolify never reads `EXPOSE` for a build it
247
+ * runs itself, so without an explicit `agora.ts`'s `AGORA_APP_PORTS_EXPOSES`,
248
+ * Traefik is left routing to a port nothing listens on, and a company's
249
+ * Agora answers 502 behind the tunnel. Seen live on Company Included,
250
+ * 2026-10-05.
247
251
  */
248
252
  const AGORA_REPO = 'wtfalch/agora';
249
253
  const AGORA_APP_DOCKERFILE = '/apps/agora/Dockerfile';
@@ -665,7 +669,7 @@ export class FoundryStepExecutor {
665
669
  case 'cloudflare:tunnel':
666
670
  return this.cloudflareTunnel(action, org, context);
667
671
  case 'cloudflare:zone':
668
- return this.cloudflareZone(action, org);
672
+ return this.cloudflareZone(action, org, context);
669
673
  case 'cloudflare:access':
670
674
  return this.cloudflareAccess(action, org);
671
675
  case 'cloudflare:ratelimit':
@@ -1232,11 +1236,16 @@ export class FoundryStepExecutor {
1232
1236
  throw new Error(`zitadel:login-host for ${org.slug}: the loginHostBindings port is not wired`);
1233
1237
  }
1234
1238
  const host = action.desired.host;
1235
- await this.ports.zitadel.ensureTrustedDomain(host);
1236
- // zitadel-login's own routing table, written before any app is repointed
1237
- // so no app ever points at a host with no binding.
1239
+ // The platform's own shared sign-in host: already trusted and routed by
1240
+ // the shared instance, so it gets no trusted domain or binding here.
1241
+ const shared = action.desired.shared === true;
1238
1242
  const clientIds = loginHostClientIds(recorded);
1239
- await bindings.upsertBinding({ host, organizationId: zitadelOrgId, clientIds });
1243
+ if (!shared) {
1244
+ await this.ports.zitadel.ensureTrustedDomain(host);
1245
+ // zitadel-login's own routing table, written before any app is repointed
1246
+ // so no app ever points at a host with no binding.
1247
+ await bindings.upsertBinding({ host, organizationId: zitadelOrgId, clientIds });
1248
+ }
1240
1249
  // Only apps whose client id the binding now holds: any other app in the
1241
1250
  // project would lose sign-in. The rest move on a later run, once the
1242
1251
  // recorded client ids differ from the binding and replan this step.
@@ -1337,7 +1346,10 @@ export class FoundryStepExecutor {
1337
1346
  // the binding and replans the step next run; a nonempty pending list
1338
1347
  // does too (`planLoginHost`), independent of the host comparison that
1339
1348
  // normally alone decides it.
1340
- context.recordScopeId(LOGIN_BINDING_SCOPE_KEY, loginBindingState(host, clientIds));
1349
+ if (shared)
1350
+ context.recordScopeId(LOGIN_HOST_SHARED_SCOPE_KEY, '1');
1351
+ else
1352
+ context.recordScopeId(LOGIN_BINDING_SCOPE_KEY, loginBindingState(host, clientIds));
1341
1353
  context.recordScopeId(LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, pendingRemovalState(stillPending));
1342
1354
  return { resourceId: host };
1343
1355
  }
@@ -3603,11 +3615,25 @@ export class FoundryStepExecutor {
3603
3615
  * actually differs, or whenever the last restart this step confirmed
3604
3616
  * ({@link AGORA_ACTIVATED_ENV_SCOPE_KEY}) is not this exact env (an org
3605
3617
  * applied before this existed, whose live env already matched desired
3606
- * with nothing ever confirmed running it). A brand new app has nothing
3607
- * running yet to restart. The activation marker is recorded last, after
3608
- * the restart (if any) returns without throwing, so a failed restart
3609
- * leaves it stale and the next plan replans this step even though
3610
- * Coolify's own env store already matches desired.
3618
+ * with nothing ever confirmed running it). The activation marker is
3619
+ * recorded last, after the restart (if any) returns without throwing, so a
3620
+ * failed restart leaves it stale and the next plan replans this step even
3621
+ * though Coolify's own env store already matches desired.
3622
+ *
3623
+ * #504: a brand new app is never restarted -- but it is deployed, once
3624
+ * ({@link AGORA_DEPLOYED_SCOPE_KEY}). `createGithubAppApplication` never
3625
+ * sets `instant_deploy`, and Coolify defaults it to false, so without an
3626
+ * explicit deploy the app sat created but never built or started.
3627
+ *
3628
+ * #506: `ports_exposes: AGORA_APP_PORTS_EXPOSES` goes on the create call
3629
+ * itself, so Traefik has a port to route to from the moment the app
3630
+ * exists. An app `findOrCreateCallsApp` instead *found* may predate this
3631
+ * fix and carry no `ports_exposes` at all (or the wrong one), so its live
3632
+ * value is read and, when it differs, patched -- and, since the container
3633
+ * listens on `ports_exposes` only at its next deploy (the same rule
3634
+ * `coolifyApp`'s own `settingsNeedDeploy` follows for a port change
3635
+ * there), redeployed even on a run that already has its one #504 deploy
3636
+ * behind it.
3611
3637
  */
3612
3638
  async coolifyAgora(action, org, context) {
3613
3639
  // The host values the plan read, carried in the step: never read again
@@ -3637,9 +3663,22 @@ export class FoundryStepExecutor {
3637
3663
  name,
3638
3664
  domains: coolifyDomains([agoraHostnameFor(org)]),
3639
3665
  dockerfile_location: AGORA_APP_DOCKERFILE,
3666
+ ports_exposes: AGORA_APP_PORTS_EXPOSES,
3640
3667
  }));
3641
3668
  // Every app foundry deploys keeps its own logs (#75), reused as-is here.
3642
3669
  await this.ensureLogVolume(app.appId, org, app.existed);
3670
+ // #506: an app found rather than just created may predate passing
3671
+ // `ports_exposes` at create -- Traefik then routes to a port nothing
3672
+ // listens on and Agora answers 502 behind the tunnel. A freshly created
3673
+ // app already got the right port above, so only a found one is checked.
3674
+ const portDiffers = app.existed &&
3675
+ (await this.ports.coolify.getApplication(app.appId)).ports_exposes !==
3676
+ AGORA_APP_PORTS_EXPOSES;
3677
+ if (portDiffers) {
3678
+ await this.ports.coolify.updateApplication(app.appId, {
3679
+ ports_exposes: AGORA_APP_PORTS_EXPOSES,
3680
+ });
3681
+ }
3643
3682
  // #442: read before overwriting -- `bulkUpsertEnvs` below always leaves
3644
3683
  // Coolify's own env store matching `env`, which is why that store alone
3645
3684
  // can no longer tell a changed value from one already live.
@@ -3650,7 +3689,25 @@ export class FoundryStepExecutor {
3650
3689
  await this.ports.coolify.bulkUpsertEnvs(app.appId, Object.entries(env).map(([key, value]) => ({ key, value })));
3651
3690
  const revision = agoraEnvRevision(env);
3652
3691
  const alreadyActivated = context.recordedIds()[AGORA_ACTIVATED_ENV_SCOPE_KEY] === revision;
3653
- if (app.existed && (envChanged || !alreadyActivated)) {
3692
+ // #504: `createGithubAppApplication` never sets `instant_deploy`, and
3693
+ // Coolify defaults it to false -- a brand new app, and one found by name
3694
+ // that a previous apply created but crashed before deploying, both sat
3695
+ // built but never started. `restart` cannot fix either: it starts a
3696
+ // container that was never built. Deployed once this executor has
3697
+ // confirmed (the marker never changes value once set), never again -- a
3698
+ // live app is only ever restarted from here on, unless #506's port fix
3699
+ // below needs its own deploy.
3700
+ if (!context.recordedIds()[AGORA_DEPLOYED_SCOPE_KEY]) {
3701
+ await this.ports.coolify.deploy(app.appId);
3702
+ context.recordScopeId(AGORA_DEPLOYED_SCOPE_KEY, '1');
3703
+ }
3704
+ else if (portDiffers) {
3705
+ // #506: a port fix needs its own full deploy -- env drift alone only
3706
+ // needs the cheaper restart, and this deploy already carries this
3707
+ // run's env (pushed above), so the two never both run.
3708
+ await this.ports.coolify.deploy(app.appId);
3709
+ }
3710
+ else if (app.existed && (envChanged || !alreadyActivated)) {
3654
3711
  await this.ports.coolify.restart(app.appId);
3655
3712
  }
3656
3713
  context.recordScopeId(AGORA_ACTIVATED_ENV_SCOPE_KEY, revision);
@@ -3679,6 +3736,10 @@ export class FoundryStepExecutor {
3679
3736
  *
3680
3737
  * `AUTH_APP_KEY` is minted once here; its broker entry is the next step
3681
3738
  * (`bouleBrokerEntry`).
3739
+ *
3740
+ * #504: a brand new app is never restarted -- but it is deployed, once
3741
+ * ({@link BOULE_DEPLOYED_SCOPE_KEY}), the same reason and the same marker
3742
+ * shape {@link coolifyAgora}'s own doc comment explains.
3682
3743
  */
3683
3744
  async coolifyBoule(action, org, context) {
3684
3745
  // The issuer and Archon URL the plan read, carried in the step: the env
@@ -3730,7 +3791,17 @@ export class FoundryStepExecutor {
3730
3791
  if (!dbPush.pushed) {
3731
3792
  throw new Error(`Boule for ${org.slug}: its database has no internal_db_url yet -- retry this apply once Coolify reports one`);
3732
3793
  }
3733
- if (app.existed && (envChanged || cookieSecret !== null || appKey !== null || dbPush.changed)) {
3794
+ // #504: see `coolifyAgora`'s own comment just above -- `createGithubAppApplication`
3795
+ // never sets `instant_deploy`, so a brand new app (or one found by name
3796
+ // that a previous apply created but crashed before deploying) sat built
3797
+ // but never started. Deployed once this executor has confirmed, never
3798
+ // again -- a live app is only ever restarted from here on.
3799
+ if (!context.recordedIds()[BOULE_DEPLOYED_SCOPE_KEY]) {
3800
+ await this.ports.coolify.deploy(app.appId);
3801
+ context.recordScopeId(BOULE_DEPLOYED_SCOPE_KEY, '1');
3802
+ }
3803
+ else if (app.existed &&
3804
+ (envChanged || cookieSecret !== null || appKey !== null || dbPush.changed)) {
3734
3805
  await this.ports.coolify.restart(app.appId);
3735
3806
  }
3736
3807
  return { resourceId: app.appId };
@@ -4219,8 +4290,17 @@ export class FoundryStepExecutor {
4219
4290
  * shared-hostname collision this ticket exists to refuse. An org re-running
4220
4291
  * its own already-recorded zone is unaffected: the store then names this
4221
4292
  * same org as the owner.
4293
+ *
4294
+ * #510: also records the zone's own id under the flat `cloudflare_zone`
4295
+ * scope key, alongside the `cloudflare:zone:<domain>` resource id `runSteps`
4296
+ * already records from this method's return value. Without it, nothing
4297
+ * ever gives `ProvidersCloudflareReader` a zone id to resolve this org's
4298
+ * scope with (`providers-readers.ts`'s `storeScopeResolver` doc comment),
4299
+ * so that reader's `listResources` always returned `[]` and `diff.ts`'s
4300
+ * `planZone` re-proposed "provision a Cloudflare zone" on every apply, even
4301
+ * once the zone was live.
4222
4302
  */
4223
- async cloudflareZone(action, org) {
4303
+ async cloudflareZone(action, org, context) {
4224
4304
  const domain = action.key;
4225
4305
  const cf = this.ports.cloudflare;
4226
4306
  const existing = await cf.findZoneByApex(domain);
@@ -4229,9 +4309,11 @@ export class FoundryStepExecutor {
4229
4309
  if (owner !== null && owner !== org.id) {
4230
4310
  throw new Error(`cloudflare:zone for ${org.slug}: zone ${existing.id} (${domain}) is already recorded under org ${owner}; foundry does not adopt it`);
4231
4311
  }
4312
+ context.recordScopeId('cloudflare_zone', existing.id);
4232
4313
  return { resourceId: existing.id };
4233
4314
  }
4234
4315
  const created = await cf.createZone(domain);
4316
+ context.recordScopeId('cloudflare_zone', created.id);
4235
4317
  return { resourceId: created.id };
4236
4318
  }
4237
4319
  // ---- mail -----------------------------------------------------------
@@ -5318,7 +5400,14 @@ export class FoundryStepExecutor {
5318
5400
  // `LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY` -- nothing else ever runs
5319
5401
  // for this org again to finish removing it once destroy deletes
5320
5402
  // the ZITADEL project and org below, so it has to go here too.
5321
- for (const host of [target.id, ...pendingRemovalHosts(recordedIds)]) {
5403
+ // A shared instance host's trusted domain is not this org's to remove.
5404
+ // The flag may be missing, so the recorded host is also compared
5405
+ // with the platform's shared auth host.
5406
+ const sharedHost = await this.ports.platformOrg?.authHostname?.();
5407
+ const isShared = recordedIds[LOGIN_HOST_SHARED_SCOPE_KEY] === '1' ||
5408
+ (sharedHost != null && target.id === sharedHost);
5409
+ const own = isShared ? [] : [target.id];
5410
+ for (const host of [...own, ...pendingRemovalHosts(recordedIds)]) {
5322
5411
  try {
5323
5412
  await this.ports.zitadel.deleteTrustedDomain(host);
5324
5413
  }
@@ -22,18 +22,22 @@ import type { DeclaredOrg } from '../org.js';
22
22
  * org's own `<slug>.oikos.host` can collide with this subdomain, the same
23
23
  * way `AGORA_SUFFIX` reserves `-agora`.
24
24
  *
25
- * #488: `auth.<domain>` is refused when it would equal `platformAuthHostname`
26
- * -- the platform's own org, declared with-domain on the platform's own
27
- * domain, would otherwise compute the shared ZITADEL instance's own login
28
- * host (`platform.auth.hostname`, e.g. `auth.companyincriminated.com` ->
29
- * `auth.companyincluded.com`). Archon's login-host write throws on that
30
- * duplicate host (`archon`'s `validateLoginHostRegistry`); if it were ever
31
- * written instead, `auth`'s own `company-host.ts` denies every request
32
- * through a duplicate host, the shared one included. `platformAuthHostname`
33
- * is `null` when the caller has none to compare against (no platform
34
- * declared, or sign-in not ready yet), in which case this never refuses.
25
+ * #488: the platform's own org is not handled here: it signs in on the shared
26
+ * ZITADEL instance's own host (`platformAuthHostname`), whatever
27
+ * `issuer.usesDomain` says (William, 2026-10-06), and `planLoginHost` picks
28
+ * that host itself. That host must never get a trusted
29
+ * domain or a login binding of its own:
30
+ * Archon's login-host write throws on a duplicate host, and `auth`'s own
31
+ * `company-host.ts` denies every request through one, the shared one
32
+ * included (`planLoginHost` and `zitadelLoginHost` skip both for it).
35
33
  */
36
- export declare function loginHostnameFor(org: DeclaredOrg, platformAuthHostname?: string | null): string;
34
+ export declare function loginHostnameFor(org: DeclaredOrg): string;
35
+ /**
36
+ * Scope id set to `'1'` once an org's recorded login host is the platform's
37
+ * shared instance host: that host's trusted domain belongs to the shared
38
+ * instance, so `destroy` must not remove it.
39
+ */
40
+ export declare const LOGIN_HOST_SHARED_SCOPE_KEY = "zitadel_login_host_shared";
37
41
  /**
38
42
  * One entry in zitadel-login's own `LOGIN_HOST_BINDINGS`
39
43
  * (`auth/docs/company-login-hosts.md`) -- a single-line JSON document naming
@@ -23,30 +23,30 @@ import { BOULE_CLIENT_ID_SCOPE_KEY } from './boule.js';
23
23
  * org's own `<slug>.oikos.host` can collide with this subdomain, the same
24
24
  * way `AGORA_SUFFIX` reserves `-agora`.
25
25
  *
26
- * #488: `auth.<domain>` is refused when it would equal `platformAuthHostname`
27
- * -- the platform's own org, declared with-domain on the platform's own
28
- * domain, would otherwise compute the shared ZITADEL instance's own login
29
- * host (`platform.auth.hostname`, e.g. `auth.companyincriminated.com` ->
30
- * `auth.companyincluded.com`). Archon's login-host write throws on that
31
- * duplicate host (`archon`'s `validateLoginHostRegistry`); if it were ever
32
- * written instead, `auth`'s own `company-host.ts` denies every request
33
- * through a duplicate host, the shared one included. `platformAuthHostname`
34
- * is `null` when the caller has none to compare against (no platform
35
- * declared, or sign-in not ready yet), in which case this never refuses.
26
+ * #488: the platform's own org is not handled here: it signs in on the shared
27
+ * ZITADEL instance's own host (`platformAuthHostname`), whatever
28
+ * `issuer.usesDomain` says (William, 2026-10-06), and `planLoginHost` picks
29
+ * that host itself. That host must never get a trusted
30
+ * domain or a login binding of its own:
31
+ * Archon's login-host write throws on a duplicate host, and `auth`'s own
32
+ * `company-host.ts` denies every request through one, the shared one
33
+ * included (`planLoginHost` and `zitadelLoginHost` skip both for it).
36
34
  */
37
- export function loginHostnameFor(org, platformAuthHostname = null) {
35
+ export function loginHostnameFor(org) {
38
36
  // `issuer.usesDomain` is the issuer's opt-in into the org's domain
39
37
  // (`org.ts`'s `IssuerConfig`): a domain alone never moves it, so a domain
40
38
  // with `usesDomain` false stays on the tier 1 host.
41
39
  if (org.domain && org.issuer.usesDomain) {
42
- const host = `auth.${org.domain}`;
43
- if (platformAuthHostname && host === platformAuthHostname) {
44
- return `${org.slug}-auth.oikos.host`;
45
- }
46
- return host;
40
+ return `auth.${org.domain}`;
47
41
  }
48
42
  return `${org.slug}-auth.oikos.host`;
49
43
  }
44
+ /**
45
+ * Scope id set to `'1'` once an org's recorded login host is the platform's
46
+ * shared instance host: that host's trusted domain belongs to the shared
47
+ * instance, so `destroy` must not remove it.
48
+ */
49
+ export const LOGIN_HOST_SHARED_SCOPE_KEY = 'zitadel_login_host_shared';
50
50
  /**
51
51
  * Scope id holding what the binding was last written with
52
52
  * ({@link loginBindingState}). Client ids are not secrets. The planner
@@ -45,6 +45,7 @@ export declare class PlatformOrgPorts {
45
45
  files(): Promise<FilesOrganisationPort>;
46
46
  ai(): Promise<AiOrganisationPort>;
47
47
  jobs(): Promise<JobsSchedulerPort>;
48
+ authHostname(): Promise<string | null>;
48
49
  private recorded;
49
50
  private held;
50
51
  }
@@ -47,6 +47,9 @@ export class PlatformOrgPorts {
47
47
  const token = await this.held('jobs', JOBS_ADMIN_TOKEN_REF);
48
48
  return new HttpJobsSchedulerClient(`https://${platform.jobs.hostname}`, token, this.options.fetch);
49
49
  }
50
+ async authHostname() {
51
+ return (await this.options.store.get())?.auth.hostname ?? null;
52
+ }
50
53
  async recorded(service, scopeKey) {
51
54
  const value = (await this.options.store.getServiceIds())[scopeKey];
52
55
  if (!value) {
@@ -640,8 +640,9 @@ export declare const PLATFORM_AI_MANAGEMENT_KEY_REF = "ai:platform:managementKey
640
640
  * What Archon's management key may do in ai: exactly `ai`'s own
641
641
  * `ARCHON_AI_PERMISSIONS` (`apps/host/scripts/authz/bootstrap-archon.ts`) --
642
642
  * configuration only (offerings, provisioning, offers, budgets, usage, cost)
643
- * plus deleting (archiving) an organisation, never connections, runs or
644
- * webhooks. ai's `/v1/platform/keys` route refuses any permission outside
643
+ * plus deleting (archiving) an organisation and setting another
644
+ * organisation's webhook address and budget, never connections, runs or
645
+ * webhook deliveries. ai's `/v1/platform/keys` route refuses any permission outside
645
646
  * this same list, so a request for more than this is a 403, not a wider key.
646
647
  */
647
648
  export declare const PLATFORM_AI_MANAGEMENT_PERMISSIONS: readonly string[];
@@ -2330,11 +2330,14 @@ const PLATFORM_ARCHON_PROJECT = 'platform';
2330
2330
  const PLATFORM_ARCHON_APP_NAME = 'platform-archon';
2331
2331
  const PLATFORM_ARCHON_DB_NAME = 'platform-archon-db';
2332
2332
  /**
2333
- * The database Coolify creates for Archon. Archon's own
2334
- * `drizzle/0001_runtime_role.sql` names its runtime role
2335
- * `current_database() || '_rt'`, so {@link ARCHON_RUNTIME_ROLE} follows.
2333
+ * The database Archon runs on. Live platform-archon moved to `archon_v2` on
2334
+ * the same server (2026-10-02, the move onto `@wtfalch/db`), while its Coolify
2335
+ * resource still names `archon` as its first database. So both URLs built
2336
+ * here set this database themselves, never `internal_db_url`'s path.
2337
+ * Archon's own migrations grant to `current_database() || '_rt'`, so
2338
+ * {@link ARCHON_RUNTIME_ROLE} follows.
2336
2339
  */
2337
- const ARCHON_POSTGRES_DB = 'archon';
2340
+ const ARCHON_POSTGRES_DB = 'archon_v2';
2338
2341
  const ARCHON_RUNTIME_ROLE = `${ARCHON_POSTGRES_DB}_rt`;
2339
2342
  /** Fixed so the tunnel addresses the container directly (see `provisionPlatformAuth`). */
2340
2343
  const PLATFORM_ARCHON_INTERNAL_NAME = 'platform-archon';
@@ -2668,11 +2671,12 @@ async function provisionPlatformArchon(context, deps) {
2668
2671
  // The owner URL must name the adopted database's host, or the app's two
2669
2672
  // database URLs would point at different databases.
2670
2673
  const liveOwnerUrl = await currentEnvValue(deps.coolify, appId, 'DATABASE_URL_OWNER');
2674
+ const ownerUrl = archonDatabaseUrl(db.internal_db_url);
2671
2675
  if (liveOwnerUrl) {
2672
2676
  assertSameDatabaseHost('DATABASE_URL_OWNER', liveOwnerUrl, db.internal_db_url);
2673
2677
  }
2674
2678
  else {
2675
- envs.push({ key: 'DATABASE_URL_OWNER', value: db.internal_db_url });
2679
+ envs.push({ key: 'DATABASE_URL_OWNER', value: ownerUrl });
2676
2680
  }
2677
2681
  const liveReportingSite = await currentEnvValue(deps.coolify, appId, 'REPORTING_SITE');
2678
2682
  if (!liveReportingSite) {
@@ -2692,7 +2696,7 @@ async function provisionPlatformArchon(context, deps) {
2692
2696
  // reflects the env Archon actually ends up running with, not just this
2693
2697
  // run's delta.
2694
2698
  const archonEnvHashOverrides = new Map([
2695
- ['DATABASE_URL_OWNER', liveOwnerUrl ?? db.internal_db_url],
2699
+ ['DATABASE_URL_OWNER', liveOwnerUrl ?? ownerUrl],
2696
2700
  ['REPORTING_SITE', liveReportingSite ?? 'archon'],
2697
2701
  ]);
2698
2702
  const effectiveCookieSecret = cookieSecret ?? (await currentEnvValue(deps.coolify, appId, 'AUTH_COOKIE_SECRET'));
@@ -3625,10 +3629,9 @@ async function ensureArchonRuntimeUrl(coolify, appId, dbInternalUrl) {
3625
3629
  return null;
3626
3630
  }
3627
3631
  let host;
3628
- let pathname;
3629
3632
  let search;
3630
3633
  try {
3631
- ({ host, pathname, search } = new URL(dbInternalUrl));
3634
+ ({ host, search } = new URL(dbInternalUrl));
3632
3635
  }
3633
3636
  catch {
3634
3637
  // Node's `ERR_INVALID_URL` carries the whole input, credential included, so
@@ -3636,7 +3639,19 @@ async function ensureArchonRuntimeUrl(coolify, appId, dbInternalUrl) {
3636
3639
  throw new Error('platform:archon: the database internal_db_url could not be parsed; not generating DATABASE_URL. Check the database in Coolify and apply again');
3637
3640
  }
3638
3641
  const password = randomBytes(24).toString('base64url');
3639
- return `postgres://${ARCHON_RUNTIME_ROLE}:${password}@${host}${pathname}${search}`;
3642
+ return `postgres://${ARCHON_RUNTIME_ROLE}:${password}@${host}/${ARCHON_POSTGRES_DB}${search}`;
3643
+ }
3644
+ /** `internalUrl` on {@link ARCHON_POSTGRES_DB}. The error never carries the URL: it holds a credential. */
3645
+ function archonDatabaseUrl(internalUrl) {
3646
+ let url;
3647
+ try {
3648
+ url = new URL(internalUrl);
3649
+ }
3650
+ catch {
3651
+ throw new Error('platform:archon: the database internal_db_url could not be parsed; not generating DATABASE_URL_OWNER. Check the database in Coolify and apply again');
3652
+ }
3653
+ url.pathname = `/${ARCHON_POSTGRES_DB}`;
3654
+ return url.toString();
3640
3655
  }
3641
3656
  /**
3642
3657
  * Thrown by {@link createAiManagementKeyMinter} (never by a test's own
@@ -3720,8 +3735,9 @@ export const PLATFORM_AI_MANAGEMENT_KEY_REF = 'ai:platform:managementKey';
3720
3735
  * What Archon's management key may do in ai: exactly `ai`'s own
3721
3736
  * `ARCHON_AI_PERMISSIONS` (`apps/host/scripts/authz/bootstrap-archon.ts`) --
3722
3737
  * configuration only (offerings, provisioning, offers, budgets, usage, cost)
3723
- * plus deleting (archiving) an organisation, never connections, runs or
3724
- * webhooks. ai's `/v1/platform/keys` route refuses any permission outside
3738
+ * plus deleting (archiving) an organisation and setting another
3739
+ * organisation's webhook address and budget, never connections, runs or
3740
+ * webhook deliveries. ai's `/v1/platform/keys` route refuses any permission outside
3725
3741
  * this same list, so a request for more than this is a 403, not a wider key.
3726
3742
  */
3727
3743
  export const PLATFORM_AI_MANAGEMENT_PERMISSIONS = [
@@ -3732,6 +3748,8 @@ export const PLATFORM_AI_MANAGEMENT_PERMISSIONS = [
3732
3748
  'ai.organisations:provision',
3733
3749
  'ai.organisations:offer',
3734
3750
  'ai.organisations:delete',
3751
+ 'ai.organisations:webhook',
3752
+ 'ai.organisations:budget',
3735
3753
  'ai.keys:issue',
3736
3754
  'ai.budgets:read',
3737
3755
  'ai.budgets:update',
@@ -1,6 +1,6 @@
1
1
  import type { CloudflareClient } from '@wtfalch/providers/cloudflare';
2
2
  import type { CoolifyClient } from '@wtfalch/providers/coolify';
3
- import type { ZitadelClient } from '@wtfalch/providers/zitadel';
3
+ import { type ZitadelClient } from '@wtfalch/providers/zitadel';
4
4
  import type { OrgId } from '../org.js';
5
5
  import type { DeclaredOrgStore } from '../store.js';
6
6
  import type { CloudflareReader, CmsReader, CoolifyReader, LiveResource, ZitadelReader } from './live.js';
@@ -53,15 +53,17 @@ export type ScopeResolver = (orgId: OrgId) => Promise<string | null>;
53
53
  * resource it creates, keyed `system:kind:key` ({@link storeRecordedIds}
54
54
  * below reads those back), and #20's `FoundryStepExecutor` (`executor.ts`)
55
55
  * now also calls `StepExecutionContext.recordScopeId` for `coolify_project`,
56
- * `zitadel_org` and `zitadel_project`, so a resolver built with one of those
57
- * three `serviceKey`s returns the real id once that step has run. A
58
- * Cloudflare zone is the one scope id nothing writes yet: `executor.ts`'s
59
- * `cloudflareZone` records its result only under `cloudflare:zone:<domain>`
60
- * (a resource id, read back by {@link storeRecordedIds}), not under a plain
61
- * `cloudflare_zone` scope key — a resolver built with that `serviceKey`
62
- * still returns `null` for every org. That is the correct "nothing live yet"
63
- * answer: a reader passed `null` returns `[]`, which `computePlanDetail`
64
- * (`diff.ts`) already reads as "no live resource".
56
+ * `zitadel_org`, `zitadel_project` and (#510) `cloudflare_zone`, so a
57
+ * resolver built with one of those `serviceKey`s returns the real id once
58
+ * that step has run. Before #510, `cloudflareZone` recorded its result only
59
+ * under `cloudflare:zone:<domain>` (a resource id, read back by
60
+ * {@link storeRecordedIds}), never under the plain `cloudflare_zone` scope
61
+ * key — so a resolver built with that `serviceKey` always returned `null`,
62
+ * `ProvidersCloudflareReader.listResources` always returned `[]`, and
63
+ * `diff.ts`'s `planZone` re-proposed provisioning the zone on every apply
64
+ * even once it was live. A scope key that genuinely has nothing recorded yet
65
+ * still correctly resolves to `null`: a reader passed `null` returns `[]`,
66
+ * which `computePlanDetail` (`diff.ts`) already reads as "no live resource".
65
67
  */
66
68
  export declare function storeScopeResolver(store: DeclaredOrgStore, serviceKey: string): ScopeResolver;
67
69
  /**
@@ -112,7 +114,8 @@ export declare class ProvidersCloudflareReader implements CloudflareReader {
112
114
  /**
113
115
  * Backed by `@wtfalch/providers/zitadel`'s `ZitadelClient.listResources`: one
114
116
  * resource per project and per project grant in a ZITADEL org (v1 scope —
115
- * see that method's own doc comment).
117
+ * see that method's own doc comment), plus (#510) a synthesized `issuer`
118
+ * resource once the org's own ZITADEL org is confirmed live.
116
119
  */
117
120
  export declare class ProvidersZitadelReader implements ZitadelReader {
118
121
  private readonly client;
@@ -120,6 +123,26 @@ export declare class ProvidersZitadelReader implements ZitadelReader {
120
123
  private readonly resolveRecordedIds;
121
124
  constructor(client: ZitadelClient, resolveScope: ScopeResolver, resolveRecordedIds: RecordedIds);
122
125
  listResources(orgId: OrgId): Promise<LiveResource[]>;
126
+ /**
127
+ * #510: `client.listResources` reports only what is *inside* a ZITADEL org
128
+ * (its projects and grants), never the org record itself, so nothing ever
129
+ * matched `diff.ts`'s `planIssuer`'s own `zitadel:issuer:<orgId>` lookup —
130
+ * every plan re-proposed "provision the org's own ZITADEL org" after a
131
+ * clean apply, even once `zitadel_org` was recorded and live. `orgId` here
132
+ * is foundry's own id ({@link resourceKey}'s `key` for an issuer, `diff.ts`'s
133
+ * `planIssuer`), not `zitadelOrgId` — the resource foundry tracks is "this
134
+ * org's issuer", and ZITADEL's own org id is an implementation detail of
135
+ * where it lives, the same distinction {@link applyRecordedIds} draws for
136
+ * every other recorded resource.
137
+ *
138
+ * `resolveScope` only reads what was last recorded; it never confirms the
139
+ * org still exists in ZITADEL. A 404 here means the recorded id no longer
140
+ * resolves (the org was removed out of band) and reads as "not live" — the
141
+ * same `[]` answer a never-recorded scope already gives. Any other failure
142
+ * propagates, same as every other call in this reader. An org ZITADEL still
143
+ * returns but marks `ORG_STATE_REMOVED` is not live either.
144
+ */
145
+ private liveIssuer;
123
146
  }
124
147
  /**
125
148
  * Backed by the same `@wtfalch/providers/coolify`'s `CoolifyClient.listResources`
@@ -1,3 +1,4 @@
1
+ import { ZitadelError } from '@wtfalch/providers/zitadel';
1
2
  import { BOULE_MANAGED_ENV_KEYS } from './boule.js';
2
3
  /**
3
4
  * A {@link ScopeResolver} backed by `AppliedState.serviceIds` (`../state.ts`)
@@ -8,15 +9,17 @@ import { BOULE_MANAGED_ENV_KEYS } from './boule.js';
8
9
  * resource it creates, keyed `system:kind:key` ({@link storeRecordedIds}
9
10
  * below reads those back), and #20's `FoundryStepExecutor` (`executor.ts`)
10
11
  * now also calls `StepExecutionContext.recordScopeId` for `coolify_project`,
11
- * `zitadel_org` and `zitadel_project`, so a resolver built with one of those
12
- * three `serviceKey`s returns the real id once that step has run. A
13
- * Cloudflare zone is the one scope id nothing writes yet: `executor.ts`'s
14
- * `cloudflareZone` records its result only under `cloudflare:zone:<domain>`
15
- * (a resource id, read back by {@link storeRecordedIds}), not under a plain
16
- * `cloudflare_zone` scope key — a resolver built with that `serviceKey`
17
- * still returns `null` for every org. That is the correct "nothing live yet"
18
- * answer: a reader passed `null` returns `[]`, which `computePlanDetail`
19
- * (`diff.ts`) already reads as "no live resource".
12
+ * `zitadel_org`, `zitadel_project` and (#510) `cloudflare_zone`, so a
13
+ * resolver built with one of those `serviceKey`s returns the real id once
14
+ * that step has run. Before #510, `cloudflareZone` recorded its result only
15
+ * under `cloudflare:zone:<domain>` (a resource id, read back by
16
+ * {@link storeRecordedIds}), never under the plain `cloudflare_zone` scope
17
+ * key — so a resolver built with that `serviceKey` always returned `null`,
18
+ * `ProvidersCloudflareReader.listResources` always returned `[]`, and
19
+ * `diff.ts`'s `planZone` re-proposed provisioning the zone on every apply
20
+ * even once it was live. A scope key that genuinely has nothing recorded yet
21
+ * still correctly resolves to `null`: a reader passed `null` returns `[]`,
22
+ * which `computePlanDetail` (`diff.ts`) already reads as "no live resource".
20
23
  */
21
24
  export function storeScopeResolver(store, serviceKey) {
22
25
  return async (orgId) => {
@@ -179,18 +182,34 @@ async function withEnvNamesConfig(client, resource, recorded) {
179
182
  * the `agora` kind, like {@link withLogVolumeConfig}. Unlike that read, a
180
183
  * failure here is not swallowed: a plan that cannot see the live config must
181
184
  * fail, not report it as missing.
185
+ *
186
+ * #506: also the live app's own `ports_exposes`, as `config.ports_exposes`,
187
+ * so `planAgora` can plan the fix for an app Coolify never got that setting
188
+ * on even when its env already matches -- the exact shape of the Company
189
+ * Included incident this issue names, where re-planning without this read
190
+ * would see no drift at all and never run `coolifyAgora` again.
182
191
  */
183
192
  async function withAgoraEnvConfig(client, resource, recorded) {
184
193
  if (resource.kind !== 'application' || !recorded[resource.key]?.startsWith('agora:')) {
185
194
  return resource;
186
195
  }
187
- const envs = await client.listEnvs(resource.key);
196
+ const [envs, app] = await Promise.all([
197
+ client.listEnvs(resource.key),
198
+ client.getApplication(resource.key),
199
+ ]);
188
200
  const env = {};
189
201
  for (const e of envs) {
190
202
  if (!e.is_preview && e.key.startsWith('AGORA_'))
191
203
  env[e.key] = e.value;
192
204
  }
193
- return { ...resource, config: { ...resource.config, env } };
205
+ // `@wtfalch/providers`' `CoolifyApplication` type does not declare
206
+ // `ports_exposes` (as of 0.7.3), but `getApplication` returns the raw
207
+ // Coolify API response (`coolify.ts`'s own `getApplication`, a plain
208
+ // `request` passthrough), which does carry it -- the same gap that
209
+ // repo's own CLAUDE.md names for other fields ("verified against a live
210
+ // instance, not the bundled openapi.yaml").
211
+ const portsExposes = app.ports_exposes;
212
+ return { ...resource, config: { ...resource.config, env, ports_exposes: portsExposes } };
194
213
  }
195
214
  /**
196
215
  * #161: the org's own recorded Boule app's live sign-in env vars, as
@@ -273,13 +292,17 @@ export class ProvidersCloudflareReader {
273
292
  // A zone needs no mapping: its key is already the zone name, which is
274
293
  // foundry's own zone key (`diff.ts`'s `planZone`), so it never matches a
275
294
  // recorded provider id and always passes through unchanged.
276
- return applyRecordedIds(toFoundryResources('cloudflare', resources), recorded, CLOUDFLARE_DROP_UNRECORDED);
295
+ return applyRecordedIds(toFoundryResources('cloudflare', resources)
296
+ // Keyed by the zone id, so a recorded zone id would map it onto the
297
+ // zone's own key and replace the zone's status; nothing plans from it.
298
+ .filter((resource) => resource.kind !== 'email_routing'), recorded, CLOUDFLARE_DROP_UNRECORDED);
277
299
  }
278
300
  }
279
301
  /**
280
302
  * Backed by `@wtfalch/providers/zitadel`'s `ZitadelClient.listResources`: one
281
303
  * resource per project and per project grant in a ZITADEL org (v1 scope —
282
- * see that method's own doc comment).
304
+ * see that method's own doc comment), plus (#510) a synthesized `issuer`
305
+ * resource once the org's own ZITADEL org is confirmed live.
283
306
  */
284
307
  export class ProvidersZitadelReader {
285
308
  client;
@@ -294,11 +317,45 @@ export class ProvidersZitadelReader {
294
317
  const zitadelOrgId = await this.resolveScope(orgId);
295
318
  if (!zitadelOrgId)
296
319
  return [];
297
- const [resources, recorded] = await Promise.all([
320
+ const [resources, recorded, issuer] = await Promise.all([
298
321
  this.client.listResources(zitadelOrgId),
299
322
  this.resolveRecordedIds(orgId),
323
+ this.liveIssuer(orgId, zitadelOrgId),
300
324
  ]);
301
- return applyRecordedIds(toFoundryResources('zitadel', resources), recorded);
325
+ const mapped = applyRecordedIds(toFoundryResources('zitadel', resources), recorded);
326
+ return issuer ? [...mapped, issuer] : mapped;
327
+ }
328
+ /**
329
+ * #510: `client.listResources` reports only what is *inside* a ZITADEL org
330
+ * (its projects and grants), never the org record itself, so nothing ever
331
+ * matched `diff.ts`'s `planIssuer`'s own `zitadel:issuer:<orgId>` lookup —
332
+ * every plan re-proposed "provision the org's own ZITADEL org" after a
333
+ * clean apply, even once `zitadel_org` was recorded and live. `orgId` here
334
+ * is foundry's own id ({@link resourceKey}'s `key` for an issuer, `diff.ts`'s
335
+ * `planIssuer`), not `zitadelOrgId` — the resource foundry tracks is "this
336
+ * org's issuer", and ZITADEL's own org id is an implementation detail of
337
+ * where it lives, the same distinction {@link applyRecordedIds} draws for
338
+ * every other recorded resource.
339
+ *
340
+ * `resolveScope` only reads what was last recorded; it never confirms the
341
+ * org still exists in ZITADEL. A 404 here means the recorded id no longer
342
+ * resolves (the org was removed out of band) and reads as "not live" — the
343
+ * same `[]` answer a never-recorded scope already gives. Any other failure
344
+ * propagates, same as every other call in this reader. An org ZITADEL still
345
+ * returns but marks `ORG_STATE_REMOVED` is not live either.
346
+ */
347
+ async liveIssuer(orgId, zitadelOrgId) {
348
+ try {
349
+ const found = await this.client.getOrg(zitadelOrgId);
350
+ if (found.state === 'ORG_STATE_REMOVED')
351
+ return null;
352
+ }
353
+ catch (err) {
354
+ if (err instanceof ZitadelError && err.status === 404)
355
+ return null;
356
+ throw err;
357
+ }
358
+ return { system: 'zitadel', kind: 'issuer', key: orgId, config: {} };
302
359
  }
303
360
  }
304
361
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.18.0",
3
+ "version": "0.19.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",