@wtfalch/foundry 0.26.4 → 0.28.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.
@@ -154,6 +154,19 @@ export declare const CALLS_SFU_PRIVATE_URL = "http://10.10.10.105:7880";
154
154
  * hostname out from under it.
155
155
  */
156
156
  export declare const CALLS_SFU_PUBLIC_URL = "https://calls-sfu.oikos.host";
157
+ /**
158
+ * A host's own SFU addresses, for a stack where the shared SFU is not the
159
+ * estate's (a local run). Optional on `FoundryStepExecutorPorts`; absent, the
160
+ * two constants above apply unchanged.
161
+ */
162
+ export interface CallsSfuUrls {
163
+ /** What a browser reaches: becomes the calls service's `LIVEKIT_PUBLIC_URL`. */
164
+ publicUrl: string;
165
+ /** What the calls service reaches: becomes its `LIVEKIT_URL`. */
166
+ privateUrl: string;
167
+ }
168
+ /** The SFU addresses to use: the override, validated, else the constants. Throws naming the bad field. */
169
+ export declare function resolveCallsSfuUrls(override?: CallsSfuUrls): CallsSfuUrls;
157
170
  /**
158
171
  * Exported for the test file. Reads `ufw status`'s own report rather than
159
172
  * assuming the exit code of a blind `ufw delete allow 7880/tcp` -- real
@@ -190,6 +203,12 @@ export declare function cloudflaredEnvFile(token: string): string;
190
203
  * writes the set it was given.
191
204
  */
192
205
  export declare function livekitConfigYaml(config: CallsSfuConfig): string;
206
+ /** The command that brings both compose services up; part of what {@link callsSfuFilesVersion} hashes. */
207
+ export declare const CALLS_SFU_UP_COMMAND = "docker compose -f /opt/livekit/docker-compose.yml up -d";
208
+ /** Run first, so the SSH session this runs over is never refused. */
209
+ export declare const CALLS_SFU_SSH_RULE_COMMAND = "ufw allow 22/tcp";
210
+ /** Run after any unscoped signalling rule is removed, in this order. */
211
+ export declare const CALLS_SFU_FIREWALL_COMMANDS: readonly string[];
193
212
  /**
194
213
  * Writes all three files, brings LiveKit and `cloudflared` up, then narrows
195
214
  * the VM firewall to exactly what #122's and #127's "Decided" sections name
package/dist/calls-sfu.js CHANGED
@@ -144,6 +144,20 @@ export const CALLS_SFU_PRIVATE_URL = `http://10.10.10.105:${LIVEKIT_SIGNALLING_P
144
144
  * hostname out from under it.
145
145
  */
146
146
  export const CALLS_SFU_PUBLIC_URL = 'https://calls-sfu.oikos.host';
