@wtfalch/foundry 0.26.4 → 0.27.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
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
package/dist/index.d.ts CHANGED
@@ -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
50
  export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep } 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';
@@ -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';
@@ -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
@@ -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';
@@ -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
  /**
@@ -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/` },
@@ -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';
@@ -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,
@@ -1301,6 +1304,7 @@ const STEP_FIELDS = {
1301
1304
  'zitadel:issuer': 'issuer.usesDomain',
1302
1305
  'zitadel:owner': 'owners',
1303
1306
  'zitadel:customers': 'hosting.entries.uses.auth',
1307
+ 'zitadel:id-token-userinfo': 'hosting.entries.uses.auth',
1304
1308
  'coolify:broker-entry': 'hosting.entries.uses.auth',
1305
1309
  'coolify:mail': 'mail',
1306
1310
  'coolify:chat': 'chat.usesDomain',
@@ -472,6 +472,22 @@ export declare function dbEnvAppliedScopeKey(entryKey: string): string;
472
472
  * service's own marker, and vice versa.
473
473
  */
474
474
  export declare function serviceDbEnvAppliedScopeKey(service: 'chat' | 'calls-service'): string;
475
+ /**
476
+ * The ZITADEL app id of company app `appKey` once "user info inside the ID
477
+ * token" was switched on for it (`updateOidcAppIdTokenUserinfoAssertion`).
478
+ * `diff.ts` plans one step for the org while any live sign-in app's recorded
479
+ * id differs from its marker, so an app made before the setting existed, or
480
+ * made again under a new id, is repaired once.
481
+ */
482
+ export declare function zitadelAppIdTokenUserinfoAppliedScopeKey(appKey: string): string;
483
+ /** The ZITADEL app id of Archon's sign-in client in the platform's own ZITADEL. */
484
+ export declare const PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY = "zitadel_platform_archon_app";
485
+ /**
486
+ * The ZITADEL app id of Archon's sign-in client once "user info inside the
487
+ * ID token" was switched on for it. `planArchonIdTokenUserinfo` plans the
488
+ * repair while this differs from the recorded client app id.
489
+ */
490
+ export declare const PLATFORM_ARCHON_ID_TOKEN_USERINFO_APPLIED_SCOPE_KEY = "archon_id_token_userinfo_applied";
475
491
  /**
476
492
  * The settings `coolifyApp` owns on an entry's app (`folder`, `port`,
477
493
  * `autoDeploy`) as the JSON the marker below holds. `coolifyApp` records it
@@ -988,6 +1004,12 @@ export interface PlanPlatformOptions {
988
1004
  };
989
1005
  /** Why Archon's step must wait: the deployment it triggered is still running. Blocks even an applied step. */
990
1006
  archonDeployRunning?: string;
1007
+ /**
1008
+ * The Archon sign-in client's ZITADEL client has
1009
+ * `updateOidcAppIdTokenUserinfoAssertion`. Not a block: without it the
1010
+ * repair step is not planned.
1011
+ */
1012
+ archonIdTokenUserinfo?: boolean;
991
1013
  };
