@wtfalch/foundry 0.13.0 → 0.14.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.
@@ -106,6 +106,50 @@ export declare const CALLS_SERVICE_NAME_SCOPE_KEY = "coolify_calls_service_inter
106
106
  export declare function callsServiceInternalName(org: DeclaredOrg): string;
107
107
  /** Whether the recorded marker says the calls service carries the name the org's current slug gives it. */
108
108
  export declare function callsServiceHasName(org: DeclaredOrg, recorded: Readonly<Record<string, string>>): boolean;
109
+ /**
110
+ * Recorded once the calls service app carries the seven ZITADEL token env
111
+ * vars (`CALLS_ISSUER` ... `CALLS_INTROSPECTION_CLIENT_SECRET`,
112
+ * `calls/docs/member-auth.md`). Its value is {@link callsTokenEnvMarker}, so a
113
+ * changed issuer, audience or organisation plans the step again. The same
114
+ * marker-and-one-restart mechanism as {@link CALLS_SERVICE_NAME_SCOPE_KEY}.
115
+ */
116
+ export declare const CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY = "coolify_calls_service_token_env";
117
+ /**
118
+ * The introspection API app minted for the calls service (once), and the
119
+ * undelivered key recorded before its secret leaves the executor
120
+ * (`foundry/CLAUDE.md`, platform rule 1). No `:` in either, as that rule says.
121
+ */
122
+ export declare const CALLS_INTROSPECTION_SCOPE_KEY = "zitadel_calls_introspection_app";
123
+ export declare const CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY = "zitadel_calls_introspection_undelivered";
124
+ /** What the plan hands the calls service step: the two host values the token env needs. */
125
+ export interface CallsTokenHost {
126
+ issuer: string;
127
+ messagingProjectId: string;
128
+ }
129
+ /**
130
+ * The token env's inputs, or null while any is missing: the host's issuer and
131
+ * messaging project id (`AgoraHostValues`) and the org's recorded ZITADEL org
132
+ * id. Null means write none of the seven, never a partial set: the calls
133
+ * service refuses to boot on one.
134
+ */
135
+ export declare function callsTokenHostFrom(host: AgoraHostValues): CallsTokenHost | null;
136
+ /** The value {@link CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY} holds once the env is in place; null when it cannot be written yet. */
137
+ export declare function callsTokenEnvMarker(host: CallsTokenHost | null, recorded: Readonly<Record<string, string>>): string | null;
138
+ /**
139
+ * The calls service's introspection API app, in the messaging project. One per
140
+ * org: the project is shared by every org, so the name carries the slug (a DNS
141
+ * label, at most 63 characters) and only an exact match is ever deleted.
142
+ */
143
+ export declare function callsIntrospectionAppName(org: DeclaredOrg): string;
144
+ /**
145
+ * The block shown, and thrown, when the messaging project or the ZITADEL org
146
+ * differs from what the token env was written for: the recorded client
147
+ * belongs to the old project, and foundry does not replace or delete it. Null
148
+ * when nothing is recorded, or only the issuer differs (the client is kept).
149
+ */
150
+ export declare function callsTokenEnvMovedMessage(org: DeclaredOrg, host: CallsTokenHost | null, recorded: Readonly<Record<string, string>>): string | null;
151
+ /** True when there is nothing to write yet, or the recorded marker says it was written. */
152
+ export declare function callsServiceHasTokenEnv(host: CallsTokenHost | null, recorded: Readonly<Record<string, string>>): boolean;
109
153
  /**
110
154
  * The env vars an org's Agora app should carry, and every input still
111
155
  * missing to build them. `AGORA_CONFIG` is present only when `missing` is
@@ -109,6 +109,69 @@ export function callsServiceInternalName(org) {
109
109
  export function callsServiceHasName(org, recorded) {
110
110
  return recorded[CALLS_SERVICE_NAME_SCOPE_KEY] === callsServiceInternalName(org);
111
111
  }
112
+ /**
113
+ * Recorded once the calls service app carries the seven ZITADEL token env
114
+ * vars (`CALLS_ISSUER` ... `CALLS_INTROSPECTION_CLIENT_SECRET`,
115
+ * `calls/docs/member-auth.md`). Its value is {@link callsTokenEnvMarker}, so a
116
+ * changed issuer, audience or organisation plans the step again. The same
117
+ * marker-and-one-restart mechanism as {@link CALLS_SERVICE_NAME_SCOPE_KEY}.
118
+ */
119
+ export const CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY = 'coolify_calls_service_token_env';
120
+ /**
121
+ * The introspection API app minted for the calls service (once), and the
122
+ * undelivered key recorded before its secret leaves the executor
123
+ * (`foundry/CLAUDE.md`, platform rule 1). No `:` in either, as that rule says.
124
+ */
125
+ export const CALLS_INTROSPECTION_SCOPE_KEY = 'zitadel_calls_introspection_app';
126
+ export const CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY = 'zitadel_calls_introspection_undelivered';
127
+ /**
128
+ * The token env's inputs, or null while any is missing: the host's issuer and
129
+ * messaging project id (`AgoraHostValues`) and the org's recorded ZITADEL org
130
+ * id. Null means write none of the seven, never a partial set: the calls
131
+ * service refuses to boot on one.
132
+ */
133
+ export function callsTokenHostFrom(host) {
134
+ if (!host.issuer || !host.messagingProjectId)
135
+ return null;
136
+ return { issuer: host.issuer, messagingProjectId: host.messagingProjectId };
137
+ }
138
+ /** The value {@link CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY} holds once the env is in place; null when it cannot be written yet. */
139
+ export function callsTokenEnvMarker(host, recorded) {
140
+ const zitadelOrgId = recorded[ZITADEL_ORG_SCOPE_KEY];
141
+ if (!host || !zitadelOrgId)
142
+ return null;
143
+ return `${host.issuer} ${host.messagingProjectId} ${zitadelOrgId}`;
144
+ }
145
+ /**
146
+ * The calls service's introspection API app, in the messaging project. One per
147
+ * org: the project is shared by every org, so the name carries the slug (a DNS
148
+ * label, at most 63 characters) and only an exact match is ever deleted.
149
+ */
150
+ export function callsIntrospectionAppName(org) {
151
+ return `calls-introspection-${org.slug}`;
152
+ }
153
+ /**
154
+ * The block shown, and thrown, when the messaging project or the ZITADEL org
155
+ * differs from what the token env was written for: the recorded client
156
+ * belongs to the old project, and foundry does not replace or delete it. Null
157
+ * when nothing is recorded, or only the issuer differs (the client is kept).
158
+ */
159
+ export function callsTokenEnvMovedMessage(org, host, recorded) {
160
+ const wanted = callsTokenEnvMarker(host, recorded);
161
+ const written = recorded[CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY];
162
+ if (!wanted || !written || !recorded[CALLS_INTROSPECTION_SCOPE_KEY])
163
+ return null;
164
+ const [, oldProject, oldOrg] = written.split(' ');
165
+ const [, newProject, newOrg] = wanted.split(' ');
166
+ if (oldProject === newProject && oldOrg === newOrg)
167
+ return null;
168
+ return `${org.slug}'s calls service is blocked, not changed: the messaging project or ZITADEL org differs from the one its token env was written for (project ${oldProject} -> ${newProject}, org ${oldOrg} -> ${newOrg}); retire the ZITADEL app ${callsIntrospectionAppName(org)} (${recorded[CALLS_INTROSPECTION_SCOPE_KEY]}) in the old project by hand, then remove the recorded keys ${CALLS_INTROSPECTION_SCOPE_KEY} and ${CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY}`;
169
+ }
170
+ /** True when there is nothing to write yet, or the recorded marker says it was written. */
171
+ export function callsServiceHasTokenEnv(host, recorded) {
172
+ const marker = callsTokenEnvMarker(host, recorded);
173
+ return marker === null || recorded[CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY] === marker;
174
+ }
112
175
  /**
113
176
  * The env vars an org's Agora app should carry, and every input still
114
177
  * missing to build them. `AGORA_CONFIG` is present only when `missing` is
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 } from '../org.js';
3
- import { NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraHostMissing, callsServiceHasName, desiredAgoraEnv, } from './agora.js';
3
+ import { CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraHostMissing, callsServiceHasName, callsServiceHasTokenEnv, callsTokenEnvMovedMessage, callsTokenHostFrom, desiredAgoraEnv, } from './agora.js';
4
4
  import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, } from './broker-entries.js';
5
5
  import { appHostname, entryHostnames, tunnelDnsHostnames } from './hostnames.js';
6
6
  import { LOGIN_BINDING_SCOPE_KEY, loginBindingState, loginHostClientIds, loginHostnameFor, pendingRemovalHosts, } from './login-host.js';
@@ -117,7 +117,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
117
117
  // (`executor.ts`'s `coolifyCallsService`) reads the Synapse/MAS addresses
118
118
  // and MAS client credentials `coolifyCalls` just recorded, the same
119
119
  // ordering `planOwner` relies on for `zitadel:issuer`.
120
- const callsService = planCallsService(org, byKey, claimed, platformReadiness.callsReady, recordedIds, calls.blocked !== null);
120
+ const callsService = planCallsService(org, byKey, claimed, platformReadiness.callsReady, recordedIds, calls.blocked !== null, 'error' in agoraHost ? null : callsTokenHostFrom(agoraHost));
121
121
  if (callsService.step)
122
122
  plannedSteps.push(callsService.step);
123
123
  // #251: same mechanism as the platform's own version-change lines -- see
@@ -275,6 +275,9 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
275
275
  }
276
276
  if (calls.blocked)
277
277
  unmanaged.push({ description: calls.blocked, key: 'coolify:calls' });
278
+ if (callsService.blocked) {
279
+ unmanaged.push({ description: callsService.blocked, key: 'coolify:calls-service' });
280
+ }
278
281
  if (filesOrganisation.blocked) {
279
282
  unmanaged.push({ description: filesOrganisation.blocked, key: 'files:organisation' });
280
283
  }
@@ -831,25 +834,55 @@ function planCalls(org, byKey, claimed, callsReady) {
831
834
  * stack it rides beside is itself blocked on `callsReady` -- {@link planCalls}
832
835
  * already reports the platform's shared SFU as missing once.
833
836
  */
