@wtfalch/foundry 0.18.0 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,
@@ -139,6 +151,17 @@ export declare const CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY = "coolify_calls_service_
139
151
  */
140
152
  export declare const CALLS_INTROSPECTION_SCOPE_KEY = "zitadel_calls_introspection_app";
141
153
  export declare const CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY = "zitadel_calls_introspection_undelivered";
154
+ /**
155
+ * The introspection API app minted for the org's chat service (once), in the
156
+ * messaging project, and the undelivered key recorded before its secret
157
+ * leaves the executor (platform rule 1). The first doubles as the marker
158
+ * `planChat` reads: recorded after the service's env carries the client. No
159
+ * `:` in either.
160
+ */
161
+ export declare const CHAT_INTROSPECTION_SCOPE_KEY = "zitadel_chat_introspection_app";
162
+ export declare const CHAT_INTROSPECTION_UNDELIVERED_SCOPE_KEY = "zitadel_chat_introspection_undelivered";
163
+ /** The chat service's introspection API app name; one per org, exact match only. */
164
+ export declare function chatIntrospectionAppName(org: DeclaredOrg): string;
142
165
  /** What the plan hands the calls service step: the two host values the token env needs. */
143
166
  export interface CallsTokenHost {
144
167
  issuer: string;
@@ -196,6 +219,19 @@ export declare function agoraEnvDrift(desired: Readonly<Record<string, string>>,
196
219
  * against (#442's own probe: "one env write, zero restarts, zero deploys").
197
220
  */
198
221
  export declare const AGORA_ACTIVATED_ENV_SCOPE_KEY = "coolify_agora_activated_env";
222
+ /**
223
+ * #504: recorded once `coolifyAgora` has actually called `deploy` on this
224
+ * org's Agora app -- `createGithubAppApplication` never sets
225
+ * `instant_deploy`, and Coolify defaults it to false, so a freshly created
226
+ * app (or one found by name that a previous apply created but crashed
227
+ * before deploying) sat at `exited:unhealthy` with zero deployments
228
+ * forever: nothing ever deployed it, and `restart` cannot start a container
229
+ * that was never built. Absent means "never deployed", regardless of
230
+ * whether the app is found live this run or created fresh -- unlike
231
+ * {@link AGORA_ACTIVATED_ENV_SCOPE_KEY}, this never changes value once set,
232
+ * so a later env change restarts rather than deploys again.
233
+ */
234
+ export declare const AGORA_DEPLOYED_SCOPE_KEY = "coolify_agora_deployed";
199
235
  /**
200
236
  * A stable digest of `env`, for comparison against what was last recorded
201
237
  * 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,
@@ -151,6 +163,19 @@ export const CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY = 'coolify_calls_service_token_en
151
163
  */
152
164
  export const CALLS_INTROSPECTION_SCOPE_KEY = 'zitadel_calls_introspection_app';
153
165
  export const CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY = 'zitadel_calls_introspection_undelivered';
166
+ /**
167
+ * The introspection API app minted for the org's chat service (once), in the
168
+ * messaging project, and the undelivered key recorded before its secret
169
+ * leaves the executor (platform rule 1). The first doubles as the marker
170
+ * `planChat` reads: recorded after the service's env carries the client. No
171
+ * `:` in either.
172
+ */
173
+ export const CHAT_INTROSPECTION_SCOPE_KEY = 'zitadel_chat_introspection_app';
174
+ export const CHAT_INTROSPECTION_UNDELIVERED_SCOPE_KEY = 'zitadel_chat_introspection_undelivered';
175
+ /** The chat service's introspection API app name; one per org, exact match only. */
176
+ export function chatIntrospectionAppName(org) {
177
+ return `chat-introspection-${org.slug}`;
178
+ }
154
179
  /**
155
180
  * The token env's inputs, or null while any is missing: the host's issuer and
156
181
  * messaging project id (`AgoraHostValues`) and the org's recorded ZITADEL org
@@ -305,6 +330,19 @@ export function agoraEnvDrift(desired, live) {
305
330
  * against (#442's own probe: "one env write, zero restarts, zero deploys").
306
331
  */
307
332
  export const AGORA_ACTIVATED_ENV_SCOPE_KEY = 'coolify_agora_activated_env';
333
+ /**
334
+ * #504: recorded once `coolifyAgora` has actually called `deploy` on this
335
+ * org's Agora app -- `createGithubAppApplication` never sets
336
+ * `instant_deploy`, and Coolify defaults it to false, so a freshly created
337
+ * app (or one found by name that a previous apply created but crashed
338
+ * before deploying) sat at `exited:unhealthy` with zero deployments
339
+ * forever: nothing ever deployed it, and `restart` cannot start a container
340
+ * that was never built. Absent means "never deployed", regardless of
341
+ * whether the app is found live this run or created fresh -- unlike
342
+ * {@link AGORA_ACTIVATED_ENV_SCOPE_KEY}, this never changes value once set,
343
+ * so a later env change restarts rather than deploys again.
344
+ */
345
+ export const AGORA_DEPLOYED_SCOPE_KEY = 'coolify_agora_deployed';
308
346
  /**
309
347
  * A stable digest of `env`, for comparison against what was last recorded
310
348
  * 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
@@ -90,6 +90,9 @@ export interface ChatServiceEnvInput {
90
90
  agoraHost: string;
91
91
  masClientId: string;
92
92
  masClientSecret: string;
93
+ /** The ZITADEL API app the service introspects Agora's opaque tokens as. */
94
+ introspectionClientId: string;
95
+ introspectionClientSecret: string;
93
96
  }
94
97
  /** Every variable `chat/apps/chat-service/src/config.ts` requires, plus the internal Synapse address and port. */
95
98
  export declare function chatServiceEnvs(input: ChatServiceEnvInput): Array<{
@@ -195,6 +195,8 @@ export function chatServiceEnvs(input) {
195
195
  { key: 'MAS_URL', value: upstreams.mas },
196
196
  { key: 'MAS_CLIENT_ID', value: input.masClientId },
197
197
  { key: 'MAS_CLIENT_SECRET', value: input.masClientSecret },
198
+ { key: 'CHAT_INTROSPECTION_CLIENT_ID', value: input.introspectionClientId },
199
+ { key: 'CHAT_INTROSPECTION_CLIENT_SECRET', value: input.introspectionClientSecret },
198
200
  // DATABASE_URL_OWNER/DATABASE_URL are pushed by executor.ts's
199
201
  // pushDatabaseUrl instead, not here (the same split coolifyApp and
200
202
  // cmsSite follow, #239) -- that call mints a distinct <dbName>_rt
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, CHAT_INTROSPECTION_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';
@@ -923,6 +964,27 @@ function planChat(org, byKey, claimed, recordedIds, reading, issuerPlanned) {
923
964
  blocked: null,
924
965
  };
925
966
  }
967
+ // A live chat made before its service had an introspection client
968
+ // answers every Agora sign-in with `token_invalid`. The marker is the
969
+ // client's app id, recorded after the service's env carries it.
970
+ if (!recordedIds[CHAT_INTROSPECTION_SCOPE_KEY]) {
971
+ return {
972
+ step: {
973
+ action: {
974
+ system: 'coolify',
975
+ kind: 'chat',
976
+ key: org.id,
977
+ desired: { usesDomain: liveUsesDomain, refreshIntrospection: true },
978
+ },
979
+ step: {
980
+ description: `give ${org.slug}'s chat service its token introspection client and redeploy it`,
981
+ destroys: false,
982
+ migrates: false,
983
+ },
984
+ },
985
+ blocked: null,
986
+ };
987
+ }
926
988
  return none;
927
989
  }
928
990
  const domain = org.domain ?? 'its domain';
@@ -1594,6 +1656,13 @@ function planAgora(org, byKey, claimed, recordedIds, reading) {
1594
1656
  if (liveEnv.AGORA_CONFIG === undefined && !drift.includes('AGORA_CONFIG')) {
1595
1657
  drift.push('AGORA_CONFIG');
1596
1658
  }
1659
+ // #506: an app Coolify never got `ports_exposes` on (or got the wrong one)
1660
+ // answers 502 behind the tunnel even though its env already matches --
1661
+ // exactly the Company Included incident this issue names. Compared
1662
+ // separately from `drift` above: it is a Coolify application setting, not
1663
+ // an env var, and `providers-readers.ts`'s `withAgoraEnvConfig` reads it
1664
+ // from `getApplication`, never `listEnvs`.
1665
+ const portDrift = live.config.ports_exposes !== AGORA_APP_PORTS_EXPOSES;
1597
1666
  // #442: `drift` alone, once this step has ever run, is comparing `env`
1598
1667
  // against Coolify's own env store -- which the executor's `bulkUpsertEnvs`
1599
1668
  // leaves matching `env` the moment the write succeeds, restarted or not.
@@ -1604,14 +1673,18 @@ function planAgora(org, byKey, claimed, recordedIds, reading) {
1604
1673
  // actually serving this exact `env`; while it disagrees, the step stays
1605
1674
  // planned no matter what Coolify's env store already shows.
1606
1675
  const activated = recordedIds[AGORA_ACTIVATED_ENV_SCOPE_KEY] === agoraEnvRevision(env);
1607
- if (drift.length === 0 && activated)
1676
+ if (drift.length === 0 && activated && !portDrift)
1608
1677
  return { step: null, blocked: null };
1678
+ const reasons = [
1679
+ ...(drift.length > 0 ? [`${drift.join(', ')} missing or different`] : []),
1680
+ ...(portDrift ? ["ports_exposes wrong -- Traefik can't reach it"] : []),
1681
+ ];
1609
1682
  return {
1610
1683
  step: {
1611
1684
  action,
1612
1685
  step: {
1613
- description: drift.length > 0
1614
- ? `update ${org.slug}'s Agora config (${drift.join(', ')} missing or different)`
1686
+ description: reasons.length > 0
1687
+ ? `update ${org.slug}'s Agora config (${reasons.join('; ')})`
1615
1688
  : `activate ${org.slug}'s Agora config -- written but not yet confirmed restarted`,
1616
1689
  destroys: false,
1617
1690
  migrates: false,
@@ -1735,13 +1808,13 @@ function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonU
1735
1808
  * park-not-delete rule (#7 corrections 1) -- turning it off never deletes
1736
1809
  * the org's own app, database or tunnel configuration, it parks it.
1737
1810
  */
1738
- function planHostingToggle(system, kind, desiredEnabled, org, byKey, claimed, describeProvision, describePark, stepKey = org.id) {
1811
+ function planHostingToggle(system, kind, desiredEnabled, org, byKey, claimed, describeProvision, describePark, stepKey = org.id, isOn = (live) => live.config.enabled !== false) {
1739
1812
  const key = resourceKey(system, kind, stepKey);
1740
1813
  const live = byKey.get(key);
1741
1814
  // A live resource is on unless it says it is parked. Providers' own
1742
1815
  // configs carry no `enabled` field at all, so only a park step's
1743
1816
  // explicit `enabled: false` reads as off.
1744
- const liveEnabled = live !== undefined && live.config.enabled !== false;
1817
+ const liveEnabled = live !== undefined && isOn(live);
1745
1818
  if (!desiredEnabled) {
1746
1819
  // Nothing declared and nothing live: there is nothing to park.
1747
1820
  if (!live)
@@ -2192,7 +2265,18 @@ function planTunnel(org, byKey, claimed, recordedIds) {
2192
2265
  (org.site.enabled && Boolean(org.domain)) ||
2193
2266
  (org.calls.enabled && org.calls.usesDomain && Boolean(org.domain)) ||
2194
2267
  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)`);
2268
+ 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,
2269
+ // Cloudflare's status, read against what is declared: a park cannot change
2270
+ // it, so judging on/off by status alone would never let the plan empty.
2271
+ // Wanted on: only `down` needs a repair (`inactive` is a fresh tunnel
2272
+ // whose connector has not dialled in yet). Wanted off: only a connected
2273
+ // tunnel needs parking; `inactive` and `down` are already parked.
2274
+ (live) => {
2275
+ const status = live.config.status;
2276
+ if (typeof status !== 'string')
2277
+ return live.config.enabled !== false;
2278
+ return desired ? status !== 'down' : status === 'healthy' || status === 'degraded';
2279
+ });
2196
2280
  if (toggled || !desired)
2197
2281
  return toggled;
2198
2282
  // 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;
@@ -2221,6 +2228,18 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2221
2228
  * so an existing one is always re-written.
2222
2229
  */
2223
2230
  private ensureCallsFileStorage;
2231
+ /**
2232
+ * The chat service's introspection client: an API app in the org's
2233
+ * messaging project (the audience Agora's token carries), so ZITADEL's
2234
+ * introspection accepts it. The chat service authenticates as it with
2235
+ * `client_secret_basic`. Minted once, the way {@link callsTokenEnvs} does:
2236
+ * the id is recorded under an undelivered key and persisted before the
2237
+ * secret leaves, the secret goes to `deliverSecret` and to the caller's env
2238
+ * write, never to a log, error or return beyond that. A re-run reads the id
2239
+ * and secret back from the service's own env and mints nothing. `changed`
2240
+ * is true only for a fresh mint.
2241
+ */
2242
+ private ensureChatIntrospection;
2224
2243
  /**
2225
2244
  * Creates the `chat.admin` role on the org's messaging project and grants
2226
2245
  * it to each owner whose invite is recorded (`zitadel:owner:<orgId>:<email>`,
@@ -2331,11 +2350,25 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2331
2350
  * actually differs, or whenever the last restart this step confirmed
2332
2351
  * ({@link AGORA_ACTIVATED_ENV_SCOPE_KEY}) is not this exact env (an org
2333
2352
  * 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.
2353
+ * with nothing ever confirmed running it). The activation marker is
2354
+ * recorded last, after the restart (if any) returns without throwing, so a
2355
+ * failed restart leaves it stale and the next plan replans this step even
2356
+ * though Coolify's own env store already matches desired.
2357
+ *
2358
+ * #504: a brand new app is never restarted -- but it is deployed, once
2359
+ * ({@link AGORA_DEPLOYED_SCOPE_KEY}). `createGithubAppApplication` never
2360
+ * sets `instant_deploy`, and Coolify defaults it to false, so without an
2361
+ * explicit deploy the app sat created but never built or started.
2362
+ *
2363
+ * #506: `ports_exposes: AGORA_APP_PORTS_EXPOSES` goes on the create call
2364
+ * itself, so Traefik has a port to route to from the moment the app
2365
+ * exists. An app `findOrCreateCallsApp` instead *found* may predate this
2366
+ * fix and carry no `ports_exposes` at all (or the wrong one), so its live
2367
+ * value is read and, when it differs, patched -- and, since the container
2368
+ * listens on `ports_exposes` only at its next deploy (the same rule
2369
+ * `coolifyApp`'s own `settingsNeedDeploy` follows for a port change
2370
+ * there), redeployed even on a run that already has its one #504 deploy
2371
+ * behind it.
2339
2372
  */
2340
2373
  private coolifyAgora;
2341
2374
  /**
@@ -2360,6 +2393,10 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2360
2393
  *
2361
2394
  * `AUTH_APP_KEY` is minted once here; its broker entry is the next step
2362
2395
  * (`bouleBrokerEntry`).
2396
+ *
2397
+ * #504: a brand new app is never restarted -- but it is deployed, once
2398
+ * ({@link BOULE_DEPLOYED_SCOPE_KEY}), the same reason and the same marker
2399
+ * shape {@link coolifyAgora}'s own doc comment explains.
2363
2400
  */
2364
2401
  private coolifyBoule;
2365
2402
  /**
@@ -2501,6 +2538,15 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2501
2538
  * shared-hostname collision this ticket exists to refuse. An org re-running
2502
2539
  * its own already-recorded zone is unaffected: the store then names this
2503
2540
  * same org as the owner.
2541
+ *
2542
+ * #510: also records the zone's own id under the flat `cloudflare_zone`
2543
+ * scope key, alongside the `cloudflare:zone:<domain>` resource id `runSteps`
2544
+ * already records from this method's return value. Without it, nothing
2545
+ * ever gives `ProvidersCloudflareReader` a zone id to resolve this org's
2546
+ * scope with (`providers-readers.ts`'s `storeScopeResolver` doc comment),
2547
+ * so that reader's `listResources` always returned `[]` and `diff.ts`'s
2548
+ * `planZone` re-proposed "provision a Cloudflare zone" on every apply, even
2549
+ * once the zone was live.
2504
2550
  */
2505
2551
  private cloudflareZone;
2506
2552
  /**