@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.
@@ -99,6 +99,11 @@ export declare function versionNotAppliedLine(service: string, appLive: boolean,
99
99
  * the same `platform:<service>` shape as {@link PLATFORM_AUTH_KEY}.
100
100
  */
101
101
  export declare const PLATFORM_ARCHON_KEY = "platform:archon";
102
+ /**
103
+ * The fingerprint (`callsSfuAppliedFingerprint`) of the last successful `sfu`
104
+ * apply. `planSfu` plans the step again when it differs from the desired one.
105
+ */
106
+ export declare const PLATFORM_SFU_APPLIED_SCOPE_KEY = "sfu_applied_fingerprint";
102
107
  /**
103
108
  * #175: the hostname and `gitRef` the last successful Archon run applied.
104
109
  * `planArchon` plans the step again when either differs from the declared
@@ -472,6 +477,22 @@ export declare function dbEnvAppliedScopeKey(entryKey: string): string;
472
477
  * service's own marker, and vice versa.
473
478
  */
474
479
  export declare function serviceDbEnvAppliedScopeKey(service: 'chat' | 'calls-service'): string;
480
+ /**
481
+ * The ZITADEL app id of company app `appKey` once "user info inside the ID
482
+ * token" was switched on for it (`updateOidcAppIdTokenUserinfoAssertion`).
483
+ * `diff.ts` plans one step for the org while any live sign-in app's recorded
484
+ * id differs from its marker, so an app made before the setting existed, or
485
+ * made again under a new id, is repaired once.
486
+ */
487
+ export declare function zitadelAppIdTokenUserinfoAppliedScopeKey(appKey: string): string;
488
+ /** The ZITADEL app id of Archon's sign-in client in the platform's own ZITADEL. */
489
+ export declare const PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY = "zitadel_platform_archon_app";
490
+ /**
491
+ * The ZITADEL app id of Archon's sign-in client once "user info inside the
492
+ * ID token" was switched on for it. `planArchonIdTokenUserinfo` plans the
493
+ * repair while this differs from the recorded client app id.
494
+ */
495
+ export declare const PLATFORM_ARCHON_ID_TOKEN_USERINFO_APPLIED_SCOPE_KEY = "archon_id_token_userinfo_applied";
475
496
  /**
476
497
  * The settings `coolifyApp` owns on an entry's app (`folder`, `port`,
477
498
  * `autoDeploy`) as the JSON the marker below holds. `coolifyApp` records it
@@ -906,6 +927,11 @@ export interface PlanPlatformOptions {
906
927
  jobsObservable?: boolean;
907
928
  /** Why the shared SFU is blocked, or `null` when it is not (default `null`). */
908
929
  sfuBlocked?: string | null;
930
+ /**
931
+ * The slugs of orgs with `calls.enabled`. With it, `planSfu` plans the step
932
+ * only when the recorded fingerprint differs; absent, it always plans it.
933
+ */
934
+ callsOrgSlugs?: readonly string[];
909
935
  /** The platform's recorded resource/scope ids (default `{}`). */
910
936
  recordedIds?: Readonly<Record<string, string>>;
911
937
  /** Per-step block reasons the executor reports (default `{}`). */
@@ -988,6 +1014,12 @@ export interface PlanPlatformOptions {
988
1014
  };
989
1015
  /** Why Archon's step must wait: the deployment it triggered is still running. Blocks even an applied step. */
990
1016
  archonDeployRunning?: string;
1017
+ /**
1018
+ * The Archon sign-in client's ZITADEL client has
1019
+ * `updateOidcAppIdTokenUserinfoAssertion`. Not a block: without it the
1020
+ * repair step is not planned.
1021
+ */
1022
+ archonIdTokenUserinfo?: boolean;
991
1023
  };
992
1024
  /**
993
1025
  * Steps whose live state differs from the declared one in a way the recorded
@@ -4,6 +4,7 @@ import { APP_ENTRY_KEY } from '../org.js';
4
4
  import { VERSION_CHANGE_KIND, } from '../plan.js';
5
5
  import { ACCESS_PROVIDER_RECORD_KEY } from './gates.js';
6
6
  import { legacyAppHostname } from './hostnames.js';
7
+ import { callsSfuAppliedFingerprint } from './platform-sfu.js';
7
8
  import { withStepSystem } from './step-system.js';
8
9
  /**
9
10
  * The `Plan` (#19's public contract) a {@link PlatformPlanDetail} carries,
@@ -122,6 +123,11 @@ export function versionNotAppliedLine(service, appLive, applied, pinned) {
122
123
  * the same `platform:<service>` shape as {@link PLATFORM_AUTH_KEY}.
123
124
  */