834
- function planCallsService(org, byKey, claimed, callsReady, recordedIds, callsBlocked) {
837
+ function planCallsService(org, byKey, claimed, callsReady, recordedIds, callsBlocked, tokenHost) {
835
838
  const key = resourceKey('coolify', 'calls-service', org.id);
836
839
  if (org.calls.enabled && !byKey.get(key) && !callsReady) {
837
840
  return { step: null, blocked: null };
838
841
  }
842
+ // The introspection secret is shown once. One minted but never written to
843
+ // the app is lost, so this reports it and mints no second (platform rule 1).
844
+ const undelivered = recordedIds[CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY];
845
+ if (org.calls.enabled &&
846
+ undelivered &&
847
+ !recordedIds[CALLS_INTROSPECTION_SCOPE_KEY] &&
848
+ byKey.has(key)) {
849
+ return {
850
+ step: null,
851
+ blocked: `${org.slug}'s calls service is blocked, not planned: its introspection client secret (ZITADEL app ${undelivered}) was minted but never written to the app and cannot be shown again; remove the recorded key ${CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY} (and delete the ZITADEL client) to mint a new one`,
852
+ };
853
+ }
854
+ const moved = org.calls.enabled && byKey.has(key)
855
+ ? callsTokenEnvMovedMessage(org, tokenHost, recordedIds)
856
+ : null;
857
+ if (moved)
858
+ return { step: null, blocked: moved };
839
859
  const toggle = planHostingToggle('coolify', 'calls-service', org.calls.enabled, org, byKey, claimed, () => `provision ${org.slug}'s calls service`, () => `park ${org.slug}'s calls service (stops serving; data is retained, not deleted -- #7 corrections 1)`);
860
+ // The host values ride in the step, as Agora's do, so the env written is
861
+ // the env the plan showed. Only while enabled: a park step carries none.
862
+ const withHost = (step) => tokenHost && org.calls.enabled
863
+ ? {
864
+ ...step,
865
+ action: {
866
+ ...step.action,
867
+ desired: { ...step.action.desired, host: tokenHost },
868
+ },
869
+ }
870
+ : step;
840
871
  if (toggle)
841
- return { step: toggle, blocked: null };
842
- // A live, enabled app made before the internal name existed (or under an
843
- // old slug) has no step from the toggle, yet Agora's address needs the
844
- // name. Run the step again until the executor has recorded the marker, but
845
- // only while it can succeed: the SFU ready and the Matrix stack not blocked.
872
+ return { step: withHost(toggle), blocked: null };
873
+ // A live, enabled app made before the internal name or the token env
874
+ // existed has no step from the toggle. Run the step again until the
875
+ // executor has recorded both markers, but only while it can succeed: the
876
+ // SFU ready and the Matrix stack not blocked.
877
+ const missingName = !callsServiceHasName(org, recordedIds);
878
+ const missingTokenEnv = !callsServiceHasTokenEnv(tokenHost, recordedIds);
846
879
  if (org.calls.enabled &&
847
880
  callsReady &&
848
881
  !callsBlocked &&
849
882
  byKey.has(key) &&
850
- !callsServiceHasName(org, recordedIds)) {
883
+ (missingName || missingTokenEnv)) {
851
884
  return {
852
- step: {
885
+ step: withHost({
853
886
  action: {
854
887
  system: 'coolify',
855
888
  kind: 'calls-service',
@@ -857,11 +890,13 @@ function planCallsService(org, byKey, claimed, callsReady, recordedIds, callsBlo
857
890
  desired: { enabled: true },
858
891
  },
859
892
  step: {
860
- description: `set the calls service's internal name for ${org.slug}`,
893
+ description: missingName
894
+ ? `set the calls service's internal name for ${org.slug}`
895
+ : `set the calls service's ZITADEL token env for ${org.slug}`,
861
896
  destroys: false,
862
897
  migrates: false,
863
898
  },
864
- },
899
+ }),
865
900
  blocked: null,
866
901
  };
867
902
  }
@@ -1767,6 +1767,21 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1767
1767
  * 1); its own database is never touched from that branch.
1768
1768
  */
1769
1769
  private coolifyCallsService;
1770
+ /**
1771
+ * The seven ZITADEL token env vars the calls service needs to accept
1772
+ * Agora's sign-in token (`calls/docs/member-auth.md`). Only called with all
1773
+ * inputs present, so the set is whole or absent.
1774
+ *
1775
+ * The introspection client is an API app in the messaging project (the
1776
+ * audience the token carries), minted once the way `platform-providers.ts`
1777
+ * mints the files one: its id is recorded under an undelivered key and
1778
+ * persisted before the secret leaves this method, the secret goes to
1779
+ * `deliverSecret` with retries and to the app's env, never to a log, error
1780
+ * or return value, and the guard is recorded by the caller after the env
1781
+ * write. A re-run reads the client id and secret back from the app's own
1782
+ * env and mints nothing.
1783
+ */
1784
+ private callsTokenEnvs;
1770
1785
  /**
1771
1786
  * Reads back the calls Matrix stack's MAS client id/secret from the MAS
1772
1787
  * app's own envs ({@link CALLS_SECRET_ENV_KEYS}) -- never mints: minting
@@ -2,7 +2,7 @@ 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 } from '../org.js';
5
- import { AGORA_CLIENT_ID_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CALLS_SERVICE_NAME_SCOPE_KEY, CALLS_SERVICE_PORT, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, agoraHostnameFor, callsServiceHasName, callsServiceInternalName, desiredAgoraEnv, entryClientIdScopeKey, } from './agora.js';
5
+ import { 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, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, ZITADEL_ORG_SCOPE_KEY, agoraHostnameFor, callsIntrospectionAppName, callsServiceHasName, callsServiceInternalName, callsTokenEnvMarker, callsTokenEnvMovedMessage, desiredAgoraEnv, entryClientIdScopeKey, } from './agora.js';
6
6
  import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
7
7
  import { MAS_IMAGE, MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
8
8
  import { ensureCloudflaredConnector } from './cloudflared-connector.js';
@@ -2452,21 +2452,118 @@ export class FoundryStepExecutor {
2452
2452
  { key: 'SYNAPSE_URL', value: `http://${synapseHost}:8008` },
2453
2453
  { key: 'CALLS_ORIGIN', value: `https://${agoraHostnameFor(org)}` },
2454
2454
  { key: 'CALLS_HOST_ADAPTER', value: 'production' },
2455
+ // Deliberately kept as it was, not widened: the calls service refuses to
2456
+ // boot with no `CALLS_TRUSTED_IP_HEADER` (`config.mjs` has no default),
2457
+ // so unsetting it would stop the service. It keys the refused-bearer
2458
+ // limiter, which is only as honest as the proxy in front overwriting
2459
+ // this header (https://github.com/wtfalch/calls/issues/82).
2455
2460
  { key: 'CALLS_TRUSTED_IP_HEADER', value: 'cf-connecting-ip' },
2456
2461
  { key: 'PORT', value: CALLS_SERVICE_PORT },
2457
2462
  ];
2458
2463
  if (bindingSecret)
2459
2464
  envs.push({ key: 'CALLS_BINDING_SECRET', value: bindingSecret });
2465
+ // The seven ZITADEL token vars, all or none (the service refuses to boot
2466
+ // on a partial set). Deliberately absent: `CALLS_CHAT_URL` and
2467
+ // `CALLS_CHAT_TOKEN`. Foundry does not deploy chat
2468
+ // (https://github.com/wtfalch/chat/issues/40), so with both unset the
2469
+ // calls service admits only `event:` rooms.
2470
+ const tokenHost = action.desired.host;
2471
+ const tokenMarker = callsTokenEnvMarker(tokenHost ?? null, context.recordedIds());
2472
+ const token = tokenHost && tokenMarker
2473
+ ? await this.callsTokenEnvs(org, app.appId, tokenHost, context)
2474
+ : null;
2475
+ if (token)
2476
+ envs.push(...token.envs);
2460
2477
  await this.ports.coolify.bulkUpsertEnvs(app.appId, envs);
2478
+ // Recorded after the write, so a failure before it leaves the app
2479
+ // unguarded and the planner reports the lost secret instead of minting again.
2480
+ if (token)
2481
+ context.recordScopeId(CALLS_INTROSPECTION_SCOPE_KEY, token.appId);
2461
2482
  // A container reads its name and env only at start, so a running app is
2462
- // restarted once, when this run is the one that gave it its name (the same
2463
- // rule `coolifyApp` follows when an existing app's env changed).
2464
- if (app.existed && !callsServiceHasName(org, context.recordedIds())) {
2483
+ // restarted once, when this run is the one that gave it its name or its
2484
+ // token env (the same rule `coolifyApp` follows when an existing app's
2485
+ // env changed).
2486
+ const givesTokenEnv = token !== null && context.recordedIds()[CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY] !== tokenMarker;
2487
+ if (app.existed && (!callsServiceHasName(org, context.recordedIds()) || givesTokenEnv)) {
2465
2488
  await this.ports.coolify.restart(app.appId);
2466
2489
  }
2467
2490
  context.recordScopeId(CALLS_SERVICE_NAME_SCOPE_KEY, name);
2491
+ if (token && tokenMarker)
2492
+ context.recordScopeId(CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY, tokenMarker);
2468
2493
  return { resourceId: app.appId };
2469
2494
  }
2495
+ /**
2496
+ * The seven ZITADEL token env vars the calls service needs to accept
2497
+ * Agora's sign-in token (`calls/docs/member-auth.md`). Only called with all
2498
+ * inputs present, so the set is whole or absent.
2499
+ *
2500
+ * The introspection client is an API app in the messaging project (the
2501
+ * audience the token carries), minted once the way `platform-providers.ts`
2502
+ * mints the files one: its id is recorded under an undelivered key and
2503
+ * persisted before the secret leaves this method, the secret goes to
2504
+ * `deliverSecret` with retries and to the app's env, never to a log, error
2505
+ * or return value, and the guard is recorded by the caller after the env
2506
+ * write. A re-run reads the client id and secret back from the app's own
2507
+ * env and mints nothing.
2508
+ */
2509
+ async callsTokenEnvs(org, appId, host, context) {
2510
+ const recorded = context.recordedIds();
2511
+ const zitadelOrgId = recorded[ZITADEL_ORG_SCOPE_KEY];
2512
+ const scope = (clientId, clientSecret) => [
2513
+ { key: 'CALLS_ISSUER', value: host.issuer },
2514
+ { key: 'CALLS_AUDIENCE', value: host.messagingProjectId },
2515
+ { key: 'CALLS_ORGANISATION_ID', value: zitadelOrgId },
2516
+ // Chat's own scope (`apps/chat-service`): the audience, and `estate`.
2517
+ { key: 'CALLS_APPLICATION_ID', value: host.messagingProjectId },
2518
+ { key: 'CALLS_PLATFORM_ID', value: 'estate' },
2519
+ { key: 'CALLS_INTROSPECTION_CLIENT_ID', value: clientId },
2520
+ { key: 'CALLS_INTROSPECTION_CLIENT_SECRET', value: clientSecret },
2521
+ ];
2522
+ const done = recorded[CALLS_INTROSPECTION_SCOPE_KEY];
2523
+ const undelivered = recorded[CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY];
2524
+ if (undelivered && !done) {
2525
+ throw new Error(`coolify:calls-service for ${org.slug}: the introspection client secret (ZITADEL app ${undelivered}) was minted but never written; remove the recorded key ${CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY} to mint a new one`);
2526
+ }
2527
+ // A changed project or org is never replaced here (see the message); an
2528
+ // issuer-only change keeps the client and rewrites the env below.
2529
+ const moved = callsTokenEnvMovedMessage(org, host, recorded);
2530
+ if (moved)
2531
+ throw new Error(`coolify:calls-service: ${moved}`);
2532
+ if (done) {
2533
+ const envs = await this.ports.coolify.listEnvs(appId);
2534
+ const read = (key) => envs.find((e) => e.key === key && !e.is_preview)?.value;
2535
+ const clientId = read('CALLS_INTROSPECTION_CLIENT_ID');
2536
+ const clientSecret = read('CALLS_INTROSPECTION_CLIENT_SECRET');
2537
+ if (!clientId || !clientSecret) {
2538
+ throw new Error(`coolify:calls-service for ${org.slug}: the introspection client (ZITADEL app ${done}) is recorded but its id or secret is no longer on the app; remove the recorded keys ${CALLS_INTROSPECTION_SCOPE_KEY} and ${CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY}, and delete the ZITADEL client, to mint a new one`);
2539
+ }
2540
+ return { envs: scope(clientId, clientSecret), appId: done };
2541
+ }
2542
+ // An app by this name with no recorded secret is a crash between create
2543
+ // and record: nothing was delivered, so it is deleted and made again.
2544
+ const stale = (await this.ports.zitadel.listApps(zitadelOrgId, host.messagingProjectId)).apps.filter((app) => app.name === callsIntrospectionAppName(org) && app.state !== 'APP_STATE_REMOVED');
2545
+ for (const app of stale) {
2546
+ try {
2547
+ await this.ports.zitadel.deleteApp(zitadelOrgId, host.messagingProjectId, app.id);
2548
+ }
2549
+ catch (error) {
2550
+ if (!isNotFoundError(error))
2551
+ throw error;
2552
+ }
2553
+ }
2554
+ const client = await this.ports.zitadel.createApiApp(zitadelOrgId, host.messagingProjectId, {
2555
+ name: callsIntrospectionAppName(org),
2556
+ });
2557
+ context.recordScopeId(CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, client.appId);
2558
+ await context.persistScopeIds();
2559
+ if (!(await this.deliverWithRetry(`zitadel:calls:${org.slug}:introspection`, client.clientSecret))) {
2560
+ throw new Error(`coolify:calls-service for ${org.slug}: the introspection client secret (ZITADEL app ${client.appId}) could not be delivered after ${JOBS_DELIVERY_ATTEMPTS} attempts`);
2561
+ }
2562
+ return {
2563
+ envs: scope(client.clientId, client.clientSecret),
2564
+ appId: client.appId,
2565
+ };
2566
+ }
2470
2567
  /**
2471
2568
  * Reads back the calls Matrix stack's MAS client id/secret from the MAS
2472
2569
  * app's own envs ({@link CALLS_SECRET_ENV_KEYS}) -- never mints: minting
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.13.0",
3
+ "version": "0.14.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",