992
1014
  /**
993
1015
  * Steps whose live state differs from the declared one in a way the recorded
@@ -521,6 +521,24 @@ export function dbEnvAppliedScopeKey(entryKey) {
521
521
  export function serviceDbEnvAppliedScopeKey(service) {
522
522
  return `service_db_env_applied.${service}`;
523
523
  }
524
+ /**
525
+ * The ZITADEL app id of company app `appKey` once "user info inside the ID
526
+ * token" was switched on for it (`updateOidcAppIdTokenUserinfoAssertion`).
527
+ * `diff.ts` plans one step for the org while any live sign-in app's recorded
528
+ * id differs from its marker, so an app made before the setting existed, or
529
+ * made again under a new id, is repaired once.
530
+ */
531
+ export function zitadelAppIdTokenUserinfoAppliedScopeKey(appKey) {
532
+ return `zitadel_app_id_token_userinfo_applied.${appKey}`;
533
+ }
534
+ /** The ZITADEL app id of Archon's sign-in client in the platform's own ZITADEL. */
535
+ export const PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY = 'zitadel_platform_archon_app';
536
+ /**
537
+ * The ZITADEL app id of Archon's sign-in client once "user info inside the
538
+ * ID token" was switched on for it. `planArchonIdTokenUserinfo` plans the
539
+ * repair while this differs from the recorded client app id.
540
+ */
541
+ export const PLATFORM_ARCHON_ID_TOKEN_USERINFO_APPLIED_SCOPE_KEY = 'archon_id_token_userinfo_applied';
524
542
  export function appSettingsOf(entry) {
525
543
  return {
526
544
  folder: entry.folder,
@@ -1137,6 +1155,10 @@ export function planPlatform(platform, live, options = {}) {
1137
1155
  plannedSteps.push(uptime.step);
1138
1156
  if (archon.step && blocked.archonRunsInsideApp)
1139
1157
  plannedSteps.push(archon.step);
1158
+ // After Archon's own step: a repair that fails must not hold up the work before it.
1159
+ const idTokenUserinfo = planArchonIdTokenUserinfo(platform, recordedIds, blocked.archonIdTokenUserinfo ?? false);
1160
+ if (idTokenUserinfo)
1161
+ plannedSteps.push(idTokenUserinfo);
1140
1162
  // #251: one line per service whose recorded applied ref no longer matches
1141
1163
  // what the next apply would use (or, for zitadel, what is actually live).
1142
1164
  // Never pushed into `plannedSteps` -- see `VersionChangeItem`'s own doc
@@ -2156,6 +2178,32 @@ function planEdge(platform, recordedIds, executorBlocked, drifted) {
2156
2178
  blocked: null,
2157
2179
  };
2158
2180
  }
2181
+ /**
2182
+ * Archon's sign-in client, once it exists, gets "user info inside the ID
2183
+ * token" switched on: a client made before the setting existed has none, so
2184
+ * its ID token carries no `email` and the console stores none for its
2185
+ * people. One `zitadel:id-token-userinfo` step keyed {@link PLATFORM_ARCHON_KEY},
2186
+ * planned while the recorded client app id differs from
2187
+ * {@link PLATFORM_ARCHON_ID_TOKEN_USERINFO_APPLIED_SCOPE_KEY} (a client made
2188
+ * again has a new id). Not planned when the host's ZITADEL client lacks the
2189
+ * method, and not blocked either: nothing is owed until the host wires it.
2190
+ */
2191
+ function planArchonIdTokenUserinfo(platform, recordedIds, wired) {
2192
+ if (!wired || !platform.archon.enabled)
2193
+ return null;
2194
+ const appId = recordedIds[PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY];
2195
+ if (!appId || recordedIds[PLATFORM_ARCHON_ID_TOKEN_USERINFO_APPLIED_SCOPE_KEY] === appId) {
2196
+ return null;
2197
+ }
2198
+ return {
2199
+ action: { system: 'zitadel', kind: 'id-token-userinfo', key: PLATFORM_ARCHON_KEY, desired: {} },
2200
+ step: {
2201
+ description: "switch on user info in the ID token for Archon's sign-in client",
2202
+ destroys: false,
2203
+ migrates: false,
2204
+ },
2205
+ };
2206
+ }
2159
2207
  /**
2160
2208
  * #604: the platform's ZITADEL as a sign-in provider in Cloudflare Access: a
2161
2209
  * ZITADEL client for Cloudflare, then edge's `ensureProvider`. One
@@ -66,7 +66,23 @@ export interface PlatformZitadelSmtp {
66
66
  changed: boolean;
67
67
  }>;
68
68
  }
69
+ /**
70
+ * Where ZITADEL's SMTP provider sends mail, instead of Lettermint's relay
71
+ * (`smtp.lettermint.co:587`, STARTTLS, user `lettermint`).
72
+ */
73
+ export interface PlatformAuthSmtp {
74
+ host: string;
75
+ /** An integer from 1 to 65535. */
76
+ port: number;
77
+ /** ZITADEL's "TLS" flag for the connection. */
78
+ tls: boolean;
79
+ user: string;
80
+ /** Resolves the SMTP password. Absent, the broker's mail key (held, else the broker's env) is the password. */
81
+ password?: () => Promise<string>;
82
+ }
69
83
  export interface PlatformAuthDeps {
84
+ /** Overrides the Lettermint SMTP relay ZITADEL is given. Absent, Lettermint exactly as before. */
85
+ smtp?: PlatformAuthSmtp;
70
86
  coolify: Pick<CoolifyPort, 'findProjectByName' | 'createProject' | 'findOrCreateEnvironment' | 'listServers' | 'findApplicationByName' | 'getApplication' | 'requireGithubApp' | 'createGithubAppApplication' | 'createDockerImageApplication' | 'createDockerfileApplication' | 'createPostgresDatabase' | 'getDatabase' | 'bulkUpsertEnvs' | 'listEnvs' | 'updateApplication' | 'deploy' | 'raw'>;
71
87
  /** F4.dns: every DNS record call, through edge at its recorded address (see {@link PlatformDnsFactory}). */
72
88
  dns: PlatformDnsFactory;
@@ -319,6 +335,7 @@ export declare class FoundryPlatformStepExecutor implements PlatformStepExecutor
319
335
  };
320
336
  archonRunsInsideApp?: boolean;
321
337
  archonDeployRunning?: string;
338
+ archonIdTokenUserinfo?: boolean;
322
339
  }>;
323
340
  /**
324
341
  * foundry#542: why the uptime Worker cannot run yet, which optional secrets
@@ -516,7 +533,7 @@ export interface PlatformArchonDeps {
516
533
  access: PlatformAccessFactory;
517
534
  cloudflare: Pick<CloudflarePort, 'findZoneByApex'>;
518
535
  /** Must target the platform's own ZITADEL instance, or `zitadelForInstance` must be set. */
519
- zitadel: Pick<ZitadelPort, 'findOrCreateOrg' | 'findOrCreateProject' | 'createOidcApp' | 'listApps' | 'deleteApp'>;
536
+ zitadel: Pick<ZitadelPort, 'findOrCreateOrg' | 'findOrCreateProject' | 'createOidcApp' | 'listApps' | 'deleteApp' | 'updateOidcAppIdTokenUserinfoAssertion'>;
520
537
  /** {@link PlatformAuthDeps.zitadelForInstance}, for Archon's sign-in client. */
521
538
  zitadelForInstance?: (issuerUrl: string) => PlatformArchonDeps['zitadel'];
522
539
  /**
@@ -9,7 +9,7 @@ import { ACCESS_POLICY_NAME, getDatabaseStarted, isNotFoundError, isRunningOrSta
9
9
  import { ACCESS_PROVIDER_RECORD_KEY, GATE_CLAIM_NAME } from './gates.js';
10
10
  import { healthCheckSettings } from './health-check-settings.js';
11
11
  import { coolifyDomains, coolifyTunnelDomains, filesShareHostname } from './hostnames.js';
12
- import { AUTH_ZITADEL_RUN_OPTIONS, JOBS_DEPLOY_PENDING_SCOPE_KEY, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_ACCESS_PROVIDER_APPLIED_AUTH_DOMAIN_SCOPE_KEY, PLATFORM_ACCESS_PROVIDER_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ACCESS_PROVIDER_UNDELIVERED_SCOPE_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_APPLIED_OPERATOR_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_COOLIFY_READ_TOKEN_REF, PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY, PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_ARCHON_UPTIME_TOKEN_SYNCED_SCOPE_KEY, 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_OWNER_INVITE_SCOPE_KEY, PLATFORM_AUTH_SMTP_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_EDGE_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY, PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY, 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_HC_API_KEY_REF, PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_HC_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_HC_KEY, PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_PREVIOUS_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_REQUESTED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_RUNNER_KEYRING_DEPLOYED_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, PLATFORM_UPTIME_ACCESS_CLIENT_ID_REF, PLATFORM_UPTIME_ACCESS_CLIENT_SECRET_REF, PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_ACCESS_TOKEN_NAME, PLATFORM_UPTIME_ACCESS_UNDELIVERED_SCOPE_KEY, PLATFORM_UPTIME_HEARTBEAT_CHECK_NAME, PLATFORM_UPTIME_KEY, PLATFORM_UPTIME_REMOVED_SCOPE_KEY, PLATFORM_UPTIME_SCRIPT_NAME, PLATFORM_UPTIME_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_TOKEN_REF, archonBootstrapOperator, desiredHeartbeatCheckNames, heartbeatCheckUrlRef, parseBrokerDeployRecord, parseLoginClientRecord, platformBackupHeartbeatCheckName, platformMailMxHost, uptimeAccessTokenConflict, uptimeRecorded, uptimeVarsDigest, } from './platform-diff.js';
12
+ import { AUTH_ZITADEL_RUN_OPTIONS, JOBS_DEPLOY_PENDING_SCOPE_KEY, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_ACCESS_PROVIDER_APPLIED_AUTH_DOMAIN_SCOPE_KEY, PLATFORM_ACCESS_PROVIDER_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ACCESS_PROVIDER_UNDELIVERED_SCOPE_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_APPLIED_OPERATOR_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_COOLIFY_READ_TOKEN_REF, PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY, PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_ID_TOKEN_USERINFO_APPLIED_SCOPE_KEY, PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_ARCHON_UPTIME_TOKEN_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY, 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_OWNER_INVITE_SCOPE_KEY, PLATFORM_AUTH_SMTP_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_EDGE_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY, PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY, 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_HC_API_KEY_REF, PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_HC_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_HC_KEY, PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_PREVIOUS_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_REQUESTED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_RUNNER_KEYRING_DEPLOYED_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, PLATFORM_UPTIME_ACCESS_CLIENT_ID_REF, PLATFORM_UPTIME_ACCESS_CLIENT_SECRET_REF, PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_ACCESS_TOKEN_NAME, PLATFORM_UPTIME_ACCESS_UNDELIVERED_SCOPE_KEY, PLATFORM_UPTIME_HEARTBEAT_CHECK_NAME, PLATFORM_UPTIME_KEY, PLATFORM_UPTIME_REMOVED_SCOPE_KEY, PLATFORM_UPTIME_SCRIPT_NAME, PLATFORM_UPTIME_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_TOKEN_REF, archonBootstrapOperator, desiredHeartbeatCheckNames, heartbeatCheckUrlRef, parseBrokerDeployRecord, parseLoginClientRecord, platformBackupHeartbeatCheckName, platformMailMxHost, uptimeAccessTokenConflict, uptimeRecorded, uptimeVarsDigest, } from './platform-diff.js';
13
13
  import { applyCallsSfuPlatformStep } from './platform-sfu.js';
14
14
  import { heldUptimeValues, provisionPlatformUptime, } from './uptime-worker.js';
15
15
  import { CreateInstanceError, createZitadelInstance, generateSystemApiKeypair, instanceWithoutBearerMessage, systemApiUsersEnv, } from './zitadel-system-api.js';
@@ -350,6 +350,21 @@ export class FoundryPlatformStepExecutor {
350
350
  }
351
351
  }
352
352
  }
353
+ // Not a block: without the method the planner plans no repair step for Archon's sign-in client.
354
+ if (this.archon &&
355
+ platform.archon.enabled &&
356
+ recordedIds[PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY]) {
357
+ try {
358
+ const zitadel = this.archon.zitadelForInstance?.(`https://${platform.auth.hostname}`) ??
359
+ this.archon.zitadel;
360
+ if (zitadel.updateOidcAppIdTokenUserinfoAssertion)
361
+ blocked.archonIdTokenUserinfo = true;
362
+ }
363
+ catch (e) {
364
+ // No ZITADEL client for this instance: the flag stays unset and the repair is not planned.
365
+ console.error(`platform:archon: could not reach the platform's ZITADEL client to plan the ID token repair: ${e instanceof Error ? e.message : String(e)}`);
366
+ }
367
+ }
353
368
  // Not a block: tells the planner to order Archon's step last (`PlanPlatformOptions.blocked.archonRunsInsideApp`).
354
369
  if (this.archon?.runsInsideApp) {
355
370
  blocked.archonRunsInsideApp = true;
@@ -582,6 +597,14 @@ export class FoundryPlatformStepExecutor {
582
597
  }
583
598
  return provisionPlatformHc(context, this.hc);
584
599
  }
600
+ if (action.system === 'zitadel' &&
601
+ action.kind === 'id-token-userinfo' &&
602
+ action.key === PLATFORM_ARCHON_KEY) {
603
+ if (!this.archon) {
604
+ throw new Error('platform:archon needs FoundryPlatformStepExecutor to be constructed with its archon dependencies (#175)');
605
+ }
606
+ return switchOnArchonIdTokenUserinfo(context, this.archon);
607
+ }
585
608
  if (action.system === 'cloudflare' && action.kind === 'access-provider') {
586
609
  if (!this.accessProvider) {
587
610
  throw new Error('cloudflare:access-provider needs FoundryPlatformStepExecutor to be constructed with its Access provider dependencies (#604)');
@@ -1725,25 +1748,29 @@ async function provisionPlatformAuth(context, deps, owners = []) {
1725
1748
  // The token is the held key, else the broker's live env. Absent, nothing
1726
1749
  // here can run; `authBrokerBlockedReason` already reports it by name.
1727
1750
  const mailToken = heldMailKey || (await currentEnvValue(deps.coolify, brokerAppId, 'LETTERMINT_API_TOKEN'));
1751
+ const smtpTarget = resolveAuthSmtp(deps.smtp);
1728
1752
  if (mailToken) {
1729
1753
  const mailZitadel = deps.zitadelForInstance?.(`https://${hostname}`) ?? deps.zitadel;
1754
+ let smtpPassword;
1730
1755
  try {
1731
1756
  // Runs every time the step does (idempotent), so a rotated token lands.
1757
+ smtpPassword = (await deps.smtp?.password?.()) ?? mailToken;
1732
1758
  const smtp = await mailZitadel.ensureSmtpProvider({
1733
- host: PLATFORM_AUTH_SMTP_HOST,
1734
- user: PLATFORM_AUTH_SMTP_USER,
1735
- password: mailToken,
1759
+ host: smtpTarget.host,
1760
+ user: smtpTarget.user,
1761
+ password: smtpPassword,
1736
1762
  senderAddress: `noreply@${platform.apex}`,
1737
1763
  senderName: PLATFORM_AUTH_MAIL_SENDER_NAME,
1738
- tls: true,
1764
+ tls: smtpTarget.tls,
1739
1765
  description: PLATFORM_AUTH_SMTP_DESCRIPTION,
1740
1766
  });
1741
1767
  await record(PLATFORM_AUTH_SMTP_SCOPE_KEY, smtp.id);
1742
1768
  }
1743
1769
  catch (error) {
1744
- // The cause, with the Lettermint key redacted, so a failure says why.
1770
+ // The cause, with the Lettermint key (and an overriding SMTP password) redacted, so a failure says why.
1745
1771
  const cause = redactSecrets(error instanceof Error ? error.message : String(error), [
1746
1772
  mailToken,
1773
+ ...(smtpPassword ? [smtpPassword] : []),
1747
1774
  ]);
1748
1775
  throw new Error(`platform:auth: could not configure ZITADEL's SMTP provider for ${hostname}: ${cause}`);
1749
1776
  }
@@ -1773,6 +1800,7 @@ async function provisionPlatformAuth(context, deps, owners = []) {
1773
1800
  owner.email,
1774
1801
  owner.email.toLowerCase(),
1775
1802
  mailToken,
1803
+ ...(smtpPassword ? [smtpPassword] : []),
1776
1804
  ]);
1777
1805
  throw new Error(`platform:auth: could not invite the platform owner into ZITADEL org ${ownerOrgId}: ${cause}`);
1778
1806
  }
@@ -1785,6 +1813,29 @@ async function provisionPlatformAuth(context, deps, owners = []) {
1785
1813
  /** Lettermint's SMTP relay, as ZITADEL's SMTP provider wants it: `host:port`, STARTTLS. */
1786
1814
  const PLATFORM_AUTH_SMTP_HOST = 'smtp.lettermint.co:587';
1787
1815
  const PLATFORM_AUTH_SMTP_USER = 'lettermint';
1816
+ /**
1817
+ * The SMTP target for ZITADEL's provider: Lettermint's, or the host's override
1818
+ * once validated. Throws a message that never holds the password.
1819
+ */
1820
+ function resolveAuthSmtp(override) {
1821
+ if (!override) {
1822
+ return { host: PLATFORM_AUTH_SMTP_HOST, user: PLATFORM_AUTH_SMTP_USER, tls: true };
1823
+ }
1824
+ const { host, port, tls, user } = override;
1825
+ if (typeof host !== 'string' || !host.trim() || /[\s/:]/.test(host)) {
1826
+ throw new Error('platform:auth: smtp.host must be a non-empty host name with no port or path');
1827
+ }
1828
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
1829
+ throw new Error('platform:auth: smtp.port must be an integer from 1 to 65535');
1830
+ }
1831
+ if (typeof tls !== 'boolean') {
1832
+ throw new Error('platform:auth: smtp.tls must be true or false');
1833
+ }
1834
+ if (typeof user !== 'string' || !user.trim()) {
1835
+ throw new Error('platform:auth: smtp.user must be non-empty');
1836
+ }
1837
+ return { host: `${host}:${port}`, user, tls };
1838
+ }
1788
1839
  /** Marks the provider as foundry's, so `ensureSmtpProvider` finds it again. */