147
+ /** The SFU addresses to use: the override, validated, else the constants. Throws naming the bad field. */
148
+ export function resolveCallsSfuUrls(override) {
149
+ if (!override)
150
+ return { publicUrl: CALLS_SFU_PUBLIC_URL, privateUrl: CALLS_SFU_PRIVATE_URL };
151
+ for (const field of ['publicUrl', 'privateUrl']) {
152
+ const value = override[field];
153
+ if (typeof value !== 'string' ||
154
+ !URL.canParse(value) ||
155
+ !/^https?:$/.test(new URL(value).protocol)) {
156
+ throw new Error(`callsSfuUrls.${field} must be an absolute http(s) URL`);
157
+ }
158
+ }
159
+ return { publicUrl: override.publicUrl, privateUrl: override.privateUrl };
160
+ }
147
161
  /**
148
162
  * Matches `ufw status`'s report of the exact wide-open rule PR 125 added
149
163
  * (`ufw allow 7880/tcp`) -- "Anywhere" in the From column -- and not the
@@ -225,6 +239,19 @@ rtc:
225
239
  node_ip: ${address}
226
240
  ${keysYaml}`;
227
241
  }
242
+ /** The command that brings both compose services up; part of what {@link callsSfuFilesVersion} hashes. */
243
+ export const CALLS_SFU_UP_COMMAND = `docker compose -f ${LIVEKIT_COMPOSE_PATH} up -d`;
244
+ /** Run first, so the SSH session this runs over is never refused. */
245
+ export const CALLS_SFU_SSH_RULE_COMMAND = 'ufw allow 22/tcp';
246
+ /** Run after any unscoped signalling rule is removed, in this order. */
247
+ export const CALLS_SFU_FIREWALL_COMMANDS = [
248
+ `ufw allow from ${CALLS_SFU_PRIVATE_CIDR} to any port ${LIVEKIT_SIGNALLING_PORT} proto tcp`,
249
+ `ufw allow ${LIVEKIT_MEDIA_TCP_PORT}/tcp`,
250
+ `ufw allow ${LIVEKIT_MEDIA_UDP_RANGE}/udp`,
251
+ 'ufw default deny incoming',
252
+ 'ufw default allow outgoing',
253
+ 'ufw --force enable',
254
+ ];
228
255
  async function run(host, command) {
229
256
  const result = await host.exec(command);
230
257
  if (result.exitCode !== 0) {
@@ -276,16 +303,12 @@ export async function applyCallsSfu(host, config) {
276
303
  await host.writeFile(LIVEKIT_COMPOSE_PATH, dockerComposeYaml());
277
304
  await writeFileAtomically(host, LIVEKIT_CONFIG_PATH, livekitConfigYaml(config));
278
305
  await host.writeFile(CLOUDFLARED_ENV_PATH, cloudflaredEnvFile(config.cloudflaredTunnelToken));
279
- await run(host, `docker compose -f ${LIVEKIT_COMPOSE_PATH} up -d`);
280
- await run(host, 'ufw allow 22/tcp');
306
+ await run(host, CALLS_SFU_UP_COMMAND);
307
+ await run(host, CALLS_SFU_SSH_RULE_COMMAND);
281
308
  const status = await host.exec('ufw status');
282
309
  if (status.exitCode === 0 && hasUnscopedSignallingRule(status.stdout)) {
283
310
  await run(host, `ufw delete allow ${LIVEKIT_SIGNALLING_PORT}/tcp`);
284
311
  }
285
- await run(host, `ufw allow from ${CALLS_SFU_PRIVATE_CIDR} to any port ${LIVEKIT_SIGNALLING_PORT} proto tcp`);
286
- await run(host, `ufw allow ${LIVEKIT_MEDIA_TCP_PORT}/tcp`);
287
- await run(host, `ufw allow ${LIVEKIT_MEDIA_UDP_RANGE}/udp`);
288
- await run(host, 'ufw default deny incoming');
289
- await run(host, 'ufw default allow outgoing');
290
- await run(host, 'ufw --force enable');
312
+ for (const command of CALLS_SFU_FIREWALL_COMMANDS)
313
+ await run(host, command);
291
314
  }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export type { OrgId, MailMode, MailConfig, ChatConfig, CallsConfig, IssuerConfig, SiteConfig, IssuerAppKind, IssuerApp, HostingConfig, HostingEntry, EntryGate, HostingEntryUses, EntryAuth, EntryFiles, EntryAi, EnvValue, SsoConfig, GuestsConfig, SoloConfig, StorageConfig, AiConfig, BuyerAddress, BuyerIdentity, AdoptedAppForm, AdoptedApp, OwnerIdentity, OwnerMailbox, DeclaredOrg, } from './org.js';
2
2
  export { APP_ENTRY_KEY, DEFAULT_GATE_LIMIT, FILES_ENTRY_PERMISSIONS, AI_ENTRY_PERMISSIONS, DEFAULT_MAIL_APPS, appEntry, ownerMailboxes, validateBuyerIdentity, validateHosting, validateMail, validateOwners, validateSite, } from './org.js';
3
- export type { DeclaredPlatform, PlatformService, PlatformServiceKey } from './platform.js';
3
+ export type { DeclaredPlatform, PlatformService, PlatformServiceKey, PlatformSfuService, } from './platform.js';
4
4
  export { PLATFORM_SERVICE_KEYS, defaultPlatformHostname, validatePlatform, } from './platform.js';
5
5
  import { type PinnedVersions } from './bootstrap/versions.js';
6
6
  export type { PinnedVersions };
@@ -22,7 +22,7 @@ export type { LiveResource, ZitadelReader, CoolifyReader, CloudflareReader, File
22
22
  export { AdoptRefusedError } from './plan/index.js';
23
23
  export { createEdgeAccess, createEdgeDns, edgeDnsRecordOwners, createEdgeRateLimits, createEdgeTunnels, createEdgeWorkers, createEdgeZones, } from './plan/index.js';
24
24
  export type { AccessAppSummary, AccessDecision, AccessPolicyRule, AccessPort, DnsPort, EdgePlatformAccess, EdgePlatformDns, EdgePlatformRateLimits, EdgePlatformWorkers, EdgePlatformZones, EdgeZonesOptions, ZonePort, EdgeRateLimitsOptions, RateLimitPort, RateLimitRuleInput, EdgeWorkersOptions, EdgePlatformTunnels, TunnelPort, } from './plan/index.js';
25
- export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_INTERNAL_URL_SCOPE_KEY, 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, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
25
+ export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_INTERNAL_URL_SCOPE_KEY, 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, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_SFU_APPLIED_SCOPE_KEY, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
26
26
  export type { HeldKeyResolver, OrgPlatformReadiness } from './plan/index.js';
27
27
  export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './plan/index.js';
28
28
  export type { PlatformOrgClientFactories } from './plan/index.js';
@@ -41,13 +41,14 @@ export { checkMailIdentityAudience } from './plan/index.js';
41
41
  export type { MailAllowlistApp } from './plan/index.js';
42
42
  export { checkMailAppsAllowlist } from './plan/index.js';
43
43
  export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, createZitadelAdminMinter, createZitadelProjectRoles, PLATFORM_AUTH_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, } from './plan/index.js';
44
- export type { PlatformAuthDeps, PlatformAuthRuntime, PlatformFilesDeps, PlatformFilesRuntime, PlatformEdgeDeps, PlatformDnsFactory, PlatformAccessFactory, PlatformUptimeAccessFactory, PlatformUptimeAccessPort, PlatformUptimeDeps, PlatformWorkersFactory, PlatformWorkersPort, PlatformAccessPort, PlatformTunnelsFactory, PlatformEdgeRuntime, } from './plan/index.js';
44
+ export type { PlatformAuthDeps, PlatformAuthSmtp, PlatformAuthRuntime, PlatformFilesDeps, PlatformFilesRuntime, PlatformEdgeDeps, PlatformDnsFactory, PlatformAccessFactory, PlatformUptimeAccessFactory, PlatformUptimeAccessPort, PlatformUptimeDeps, PlatformWorkersFactory, PlatformWorkersPort, PlatformAccessPort, PlatformTunnelsFactory, PlatformEdgeRuntime, } from './plan/index.js';
45
45
  export type { PlatformMailDeps, PlatformMailAdminPort, PlatformMailRuntime, } from './plan/index.js';
46
46
  export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, PLATFORM_AUTH_INSTANCE_BEARER_REF, } from './plan/index.js';
47
47
  export { AI_MANAGEMENT_ISSUER, AI_MANAGEMENT_KEY_ID, AI_SIGNING_KEY_REF, AiManagementKeyExistsError, createAiManagementKeyMinter, PLATFORM_AI_MANAGEMENT_KEY_REF, PLATFORM_AI_MANAGEMENT_PERMISSIONS, } from './plan/index.js';
48
48
  export type { AiManagementKeyMinter, PlatformAiDeps, PlatformAiRuntime, } from './plan/index.js';
49
49
  export type { CallsSfuConnection, CallsSfuConnectionPort, CallsSfuKeysPort, CallsSfuSshConnector, CallsSfuPlatformDeps, } from './plan/index.js';
50
- export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep } from './plan/index.js';
50
+ export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep, callsSfuAppliedFingerprint, } from './plan/index.js';
51
+ export type { CallsSfuUrls } from './calls-sfu.js';
51
52
  export type { CallsSfuSshSession } from './calls-sfu-ssh-adapter.js';
