@wtfalch/foundry 0.27.0 → 0.29.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.
@@ -3,7 +3,7 @@ import { URL } from 'node:url';
3
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
- 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';
6
+ import { BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_DATABASE_SCOPE_KEY, BOULE_DEPLOYED_SCOPE_KEY, bouleEnvDrift, bouleHostnameFor, bouleMailWanted, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
7
7
  import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
8
8
  import { MAS_IMAGE, MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
9
9
  import { CHAT_REPO, CHAT_SERVICE_DOCKERFILE, CHAT_SERVICE_PORT, CHAT_SYNAPSE_DOCKERFILE, CHAT_SYNAPSE_PORT, chatInternalNames, chatMasConfigYaml, chatServiceEnvs, chatSynapseHomeserverYaml, chatUpstreams, } from './chat-matrix.js';
@@ -354,10 +354,10 @@ const CMS_DATABASE_SCOPE_KEY = 'coolify_cms_database';
354
354
  * domain choice, the same way {@link APP_CLIENT_ID_SCOPE_KEY} carries a
355
355
  * public value out. All of that org's Lettermint work -- ensure domain,
356
356
  * restrict, publish DNS, verify -- now happens inside the same step that
357
- * reads this key (`ensureAppLettermintMail`), so unlike the Resend design
357
+ * reads this key (`ensureAppLettermintMail`), so unlike the earlier design
358
358
  * this rework replaces, no separate domain-id scope key is recorded: the
359
359
  * domain is looked up by name fresh each apply. Renamed from
360
- * `resend_mail_domain`.
360
+ * the earlier mail domain key.
361
361
  */
362
362
  const MAIL_DOMAIN_SCOPE_KEY = 'mail_domain';
363
363
  /**
@@ -2493,9 +2493,13 @@ export class FoundryStepExecutor {
2493
2493
  * domain restricted to them before any old project is deleted, so the
2494
2494
  * restriction list never becomes empty.
2495
2495
  *
2496
+ * #654: the non-platform-apex part now lives in {@link ensureOrgAppMailToken},
2497
+ * shared with Boule, which holds the same token; the rule below is its rule
2498
+ * with the consumers read from the hosted app and Boule.
2499
+ *
2496
2500
  * Mint rule ("Credential model"): the project is healthy when the app env
2497
2501
  * holds `LETTERMINT_API_TOKEN` and Lettermint lists exactly one project
2498
- * named `<slug>-app` -- unlike Resend's key, a Lettermint project's name
2502
+ * named `<slug>-app` -- unlike the earlier provider's key, a Lettermint project's name
2499
2503
  * carries no domain id, since a domain switch is handled entirely by
2500
2504
  * {@link LettermintPort.restrictDomainToProjects} below and never needs a
2501
2505
  * fresh project. Otherwise a fresh project is created and its token
@@ -2545,7 +2549,6 @@ export class FoundryStepExecutor {
2545
2549
  if (!hasPlatformApexField) {
2546
2550
  throw new Error(`coolify:app for ${org.slug}: this step's plan carries no platformApex; plan this org's app again`);
2547
2551
  }
2548
- const appName = `${org.slug}-app`;
2549
2552
  // #491: an org whose mail domain is the platform's own apex (Company
2550
2553
  // Included on companyincluded.com, today) shares the platform's existing
2551
2554
  // Lettermint project rather than minting a `<slug>-app` one of its own --
@@ -2588,44 +2591,129 @@ export class FoundryStepExecutor {
2588
2591
  await this.ports.coolify.restart(appId);
2589
2592
  return;
2590
2593
  }
2591
- const [projects, envs] = await Promise.all([
2594
+ await this.ensureOrgAppMailToken({
2595
+ org,
2596
+ context,
2597
+ mailDomain,
2598
+ self: { role: 'app', id: appId, restart: running },
2599
+ mailFrom: `${org.mail.noReplyLocalPart ?? 'no-reply'}@${mailDomain}`,
2600
+ platformApex,
2601
+ platformLettermintProjectId,
2602
+ });
2603
+ }
2604
+ /**
2605
+ * The one mint rule for the org's `<slug>-app` Lettermint token, shared by
2606
+ * the hosted app (`ensureAppLettermintMail`) and Boule
2607
+ * (`ensureBouleMail`), which hold the same token. Lettermint shows a
2608
+ * token once, so the token lives only in those apps' envs: the rule reads
2609
+ * every consumer that is recorded (the hosted app first, Boule second).
2610
+ * The project is healthy when Lettermint lists exactly one `<slug>-app`
2611
+ * project and some consumer holds a token. A healthy rule gives `self` that
2612
+ * token (the app's wins) when it lacks it or holds another; a peer gets it
2613
+ * in its own step. An
2614
+ * unhealthy one mints a fresh project and writes its token to every
2615
+ * consumer, restarting those that run, before any other `<slug>-app`
2616
+ * project is deleted. Either role may run first: an app added after Boule
2617
+ * adopts Boule's token instead of deleting its project.
2618
+ *
2619
+ * The token goes straight to `bulkUpsertEnvs` and nowhere else: never
2620
+ * returned, recorded, logged or interpolated into an error. Returns
2621
+ * whether `self`'s own token changed and whether it was restarted here.
2622
+ *
2623
+ * A peer recorded but gone from Coolify (404) is no consumer. A healthy
2624
+ * company's step writes only its own env; a re-mint writes every live
2625
+ * consumer, restarts the running ones, restricts the domain, then deletes
2626
+ * the old projects.
2627
+ */
2628
+ async ensureOrgAppMailToken(args) {
2629
+ const { org, context, mailDomain, self, mailFrom } = args;
2630
+ const appName = `${org.slug}-app`;
2631
+ const recorded = context.recordedIds();
2632
+ const appId = self.role === 'app' ? self.id : recorded[`coolify:app:${appStepKey(org)}`];
2633
+ const bouleId = self.role === 'boule' ? self.id : recorded[`coolify:boule:${org.id}`];
2634
+ const peerListEnvs = (id) => !id || id === self.id
2635
+ ? Promise.resolve(null)
2636
+ : // A recorded peer that Coolify no longer has is not a consumer: not
2637
+ // read, written or restarted (same 404 rule as the retire step).
2638
+ this.ports.coolify
2639
+ .listEnvs(id)
2640
+ .catch((error) => {
2641
+ if (!isNotFoundError(error))
2642
+ throw error;
2643
+ return null;
2644
+ });
2645
+ const [projects, selfEnvs, appEnvs, bouleEnvs] = await Promise.all([
2592
2646
  this.ports.lettermint.listProjects(),
2593
- this.ports.coolify.listEnvs(appId),
2647
+ this.ports.coolify.listEnvs(self.id),
2648
+ self.role === 'app' ? Promise.resolve(null) : peerListEnvs(appId),
2649
+ self.role === 'boule' ? Promise.resolve(null) : peerListEnvs(bouleId),
2594
2650
  ]);
2595
- const envValue = (key) => envs.find((e) => e.key === key && !e.is_preview)?.value;
2651
+ const tokenOf = (envs) => envs.find((e) => e.key === 'LETTERMINT_API_TOKEN' && !e.is_preview)?.value || undefined;
2652
+ // The hosted app first, Boule second: the app's token wins a disagreement.
2653
+ const consumers = [];
2654
+ const appSlot = self.role === 'app' ? selfEnvs : appEnvs;
2655
+ const bouleSlot = self.role === 'boule' ? selfEnvs : bouleEnvs;
2656
+ if (appSlot && appId)
2657
+ consumers.push({ id: appId, token: tokenOf(appSlot) });
2658
+ if (bouleSlot && bouleId)
2659
+ consumers.push({ id: bouleId, token: tokenOf(bouleSlot) });
2660
+ const existingToken = consumers.find((c) => c.token)?.token;
2596
2661
  const ours = projects.filter((p) => p.name === appName);
2597
2662
  const held = ours.length === 1 ? ours[0] : undefined;
2598
- const healthy = held !== undefined && Boolean(envValue('LETTERMINT_API_TOKEN'));
2663
+ const healthy = held !== undefined && existingToken !== undefined;
2599
2664
  let allProjects = projects;
2600
2665
  let keepProjectId;
2601
- const envUpdates = [];
2602
- if (healthy && held) {
2666
+ let token;
2667
+ if (healthy && held && existingToken) {
2603
2668
  keepProjectId = held.id;
2669
+ token = existingToken;
2604
2670
  }
2605
2671
  else {
2606
2672
  const created = await this.ports.lettermint.createProject(appName);
2607
2673
  keepProjectId = created.project.id;
2608
2674
  allProjects = [...projects, created.project];
2609
- envUpdates.push({ key: 'LETTERMINT_API_TOKEN', value: created.token });
2675
+ token = created.token;
2676
+ }
2677
+ // A healthy company: this step writes only its own env; a peer that lacks
2678
+ // the token gets it in its own step. A re-mint kills the old token, so it
2679
+ // reaches every live consumer here.
2680
+ const targets = consumers.filter((c) => c.token !== token && (!healthy || c.id === self.id));
2681
+ const selfChanged = targets.some((c) => c.id === self.id);
2682
+ for (const consumer of consumers) {
2683
+ const updates = [];
2684
+ if (targets.includes(consumer))
2685
+ updates.push({ key: 'LETTERMINT_API_TOKEN', value: token });
2686
+ if (consumer.id === self.id && mailFrom)
2687
+ updates.push({ key: 'MAIL_FROM', value: mailFrom });
2688
+ if (updates.length > 0)
2689
+ await this.ports.coolify.bulkUpsertEnvs(consumer.id, updates);
2610
2690
  }
2611
- envUpdates.push({
2612
- key: 'MAIL_FROM',
2613
- value: `${org.mail.noReplyLocalPart ?? 'no-reply'}@${mailDomain}`,
2614
- });
2615
- await this.ports.coolify.bulkUpsertEnvs(appId, envUpdates);
2616
2691
  // A running container read its env at start and still holds the old
2617
- // token, so a fresh one only reaches it through a restart -- before the
2618
- // old project is deleted below. A just-created app is deployed first (#566).
2619
- if (!healthy && running)
2620
- await this.ports.coolify.restart(appId);
2621
- await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects, platformApex, platformLettermintProjectId);
2692
+ // token, so a fresh one only reaches it through a restart -- after every
2693
+ // write and before the old project is deleted below, so a failure in
2694
+ // between leaves the old project for the next apply to re-mint. A just-created
2695
+ // app is deployed first (#566).
2696
+ let selfRestarted = false;
2697
+ for (const consumer of targets) {
2698
+ const isSelf = consumer.id === self.id;
2699
+ const running = isSelf
2700
+ ? self.restart
2701
+ : isRunningOrStarting((await this.ports.coolify.getApplication(consumer.id)).status);
2702
+ if (!running)
2703
+ continue;
2704
+ await this.ports.coolify.restart(consumer.id);
2705
+ if (isSelf)
2706
+ selfRestarted = true;
2707
+ }
2708
+ await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects, args.platformApex, args.platformLettermintProjectId);
2622
2709
  for (const project of allProjects) {
2623
2710
  if (project.name === appName &&
2624
2711
  project.id !== keepProjectId &&
2625
- project.id !== platformLettermintProjectId) {
2712
+ project.id !== args.platformLettermintProjectId) {
2626
2713
  await this.ports.lettermint.deleteProject(project.id);
2627
2714
  }
2628
2715
  }
2716
+ return { selfChanged, selfRestarted };
2629
2717
  }
2630
2718
  /**
2631
2719
  * #149: mints this org's own Lettermint relay credential and writes it to
@@ -4237,7 +4325,14 @@ export class FoundryStepExecutor {
4237
4325
  // written is the env the plan showed.
4238
4326
  const issuer = action.desired.issuer ?? null;
4239
4327
  const archonUrl = action.desired.archonUrl ?? null;
4240
- const { env, missing } = desiredBouleEnv(org, context.recordedIds(), issuer, archonUrl);
4328
+ // The plan decides blocked mail (`mailBlocked`); a plan with no
4329
+ // `platformApex` key predates this field and is refused before any mail
4330
+ // env is written.
4331
+ const mailWanted = bouleMailWanted(org, context.recordedIds()) && action.desired.mailBlocked !== true;
4332
+ if (mailWanted && !('platformApex' in action.desired)) {
4333
+ throw new Error(`coolify:boule for ${org.slug}: this step's plan carries no platformApex; plan this org's Boule again`);
4334
+ }
4335
+ const { env, missing } = desiredBouleEnv(org, context.recordedIds(), issuer, archonUrl, mailWanted);
4241
4336
  if (missing.length > 0) {
4242
4337
  throw new Error(`Boule for ${org.slug} cannot be configured: missing ${missing.join(', ')}. Nothing was written.`);
4243
4338
  }
@@ -4288,6 +4383,12 @@ export class FoundryStepExecutor {
4288
4383
  if (!dbPush.pushed) {
4289
4384
  throw new Error(`Boule for ${org.slug}: its database has no internal_db_url yet -- retry this apply once Coolify reports one`);
4290
4385
  }
4386
+ // The org's Lettermint token, the same one its hosted app holds. A no-op
4387
+ // for an org with mail off or no mail domain recorded: Boule then shows
4388
+ // the invite link.
4389
+ const mail = mailWanted
4390
+ ? await this.ensureBouleMail(app.appId, org, context, action, app.existed && Boolean(context.recordedIds()[BOULE_DEPLOYED_SCOPE_KEY]))
4391
+ : { changed: false, restarted: false };
4291
4392
  // #504: see `coolifyAgora`'s own comment just above -- `createGithubAppApplication`
4292
4393
  // never sets `instant_deploy`, so a brand new app (or one found by name
4293
4394
  // that a previous apply created but crashed before deploying) sat built
@@ -4300,11 +4401,53 @@ export class FoundryStepExecutor {
4300
4401
  context.recordScopeId(BOULE_DEPLOYED_SCOPE_KEY, '1');
4301
4402
  }
4302
4403
  else if (app.existed &&
4303
- (envChanged || cookieSecret !== null || appKey !== null || dbPush.changed)) {
4404
+ !mail.restarted &&
4405
+ (envChanged || cookieSecret !== null || appKey !== null || dbPush.changed || mail.changed)) {
4406
+ // A restart inside the mail rule already came after every env write above.
4304
4407
  await this.ports.coolify.restart(app.appId);
4305
4408
  }
4306
4409
  return { resourceId: app.appId };
4307
4410
  }
4411
+ /**
4412
+ * Gives Boule `LETTERMINT_API_TOKEN`: the platform's held key for an org on
4413
+ * the platform's own apex, else the org's `<slug>-app` token through the
4414
+ * shared mint rule ({@link ensureOrgAppMailToken}), which mints that project
4415
+ * when no hosted app made it yet. `MAIL_FROM` is written with the rest of
4416
+ * Boule's env. Returns whether Boule's token changed, so the caller
4417
+ * restarts a running Boule, unless `restarted`: the shared rule then already
4418
+ * restarted it (`running`), before it deleted the old project.
4419
+ */
4420
+ async ensureBouleMail(appId, org, context, action, running) {
4421
+ const none = { changed: false, restarted: false };
4422
+ const mailDomain = context.recordedIds()[MAIL_DOMAIN_SCOPE_KEY];
4423
+ if (!mailDomain)
4424
+ return none;
4425
+ const platformApex = action.desired.platformApex ?? null;
4426
+ const platformLettermintProjectId = action.desired.platformLettermintProjectId ?? null;
4427
+ if (platformApex && mailDomain.toLowerCase() === platformApex.toLowerCase()) {
4428
+ const heldToken = (await this.ports.heldKeys?.resolve(PLATFORM_AUTH_BROKER_MAIL_KEY_REF)) ?? null;
4429
+ if (!heldToken) {
4430
+ throw new Error(`coolify:boule for ${org.slug}: the platform's own Lettermint token is not held yet (held-key ref ${PLATFORM_AUTH_BROKER_MAIL_KEY_REF})`);
4431
+ }
4432
+ const current = (await this.ports.coolify.listEnvs(appId)).find((e) => e.key === 'LETTERMINT_API_TOKEN' && !e.is_preview)?.value;
4433
+ if (current === heldToken)
4434
+ return none;
4435
+ await this.ports.coolify.bulkUpsertEnvs(appId, [
4436
+ { key: 'LETTERMINT_API_TOKEN', value: heldToken },
4437
+ ]);
4438
+ return { changed: true, restarted: false };
4439
+ }
4440
+ const { selfChanged, selfRestarted } = await this.ensureOrgAppMailToken({
4441
+ org,
4442
+ context,
4443
+ mailDomain,
4444
+ self: { role: 'boule', id: appId, restart: running },
4445
+ mailFrom: null,
4446
+ platformApex,
4447
+ platformLettermintProjectId,
4448
+ });
4449
+ return { changed: selfChanged, restarted: selfRestarted };
4450
+ }
4308
4451
  // ---- jobs (#173) ---------------------------------------------------------
4309
4452
  /**
4310
4453
  * `plan/diff.ts`'s `planJobsRegistration`: registers the org's own app with
@@ -5188,7 +5331,7 @@ export class FoundryStepExecutor {
5188
5331
  * upsert `publishMailDnsRecords` above uses for Stalwart's own records --
5189
5332
  * except an existing `_dmarc` record is never overwritten (a stricter
5190
5333
  * policy an org already set, `p=quarantine`/`p=reject`, should not be
5191
- * relaxed back to Lettermint's own default). Resend's own domain also
5334
+ * relaxed back to Lettermint's own default). The earlier provider's domain also
5192
5335
  * carried an apex `SPF` TXT record that had to be merged into whatever SPF
5193
5336
  * record already existed there (RFC 7208: two `v=spf1` TXT records on one
5194
5337
  * name is a permerror); Lettermint's own DNS records
@@ -5,7 +5,7 @@ export { computePlanDetail, toPlan } from './diff.js';
5
5
  export type { StepSystem } from './step-system.js';
6
6
  export { stepSystemOf } from './step-system.js';
7
7
  export type { PlatformPlanDetail } from './platform-diff.js';
8
- export { planPlatform, toPlatformPlan, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, } from './platform-diff.js';
8
+ export { planPlatform, toPlatformPlan, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_SFU_APPLIED_SCOPE_KEY, } from './platform-diff.js';
9
9
  export type { HeldKeyResolver, OrgPlatformReadiness } from './platform-org.js';
10
10
  export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './platform-org.js';
11
11
  export type { PlatformOrgClientFactories } from './platform-org-ports.js';
@@ -23,7 +23,7 @@ export type { AiManagementKeyMinter, PlatformAiDeps, PlatformAiRuntime, } from '
23
23
  export { createZitadelAdminMinter } from './zitadel-machine-user.js';
24
24
  export { createZitadelProjectRoles } from './zitadel-project-roles.js';
25
25
  export type { CallsSfuConnection, CallsSfuConnectionPort, CallsSfuKeysPort, CallsSfuSshConnector, CallsSfuPlatformDeps, } from './platform-sfu.js';
26
- export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep } from './platform-sfu.js';
26
+ export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep, callsSfuAppliedFingerprint, } from './platform-sfu.js';
27
27
  export type { MailCopyJob, MailCopyPort, MailCopyStepResult } from './mail-copy.js';
28
28
  export { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, MissingMailCopyAccountsError, MissingMailCopySourceTokenError, MissingMailCopyStalwartTokenError, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
29
29
  export type { ScopeResolver, RecordedIds, DnsRecordOwners } from './providers-readers.js';
@@ -1,7 +1,7 @@
1
1
  export { readLiveResources, readLivePlatformResources } from './live.js';
2
2
  export { computePlanDetail, toPlan } from './diff.js';
3
3
  export { stepSystemOf } from './step-system.js';
4
- export { planPlatform, toPlatformPlan, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, } from './platform-diff.js';
4
+ export { planPlatform, toPlatformPlan, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_SFU_APPLIED_SCOPE_KEY, } from './platform-diff.js';
5
5
  export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './platform-org.js';
6
6
  export { PlatformOrgBlockedError, PlatformOrgPorts } from './platform-org-ports.js';
7
7
  export { AdoptRefusedError } from './adopt.js';
@@ -12,7 +12,7 @@ export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEME
12
12
  export { AI_MANAGEMENT_ISSUER, AI_MANAGEMENT_KEY_ID, AI_SIGNING_KEY_REF, AiManagementKeyExistsError, createAiManagementKeyMinter, PLATFORM_AI_MANAGEMENT_KEY_REF, PLATFORM_AI_MANAGEMENT_PERMISSIONS, } from './platform-providers.js';
13
13
  export { createZitadelAdminMinter } from './zitadel-machine-user.js';
14
14
  export { createZitadelProjectRoles } from './zitadel-project-roles.js';
15
- export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep } from './platform-sfu.js';
15
+ export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep, callsSfuAppliedFingerprint, } from './platform-sfu.js';
16
16
  export { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, MissingMailCopyAccountsError, MissingMailCopySourceTokenError, MissingMailCopyStalwartTokenError, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
17
17
  export { ProvidersAiReader, ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
18
18
  export { PlatformBrokerPort } from './broker-entries.js';
@@ -23,9 +23,9 @@ export interface LiveResource {
23
23
  /**
24
24
  * `'sfu'` (#170) has no reader wired below -- there is no cheap way to
25
25
  * probe the calls SFU VM's own state over SSH during planning, so
26
- * `platform-diff.ts`'s `planSfu` always plans its step when
27
- * `DeclaredPlatform.sfu.enabled` is true, rather than diffing against a
28
- * live resource the way every other system here does. It is still part of
26
+ * `platform-diff.ts`'s `planSfu` plans its step from a recorded
27
+ * fingerprint when `DeclaredPlatform.sfu.enabled` is true, rather than
28
+ * diffing against a live resource the way every other system here does. It is still part of
29
29
  * this union so `StepAction.system` (`diff.ts`) can name it. `'edge'`
30
30
  * (#317) is the same: a edge key has no live lookup of its own, the same
31
31
  * shape `ai`/`files` entry keys already follow.
@@ -1008,9 +1008,13 @@ export class FoundryPlanApply {
1008
1008
  store.getServiceIds(),
1009
1009
  ]);
1010
1010
  const sfuBlocked = await this.sfuBlockedReason(platform);
1011
+ const callsOrgSlugs = (await this.store.list())
1012
+ .filter((org) => org.calls.enabled)
1013
+ .map((org) => org.slug);
1011
1014
  const detail = computePlatformPlanDetail(platform, live, {
1012
1015
  jobsObservable: readers.coolify !== undefined,
1013
1016
  sfuBlocked,
1017
+ callsOrgSlugs,
1014
1018
  recordedIds: serviceIds,
1015
1019
  blocked: (await executor.blockedSteps?.(platform, serviceIds)) ?? {},
1016
1020
  drifted: (await executor.driftedSteps?.(platform, serviceIds)) ?? {},
@@ -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
@@ -922,6 +927,11 @@ export interface PlanPlatformOptions {
922
927
  jobsObservable?: boolean;
923
928
  /** Why the shared SFU is blocked, or `null` when it is not (default `null`). */
924
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[];
925
935
  /** The platform's recorded resource/scope ids (default `{}`). */
926
936
  recordedIds?: Readonly<Record<string, string>>;
927
937
  /** Per-step block reasons the executor reports (default `{}`). */
@@ -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
@@ -1056,7 +1062,7 @@ export function uptimeRecorded(recordedIds) {
1056
1062
  * unchanged).
1057
1063
  */
1058
1064
  export function planPlatform(platform, live, options = {}) {
1059
- const { jobsObservable = true, sfuBlocked = null, recordedIds = {}, blocked = {}, drifted = {}, } = options;
1065
+ const { jobsObservable = true, sfuBlocked = null, callsOrgSlugs, recordedIds = {}, blocked = {}, drifted = {}, } = options;
1060
1066
  const byKey = new Map(live.map((r) => [resourceKey(r.system, r.kind, r.key), r]));
1061
1067
  const claimed = new Set();
1062
1068
  const plannedSteps = [];
@@ -1071,7 +1077,7 @@ export function planPlatform(platform, live, options = {}) {
1071
1077
  // A blocked SFU is reported in `unmanaged`, not planned: a step the
1072
1078
  // executor must refuse would halt every step after it (`diff.ts` does the
1073
1079
  // same for a blocked Agora).
1074
- const sfu = platform.sfu.enabled && sfuBlocked ? null : planSfu(platform);
1080
+ const sfu = platform.sfu.enabled && sfuBlocked ? null : planSfu(platform, callsOrgSlugs, recordedIds);
1075
1081
  if (sfu)
1076
1082
  plannedSteps.push(sfu);
1077
1083
  // #478, F4.dns: first of the services, ahead of auth. Edge is the only
@@ -1505,20 +1511,29 @@ function planBackups(byKey, claimed, recordedIds, executorMissing, storageDrifte
1505
1511
  /**
1506
1512
  * The platform's shared calls SFU (#170), keyed `sfu:livekit:platform` --
1507
1513
  * there is exactly one, so `key` names the platform itself rather than a
1508
- * domain or an org id, the way `LiveResource`'s own doc comment explains for
1509
- * this system. Unlike {@link planApexZone}, this step is never matched
1510
- * against a live resource: there is no cheap way to read the SFU VM's own
1511
- * state over SSH during planning (`LiveResource`'s doc comment on the `sfu`
1512
- * system), so the step is planned whenever `platform.sfu.enabled` is true,
1513
- * full stop. That is safe rather than wasteful: `applyCallsSfu`
1514
- * (`calls-sfu.ts`'s own top doc comment) is built to be re-run, so an apply
1515
- * that runs this step again writes the same files and runs the same
1516
- * idempotent `docker compose`/`ufw` commands, changing nothing on a VM
1517
- * already caught up.
1518
- */
1519
- 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) {
1520
1525
  if (!platform.sfu.enabled)
1521
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
+ }
1522
1537
  const action = {
1523
1538
  system: 'sfu',
1524
1539
  kind: 'livekit',
@@ -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. */
@@ -805,7 +805,7 @@ export declare function createAiManagementKeyMinter(options: {
805
805
  * `_FILE` (#209 mints and pushes this once it lands) and `LETTERMINT_API_TOKEN`
806
806
  * (#210: `wtfalch/auth#138` moved the broker's account mail onto
807
807
  * `@wtfalch/mailer`'s Lettermint transport, so this is the env name the
808
- * 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
809
809
  * own live env, not a recorded scope id: whichever of #209/#210 lands first
810
810
  * (or a human, setting either directly on the app) is enough to clear that
811
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_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
- 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`). */
@@ -644,7 +644,7 @@ export class FoundryPlatformStepExecutor {
644
644
  return provisionPlatformBackupStorage(context, this.backup);
645
645
  }
646
646
  if (action.system === 'sfu' && action.kind === 'livekit') {
647
- return this.executeSfu();
647
+ return this.executeSfu(context);
648
648
  }
649
649
  throw new Error(`platform step ${action.system}:${action.kind} is not built yet`);
650
650
  }
@@ -670,7 +670,7 @@ export class FoundryPlatformStepExecutor {
670
670
  * Sorted by slug so a re-run with the same set of enabled orgs is a no-op
671
671
  * even before `applyCallsSfuPlatformStep`'s own sort (its doc comment).
672
672
  */
673
- async executeSfu() {
673
+ async executeSfu(context) {
674
674
  if (!this.sfu) {
675
675
  throw new Error('platform step sfu:livekit is not built yet (#170: pass `sfu` to FoundryPlatformStepExecutor)');
676
676
  }
@@ -680,6 +680,13 @@ export class FoundryPlatformStepExecutor {
680
680
  .map((org) => org.slug)
681
681
  .sort();
682
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
+ }));
683
690
  return undefined;
684
691
  }
685
692
  async coolifyJobs(action, context) {
@@ -1150,7 +1157,7 @@ async function liveLoginClientTokenHash(coolify, appId) {
1150
1157
  * `IAM_LOGIN_CLIENT` machine user (never the admin one, which is
1151
1158
  * `IAM_OWNER`) and delivers its token to both apps, once the broker's
1152
1159
  * other required inputs are already met.
1153
- * - ~~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
1154
1161
  * start on `AUTH_APPS` alone.~~ Closed by #210, once `wtfalch/auth#138`
1155
1162
  * moved the broker's account mail onto `@wtfalch/mailer`'s Lettermint
1156
1163
  * transport: this function now reads the key back from
@@ -2029,8 +2036,8 @@ const PLATFORM_MAIL_ZITADEL_PROJECT_NAME = 'mail';
2029
2036
  * instance-bootstrap helper `wtfalch-manage` already uses for the hand-built
2030
2037
  * `mail.wtfalch.dev`: that plan also wires an `AcmeProvider` and `DnsServer`
2031
2038
  * for Stalwart's own DNS-01 ACME challenge, and an `MtaRoute` that relays
2032
- * every outbound message through Resend -- the estate moved transactional
2033
- * 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
2034
2041
  * 0006), and this step provisions no Cloudflare DNS token for Stalwart's own
2035
2042
  * ACME either. Calling it as-is would mint an outbound relay this platform
2036
2043
  * does not want and a credential requirement this issue does not cover, so
@@ -2042,7 +2049,7 @@ const PLATFORM_MAIL_ZITADEL_PROJECT_NAME = 'mail';
2042
2049
  * William's review"):
2043
2050
  * - **No ACME/TLS for the SMTP listener.** `mx.<apex>` answers with
2044
2051
  * Stalwart's own self-signed certificate until a follow-up wires a real
2045
- * one (`mailObjects`'s `AcmeProvider`/`DnsServer` shape, minus the Resend
2052
+ * one (`mailObjects`'s `AcmeProvider`/`DnsServer` shape, minus the earlier relay
2046
2053
  * relay it also carries).
2047
2054
  * - **The Lettermint team token the org app step reads still comes from
2048
2055
  * wherever the host already reads it, not held keys.** Needs
@@ -5044,7 +5051,7 @@ async function findOrCreatePlatformApp(coolify, name, environment, create) {
5044
5051
  * `_FILE` (#209 mints and pushes this once it lands) and `LETTERMINT_API_TOKEN`
5045
5052
  * (#210: `wtfalch/auth#138` moved the broker's account mail onto
5046
5053
  * `@wtfalch/mailer`'s Lettermint transport, so this is the env name the
5047
- * 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
5048
5055
  * own live env, not a recorded scope id: whichever of #209/#210 lands first
5049
5056
  * (or a human, setting either directly on the app) is enough to clear that
5050
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;