1789
1840
  const PLATFORM_AUTH_SMTP_DESCRIPTION = 'foundry:platform:lettermint';
1790
1841
  /** The same name the broker's `from` uses (`Company Included <noreply@apex>`). */
@@ -2636,11 +2687,44 @@ const PLATFORM_ARCHON_ACCESS_SCOPE_KEY = 'cloudflare_platform_archon_access_app'
2636
2687
  /** The platform's own ZITADEL org (`foundry`, the one `provisionPlatformAuth` mints its admin user in). */
2637
2688
  export const PLATFORM_ZITADEL_ORG_SCOPE_KEY = 'zitadel_platform_org';
2638
2689
  const PLATFORM_ARCHON_ZITADEL_PROJECT_SCOPE_KEY = 'zitadel_platform_archon_project';
2639
- const PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY = 'zitadel_platform_archon_app';
2640
2690
  /** The hostname the sign-in client's redirect URIs were made for; a change makes a new client. */
2641
2691
  const PLATFORM_ARCHON_CLIENT_HOSTNAME_SCOPE_KEY = 'zitadel_platform_archon_client_hostname';
2642
2692
  /** Archon's OIDC client id: public, not a secret, so a plain scope id. */
2643
2693
  export const PLATFORM_ARCHON_CLIENT_ID_SCOPE_KEY = 'zitadel_platform_archon_client_id';
