@wtfalch/foundry 0.19.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.
@@ -151,6 +151,17 @@ export declare const CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY = "coolify_calls_service_
151
151
  */
152
152
  export declare const CALLS_INTROSPECTION_SCOPE_KEY = "zitadel_calls_introspection_app";
153
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;
154
165
  /** What the plan hands the calls service step: the two host values the token env needs. */
155
166
  export interface CallsTokenHost {
156
167
  issuer: string;
@@ -163,6 +163,19 @@ export const CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY = 'coolify_calls_service_token_en
163
163
  */
164
164
  export const CALLS_INTROSPECTION_SCOPE_KEY = 'zitadel_calls_introspection_app';
165
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
+ }
166
179
  /**
167
180
  * The token env's inputs, or null while any is missing: the host's issuer and
168
181
  * messaging project id (`AgoraHostValues`) and the org's recorded ZITADEL org
@@ -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, 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';
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';
@@ -964,6 +964,27 @@ function planChat(org, byKey, claimed, recordedIds, reading, issuerPlanned) {
964
964
  blocked: null,
965
965
  };
966
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
+ }
967
988
  return none;
968
989
  }
969
990
  const domain = org.domain ?? 'its domain';
@@ -2228,6 +2228,18 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2228
2228
  * so an existing one is always re-written.
2229
2229
  */
