@wtfalch/foundry 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -15,7 +15,7 @@ export { createPostgresAuditLog } from './store/audit.js';
15
15
  export type { FoundryStoreDb } from './store/postgres.js';
16
16
  export { declaredOrgs, foundryAppliedState, foundryAuditEvents, foundryDeclaredPlatform, foundryRequestedChanges, type AuditEventRow, type DeclaredOrgRow, type NewDeclaredOrgRow, type AppliedStateRow, type NewAppliedStateRow, type DeclaredPlatformRow, type NewDeclaredPlatformRow, type RequestedChangeRow, type NewRequestedChangeRow, } from './store/schema.js';
17
17
  export type { LiveResource, ZitadelReader, CoolifyReader, CloudflareReader, FilesReader, AiReader, CmsReader, LiveReaders, PlatformCloudflareReader, PlatformCoolifyReader, PlatformLiveReaders, StepAction, PlannedStep, PlanDetail, PlatformPlanDetail, AuditActor, AuditLog, AuthzGate, StepExecutor, StepDestroyer, DestroyTarget, StepExecutionContext, PlatformStepExecutor, PlatformStepExecutionContext, FoundryPlanApplyOptions, } from './plan/index.js';
18
- export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
18
+ export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
19
19
  export type { HeldKeyResolver, OrgPlatformReadiness } from './plan/index.js';
20
20
  export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './plan/index.js';
21
21
  export type { PlatformOrgClientFactories } from './plan/index.js';
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ export { validateSlug, slugFromName, RESERVED_SLUGS } from './slug.js';
5
5
  export { createPostgresDeclaredOrgStore, createPostgresPlatformStore, createPostgresRequestedChangeStore, DeclaredOrgError, PlatformError, } from './store/postgres.js';
6
6
  export { createPostgresAuditLog } from './store/audit.js';
7
7
  export { declaredOrgs, foundryAppliedState, foundryAuditEvents, foundryDeclaredPlatform, foundryRequestedChanges, } from './store/schema.js';
8
- export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
8
+ export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
9
9
  export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './plan/index.js';
10
10
  export { PlatformOrgBlockedError, PlatformOrgPorts } from './plan/index.js';
11
11
  export { FoundryStepExecutor, NotBuiltError, healthUrls, loginHostnameFor } from './plan/index.js';
@@ -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
@@ -3,7 +3,7 @@ export { readLiveResources, readLivePlatformResources } from './live.js';
3
3
  export type { StepAction, PlannedStep, PlanDetail } from './diff.js';
4
4
  export { computePlanDetail, toPlan } from './diff.js';
5
5
  export type { PlatformPlanDetail } from './platform-diff.js';
6
- export { planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, } from './platform-diff.js';
6
+ export { planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, } from './platform-diff.js';
7
7
  export type { HeldKeyResolver, OrgPlatformReadiness } from './platform-org.js';
8
8
  export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './platform-org.js';
9
9
  export type { PlatformOrgClientFactories } from './platform-org-ports.js';
@@ -1,6 +1,6 @@
1
1
  export { readLiveResources, readLivePlatformResources } from './live.js';
2
2
  export { computePlanDetail, toPlan } from './diff.js';
3
- export { planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, } from './platform-diff.js';
3
+ export { planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, } from './platform-diff.js';
4
4
  export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './platform-org.js';
5
5
  export { PlatformOrgBlockedError, PlatformOrgPorts } from './platform-org-ports.js';
6
6
  export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan-apply.js';
@@ -299,6 +299,14 @@ export declare const PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY = "mail_applied_git
299
299
  * own internal address, never its admin credential.
300
300
  */
301
301
  export declare const PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY = "mail_internal_url";