2694
+ /**
2695
+ * Switches on "user info inside the ID token" for Archon's sign-in client
2696
+ * (`zitadel:id-token-userinfo` keyed {@link PLATFORM_ARCHON_KEY}). The marker
2697
+ * is the client's app id, recorded only after the call returned, so a call
2698
+ * that throws leaves none and the next plan names the step again. The error
2699
+ * names the ids only, never the cause.
2700
+ */
2701
+ async function switchOnArchonIdTokenUserinfo(context, deps) {
2702
+ const { platform } = context;
2703
+ const recorded = context.recordedIds();
2704
+ const zitadel = deps.zitadelForInstance?.(`https://${platform.auth.hostname}`) ?? deps.zitadel;
2705
+ // The planner plans nothing without the method; a stale plan does nothing.
2706
+ if (!zitadel.updateOidcAppIdTokenUserinfoAssertion)
2707
+ return {};
2708
+ const orgId = recorded[PLATFORM_ZITADEL_ORG_SCOPE_KEY];
2709
+ const projectId = recorded[PLATFORM_ARCHON_ZITADEL_PROJECT_SCOPE_KEY];
2710
+ const appId = recorded[PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY];
2711
+ if (!orgId || !projectId || !appId) {
2712
+ throw new Error('platform:archon: the sign-in client is not recorded yet -- cannot switch on user info in its ID token');
2713
+ }
2714
+ let done;
2715
+ try {
2716
+ done = await zitadel.updateOidcAppIdTokenUserinfoAssertion(orgId, projectId, appId);
2717
+ }
2718
+ catch {
2719
+ throw new Error(`platform:archon: switching on user info in the ID token failed for the sign-in client ${appId}`);
2720
+ }
2721
+ // A client that answers every property (`packages/local`) resolves to something else: no marker.
2722
+ if (typeof done !== 'boolean')
2723
+ return {};
2724
+ context.recordScopeId(PLATFORM_ARCHON_ID_TOKEN_USERINFO_APPLIED_SCOPE_KEY, appId);
2725
+ await context.persist?.();
2726
+ return {};
2727
+ }
2644
2728
  /**
2645
2729
  * #175: makes `coolify:app` keyed {@link PLATFORM_ARCHON_KEY} true: Archon
2646
2730
  * from https://github.com/wtfalch/archon on `platform.archon.gitRef`, its own
@@ -2916,6 +3000,8 @@ async function provisionPlatformArchon(context, deps) {
2916
3000
  postLogoutRedirectUris: [`https://${hostname}/`],
2917
3001
  });
2918
3002
  context.recordScopeId(PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY, created.appId);
3003
+ // Made with the setting on (providers 0.9.0): nothing to repair on this id.
3004
+ context.recordScopeId(PLATFORM_ARCHON_ID_TOKEN_USERINFO_APPLIED_SCOPE_KEY, created.appId);
2919
3005
  context.recordScopeId(PLATFORM_ARCHON_CLIENT_HOSTNAME_SCOPE_KEY, hostname);
2920
3006
  await record(PLATFORM_ARCHON_CLIENT_ID_SCOPE_KEY, created.clientId);
2921
3007
  clientId = created.clientId;
@@ -4,6 +4,8 @@ import { type ZitadelClient } from '@wtfalch/providers/zitadel';
4
4
  import type { OrgId } from '../org.js';
5
5
  import type { DeclaredOrgStore } from '../store.js';
6
6
  import type { AiReader, CloudflareReader, CmsReader, CoolifyReader, LiveResource, ZitadelReader } from './live.js';
7
+ /** The `CoolifyClient` methods the readers call, so a host's own implementation needs no cast. */
8
+ type CoolifyReaderClient = Pick<CoolifyClient, 'listResources' | 'getApplication' | 'getDatabase' | 'listEnvs' | 'listStorages'>;
7
9
  /**
8
10
  * Real `CoolifyReader`, `CloudflareReader` and `ZitadelReader` implementations
9
11
  * (`live.ts:31-45`), backed by `@wtfalch/providers`' HTTP clients instead of
@@ -94,7 +96,7 @@ export declare class ProvidersCoolifyReader implements CoolifyReader {
94
96
  private readonly client;
95
97
  private readonly resolveScope;
96
98
  private readonly resolveRecordedIds;
97
- constructor(client: CoolifyClient, resolveScope: ScopeResolver, resolveRecordedIds: RecordedIds);
99
+ constructor(client: CoolifyReaderClient, resolveScope: ScopeResolver, resolveRecordedIds: RecordedIds);
98
100
  listResources(orgId: OrgId): Promise<LiveResource[]>;
99
101
  /** The project's resources as Coolify reports them, unmapped: what adopt checks against a declaration. */
100
102
  listProject(projectUuid: string): Promise<LiveResource[]>;
@@ -229,6 +231,7 @@ export declare class ProvidersCmsReader implements CmsReader {
229
231
  private readonly resolveProjectScope;
230
232
  private readonly resolveCmsAppId;
231
233
  private readonly resolveSiteAppId;
232
- constructor(client: CoolifyClient, resolveProjectScope: ScopeResolver, resolveCmsAppId: ScopeResolver, resolveSiteAppId: ScopeResolver);
234
+ constructor(client: CoolifyReaderClient, resolveProjectScope: ScopeResolver, resolveCmsAppId: ScopeResolver, resolveSiteAppId: ScopeResolver);
233
235
  listResources(orgId: OrgId): Promise<LiveResource[]>;
234
236
  }
237
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.26.4",
3
+ "version": "0.27.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",