2230
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;
2231
2243
  /**
2232
2244
  * Creates the `chat.admin` role on the org's messaging project and grants
2233
2245
  * it to each owner whose invite is recorded (`zitadel:owner:<orgId>:<email>`,
@@ -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, filesCollectionEnvName, ownerMailboxRecorded, ownerMailboxes, sharedSecretRef, } from '../org.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';
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_INTROSPECTION_SCOPE_KEY, CHAT_INTROSPECTION_UNDELIVERED_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, chatIntrospectionAppName, desiredAgoraEnv, entryClientIdScopeKey, } from './agora.js';
6
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';
@@ -3079,6 +3079,74 @@ export class FoundryStepExecutor {
3079
3079
  }
3080
3080
  }
3081
3081
  // ---- chat -----------------------------------------------------------------
3082
+ /**
3083
+ * The chat service's introspection client: an API app in the org's
3084
+ * messaging project (the audience Agora's token carries), so ZITADEL's
3085
+ * introspection accepts it. The chat service authenticates as it with
3086
+ * `client_secret_basic`. Minted once, the way {@link callsTokenEnvs} does:
3087
+ * the id is recorded under an undelivered key and persisted before the
3088
+ * secret leaves, the secret goes to `deliverSecret` and to the caller's env
3089
+ * write, never to a log, error or return beyond that. A re-run reads the id
3090
+ * and secret back from the service's own env and mints nothing. `changed`
3091
+ * is true only for a fresh mint.
3092
+ */
3093
+ async ensureChatIntrospection(org, zitadelOrgId, projectId, serviceAppId, context) {
3094
+ const recorded = context.recordedIds();
3095
+ const done = recorded[CHAT_INTROSPECTION_SCOPE_KEY];
3096
+ const undelivered = recorded[CHAT_INTROSPECTION_UNDELIVERED_SCOPE_KEY];
3097
+ if (done) {
3098
+ const envs = await this.ports.coolify.listEnvs(serviceAppId);
3099
+ const read = (key) => envs.find((e) => e.key === key && !e.is_preview)?.value;
3100
+ const clientId = read('CHAT_INTROSPECTION_CLIENT_ID');
3101
+ const clientSecret = read('CHAT_INTROSPECTION_CLIENT_SECRET');
3102
+ if (!clientId || !clientSecret) {
3103
+ throw new Error(`coolify:chat for ${org.slug}: the introspection client (ZITADEL app ${done}) is recorded but its id or secret is no longer on the service; remove the recorded keys ${CHAT_INTROSPECTION_SCOPE_KEY} and ${CHAT_INTROSPECTION_UNDELIVERED_SCOPE_KEY}, and delete the ZITADEL client, to mint a new one`);
3104
+ }
3105
+ return { clientId, clientSecret, appId: done, changed: false };
3106
+ }
3107
+ if (undelivered) {
3108
+ // A minted client with no marker: a crash or failure after the mint.
3109
+ // Both values on the service mean the write landed and only the marker
3110
+ // was lost; otherwise nothing usable was delivered, and the app is
3111
+ // deleted below and made again.
3112
+ const envs = await this.ports.coolify.listEnvs(serviceAppId);
3113
+ const read = (key) => envs.find((e) => e.key === key && !e.is_preview)?.value;
3114
+ const clientId = read('CHAT_INTROSPECTION_CLIENT_ID');
3115
+ const clientSecret = read('CHAT_INTROSPECTION_CLIENT_SECRET');
3116
+ if (clientId && clientSecret) {
3117
+ return { clientId, clientSecret, appId: undelivered, changed: false };
3118
+ }
3119
+ }
3120
+ // An app by this name with no recorded secret is a crash between create
3121
+ // and record: nothing was delivered, so it is deleted and made again.
3122
+ const stale = (await this.ports.zitadel.listApps(zitadelOrgId, projectId)).apps.filter((app) => app.name === chatIntrospectionAppName(org) && app.state !== 'APP_STATE_REMOVED');
3123
+ const staleIds = new Set(stale.map((app) => app.id));
3124
+ if (undelivered)
3125
+ staleIds.add(undelivered);
3126
+ for (const id of staleIds) {
3127
+ try {
3128
+ await this.ports.zitadel.deleteApp(zitadelOrgId, projectId, id);
3129
+ }
3130
+ catch (error) {
3131
+ if (!isNotFoundError(error))
3132
+ throw error;
3133
+ }
3134
+ }
3135
+ const client = await this.ports.zitadel.createApiApp(zitadelOrgId, projectId, {
3136
+ name: chatIntrospectionAppName(org),
3137
+ });
3138
+ context.recordScopeId(CHAT_INTROSPECTION_UNDELIVERED_SCOPE_KEY, client.appId);
3139
+ await context.persistScopeIds();
3140
+ if (!(await this.deliverWithRetry(`zitadel:chat:${org.slug}:introspection`, client.clientSecret))) {
3141
+ throw new Error(`coolify:chat for ${org.slug}: the introspection client secret (ZITADEL app ${client.appId}) could not be delivered after ${JOBS_DELIVERY_ATTEMPTS} attempts`);
3142
+ }
3143
+ return {
3144
+ clientId: client.clientId,
3145
+ clientSecret: client.clientSecret,
3146
+ appId: client.appId,
3147
+ changed: true,
3148
+ };
3149
+ }
3082
3150
  /**
3083
3151
  * Creates the `chat.admin` role on the org's messaging project and grants
3084
3152
  * it to each owner whose invite is recorded (`zitadel:owner:<orgId>:<email>`,
@@ -3147,6 +3215,29 @@ export class FoundryStepExecutor {
3147
3215
  context.recordScopeId(markerKey, '1');
3148
3216
  return { resourceId: serviceAppId };
3149
3217
  }
3218
+ // `planChat` plans this for a live chat whose service has no
3219
+ // introspection client: mint it once, push only its two env vars and
3220
+ // redeploy. Never the re-key paths or a fresh provision.
3221
+ if (action.desired.refreshIntrospection === true) {
3222
+ const serviceAppId = recorded[CHAT_SERVICE_SCOPE_KEY];
3223
+ const zitadelOrgId = recorded[ZITADEL_ORG_SCOPE_KEY];
3224
+ const projectId = recorded[ZITADEL_MESSAGING_PROJECT_SCOPE_KEY];
3225
+ if (!serviceAppId || !zitadelOrgId || !projectId) {
3226
+ throw new Error(`coolify:chat introspection for ${org.slug}: the chat service app, the ZITADEL org or the messaging project is not recorded yet`);
3227
+ }
3228
+ const hadMarker = Boolean(recorded[CHAT_INTROSPECTION_SCOPE_KEY]);
3229
+ const client = await this.ensureChatIntrospection(org, zitadelOrgId, projectId, serviceAppId, context);
3230
+ await this.ports.coolify.bulkUpsertEnvs(serviceAppId, [
3231
+ { key: 'CHAT_INTROSPECTION_CLIENT_ID', value: client.clientId },
3232
+ { key: 'CHAT_INTROSPECTION_CLIENT_SECRET', value: client.clientSecret },
3233
+ ]);
3234
+ // Deploy first, marker last: a failed deploy leaves the marker unset,
3235
+ // so the next plan still carries this step and the container gets the env.
3236
+ if (client.changed || !hadMarker)
3237
+ await this.ports.coolify.deploy(serviceAppId);
3238
+ context.recordScopeId(CHAT_INTROSPECTION_SCOPE_KEY, client.appId);
3239
+ return { resourceId: serviceAppId };
3240
+ }
3150
3241
  if (action.desired.usesDomain === false) {
3151
3242
  throw new NotBuiltError(`coolify:chat reverse re-key for ${org.slug}`, 'moving its chat back onto the original server_name is not built (#7 amendment 3)');
3152
3243
  }
@@ -3280,6 +3371,7 @@ export class FoundryStepExecutor {
3280
3371
  rsaKeyPem: secrets.value.masRsaKeyPem,
3281
3372
  ecKeyPem: secrets.value.masEcKeyPem,
3282
3373
  }));
3374
+ const introspection = await this.ensureChatIntrospection(org, zitadelOrgId, project.id, service.appId, context);
3283
3375
  await this.ports.coolify.bulkUpsertEnvs(service.appId, chatServiceEnvs({
3284
3376
  orgSlug: org.slug,
3285
3377
  companyName: org.displayName,
@@ -3289,6 +3381,8 @@ export class FoundryStepExecutor {
3289
3381
  agoraHost,
3290
3382
  masClientId: secrets.value.masClientId,
3291
3383
  masClientSecret: secrets.value.masClientSecret,
3384
+ introspectionClientId: introspection.clientId,
3385
+ introspectionClientSecret: introspection.clientSecret,
3292
3386
  }));
3293
3387
  // Split the same way `coolifyApp`'s and `cmsSite`'s own databases are
3294
3388
  // (#239, #456): `DATABASE_URL_OWNER` is the superuser string,
@@ -3306,11 +3400,14 @@ export class FoundryStepExecutor {
3306
3400
  for (const app of [mas, synapse, service]) {
3307
3401
  if (!app.existed ||
3308
3402
  secrets.changed ||
3309
- (app === service && serviceDbPush.changed) ||
3403
+ (app === service && (serviceDbPush.changed || introspection.changed)) ||
3310
3404
  !isRunningOrStarting((await this.ports.coolify.getApplication(app.appId)).status)) {
3311
3405
  await this.ports.coolify.deploy(app.appId);
3312
3406
  }
3313
3407
  }
3408
+ // After the write and the deploys, so a failure before them leaves the
3409
+ // marker unset and the next apply repeats the step.
3410
+ context.recordScopeId(CHAT_INTROSPECTION_SCOPE_KEY, introspection.appId);
3314
3411
  // #456: recorded here too, not only on the `refreshDatabaseSplit` replan
3315
3412
  // path above -- without it a fresh provision's very first apply never
3316
3413
  // sets this marker, so `planChat` (`!recordedIds[serviceDbEnvAppliedScopeKey('chat')]`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.19.0",
3
+ "version": "0.19.1",
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",