302
+ /**
303
+ * #346: the mail ZITADEL project's id in the platform's own sign-in, the
304
+ * {@link PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY} pattern for mail --
305
+ * `provisionPlatformMail` finds or creates it and uses the id as the OIDC
306
+ * audience Stalwart's directory checks an access token's `aud` claim
307
+ * against, so no host has to register one by hand.
308
+ */
309
+ export declare const PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY = "zitadel_platform_mail_project";
302
310
  /**
303
311
  * #177: the one host the platform's mail server takes SMTP/MX on, and so the
304
312
  * `mailHost` every org's own mail step writes. `provisionPlatformMail` (the
@@ -330,6 +330,14 @@ export const PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY = 'mail_applied_git_ref';
330
330
  * own internal address, never its admin credential.
331
331
  */
332
332
  export const PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY = 'mail_internal_url';
333
+ /**
334
+ * #346: the mail ZITADEL project's id in the platform's own sign-in, the
335
+ * {@link PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY} pattern for mail --
336
+ * `provisionPlatformMail` finds or creates it and uses the id as the OIDC
337
+ * audience Stalwart's directory checks an access token's `aud` claim
338
+ * against, so no host has to register one by hand.
339
+ */
340
+ export const PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY = 'zitadel_platform_mail_project';
333
341
  /**
334
342
  * #177: the one host the platform's mail server takes SMTP/MX on, and so the
335
343
  * `mailHost` every org's own mail step writes. `provisionPlatformMail` (the
@@ -248,10 +248,10 @@ export declare const PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION = "foundry:platform:adm
248
248
  * instance's own URL and the recovery-admin credential this step generated
249
249
  * ({@link PlatformMailDeps.stalwartForInstance}), the same way
250
250
  * `zitadelForInstance` is built from a freshly issued instance's issuer URL.
251
- * The real implementation wraps `@wtfalch/email/postmaster`'s `openInstance`
252
- * (for the `StalwartClient`), a hand-built settings write (see
253
- * `provisionPlatformMail`'s own doc comment for why not `mailObjects`) and
254
- * `StalwartClient.createApiKey`.
251
+ * {@link defaultStalwartForInstance} is the real implementation, wrapping
252
+ * `@wtfalch/email/postmaster`'s `openInstance`, `applyObjects` with
253
+ * `platformMailSettingsObjects` (see `provisionPlatformMail`'s own doc
254
+ * comment for why not `mailObjects`) and `StalwartClient.createApiKey`.
255
255
  */