52
53
  export { connectCallsSfuSsh } from './calls-sfu-ssh-client.js';
53
54
  export type { CallsSfuSshConnectOptions } from './calls-sfu-ssh-client.js';
package/dist/index.js CHANGED
@@ -13,7 +13,7 @@ export { createPostgresAuditLog } from './store/audit.js';
13
13
  export { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryAuditEvents, foundryDeclaredPlatform, foundryRequestedChanges, } from './store/schema.js';
14
14
  export { AdoptRefusedError } from './plan/index.js';
15
15
  export { createEdgeAccess, createEdgeDns, edgeDnsRecordOwners, createEdgeRateLimits, createEdgeTunnels, createEdgeWorkers, createEdgeZones, } from './plan/index.js';
16
- export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_INTERNAL_URL_SCOPE_KEY, 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, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
16
+ export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_INTERNAL_URL_SCOPE_KEY, 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, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_SFU_APPLIED_SCOPE_KEY, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
17
17
  export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './plan/index.js';
18
18
  export { PlatformOrgBlockedError, PlatformOrgPorts } from './plan/index.js';
19
19
  export { FoundryStepExecutor, NotBuiltError, healthUrls, loginHostnameFor } from './plan/index.js';
@@ -26,7 +26,7 @@ export { checkMailAppsAllowlist } from './plan/index.js';
26
26
  export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, createZitadelAdminMinter, createZitadelProjectRoles, PLATFORM_AUTH_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, } from './plan/index.js';
27
27
  export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, PLATFORM_AUTH_INSTANCE_BEARER_REF, } from './plan/index.js';
28
28
  export { AI_MANAGEMENT_ISSUER, AI_MANAGEMENT_KEY_ID, AI_SIGNING_KEY_REF, AiManagementKeyExistsError, createAiManagementKeyMinter, PLATFORM_AI_MANAGEMENT_KEY_REF, PLATFORM_AI_MANAGEMENT_PERMISSIONS, } from './plan/index.js';
29
- export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep } from './plan/index.js';
29
+ export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep, callsSfuAppliedFingerprint, } from './plan/index.js';
30
30
  export { connectCallsSfuSsh } from './calls-sfu-ssh-client.js';
31
31
  export { UnknownCallsSfuHostKeyError, toCallsSfuHostPort, applyCallsSfuOverSession, } from './calls-sfu-ssh-adapter.js';
32
32
  export { mintLivekitCredentials } from './calls-sfu-keys.js';
@@ -0,0 +1,7 @@
1
+ -- @wtfalch/foundry: the platform's `sfu.connectionRevision`, a value a host
2
+ -- changes when the SFU VM's SSH key, host key or tunnel token changes, so the
3
+ -- platform plan shows the `sfu` step again. Declared; null until set.
4
+ -- Mirrors src/store/schema.ts's `foundryDeclaredPlatform` table addition.
5
+
6
+ alter table foundry_declared_platform
7
+ add column if not exists sfu_connection_revision text;
@@ -43,6 +43,8 @@ export interface DraftStep {
43
43
  export interface PlanOrgOptions {
44
44
  /** #267: the executor was built with a `loginHostBindings` port. */
45
45
  loginHostBindings?: boolean;
46
+ /** The executor's ZITADEL client has `updateOidcAppIdTokenUserinfoAssertion`; without it `planIdTokenUserinfo` plans nothing. */
47
+ zitadelIdTokenUserinfo?: boolean;
46
48
  /** F5: the executor was built with a `broker` port. */
47
49
  broker?: boolean;
48
50
  /** #161: the slugs of the other declared orgs, so a Boule whose hostname another org's slug would take is blocked. */
package/dist/plan/diff.js CHANGED
@@ -12,7 +12,7 @@ import { appHostname, entryHostnames, tunnelDnsHostnames, tunnelDomainList } fro
12
12
  import { lockedHostnames } from './locked-hostnames.js';
13
13
  import { LOGIN_BINDING_SCOPE_KEY, companyLoginTunnelHost, loginBindingState, loginHostClientIds, loginHostnameFor, pendingRemovalHosts, } from './login-host.js';
14
14
  import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, } from './mail-copy.js';
15
- import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, ORG_MAIL_APPLIED_MODE_SCOPE_KEY, ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY, appEnvAppliedScopeKey, appFolderDiffers, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, versionNotAppliedLine, } from './platform-diff.js';
15
+ import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, ORG_MAIL_APPLIED_MODE_SCOPE_KEY, ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY, appEnvAppliedScopeKey, appFolderDiffers, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, versionNotAppliedLine, zitadelAppIdTokenUserinfoAppliedScopeKey, } from './platform-diff.js';
16
16
  import { DEFAULT_ORG_PLATFORM_READINESS, } from './platform-org.js';
17
17
  import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
18
18
  import { appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec } from './proxy-labels.js';
@@ -441,6 +441,10 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
441
441
  plannedSteps.push(step);
442
442
  }
443
443
  }
444
+ // Last: a repair that fails must not stop any step after it (`runSteps` stops at the first failure).
445
+ const idTokenUserinfo = planIdTokenUserinfo(org, live, recordedIds, plannedSteps, platformReadiness.authReady, options.zitadelIdTokenUserinfo ?? false);
446
+ if (idTokenUserinfo)
447
+ plannedSteps.push(idTokenUserinfo);
444
448
  // #10 (resolved): "foundry manages only what the declared org names";
445
449
  // anything left unclaimed is listed, never proposed for deletion.
446
450
  // A resource foundry made for the org is not a finding: only a resource
@@ -861,6 +865,45 @@ function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired
861
865
  // repoint nothing until the port that writes the binding is wired.
862
866
  return loginHostWaiting(org, host, recordedIds, bindingsWired, issuerPlanned) ?? stepFor(false);
863
867
  }