124
125
  export const PLATFORM_ARCHON_KEY = 'platform:archon';
126
+ /**
127
+ * The fingerprint (`callsSfuAppliedFingerprint`) of the last successful `sfu`
128
+ * apply. `planSfu` plans the step again when it differs from the desired one.
129
+ */
130
+ export const PLATFORM_SFU_APPLIED_SCOPE_KEY = 'sfu_applied_fingerprint';
125
131
  /**
126
132
  * #175: the hostname and `gitRef` the last successful Archon run applied.
127
133
  * `planArchon` plans the step again when either differs from the declared
@@ -521,6 +527,24 @@ export function dbEnvAppliedScopeKey(entryKey) {
521
527
  export function serviceDbEnvAppliedScopeKey(service) {
522
528
  return `service_db_env_applied.${service}`;
523
529
  }
530
+ /**
531
+ * The ZITADEL app id of company app `appKey` once "user info inside the ID
532
+ * token" was switched on for it (`updateOidcAppIdTokenUserinfoAssertion`).
533
+ * `diff.ts` plans one step for the org while any live sign-in app's recorded
534
+ * id differs from its marker, so an app made before the setting existed, or
535
+ * made again under a new id, is repaired once.
536
+ */
537
+ export function zitadelAppIdTokenUserinfoAppliedScopeKey(appKey) {
538
+ return `zitadel_app_id_token_userinfo_applied.${appKey}`;
539
+ }
540
+ /** The ZITADEL app id of Archon's sign-in client in the platform's own ZITADEL. */
541
+ export const PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY = 'zitadel_platform_archon_app';
542
+ /**
543
+ * The ZITADEL app id of Archon's sign-in client once "user info inside the
544
+ * ID token" was switched on for it. `planArchonIdTokenUserinfo` plans the
545
+ * repair while this differs from the recorded client app id.
546
+ */
547
+ export const PLATFORM_ARCHON_ID_TOKEN_USERINFO_APPLIED_SCOPE_KEY = 'archon_id_token_userinfo_applied';
524
548
  export function appSettingsOf(entry) {
525
549
  return {
526
550
  folder: entry.folder,
@@ -1038,7 +1062,7 @@ export function uptimeRecorded(recordedIds) {
1038
1062
  * unchanged).
1039
1063
  */
1040
1064
  export function planPlatform(platform, live, options = {}) {
1041
- const { jobsObservable = true, sfuBlocked = null, recordedIds = {}, blocked = {}, drifted = {}, } = options;
1065
+ const { jobsObservable = true, sfuBlocked = null, callsOrgSlugs, recordedIds = {}, blocked = {}, drifted = {}, } = options;
1042
1066
  const byKey = new Map(live.map((r) => [resourceKey(r.system, r.kind, r.key), r]));
1043
1067
  const claimed = new Set();
1044
1068
  const plannedSteps = [];
@@ -1053,7 +1077,7 @@ export function planPlatform(platform, live, options = {}) {
1053
1077
  // A blocked SFU is reported in `unmanaged`, not planned: a step the
1054
1078
  // executor must refuse would halt every step after it (`diff.ts` does the
1055
1079
  // same for a blocked Agora).
1056
- const sfu = platform.sfu.enabled && sfuBlocked ? null : planSfu(platform);
1080
+ const sfu = platform.sfu.enabled && sfuBlocked ? null : planSfu(platform, callsOrgSlugs, recordedIds);
1057
1081
  if (sfu)
1058
1082
  plannedSteps.push(sfu);
1059
1083
  // #478, F4.dns: first of the services, ahead of auth. Edge is the only
@@ -1137,6 +1161,10 @@ export function planPlatform(platform, live, options = {}) {
1137
1161
  plannedSteps.push(uptime.step);
1138
1162
  if (archon.step && blocked.archonRunsInsideApp)
1139
1163
  plannedSteps.push(archon.step);
1164
+ // After Archon's own step: a repair that fails must not hold up the work before it.
1165
+ const idTokenUserinfo = planArchonIdTokenUserinfo(platform, recordedIds, blocked.archonIdTokenUserinfo ?? false);
1166
+ if (idTokenUserinfo)
1167
+ plannedSteps.push(idTokenUserinfo);
1140
1168
  // #251: one line per service whose recorded applied ref no longer matches
1141
1169
  // what the next apply would use (or, for zitadel, what is actually live).
1142
1170
  // Never pushed into `plannedSteps` -- see `VersionChangeItem`'s own doc
@@ -1483,20 +1511,29 @@ function planBackups(byKey, claimed, recordedIds, executorMissing, storageDrifte
1483
1511
  /**
1484
1512
  * The platform's shared calls SFU (#170), keyed `sfu:livekit:platform` --
1485
1513
  * there is exactly one, so `key` names the platform itself rather than a
1486
- * domain or an org id, the way `LiveResource`'s own doc comment explains for
1487
- * this system. Unlike {@link planApexZone}, this step is never matched
1488
- * against a live resource: there is no cheap way to read the SFU VM's own
1489
- * state over SSH during planning (`LiveResource`'s doc comment on the `sfu`
1490
- * system), so the step is planned whenever `platform.sfu.enabled` is true,
1491
- * full stop. That is safe rather than wasteful: `applyCallsSfu`
1492
- * (`calls-sfu.ts`'s own top doc comment) is built to be re-run, so an apply
1493
- * that runs this step again writes the same files and runs the same
1494
- * idempotent `docker compose`/`ufw` commands, changing nothing on a VM
1495
- * already caught up.
1496
- */
1497
- function planSfu(platform) {
1514
+ * domain or an org id. It is never matched against a live resource: there is
1515
+ * no cheap way to read the SFU VM's own state over SSH during planning
1516
+ * (`LiveResource`'s doc comment on the `sfu` system). Instead the executor
1517
+ * records {@link callsSfuAppliedFingerprint} after a successful apply, and
1518
+ * with `callsOrgSlugs` the step is planned only when that record differs
1519
+ * from the desired one (new org set, changed calls files, new
1520
+ * `sfu.connectionRevision`). Without `callsOrgSlugs` it is planned whenever
1521
+ * `platform.sfu.enabled` is true. Planning it needlessly is safe:
1522
+ * `applyCallsSfu` is built to be re-run.
1523
+ */
1524
+ function planSfu(platform, callsOrgSlugs, recordedIds) {
1498
1525
  if (!platform.sfu.enabled)
1499
1526
  return null;
1527
+ if (callsOrgSlugs &&
1528
+ recordedIds[PLATFORM_SFU_APPLIED_SCOPE_KEY] ===
1529
+ callsSfuAppliedFingerprint({
1530
+ orgSlugs: callsOrgSlugs,
1531
+ ...(platform.sfu.connectionRevision
1532
+ ? { connectionRevision: platform.sfu.connectionRevision }
1533
+ : {}),
1534
+ })) {
1535
+ return null;
1536
+ }
1500
1537
  const action = {
1501
1538
  system: 'sfu',
1502
1539
  kind: 'livekit',
@@ -2156,6 +2193,32 @@ function planEdge(platform, recordedIds, executorBlocked, drifted) {
2156
2193
  blocked: null,
2157
2194
  };
2158
2195
  }
2196
+ /**
2197
+ * Archon's sign-in client, once it exists, gets "user info inside the ID
2198
+ * token" switched on: a client made before the setting existed has none, so
2199
+ * its ID token carries no `email` and the console stores none for its
2200
+ * people. One `zitadel:id-token-userinfo` step keyed {@link PLATFORM_ARCHON_KEY},
2201
+ * planned while the recorded client app id differs from
2202
+ * {@link PLATFORM_ARCHON_ID_TOKEN_USERINFO_APPLIED_SCOPE_KEY} (a client made
2203
+ * again has a new id). Not planned when the host's ZITADEL client lacks the
2204
+ * method, and not blocked either: nothing is owed until the host wires it.
2205
+ */
2206
+ function planArchonIdTokenUserinfo(platform, recordedIds, wired) {
2207
+ if (!wired || !platform.archon.enabled)
2208
+ return null;
2209
+ const appId = recordedIds[PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY];
2210
+ if (!appId || recordedIds[PLATFORM_ARCHON_ID_TOKEN_USERINFO_APPLIED_SCOPE_KEY] === appId) {
2211
+ return null;
2212
+ }
2213
+ return {
2214
+ action: { system: 'zitadel', kind: 'id-token-userinfo', key: PLATFORM_ARCHON_KEY, desired: {} },
2215
+ step: {
2216
+ description: "switch on user info in the ID token for Archon's sign-in client",
2217
+ destroys: false,
2218
+ migrates: false,
2219
+ },
2220
+ };
2221
+ }
2159
2222
  /**
2160
2223
  * #604: the platform's ZITADEL as a sign-in provider in Cloudflare Access: a
2161
2224
  * ZITADEL client for Cloudflare, then edge's `ensureProvider`. One
@@ -79,10 +79,9 @@ export interface OrgPlatformReadiness {
79
79
  aiUrl?: string | null;
80
80
  /**
81
81
  * The shared calls SFU (#170) an org's own calls Matrix stack depends on.
82
- * Declared is enough here: unlike the others, `platform-diff.ts`'s
83
- * `planSfu` never diffs the SFU against a live resource either (its own
84
- * doc comment: "there is no cheap way to read the SFU VM's own state over
85
- * SSH during planning"), so "applied" has no separate signal to check.
82
+ * Declared is enough here: `platform-diff.ts`'s `planSfu` plans the step
83
+ * from a recorded fingerprint, which is no signal an org's plan could
84
+ * read, so "applied" has no separate check.
86
85
  */
87
86
  callsReady: boolean;
88
87
  /** The platform's own sign-in (#168), which every org's own ZITADEL org lives inside. */
@@ -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
  /**
@@ -788,7 +805,7 @@ export declare function createAiManagementKeyMinter(options: {
788
805
  * `_FILE` (#209 mints and pushes this once it lands) and `LETTERMINT_API_TOKEN`
789
806
  * (#210: `wtfalch/auth#138` moved the broker's account mail onto
790
807
  * `@wtfalch/mailer`'s Lettermint transport, so this is the env name the
791
- * broker actually reads now, not `RESEND_API_TOKEN`). Read from the broker's
808
+ * broker actually reads now, not the earlier provider's token). Read from the broker's
792
809
  * own live env, not a recorded scope id: whichever of #209/#210 lands first
793
810
  * (or a human, setting either directly on the app) is enough to clear that
794
811
  * one input, in either order, with no coupling to this step's own execution.
@@ -9,8 +9,8 @@ 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';
13
- import { applyCallsSfuPlatformStep } from './platform-sfu.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_SFU_APPLIED_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
+ import { applyCallsSfuPlatformStep, callsSfuAppliedFingerprint, } 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';
16
16
  /** The key `runPlatformSteps` records the apex zone's id under (`system:kind:key`). */
@@ -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)');
@@ -621,7 +644,7 @@ export class FoundryPlatformStepExecutor {
621
644
  return provisionPlatformBackupStorage(context, this.backup);
622
645
  }
623
646
  if (action.system === 'sfu' && action.kind === 'livekit') {
624
- return this.executeSfu();
647
+ return this.executeSfu(context);
625
648
  }
626
649
  throw new Error(`platform step ${action.system}:${action.kind} is not built yet`);
627
650
  }
@@ -647,7 +670,7 @@ export class FoundryPlatformStepExecutor {
647
670
  * Sorted by slug so a re-run with the same set of enabled orgs is a no-op
648
671
  * even before `applyCallsSfuPlatformStep`'s own sort (its doc comment).
649
672
  */
650
- async executeSfu() {
673
+ async executeSfu(context) {
651
674
  if (!this.sfu) {
652
675
  throw new Error('platform step sfu:livekit is not built yet (#170: pass `sfu` to FoundryPlatformStepExecutor)');
653
676
  }
@@ -657,6 +680,13 @@ export class FoundryPlatformStepExecutor {
657
680
  .map((org) => org.slug)
658
681
  .sort();
659
682
  await applyCallsSfuPlatformStep(this.sfu, orgSlugs);
683
+ // Only after the apply succeeded: a failed apply records nothing, so the
684
+ // next plan shows the step again.
685
+ const { connectionRevision } = context.platform.sfu;
686
+ context.recordScopeId(PLATFORM_SFU_APPLIED_SCOPE_KEY, callsSfuAppliedFingerprint({
687
+ orgSlugs,
688
+ ...(connectionRevision ? { connectionRevision } : {}),
689
+ }));
660
690
  return undefined;
661
691
  }
662
692
  async coolifyJobs(action, context) {
@@ -1127,7 +1157,7 @@ async function liveLoginClientTokenHash(coolify, appId) {
1127
1157
  * `IAM_LOGIN_CLIENT` machine user (never the admin one, which is
1128
1158
  * `IAM_OWNER`) and delivers its token to both apps, once the broker's
1129
1159
  * other required inputs are already met.
1130
- * - ~~The broker never receives `RESEND_API_TOKEN` either, so it will not
1160
+ * - ~~The broker never receives the earlier mail provider's token either, so it will not
1131
1161
  * start on `AUTH_APPS` alone.~~ Closed by #210, once `wtfalch/auth#138`
1132
1162
  * moved the broker's account mail onto `@wtfalch/mailer`'s Lettermint
1133
1163
  * transport: this function now reads the key back from
@@ -1725,25 +1755,29 @@ async function provisionPlatformAuth(context, deps, owners = []) {
1725
1755
  // The token is the held key, else the broker's live env. Absent, nothing
1726
1756
  // here can run; `authBrokerBlockedReason` already reports it by name.
1727
1757
  const mailToken = heldMailKey || (await currentEnvValue(deps.coolify, brokerAppId, 'LETTERMINT_API_TOKEN'));
1758
+ const smtpTarget = resolveAuthSmtp(deps.smtp);
1728
1759
  if (mailToken) {
1729
1760
  const mailZitadel = deps.zitadelForInstance?.(`https://${hostname}`) ?? deps.zitadel;
1761
+ let smtpPassword;
1730
1762
  try {
1731
1763
  // Runs every time the step does (idempotent), so a rotated token lands.
1764
+ smtpPassword = (await deps.smtp?.password?.()) ?? mailToken;
1732
1765
  const smtp = await mailZitadel.ensureSmtpProvider({
1733
- host: PLATFORM_AUTH_SMTP_HOST,
1734
- user: PLATFORM_AUTH_SMTP_USER,
1735
- password: mailToken,
1766
+ host: smtpTarget.host,
1767
+ user: smtpTarget.user,
1768
+ password: smtpPassword,
1736
1769
  senderAddress: `noreply@${platform.apex}`,
1737
1770
  senderName: PLATFORM_AUTH_MAIL_SENDER_NAME,
1738
- tls: true,
1771
+ tls: smtpTarget.tls,
1739
1772
  description: PLATFORM_AUTH_SMTP_DESCRIPTION,
1740
1773
  });
1741
1774
  await record(PLATFORM_AUTH_SMTP_SCOPE_KEY, smtp.id);
1742
1775
  }
1743
1776
  catch (error) {
1744
- // The cause, with the Lettermint key redacted, so a failure says why.
1777
+ // The cause, with the Lettermint key (and an overriding SMTP password) redacted, so a failure says why.
1745
1778
  const cause = redactSecrets(error instanceof Error ? error.message : String(error), [
1746
1779
  mailToken,
1780
+ ...(smtpPassword ? [smtpPassword] : []),
1747
1781
  ]);
1748
1782
  throw new Error(`platform:auth: could not configure ZITADEL's SMTP provider for ${hostname}: ${cause}`);
1749
1783
  }
@@ -1773,6 +1807,7 @@ async function provisionPlatformAuth(context, deps, owners = []) {
1773
1807
  owner.email,
1774
1808
  owner.email.toLowerCase(),
1775
1809
  mailToken,
1810
+ ...(smtpPassword ? [smtpPassword] : []),
1776
1811
  ]);
1777
1812
  throw new Error(`platform:auth: could not invite the platform owner into ZITADEL org ${ownerOrgId}: ${cause}`);
1778
1813
  }
@@ -1785,6 +1820,29 @@ async function provisionPlatformAuth(context, deps, owners = []) {
1785
1820
  /** Lettermint's SMTP relay, as ZITADEL's SMTP provider wants it: `host:port`, STARTTLS. */
1786
1821
  const PLATFORM_AUTH_SMTP_HOST = 'smtp.lettermint.co:587';
1787
1822
  const PLATFORM_AUTH_SMTP_USER = 'lettermint';
1823
+ /**
1824
+ * The SMTP target for ZITADEL's provider: Lettermint's, or the host's override
1825
+ * once validated. Throws a message that never holds the password.
1826
+ */
1827
+ function resolveAuthSmtp(override) {
1828
+ if (!override) {
1829
+ return { host: PLATFORM_AUTH_SMTP_HOST, user: PLATFORM_AUTH_SMTP_USER, tls: true };
1830
+ }
1831
+ const { host, port, tls, user } = override;
1832
+ if (typeof host !== 'string' || !host.trim() || /[\s/:]/.test(host)) {
1833
+ throw new Error('platform:auth: smtp.host must be a non-empty host name with no port or path');
1834
+ }
1835
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
1836
+ throw new Error('platform:auth: smtp.port must be an integer from 1 to 65535');
1837
+ }
1838
+ if (typeof tls !== 'boolean') {
1839
+ throw new Error('platform:auth: smtp.tls must be true or false');
1840
+ }
1841
+ if (typeof user !== 'string' || !user.trim()) {
1842
+ throw new Error('platform:auth: smtp.user must be non-empty');
1843
+ }
1844
+ return { host: `${host}:${port}`, user, tls };
1845
+ }
1788
1846
  /** Marks the provider as foundry's, so `ensureSmtpProvider` finds it again. */
1789
1847
  const PLATFORM_AUTH_SMTP_DESCRIPTION = 'foundry:platform:lettermint';
1790
1848
  /** The same name the broker's `from` uses (`Company Included <noreply@apex>`). */
@@ -1978,8 +2036,8 @@ const PLATFORM_MAIL_ZITADEL_PROJECT_NAME = 'mail';
1978
2036
  * instance-bootstrap helper `wtfalch-manage` already uses for the hand-built
1979
2037
  * `mail.wtfalch.dev`: that plan also wires an `AcmeProvider` and `DnsServer`
1980
2038
  * for Stalwart's own DNS-01 ACME challenge, and an `MtaRoute` that relays
1981
- * every outbound message through Resend -- the estate moved transactional
1982
- * mail off Resend onto Lettermint before this issue (`@wtfalch/mailer` ADR
2039
+ * every outbound message through the earlier mail provider -- the estate moved transactional
2040
+ * mail off it onto Lettermint before this issue (`@wtfalch/mailer` ADR
1983
2041
  * 0006), and this step provisions no Cloudflare DNS token for Stalwart's own
1984
2042
  * ACME either. Calling it as-is would mint an outbound relay this platform
1985
2043
  * does not want and a credential requirement this issue does not cover, so
@@ -1991,7 +2049,7 @@ const PLATFORM_MAIL_ZITADEL_PROJECT_NAME = 'mail';
1991
2049
  * William's review"):
1992
2050
  * - **No ACME/TLS for the SMTP listener.** `mx.<apex>` answers with
1993
2051
  * Stalwart's own self-signed certificate until a follow-up wires a real
1994
- * one (`mailObjects`'s `AcmeProvider`/`DnsServer` shape, minus the Resend
2052
+ * one (`mailObjects`'s `AcmeProvider`/`DnsServer` shape, minus the earlier relay
1995
2053
  * relay it also carries).
1996
2054
  * - **The Lettermint team token the org app step reads still comes from
1997
2055
  * wherever the host already reads it, not held keys.** Needs
@@ -2636,11 +2694,44 @@ const PLATFORM_ARCHON_ACCESS_SCOPE_KEY = 'cloudflare_platform_archon_access_app'
2636
2694
  /** The platform's own ZITADEL org (`foundry`, the one `provisionPlatformAuth` mints its admin user in). */
2637
2695
  export const PLATFORM_ZITADEL_ORG_SCOPE_KEY = 'zitadel_platform_org';
2638
2696
  const PLATFORM_ARCHON_ZITADEL_PROJECT_SCOPE_KEY = 'zitadel_platform_archon_project';
2639
- const PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY = 'zitadel_platform_archon_app';
2640
2697
  /** The hostname the sign-in client's redirect URIs were made for; a change makes a new client. */
2641
2698
  const PLATFORM_ARCHON_CLIENT_HOSTNAME_SCOPE_KEY = 'zitadel_platform_archon_client_hostname';
2642
2699
  /** Archon's OIDC client id: public, not a secret, so a plain scope id. */
2643
2700
  export const PLATFORM_ARCHON_CLIENT_ID_SCOPE_KEY = 'zitadel_platform_archon_client_id';
2701
+ /**
2702
+ * Switches on "user info inside the ID token" for Archon's sign-in client
2703
+ * (`zitadel:id-token-userinfo` keyed {@link PLATFORM_ARCHON_KEY}). The marker
2704
+ * is the client's app id, recorded only after the call returned, so a call
2705
+ * that throws leaves none and the next plan names the step again. The error
2706
+ * names the ids only, never the cause.
2707
+ */
2708
+ async function switchOnArchonIdTokenUserinfo(context, deps) {
2709
+ const { platform } = context;
2710
+ const recorded = context.recordedIds();
2711
+ const zitadel = deps.zitadelForInstance?.(`https://${platform.auth.hostname}`) ?? deps.zitadel;
2712
+ // The planner plans nothing without the method; a stale plan does nothing.
2713
+ if (!zitadel.updateOidcAppIdTokenUserinfoAssertion)
2714
+ return {};
2715
+ const orgId = recorded[PLATFORM_ZITADEL_ORG_SCOPE_KEY];
2716
+ const projectId = recorded[PLATFORM_ARCHON_ZITADEL_PROJECT_SCOPE_KEY];
2717
+ const appId = recorded[PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY];
2718
+ if (!orgId || !projectId || !appId) {
2719
+ throw new Error('platform:archon: the sign-in client is not recorded yet -- cannot switch on user info in its ID token');
2720
+ }
2721
+ let done;
2722
+ try {
2723
+ done = await zitadel.updateOidcAppIdTokenUserinfoAssertion(orgId, projectId, appId);
2724
+ }
2725
+ catch {
2726
+ throw new Error(`platform:archon: switching on user info in the ID token failed for the sign-in client ${appId}`);
2727
+ }
2728
+ // A client that answers every property (`packages/local`) resolves to something else: no marker.
2729
+ if (typeof done !== 'boolean')
2730
+ return {};
2731
+ context.recordScopeId(PLATFORM_ARCHON_ID_TOKEN_USERINFO_APPLIED_SCOPE_KEY, appId);
2732
+ await context.persist?.();
2733
+ return {};
2734
+ }
2644
2735
  /**
2645
2736
  * #175: makes `coolify:app` keyed {@link PLATFORM_ARCHON_KEY} true: Archon
2646
2737
  * from https://github.com/wtfalch/archon on `platform.archon.gitRef`, its own
@@ -2916,6 +3007,8 @@ async function provisionPlatformArchon(context, deps) {
2916
3007
  postLogoutRedirectUris: [`https://${hostname}/`],
2917
3008
  });
2918
3009
  context.recordScopeId(PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY, created.appId);
3010
+ // Made with the setting on (providers 0.9.0): nothing to repair on this id.
3011
+ context.recordScopeId(PLATFORM_ARCHON_ID_TOKEN_USERINFO_APPLIED_SCOPE_KEY, created.appId);
2919
3012
  context.recordScopeId(PLATFORM_ARCHON_CLIENT_HOSTNAME_SCOPE_KEY, hostname);
2920
3013
  await record(PLATFORM_ARCHON_CLIENT_ID_SCOPE_KEY, created.clientId);
2921
3014
  clientId = created.clientId;
@@ -4958,7 +5051,7 @@ async function findOrCreatePlatformApp(coolify, name, environment, create) {
4958
5051
  * `_FILE` (#209 mints and pushes this once it lands) and `LETTERMINT_API_TOKEN`
4959
5052
  * (#210: `wtfalch/auth#138` moved the broker's account mail onto
4960
5053
  * `@wtfalch/mailer`'s Lettermint transport, so this is the env name the
4961
- * broker actually reads now, not `RESEND_API_TOKEN`). Read from the broker's
5054
+ * broker actually reads now, not the earlier provider's token). Read from the broker's
4962
5055
  * own live env, not a recorded scope id: whichever of #209/#210 lands first
4963
5056
  * (or a human, setting either directly on the app) is enough to clear that
4964
5057
  * one input, in either order, with no coupling to this step's own execution.
@@ -37,6 +37,16 @@
37
37
  */
38
38
  import { type LivekitCredentials } from '../calls-sfu-keys.js';
39
39
  import { type CallsSfuSshSession } from '../calls-sfu-ssh-adapter.js';
40
+ /**
41
+ * What the last successful `sfu` apply is recorded as
42
+ * (`PLATFORM_SFU_APPLIED_SCOPE_KEY`): a SHA-256 hex digest of foundry's calls
43
+ * files, the sorted distinct org slugs and the connection revision. It holds
44
+ * no secret and does not depend on slug order or duplicates.
45
+ */
46
+ export declare function callsSfuAppliedFingerprint(input: {
47
+ orgSlugs: readonly string[];
48
+ connectionRevision?: string;
49
+ }): string;
40
50
  /** The SFU VM's own address, SSH credentials and host key fingerprint -- held keys a human stored ahead of time. */
41
51
  export interface CallsSfuConnection {
42
52
  address: string;
@@ -35,9 +35,48 @@
35
35
  * live outside this package. `calls-sfu-ssh-adapter.ts`'s {@link CallsSfuSshSession} is the narrow shape
36
36
  * a test fakes instead.
37
37
  */
38
+ import { createHash } from 'node:crypto';
38
39
  import { redactSecrets } from '../calls-sfu-deploy-support.js';
39
40
  import { mintLivekitCredentials } from '../calls-sfu-keys.js';
40
41
  import { applyCallsSfuOverSession } from '../calls-sfu-ssh-adapter.js';
42
+ import { CALLS_SFU_FIREWALL_COMMANDS, CALLS_SFU_SSH_RULE_COMMAND, CALLS_SFU_UP_COMMAND, cloudflaredEnvFile, dockerComposeYaml, livekitConfigYaml, } from '../calls-sfu.js';
43
+ /**
44
+ * Hash of what `applyCallsSfu` writes and runs, rendered from placeholder
45
+ * values so no secret is involved: a change to the compose file, the
46
+ * `livekit.yaml` template, the env file template or a command changes it.
47
+ */
48
+ function callsSfuFilesVersion() {
49
+ const placeholder = {
50
+ address: 'address',
51
+ livekitKeys: { key: 'secret' },
52
+ cloudflaredTunnelToken: 'token',
53
+ };
54
+ return createHash('sha256')
55
+ .update(JSON.stringify([
56
+ dockerComposeYaml(),
57
+ livekitConfigYaml(placeholder),
58
+ cloudflaredEnvFile(placeholder.cloudflaredTunnelToken),
59
+ CALLS_SFU_UP_COMMAND,
60
+ CALLS_SFU_SSH_RULE_COMMAND,
61
+ CALLS_SFU_FIREWALL_COMMANDS,
62
+ ]))
63
+ .digest('hex');
64
+ }
65
+ /**
66
+ * What the last successful `sfu` apply is recorded as
67
+ * (`PLATFORM_SFU_APPLIED_SCOPE_KEY`): a SHA-256 hex digest of foundry's calls
68
+ * files, the sorted distinct org slugs and the connection revision. It holds
69
+ * no secret and does not depend on slug order or duplicates.
70
+ */
71
+ export function callsSfuAppliedFingerprint(input) {
72
+ return createHash('sha256')
73
+ .update(JSON.stringify([
74
+ callsSfuFilesVersion(),
75
+ [...new Set(input.orgSlugs)].sort(),
76
+ input.connectionRevision ?? '',
77
+ ]))
78
+ .digest('hex');
79
+ }
41
80
  /**
42
81
  * The key id the platform's own LiveKit pair is held under, alongside every
43
82
  * org's own pair, in the same {@link CallsSfuKeysPort} (#152 round 2 review:
@@ -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 {};