256
256
  export interface PlatformMailAdminPort {
257
257
  /**
@@ -261,6 +261,8 @@ export interface PlatformMailAdminPort {
261
261
  */
262
262
  applySettings(input: {
263
263
  mailHost: string;
264
+ /** The platform's own apex domain, `platformMailSettingsObjects`'s `usernameDomain`. */
265
+ apex: string;
264
266
  oidc: {
265
267
  issuerUrl: string;
266
268
  audience: string;
@@ -269,6 +271,26 @@ export interface PlatformMailAdminPort {
269
271
  /** `StalwartClient.createApiKey`'s own signature. */
270
272
  createApiKey(description: string, permissions: readonly string[] | 'inherit'): Promise<string>;
271
273
  }
274
+ /**
275
+ * #346: the real {@link PlatformMailAdminPort}, built from the instance's URL
276
+ * and the recovery-admin credential `provisionPlatformMail` just read back.
277
+ * `stalwartForInstance` defaults to this, so a host need not build its own --
278
+ * the narrower settings write `provisionPlatformMail`'s own doc comment
279
+ * explains ("Why not `mailObjects`/`applyObjects`" as-is) instead goes
280
+ * through `platformMailSettingsObjects`, which `applyObjects` resolves and
281
+ * writes the same way `mailObjects` would.
282
+ *
283
+ * Exported only so a unit test can call it directly against a mocked
284
+ * `@wtfalch/email/postmaster` and check that `url`/`user`/`password` reach
285
+ * `openInstance` and `apex` reaches `applySettings` -- every other test
286
+ * reaches this function only indirectly, through an injected
287
+ * `stalwartForInstance` fake.
288
+ */
289
+ export declare function defaultStalwartForInstance(input: {
290
+ url: string;
291
+ user: string;
292
+ password: string;
293
+ }): PlatformMailAdminPort;
272
294
  /**
273
295
  * #169: everything {@link provisionPlatformMail} needs beyond
274
296
  * {@link PlatformStepExecutionContext}, mirroring {@link PlatformAuthDeps}'s
@@ -278,18 +300,22 @@ export interface PlatformMailDeps {
278
300
  coolify: Pick<CoolifyPort, 'findProjectByName' | 'createProject' | 'findOrCreateEnvironment' | 'listServers' | 'findApplicationByName' | 'getApplication' | 'createDockerImageApplication' | 'createDockerfileApplication' | 'raw' | 'listStorages' | 'createPersistentStorage' | 'bulkUpsertEnvs' | 'listEnvs' | 'updateApplication' | 'deploy'>;
279
301
  cloudflare: Pick<CloudflarePort, 'findZoneByApex' | 'findTunnelByName' | 'createTunnel' | 'putTunnelConfig' | 'getTunnelToken' | 'findDnsRecord' | 'createDnsRecord' | 'updateDnsRecord'>;
280
302
  /**
281
- * The ZITADEL resource id an access token's `aud` claim must carry for
282
- * Stalwart's OIDC directory to accept it -- a resource API registered
283
- * against the platform's own sign-in (#168), which neither that step nor
284
- * this one creates. See `provisionPlatformMail`'s own doc comment, "Known
285
- * gaps".
303
+ * Must target the platform's own ZITADEL instance, or `zitadelForInstance`
304
+ * must be set. `provisionPlatformMail` finds or creates a `mail` project
305
+ * in it (the {@link PlatformFilesDeps}/{@link PlatformArchonDeps} pattern)
306
+ * and uses the project's id as the OIDC audience Stalwart's directory
307
+ * checks an access token's `aud` claim against -- #346, no host has to
308
+ * register a resource API for this by hand any more.
286
309
  */
287
- oidcAudience: string;
310
+ zitadel: Pick<ZitadelPort, 'findOrCreateOrg' | 'findOrCreateProject'>;
311
+ /** {@link PlatformArchonDeps.zitadelForInstance}, for Stalwart's OIDC directory. */
312
+ zitadelForInstance?: (issuerUrl: string) => PlatformMailDeps['zitadel'];
288
313
  /**
289
314
  * {@link PlatformMailAdminPort}, built from the instance this step just
290
- * deployed and the recovery credential it just read back.
315
+ * deployed and the recovery credential it just read back. Defaults to
316
+ * {@link defaultStalwartForInstance}.
291
317
  */
292
- stalwartForInstance(input: {
318
+ stalwartForInstance?(input: {
293
319
  url: string;
294
320
  user: string;
295
321
  password: string;
@@ -1,10 +1,11 @@
1
1
  import { createHash, createPrivateKey, generateKeyPairSync, randomBytes, randomUUID, sign, } from 'node:crypto';
2
2
  import { isIPv4 } from 'node:net';
3
+ import { applyObjects, openInstance, platformMailSettingsObjects } from '@wtfalch/email/postmaster';
3
4
  import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
4
5
  import { redactSecrets } from '../calls-sfu-deploy-support.js';
5
6
  import { ensureCloudflaredConnector } from './cloudflared-connector.js';
6
7
  import { ACCESS_POLICY_NAME, isNotFoundError, isRunningOrStarting } from './executor.js';
7
- import { JOBS_SCHEDULER_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, parseBrokerDeployRecord, parseLoginClientRecord, platformMailMxHost, } from './platform-diff.js';
8
+ import { JOBS_SCHEDULER_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, parseBrokerDeployRecord, parseLoginClientRecord, platformMailMxHost, } from './platform-diff.js';
8
9
  import { applyCallsSfuPlatformStep } from './platform-sfu.js';
9
10
  import { CreateInstanceError, createZitadelInstance, generateSystemApiKeypair, instanceWithoutBearerMessage, systemApiUsersEnv, } from './zitadel-system-api.js';
10
11
  /** The key `runPlatformSteps` records the apex zone's id under (`system:kind:key`). */
@@ -1389,6 +1390,32 @@ const PLATFORM_MAIL_DEPLOYED_GIT_REF_SCOPE_KEY = 'mail_deployed_git_ref';
1389
1390
  * {@link PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY} records in that case.
1390
1391
  */
1391
1392
  export const PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION = 'foundry:platform:admin';
1393
+ /**
1394
+ * #346: the real {@link PlatformMailAdminPort}, built from the instance's URL
1395
+ * and the recovery-admin credential `provisionPlatformMail` just read back.
1396
+ * `stalwartForInstance` defaults to this, so a host need not build its own --
1397
+ * the narrower settings write `provisionPlatformMail`'s own doc comment
1398
+ * explains ("Why not `mailObjects`/`applyObjects`" as-is) instead goes
1399
+ * through `platformMailSettingsObjects`, which `applyObjects` resolves and
1400
+ * writes the same way `mailObjects` would.
1401
+ *
1402
+ * Exported only so a unit test can call it directly against a mocked
1403
+ * `@wtfalch/email/postmaster` and check that `url`/`user`/`password` reach
1404
+ * `openInstance` and `apex` reaches `applySettings` -- every other test
1405
+ * reaches this function only indirectly, through an injected
1406
+ * `stalwartForInstance` fake.
1407
+ */
1408
+ export function defaultStalwartForInstance(input) {
1409
+ const instance = openInstance({ url: input.url, secret: input.password, user: input.user });
1410
+ return {
1411
+ async applySettings({ mailHost, apex, oidc }) {
1412
+ await applyObjects(instance.client, platformMailSettingsObjects({ apex, mailHost, oidc }), () => { });
1413
+ },
1414
+ createApiKey: (description, permissions) => instance.client.createApiKey(description, permissions),
1415
+ };
1416
+ }
1417
+ /** The ZITADEL project and resource api Stalwart's own OIDC directory checks access tokens' `aud` claim against (#346). */
1418
+ const PLATFORM_MAIL_ZITADEL_PROJECT_NAME = 'mail';
1392
1419
  /**
1393
1420
  * #169: makes `coolify:app` keyed {@link PLATFORM_MAIL_KEY} true -- Stalwart
1394
1421
  * on Coolify with its own persistent storage, reached at
@@ -1423,8 +1450,9 @@ export const PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION = 'foundry:platform:admin';
1423
1450
  * 0006), and this step provisions no Cloudflare DNS token for Stalwart's own
1424
1451
  * ACME either. Calling it as-is would mint an outbound relay this platform
1425
1452
  * does not want and a credential requirement this issue does not cover, so
1426
- * {@link PlatformMailAdminPort.applySettings} is a narrower, hand-built
1427
- * settings write instead: the default hostname and the OIDC directory only.
1453
+ * {@link PlatformMailAdminPort.applySettings} is a narrower settings write
1454
+ * instead, through `platformMailSettingsObjects`: the default hostname and
1455
+ * the OIDC directory only.
1428
1456
  *
1429
1457
  * Known gaps, left for review (like #168's own "the PR stays open for
1430
1458
  * William's review"):
@@ -1432,11 +1460,6 @@ export const PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION = 'foundry:platform:admin';
1432
1460
  * Stalwart's own self-signed certificate until a follow-up wires a real
1433
1461
  * one (`mailObjects`'s `AcmeProvider`/`DnsServer` shape, minus the Resend
1434
1462
  * relay it also carries).
1435
- * - **`oidcAudience` is supplied, not resolved.** #168 mints foundry's own
1436
- * ZITADEL admin machine user but registers no resource API for Stalwart's
1437
- * access-token `aud` check; whoever supplies
1438
- * {@link PlatformMailDeps.oidcAudience} must have registered one by hand
1439
- * until that is built.
1440
1463
  * - **The Lettermint team token the org app step reads still comes from
1441
1464
  * wherever the host already reads it, not held keys.** Needs
1442
1465
  * `StepExecutionContext` to carry the platform, which `plan-apply.ts`'s
@@ -1446,6 +1469,17 @@ export const PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION = 'foundry:platform:admin';
1446
1469
  * instance -- `diff.ts`'s `planMail` already blocks the step until the
1447
1470
  * platform's own mail is applied, so the fallback only ever served a
1448
1471
  * plan recorded before that, and now fails loudly instead.)
1472
+ * - **A ZITADEL client's token may not carry the mail project's id in
1473
+ * `aud` by default.** #346's own "Human steps": confirm this on the first
1474
+ * live run, and if it does not, the client must request the mail
1475
+ * project's audience scope.
1476
+ * - **The admin API key is minted with `'inherit'` permissions**, not a
1477
+ * narrower set scoped to only what `applySettings` needs.
1478
+ * - **The undelivered-key guard records before `createApiKey` mints**, not
1479
+ * after -- so a crash between the two records an id (`admin.createApiKey`
1480
+ * has not even run) that no key was ever minted for. Harmless today
1481
+ * (the guard only ever blocks a retry and names an id to revoke by), but
1482
+ * worth tightening if `createApiKey` ever starts returning its own id.
1449
1483
  */
1450
1484
  async function provisionPlatformMail(context, deps) {
1451
1485
  const { platform } = context;
@@ -1610,6 +1644,57 @@ async function provisionPlatformMail(context, deps) {
1610
1644
  proxied: false,
1611
1645
  });
1612
1646
  }
1647
+ // ---- the mail ZITADEL project, in the platform's sign-in -------------------
1648
+ // #346: found or created on every run, like the files/Archon steps' own
1649
+ // projects -- its id is the OIDC audience Stalwart's directory checks an
1650
+ // access token's `aud` claim against, so no host has to register a
1651
+ // resource API for this by hand.
1652
+ const issuer = `https://${platform.auth.hostname}`;
1653
+ const zitadel = deps.zitadelForInstance?.(issuer) ?? deps.zitadel;
1654
+ const { org } = await zitadel.findOrCreateOrg(PLATFORM_AUTH_ORG_NAME);
1655
+ const { project } = await zitadel.findOrCreateProject(org.id, PLATFORM_MAIL_ZITADEL_PROJECT_NAME);
1656
+ const recordedProjectId = recorded[PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY];
1657
+ const adminKeyRecorded = Boolean(recorded[PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY]);
1658
+ if (adminKeyRecorded && recordedProjectId !== project.id) {
1659
+ // #346 round 3: the OIDC-directory write below (next block) runs only
1660
+ // once, while the admin key is unrecorded -- so once it is recorded, a
1661
+ // changed project id (the project was deleted and recreated, ZITADEL was
1662
+ // re-bootstrapped, or `recordedProjectId` is simply missing, from a
1663
+ // platform applied before this change) has no other path back to a
1664
+ // correct audience. Re-apply the settings write with the new id, over
1665
+ // the same recovery-admin credential the first run reads back, and mint
1666
+ // no new key -- that stays gated on the admin key being unrecorded,
1667
+ // below. The record is written only after `applySettings` succeeds, so a
1668
+ // throw here leaves the old (still-required) id in place for the next
1669
+ // run to retry. A project deleted out of band is noticed only here, on
1670
+ // the next plan/apply -- the same way the files/Archon steps' own
1671
+ // projects are.
1672
+ await waitForMailInstance(`https://${hostname}${STALWART_READY_PATH}`, runtime, sleep);
1673
+ const recoveryEnv = (await deps.coolify.listEnvs(appId)).find((e) => e.key === 'STALWART_RECOVERY_ADMIN' && !e.is_preview);
1674
+ if (!recoveryEnv) {
1675
+ throw new Error('platform:mail: STALWART_RECOVERY_ADMIN is not set on the app; cannot authenticate to re-apply the OIDC audience');
1676
+ }
1677
+ const password = recoveryEnv.value.slice(`${STALWART_RECOVERY_USER}:`.length);
1678
+ try {
1679
+ const admin = (deps.stalwartForInstance ?? defaultStalwartForInstance)({
1680
+ url: `https://${hostname}`,
1681
+ user: STALWART_RECOVERY_USER,
1682
+ password,
1683
+ });
1684
+ await admin.applySettings({
1685
+ mailHost,
1686
+ apex: platform.apex,
1687
+ oidc: { issuerUrl: issuer, audience: project.id },
1688
+ });
1689
+ await record(PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, project.id);
1690
+ }
1691
+ catch (error) {
1692
+ throw redactedMailError(error, [password]);
1693
+ }
1694
+ }
1695
+ else if (!recordedProjectId) {
1696
+ await record(PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, project.id);
1697
+ }
1613
1698
  // ---- OIDC directory and foundry's own admin API key, once --------------------
1614
1699
  if (!recorded[PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY]) {
1615
1700
  const lost = recorded[PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY];
@@ -1623,14 +1708,15 @@ async function provisionPlatformMail(context, deps) {
1623
1708
  }
1624
1709
  const password = recoveryEnv.value.slice(`${STALWART_RECOVERY_USER}:`.length);
1625
1710
  try {
1626
- const admin = deps.stalwartForInstance({
1711
+ const admin = (deps.stalwartForInstance ?? defaultStalwartForInstance)({
1627
1712
  url: `https://${hostname}`,
1628
1713
  user: STALWART_RECOVERY_USER,
1629
1714
  password,
1630
1715
  });
1631
1716
  await admin.applySettings({
1632
1717
  mailHost,
1633
- oidc: { issuerUrl: `https://${platform.auth.hostname}`, audience: deps.oidcAudience },
1718
+ apex: platform.apex,
1719
+ oidc: { issuerUrl: issuer, audience: project.id },
1634
1720
  });
1635
1721
  // Recorded and persisted before the secret leaves this function: if
1636
1722
  // delivery fails for good, the next plan reports the lost key rather
@@ -1696,9 +1782,9 @@ async function resolveMailServer(coolify, recordedUuid) {
1696
1782
  }
1697
1783
  /**
1698
1784
  * The mail step's own reasons it cannot run yet, as fixed text: missing
1699
- * dependencies or audience, a floating image tag, a server with no usable
1700
- * address. Skipped once the step is applied at the declared values, so a
1701
- * live app is not blocked by a later Coolify change to the server's address.
1785
+ * dependencies, a floating image tag, a server with no usable address.
1786
+ * Skipped once the step is applied at the declared values, so a live app is
1787
+ * not blocked by a later Coolify change to the server's address.
1702
1788
  */
1703
1789
  async function mailBlockedReason(platform, mail, recordedIds) {
1704
1790
  if (!mail) {
@@ -1710,9 +1796,6 @@ async function mailBlockedReason(platform, mail, recordedIds) {
1710
1796
  recordedIds[PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY]) {
1711
1797
  return null;
1712
1798
  }
1713
- if (!mail.oidcAudience || mail.oidcAudience.trim() === '') {
1714
- return "the platform's mail server is blocked, not planned: the host supplies no oidcAudience, the ZITADEL resource id Stalwart's OIDC directory checks access tokens against";
1715
- }
1716
1799
  const tagBlocked = mailImageTagBlocked(platform.mail.gitRef);
1717
1800
  if (tagBlocked)
1718
1801
  return tagBlocked;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.13.0",
3
+ "version": "0.15.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",
@@ -42,7 +42,7 @@
42
42
  "dependencies": {
43
43
  "@wtfalch/audit": "0.6.0",
44
44
  "@wtfalch/auth": "0.16.0",
45
- "@wtfalch/email": "0.15.0",
45
+ "@wtfalch/email": "^0.17.0",
46
46
  "@wtfalch/providers": "0.6.0",
47
47
  "ssh2": "1.17.0",
48
48
  "zod": "4.6.5"