868
+ /**
869
+ * Every sign-in app this org already has live (web or native, not api) gets
870
+ * "user info inside the ID token" switched on once: apps made before the
871
+ * setting existed have none, and `@wtfalch/auth` builds the signed-in user
872
+ * from ID token claims only. One `zitadel:id-token-userinfo` step names the
873
+ * apps whose recorded id differs from their marker
874
+ * ({@link zitadelAppIdTokenUserinfoAppliedScopeKey}); a re-created app has a
875
+ * new id, so it is named again. Plans nothing when the host's ZITADEL client
876
+ * lacks the method, before the org and project are recorded, or while
877
+ * the platform's sign-in is not ready.
878
+ */
879
+ function planIdTokenUserinfo(org, live, recordedIds, plannedSteps, authReady, wired) {
880
+ if (!wired || !authReady || !recordedIds.zitadel_org || !recordedIds.zitadel_project)
881
+ return null;
882
+ const apps = live
883
+ .filter((r) => r.system === 'zitadel' &&
884
+ r.kind === 'app' &&
885
+ (r.config.appType === 'web' || r.config.appType === 'native'))
886
+ .map((r) => r.key)
887
+ // An app this plan recreates gets its marker when it is made.
888
+ .filter((appKey) => !plannedSteps.some((s) => s.action.system === 'zitadel' &&
889
+ s.action.kind === 'app' &&
890
+ s.action.key === appKey &&
891
+ s.action.desired.recreate === true))
892
+ .filter((appKey) => {
893
+ const appId = recordedIds[`zitadel:app:${appKey}`];
894
+ return (Boolean(appId) && recordedIds[zitadelAppIdTokenUserinfoAppliedScopeKey(appKey)] !== appId);
895
+ });
896
+ if (apps.length === 0)
897
+ return null;
898
+ return {
899
+ action: { system: 'zitadel', kind: 'id-token-userinfo', key: org.id, desired: { apps } },
900
+ step: {
901
+ description: `switch on user info in the ID token for ${org.slug}'s sign-in apps (${apps.join(', ')})`,
902
+ destroys: false,
903
+ migrates: false,
904
+ },
905
+ };
906
+ }
864
907
  /** Why a login-host step cannot run yet, or `null` when nothing is missing. */
865
908
  function loginHostWaiting(org, host, recordedIds, bindingsWired, issuerPlanned) {
866
909
  if (!bindingsWired) {
@@ -1,4 +1,5 @@
1
1
  import type { OrgMailOptions, OrgMailOrg, OrgMailResult } from '@wtfalch/email/postmaster';
2
+ import { type CallsSfuUrls } from '../calls-sfu.js';
2
3
  import type { DeclaredOrg, HostingBuildPack } from '../org.js';
3
4
  import type { DeclaredOrgStore } from '../store.js';
4
5
  import { type BrokerPort } from './broker-entries.js';
@@ -518,7 +519,7 @@ export interface WorkersPort {
518
519
  /**
519
520
  * One DNS record a Lettermint domain's own response says must be published --
520
521
  * `purpose` is Lettermint's own label (`return_path`, `dmarc`, `dkim_legacy`,
521
- * `dkim_primary`, `dkim_secondary`), replacing Resend's `record` field, from
522
+ * `dkim_primary`, `dkim_secondary`), replacing the earlier provider's `record` field, from
522
523
  * {@link LettermintPort.createDomain}/{@link LettermintPort.getDomain}, the
523
524
  * same shape https://lettermint.co/docs/api-reference/team/0.0.1/lettermint-team-openapi.json's
524
525
  * `DomainDnsRecordData` documents (`fqdn`/`content` renamed to this port's own
@@ -551,17 +552,17 @@ export interface LettermintProject {
551
552
  * has no Lettermint client yet, unlike Coolify/Cloudflare/ZITADEL above, so
552
553
  * this port names Lettermint's own REST shapes directly
553
554
  * (`.plans/2026-09-28-lettermint-api.md`, its own primary-source citations).
554
- * Replaces `ResendPort` (#138/#140), trimmed to what `coolifyApp`/`coolifyMail`
555
+ * Replaces the earlier mail port (#138/#140), trimmed to what `coolifyApp`/`coolifyMail`
555
556
  * below actually call.
556
557
  *
557
- * Unlike Resend's per-domain sending key, a Lettermint domain is
558
+ * Unlike the earlier provider's per-domain sending key, a Lettermint domain is
558
559
  * unrestricted by default -- any project in the team could send from it --
559
560
  * so a project's own domain access is a separate call
560
561
  * ({@link restrictDomainToProjects}), and the credential unit is a *project*,
561
562
  * not a per-domain key: Lettermint's API mints, at most, one live token per
562
563
  * project and has no call to list or revoke an individual token within one
563
564
  * (`.plans/2026-09-28-lettermint-api.md` §4), so `<slug>-app` is a project
564
- * name, never suffixed with a domain id the way Resend's key name was.
565
+ * name, never suffixed with a domain id the way the earlier provider's key name was.
565
566
  */
566
567
  export interface LettermintPort {
567
568
  /**
@@ -584,7 +585,7 @@ export interface LettermintPort {
584
585
  /**
585
586
  * Asks Lettermint to re-check DNS now. Never polled or awaited to
586
587
  * completion -- the same "do not block the plan on DNS propagation" rule
587
- * `ResendPort.verifyDomain` followed: record the status, and let the next
588
+ * the earlier mail port followed: record the status, and let the next
588
589
  * apply re-check it.
589
590
  */
590
591
  verifyDomain(id: string): Promise<{
@@ -1009,6 +1010,18 @@ export interface ZitadelPort {
1009
1010
  * the binding holds; `zitadelApp` never sets it at creation.
1010
1011
  */
1011
1012
  updateOidcAppLoginBaseUri(orgId: string, projectId: string, appId: string, loginBaseUri: string): Promise<void>;
1013
+ /**
1014
+ * Switches on "user info inside the ID token" for an already-created app,
1015
+ * read-modify-write (`@wtfalch/providers`' `ZitadelClient.
1016
+ * updateOidcAppIdTokenUserinfoAssertion`): ZITADEL defaults it to off, and
1017
+ * `@wtfalch/auth` builds the signed-in user from ID token claims only, so
1018
+ * an app made without it has no `email` claim. Resolves `true` when it
1019
+ * wrote the setting, `false` when the app already had it on (no write).
1020
+ * Optional: a host whose client lacks it gets no repair step planned
1021
+ * (`FoundryPlanApplyOptions.zitadelIdTokenUserinfo`), and nothing throws.
1022
+ * `createOidcApp` and `createOidcAppWithSecret` send it on themselves.
1023
+ */
1024
+ updateOidcAppIdTokenUserinfoAssertion?(orgId: string, projectId: string, appId: string): Promise<boolean>;
1012
1025
  }
1013
1026
  /**
1014
1027
  * #274 fix round 4: bridges `@wtfalch/providers`' real `ZitadelClient.
@@ -1377,6 +1390,12 @@ export interface FoundryStepExecutorPorts {
1377
1390
  jobsScheduler?: JobsSchedulerPort;
1378
1391
  /** The shared calls SFU's own LiveKit credentials (#145) -- see {@link CallsSfuPort}'s own doc comment. */
1379
1392
  callsSfu: CallsSfuPort;
1393
+ /**
1394
+ * The calls service's `LIVEKIT_URL` / `LIVEKIT_PUBLIC_URL`, for a host whose
1395
+ * SFU is not the estate's shared one. Absent, the estate's addresses apply.
1396
+ * Refused (the calls-service step throws) unless both are absolute http(s) URLs.
1397
+ */
1398
+ callsSfuUrls?: CallsSfuUrls;
1380
1399
  /**
1381
1400
  * #113: the org's old-mail copy into Stalwart, ahead of a `received` ->
1382
1401
  * `hosted` MX cutover -- see {@link MailCopyPort}'s own doc comment.
@@ -1696,6 +1715,15 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1696
1715
  * recorded).
1697
1716
  */
1698
1717
  private zitadelOwner;
1718
+ /**
1719
+ * Switches on "user info inside the ID token" for each live sign-in app
1720
+ * the planner named. An app's marker (its ZITADEL app id) is recorded and
1721
+ * persisted only after its call resolved to a boolean, so a call that throws
1722
+ * or resolves to anything else leaves no marker and the next plan names the
1723
+ * app again. The error names ids only,
1724
+ * never the cause.
1725
+ */
1726
+ private zitadelIdTokenUserinfo;
1699
1727
  /**
1700
1728
  * `plan/diff.ts`'s `planLoginHost` (#267): adds the org's own company
1701
1729
  * login host (`action.desired.host`) to the ZITADEL instance's trusted
@@ -1931,7 +1959,7 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1931
1959
  *
1932
1960
  * Mint rule ("Credential model"): the project is healthy when the app env
1933
1961
  * holds `LETTERMINT_API_TOKEN` and Lettermint lists exactly one project
1934
- * named `<slug>-app` -- unlike Resend's key, a Lettermint project's name
1962
+ * named `<slug>-app` -- unlike the earlier provider's key, a Lettermint project's name
1935
1963
  * carries no domain id, since a domain switch is handled entirely by
1936
1964
  * {@link LettermintPort.restrictDomainToProjects} below and never needs a
1937
1965
  * fresh project. Otherwise a fresh project is created and its token
@@ -2726,7 +2754,7 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2726
2754
  * upsert `publishMailDnsRecords` above uses for Stalwart's own records --
2727
2755
  * except an existing `_dmarc` record is never overwritten (a stricter
2728
2756
  * policy an org already set, `p=quarantine`/`p=reject`, should not be
2729
- * relaxed back to Lettermint's own default). Resend's own domain also
2757
+ * relaxed back to Lettermint's own default). The earlier provider's domain also
2730
2758
  * carried an apex `SPF` TXT record that had to be merged into whatever SPF
2731
2759
  * record already existed there (RFC 7208: two `v=spf1` TXT records on one
2732
2760
  * name is a permerror); Lettermint's own DNS records
@@ -1,6 +1,6 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { URL } from 'node:url';
3
- import { CALLS_SFU_PRIVATE_URL, CALLS_SFU_PUBLIC_URL } from '../calls-sfu.js';
3
+ import { resolveCallsSfuUrls } from '../calls-sfu.js';
4
4
  import { APP_ENTRY_KEY, appEntry, filesCollectionEnvName, ownerMailboxRecorded, ownerMailboxes, sharedSecretRef, } from '../org.js';
5
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_DEPLOYED_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';
@@ -24,7 +24,7 @@ import { healthCheckSettings } from './health-check-settings.js';
24
24
  import { appHostname, coolifyTunnelDomains, entryHostnames, tunnelDnsHostnames, tunnelDomainList, tunnelIngressHostnames, } from './hostnames.js';
25
25
  import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, LOGIN_HOST_SHARED_SCOPE_KEY, customersEntryKeys, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
26
26
  import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
27
- import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, ORG_MAIL_APPLIED_MODE_SCOPE_KEY, ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY, appDomainsAppliedScopeKey, appEnvAppliedScopeKey, appFolderDiffers, appNameFor, appSettingsAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, } from './platform-diff.js';
27
+ import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, ORG_MAIL_APPLIED_MODE_SCOPE_KEY, ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY, appDomainsAppliedScopeKey, appEnvAppliedScopeKey, appFolderDiffers, appNameFor, appSettingsAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, zitadelAppIdTokenUserinfoAppliedScopeKey, } from './platform-diff.js';
28
28
  import { AUTH_LOGIN_PORT, PLATFORM_AUTH_BROKER_MAIL_KEY_REF, PLATFORM_AUTH_LOGIN_INTERNAL_NAME, } from './platform-providers.js';
29
29
  import { appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec, entryProxyLabels, sameLabelSets, } from './proxy-labels.js';
30
30
  import { entryWorkerAppliedScopeKey, entryWorkerExclusionsScopeKey, entryWorkerRetiredScopeKey, entryWorkerRouteIdScopeKey, entryWorkerRoutePatternScopeKey, entryWorkerRoutePendingScopeKey, entryWorkerRouteZoneScopeKey, entryWorkerUploadedScopeKey, } from './worker.js';
@@ -354,10 +354,10 @@ const CMS_DATABASE_SCOPE_KEY = 'coolify_cms_database';
354
354
  * domain choice, the same way {@link APP_CLIENT_ID_SCOPE_KEY} carries a
355
355
  * public value out. All of that org's Lettermint work -- ensure domain,
356
356
  * restrict, publish DNS, verify -- now happens inside the same step that
357
- * reads this key (`ensureAppLettermintMail`), so unlike the Resend design
357
+ * reads this key (`ensureAppLettermintMail`), so unlike the earlier design
358
358
  * this rework replaces, no separate domain-id scope key is recorded: the
359
359
  * domain is looked up by name fresh each apply. Renamed from
360
- * `resend_mail_domain`.
360
+ * the earlier mail domain key.
361
361
  */
362
362
  const MAIL_DOMAIN_SCOPE_KEY = 'mail_domain';
363
363
  /**
@@ -770,6 +770,8 @@ export class FoundryStepExecutor {
770
770
  return this.zitadelLoginHost(action, org, context);
771
771
  case 'zitadel:app':
772
772
  return this.zitadelApp(action, org, context);
773
+ case 'zitadel:id-token-userinfo':
774
+ return this.zitadelIdTokenUserinfo(action, org, context);
773
775
  case 'zitadel:customers':
774
776
  return this.zitadelCustomers(org, context);
775
777
  case 'coolify:app':
@@ -1312,6 +1314,44 @@ export class FoundryStepExecutor {
1312
1314
  }
1313
1315
  return { resourceId: userId };
1314
1316
  }
1317
+ /**
1318
+ * Switches on "user info inside the ID token" for each live sign-in app
1319
+ * the planner named. An app's marker (its ZITADEL app id) is recorded and
1320
+ * persisted only after its call resolved to a boolean, so a call that throws
1321
+ * or resolves to anything else leaves no marker and the next plan names the
1322
+ * app again. The error names ids only,
1323
+ * never the cause.
1324
+ */
1325
+ async zitadelIdTokenUserinfo(action, org, context) {
1326
+ const update = this.ports.zitadel.updateOidcAppIdTokenUserinfoAssertion?.bind(this.ports.zitadel);
1327
+ // The planner plans nothing without the method; a stale plan does nothing.
1328
+ if (!update)
1329
+ return {};
1330
+ const recorded = context.recordedIds();
1331
+ const zitadelOrgId = recorded.zitadel_org;
1332
+ const projectId = recorded.zitadel_project;
1333
+ if (!zitadelOrgId || !projectId) {
1334
+ throw new Error(`zitadel:id-token-userinfo for ${org.slug}: no ZITADEL org and project recorded yet -- the issuer must be provisioned first`);
1335
+ }
1336
+ for (const appKey of action.desired.apps) {
1337
+ const appId = recorded[`zitadel:app:${appKey}`];
1338
+ if (!appId)
1339
+ continue;
1340
+ let done;
1341
+ try {
1342
+ done = await update(zitadelOrgId, projectId, appId);
1343
+ }
1344
+ catch {
1345
+ throw new Error(`zitadel:id-token-userinfo for ${org.slug}: switching on user info in the ID token failed for app '${appKey}' (${appId})`);
1346
+ }
1347
+ // A client that answers every property (`packages/local`) resolves to something else: no marker.
1348
+ if (typeof done !== 'boolean')
1349
+ continue;
1350
+ context.recordScopeId(zitadelAppIdTokenUserinfoAppliedScopeKey(appKey), appId);
1351
+ await context.persistScopeIds();
1352
+ }
1353
+ return {};
1354
+ }
1315
1355
  /**
1316
1356
  * `plan/diff.ts`'s `planLoginHost` (#267): adds the org's own company
1317
1357
  * login host (`action.desired.host`) to the ZITADEL instance's trusted
@@ -1706,6 +1746,8 @@ export class FoundryStepExecutor {
1706
1746
  // never something an app's env needs.
1707
1747
  context.recordScopeId(clientIdScopeKey, created.clientId);
1708
1748
  }
1749
+ // Made with the setting on (providers 0.9.0): nothing to repair on this id.
1750
+ context.recordScopeId(zitadelAppIdTokenUserinfoAppliedScopeKey(action.key), created.appId);
1709
1751
  return { resourceId: created.appId };
1710
1752
  }
1711
1753
  /**
@@ -2453,7 +2495,7 @@ export class FoundryStepExecutor {
2453
2495
  *
2454
2496
  * Mint rule ("Credential model"): the project is healthy when the app env
2455
2497
  * holds `LETTERMINT_API_TOKEN` and Lettermint lists exactly one project
2456
- * named `<slug>-app` -- unlike Resend's key, a Lettermint project's name
2498
+ * named `<slug>-app` -- unlike the earlier provider's key, a Lettermint project's name
2457
2499
  * carries no domain id, since a domain switch is handled entirely by
2458
2500
  * {@link LettermintPort.restrictDomainToProjects} below and never needs a
2459
2501
  * fresh project. Otherwise a fresh project is created and its token
@@ -3771,6 +3813,8 @@ export class FoundryStepExecutor {
3771
3813
  await this.ports.coolify.raw('POST', `/applications/${appId}/stop`);
3772
3814
  return { resourceId: appId };
3773
3815
  }
3816
+ // Refused before anything is created, like the missing-stack refusal below.
3817
+ const sfuUrls = resolveCallsSfuUrls(this.ports.callsSfuUrls);
3774
3818
  const recorded = context.recordedIds();
3775
3819
  const synapseId = recorded[CALLS_SYNAPSE_SCOPE_KEY];
3776
3820
  const masId = recorded[CALLS_MAS_SCOPE_KEY];
@@ -3832,8 +3876,8 @@ export class FoundryStepExecutor {
3832
3876
  await this.ensureLogVolume(app.appId, org, app.existed);
3833
3877
  const bindingSecret = await this.ensureCallsBindingSecret(app.appId);
3834
3878
  const envs = [
3835
- { key: 'LIVEKIT_URL', value: CALLS_SFU_PRIVATE_URL },
3836
- { key: 'LIVEKIT_PUBLIC_URL', value: CALLS_SFU_PUBLIC_URL },
3879
+ { key: 'LIVEKIT_URL', value: sfuUrls.privateUrl },
3880
+ { key: 'LIVEKIT_PUBLIC_URL', value: sfuUrls.publicUrl },
3837
3881
  { key: 'LIVEKIT_API_KEY', value: sfu.apiKey },
3838
3882
  { key: 'LIVEKIT_API_SECRET', value: sfu.apiSecret },
3839
3883
  { key: 'MAS_URL', value: `http://${masHost}:8080/` },
@@ -5144,7 +5188,7 @@ export class FoundryStepExecutor {
5144
5188
  * upsert `publishMailDnsRecords` above uses for Stalwart's own records --
5145
5189
  * except an existing `_dmarc` record is never overwritten (a stricter
5146
5190
  * policy an org already set, `p=quarantine`/`p=reject`, should not be
5147
- * relaxed back to Lettermint's own default). Resend's own domain also
5191
+ * relaxed back to Lettermint's own default). The earlier provider's domain also
5148
5192
  * carried an apex `SPF` TXT record that had to be merged into whatever SPF
5149
5193
  * record already existed there (RFC 7208: two `v=spf1` TXT records on one
5150
5194
  * name is a permerror); Lettermint's own DNS records
@@ -5,7 +5,7 @@ export { computePlanDetail, toPlan } from './diff.js';
5
5
  export type { StepSystem } from './step-system.js';
6
6
  export { stepSystemOf } from './step-system.js';
7
7
  export type { PlatformPlanDetail } from './platform-diff.js';
8
- export { planPlatform, toPlatformPlan, JOBS_INTERNAL_URL_SCOPE_KEY, 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, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, } from './platform-diff.js';
8
+ export { planPlatform, toPlatformPlan, JOBS_INTERNAL_URL_SCOPE_KEY, 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, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_SFU_APPLIED_SCOPE_KEY, } from './platform-diff.js';
9
9
  export type { HeldKeyResolver, OrgPlatformReadiness } from './platform-org.js';
10
10
  export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './platform-org.js';
11
11
  export type { PlatformOrgClientFactories } from './platform-org-ports.js';
@@ -15,7 +15,7 @@ export { AdoptRefusedError } from './adopt.js';
15
15
  export type { AuditActor, AuditLog, AuthzGate, StepExecutor, StepDestroyer, DestroyTarget, StepExecutionContext, PlatformStepExecutor, PlatformStepExecutionContext, FoundryPlanApplyOptions, } from './plan-apply.js';
16
16
  export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan-apply.js';
17
17
  export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, filesPublicHostsValue, filesShareDomains, } from './platform-providers.js';
18
- export type { PlatformAuthDeps, PlatformAuthRuntime, PlatformFilesDeps, PlatformFilesRuntime, PlatformEdgeDeps, PlatformDnsFactory, PlatformAccessFactory, PlatformAccessPort, PlatformTunnelsFactory, PlatformEdgeRuntime, } from './platform-providers.js';
18
+ export type { PlatformAuthDeps, PlatformAuthSmtp, PlatformAuthRuntime, PlatformFilesDeps, PlatformFilesRuntime, PlatformEdgeDeps, PlatformDnsFactory, PlatformAccessFactory, PlatformAccessPort, PlatformTunnelsFactory, PlatformEdgeRuntime, } from './platform-providers.js';
19
19
  export type { PlatformMailDeps, PlatformMailAdminPort, PlatformMailRuntime, } from './platform-providers.js';
20
20
  export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, PLATFORM_AUTH_INSTANCE_BEARER_REF, } from './platform-providers.js';
21
21
  export { AI_MANAGEMENT_ISSUER, AI_MANAGEMENT_KEY_ID, AI_SIGNING_KEY_REF, AiManagementKeyExistsError, createAiManagementKeyMinter, PLATFORM_AI_MANAGEMENT_KEY_REF, PLATFORM_AI_MANAGEMENT_PERMISSIONS, } from './platform-providers.js';
@@ -23,7 +23,7 @@ export type { AiManagementKeyMinter, PlatformAiDeps, PlatformAiRuntime, } from '
23
23
  export { createZitadelAdminMinter } from './zitadel-machine-user.js';
24
24
  export { createZitadelProjectRoles } from './zitadel-project-roles.js';
25
25
  export type { CallsSfuConnection, CallsSfuConnectionPort, CallsSfuKeysPort, CallsSfuSshConnector, CallsSfuPlatformDeps, } from './platform-sfu.js';
26
- export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep } from './platform-sfu.js';
26
+ export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep, callsSfuAppliedFingerprint, } from './platform-sfu.js';
27
27
  export type { MailCopyJob, MailCopyPort, MailCopyStepResult } from './mail-copy.js';
28
28
  export { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, MissingMailCopyAccountsError, MissingMailCopySourceTokenError, MissingMailCopyStalwartTokenError, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
29
29
  export type { ScopeResolver, RecordedIds, DnsRecordOwners } from './providers-readers.js';
@@ -1,7 +1,7 @@
1
1
  export { readLiveResources, readLivePlatformResources } from './live.js';
2
2
  export { computePlanDetail, toPlan } from './diff.js';
3
3
  export { stepSystemOf } from './step-system.js';
4
- export { planPlatform, toPlatformPlan, JOBS_INTERNAL_URL_SCOPE_KEY, 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, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, } from './platform-diff.js';
4
+ export { planPlatform, toPlatformPlan, JOBS_INTERNAL_URL_SCOPE_KEY, 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, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_SFU_APPLIED_SCOPE_KEY, } from './platform-diff.js';
5
5
  export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './platform-org.js';
6
6
  export { PlatformOrgBlockedError, PlatformOrgPorts } from './platform-org-ports.js';
7
7
  export { AdoptRefusedError } from './adopt.js';
@@ -12,7 +12,7 @@ export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEME
12
12
  export { AI_MANAGEMENT_ISSUER, AI_MANAGEMENT_KEY_ID, AI_SIGNING_KEY_REF, AiManagementKeyExistsError, createAiManagementKeyMinter, PLATFORM_AI_MANAGEMENT_KEY_REF, PLATFORM_AI_MANAGEMENT_PERMISSIONS, } from './platform-providers.js';
13
13
  export { createZitadelAdminMinter } from './zitadel-machine-user.js';
14
14
  export { createZitadelProjectRoles } from './zitadel-project-roles.js';
15
- export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep } from './platform-sfu.js';
15
+ export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep, callsSfuAppliedFingerprint, } from './platform-sfu.js';
16
16
  export { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, MissingMailCopyAccountsError, MissingMailCopySourceTokenError, MissingMailCopyStalwartTokenError, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
17
17
  export { ProvidersAiReader, ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
18
18
  export { PlatformBrokerPort } from './broker-entries.js';
@@ -23,9 +23,9 @@ export interface LiveResource {
23
23
  /**
24
24
  * `'sfu'` (#170) has no reader wired below -- there is no cheap way to
25
25
  * probe the calls SFU VM's own state over SSH during planning, so
26
- * `platform-diff.ts`'s `planSfu` always plans its step when
27
- * `DeclaredPlatform.sfu.enabled` is true, rather than diffing against a
28
- * live resource the way every other system here does. It is still part of
26
+ * `platform-diff.ts`'s `planSfu` plans its step from a recorded
27
+ * fingerprint when `DeclaredPlatform.sfu.enabled` is true, rather than
28
+ * diffing against a live resource the way every other system here does. It is still part of
29
29
  * this union so `StepAction.system` (`diff.ts`) can name it. `'edge'`
30
30
  * (#317) is the same: a edge key has no live lookup of its own, the same
31
31
  * shape `ai`/`files` entry keys already follow.
@@ -361,6 +361,13 @@ export interface FoundryPlanApplyOptions {
361
361
  * before zitadel-login's binding can be written.
362
362
  */
363
363
  loginHostBindings?: boolean;
364
+ /**
365
+ * True when the host's ZITADEL client has `updateOidcAppIdTokenUserinfoAssertion`
366
+ * (`@wtfalch/providers` at the version that has it). Optional, and false
367
+ * without it: no org plans the step that switches on user info in the ID
368
+ * token of its existing sign-in apps.
369
+ */
370
+ zitadelIdTokenUserinfo?: boolean;
364
371
  /**
365
372
  * F5: true when the host's executor was built with a `broker` port.
366
373
  * Optional, and false without it: each entry's broker entry is then
@@ -461,6 +468,7 @@ export declare class FoundryPlanApply implements PlanApply {
461
468
  private readonly agora;
462
469
  private readonly jobsScheduler;
463
470
  private readonly loginHostBindings;
471
+ private readonly zitadelIdTokenUserinfo;
464
472
  private readonly broker;
465
473
  private readonly edgeWorkers;
466
474
  private readonly hostFiles;
@@ -121,6 +121,7 @@ export class FoundryPlanApply {
121
121
  agora;
122
122
  jobsScheduler;
123
123
  loginHostBindings;
124
+ zitadelIdTokenUserinfo;
124
125
  broker;
125
126
  edgeWorkers;
126
127
  hostFiles;
@@ -142,6 +143,7 @@ export class FoundryPlanApply {
142
143
  this.agora = options.agora;
143
144
  this.jobsScheduler = options.jobsScheduler ?? false;
144
145
  this.loginHostBindings = options.loginHostBindings ?? false;
146
+ this.zitadelIdTokenUserinfo = options.zitadelIdTokenUserinfo ?? false;
145
147
  this.broker = options.broker ?? false;
146
148
  this.edgeWorkers = options.edgeWorkers ?? false;
147
149
  this.hostFiles = options.filesOrganisation ?? false;
@@ -876,6 +878,7 @@ export class FoundryPlanApply {
876
878
  const platformHostnames = await this.platformServiceHostnames();
877
879
  const detail = computePlanDetail(org, live, applied?.serviceIds ?? {}, agoraHost, this.jobsScheduler, platformReadiness, {
878
880
  loginHostBindings: this.loginHostBindings,
881
+ zitadelIdTokenUserinfo: this.zitadelIdTokenUserinfo,
879
882
  broker: this.broker,
880
883
  edgeWorkers: this.edgeWorkers,
881
884
  otherSlugs,
@@ -1005,9 +1008,13 @@ export class FoundryPlanApply {
1005
1008
  store.getServiceIds(),
1006
1009
  ]);
1007
1010
  const sfuBlocked = await this.sfuBlockedReason(platform);
1011
+ const callsOrgSlugs = (await this.store.list())
1012
+ .filter((org) => org.calls.enabled)
1013
+ .map((org) => org.slug);
1008
1014
  const detail = computePlatformPlanDetail(platform, live, {
1009
1015
  jobsObservable: readers.coolify !== undefined,
1010
1016
  sfuBlocked,
1017
+ callsOrgSlugs,
1011
1018
  recordedIds: serviceIds,
1012
1019
  blocked: (await executor.blockedSteps?.(platform, serviceIds)) ?? {},
1013
1020
  drifted: (await executor.driftedSteps?.(platform, serviceIds)) ?? {},
@@ -1301,6 +1308,7 @@ const STEP_FIELDS = {
1301
1308
  'zitadel:issuer': 'issuer.usesDomain',
1302
1309
  'zitadel:owner': 'owners',
1303
1310
  'zitadel:customers': 'hosting.entries.uses.auth',
1311
+ 'zitadel:id-token-userinfo': 'hosting.entries.uses.auth',
1304
1312
  'coolify:broker-entry': 'hosting.entries.uses.auth',
1305
1313
  'coolify:mail': 'mail',
1306
1314
  'coolify:chat': 'chat.usesDomain',