@wtfalch/foundry 0.27.0 → 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.
@@ -203,6 +203,12 @@ export declare function cloudflaredEnvFile(token: string): string;
203
203
  * writes the set it was given.
204
204
  */
205
205
  export declare function livekitConfigYaml(config: CallsSfuConfig): string;
206
+ /** The command that brings both compose services up; part of what {@link callsSfuFilesVersion} hashes. */
207
+ export declare const CALLS_SFU_UP_COMMAND = "docker compose -f /opt/livekit/docker-compose.yml up -d";
208
+ /** Run first, so the SSH session this runs over is never refused. */
209
+ export declare const CALLS_SFU_SSH_RULE_COMMAND = "ufw allow 22/tcp";
210
+ /** Run after any unscoped signalling rule is removed, in this order. */
211
+ export declare const CALLS_SFU_FIREWALL_COMMANDS: readonly string[];
206
212
  /**
207
213
  * Writes all three files, brings LiveKit and `cloudflared` up, then narrows
208
214
  * the VM firewall to exactly what #122's and #127's "Decided" sections name
package/dist/calls-sfu.js CHANGED
@@ -239,6 +239,19 @@ rtc:
239
239
  node_ip: ${address}
240
240
  ${keysYaml}`;
241
241
  }
242
+ /** The command that brings both compose services up; part of what {@link callsSfuFilesVersion} hashes. */
243
+ export const CALLS_SFU_UP_COMMAND = `docker compose -f ${LIVEKIT_COMPOSE_PATH} up -d`;
244
+ /** Run first, so the SSH session this runs over is never refused. */
245
+ export const CALLS_SFU_SSH_RULE_COMMAND = 'ufw allow 22/tcp';
246
+ /** Run after any unscoped signalling rule is removed, in this order. */
247
+ export const CALLS_SFU_FIREWALL_COMMANDS = [
248
+ `ufw allow from ${CALLS_SFU_PRIVATE_CIDR} to any port ${LIVEKIT_SIGNALLING_PORT} proto tcp`,
249
+ `ufw allow ${LIVEKIT_MEDIA_TCP_PORT}/tcp`,
250
+ `ufw allow ${LIVEKIT_MEDIA_UDP_RANGE}/udp`,
251
+ 'ufw default deny incoming',
252
+ 'ufw default allow outgoing',
253
+ 'ufw --force enable',
254
+ ];
242
255
  async function run(host, command) {
243
256
  const result = await host.exec(command);
244
257
  if (result.exitCode !== 0) {
@@ -290,16 +303,12 @@ export async function applyCallsSfu(host, config) {
290
303
  await host.writeFile(LIVEKIT_COMPOSE_PATH, dockerComposeYaml());
291
304
  await writeFileAtomically(host, LIVEKIT_CONFIG_PATH, livekitConfigYaml(config));
292
305
  await host.writeFile(CLOUDFLARED_ENV_PATH, cloudflaredEnvFile(config.cloudflaredTunnelToken));
293
- await run(host, `docker compose -f ${LIVEKIT_COMPOSE_PATH} up -d`);
294
- await run(host, 'ufw allow 22/tcp');
306
+ await run(host, CALLS_SFU_UP_COMMAND);
307
+ await run(host, CALLS_SFU_SSH_RULE_COMMAND);
295
308
  const status = await host.exec('ufw status');
296
309
  if (status.exitCode === 0 && hasUnscopedSignallingRule(status.stdout)) {
297
310
  await run(host, `ufw delete allow ${LIVEKIT_SIGNALLING_PORT}/tcp`);
298
311
  }
299
- await run(host, `ufw allow from ${CALLS_SFU_PRIVATE_CIDR} to any port ${LIVEKIT_SIGNALLING_PORT} proto tcp`);
300
- await run(host, `ufw allow ${LIVEKIT_MEDIA_TCP_PORT}/tcp`);
301
- await run(host, `ufw allow ${LIVEKIT_MEDIA_UDP_RANGE}/udp`);
302
- await run(host, 'ufw default deny incoming');
303
- await run(host, 'ufw default allow outgoing');
304
- await run(host, 'ufw --force enable');
312
+ for (const command of CALLS_SFU_FIREWALL_COMMANDS)
313
+ await run(host, command);
305
314
  }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export type { OrgId, MailMode, MailConfig, ChatConfig, CallsConfig, IssuerConfig, SiteConfig, IssuerAppKind, IssuerApp, HostingConfig, HostingEntry, EntryGate, HostingEntryUses, EntryAuth, EntryFiles, EntryAi, EnvValue, SsoConfig, GuestsConfig, SoloConfig, StorageConfig, AiConfig, BuyerAddress, BuyerIdentity, AdoptedAppForm, AdoptedApp, OwnerIdentity, OwnerMailbox, DeclaredOrg, } from './org.js';
2
2
  export { APP_ENTRY_KEY, DEFAULT_GATE_LIMIT, FILES_ENTRY_PERMISSIONS, AI_ENTRY_PERMISSIONS, DEFAULT_MAIL_APPS, appEntry, ownerMailboxes, validateBuyerIdentity, validateHosting, validateMail, validateOwners, validateSite, } from './org.js';
3
- export type { DeclaredPlatform, PlatformService, PlatformServiceKey } from './platform.js';
3
+ export type { DeclaredPlatform, PlatformService, PlatformServiceKey, PlatformSfuService, } from './platform.js';
4
4
  export { PLATFORM_SERVICE_KEYS, defaultPlatformHostname, validatePlatform, } from './platform.js';
5
5
  import { type PinnedVersions } from './bootstrap/versions.js';
6
6
  export type { PinnedVersions };
@@ -22,7 +22,7 @@ export type { LiveResource, ZitadelReader, CoolifyReader, CloudflareReader, File
22
22
  export { AdoptRefusedError } from './plan/index.js';
23
23
  export { createEdgeAccess, createEdgeDns, edgeDnsRecordOwners, createEdgeRateLimits, createEdgeTunnels, createEdgeWorkers, createEdgeZones, } from './plan/index.js';
24
24
  export type { AccessAppSummary, AccessDecision, AccessPolicyRule, AccessPort, DnsPort, EdgePlatformAccess, EdgePlatformDns, EdgePlatformRateLimits, EdgePlatformWorkers, EdgePlatformZones, EdgeZonesOptions, ZonePort, EdgeRateLimitsOptions, RateLimitPort, RateLimitRuleInput, EdgeWorkersOptions, EdgePlatformTunnels, TunnelPort, } from './plan/index.js';
25
- export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
25
+ export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_SFU_APPLIED_SCOPE_KEY, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
26
26
  export type { HeldKeyResolver, OrgPlatformReadiness } from './plan/index.js';
27
27
  export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './plan/index.js';
28
28
  export type { PlatformOrgClientFactories } from './plan/index.js';
@@ -47,7 +47,7 @@ export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEME
47
47
  export { AI_MANAGEMENT_ISSUER, AI_MANAGEMENT_KEY_ID, AI_SIGNING_KEY_REF, AiManagementKeyExistsError, createAiManagementKeyMinter, PLATFORM_AI_MANAGEMENT_KEY_REF, PLATFORM_AI_MANAGEMENT_PERMISSIONS, } from './plan/index.js';
48
48
  export type { AiManagementKeyMinter, PlatformAiDeps, PlatformAiRuntime, } from './plan/index.js';
49
49
  export type { CallsSfuConnection, CallsSfuConnectionPort, CallsSfuKeysPort, CallsSfuSshConnector, CallsSfuPlatformDeps, } from './plan/index.js';
50
- export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep } from './plan/index.js';
50
+ export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep, callsSfuAppliedFingerprint, } from './plan/index.js';
51
51
  export type { CallsSfuUrls } from './calls-sfu.js';
52
52
  export type { CallsSfuSshSession } from './calls-sfu-ssh-adapter.js';
53
53
  export { connectCallsSfuSsh } from './calls-sfu-ssh-client.js';
package/dist/index.js CHANGED
@@ -13,7 +13,7 @@ export { createPostgresAuditLog } from './store/audit.js';
13
13
  export { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryAuditEvents, foundryDeclaredPlatform, foundryRequestedChanges, } from './store/schema.js';
14
14
  export { AdoptRefusedError } from './plan/index.js';
15
15
  export { createEdgeAccess, createEdgeDns, edgeDnsRecordOwners, createEdgeRateLimits, createEdgeTunnels, createEdgeWorkers, createEdgeZones, } from './plan/index.js';
16
- export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
16
+ export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_SFU_APPLIED_SCOPE_KEY, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
17
17
  export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './plan/index.js';
18
18
  export { PlatformOrgBlockedError, PlatformOrgPorts } from './plan/index.js';
19
19
  export { FoundryStepExecutor, NotBuiltError, healthUrls, loginHostnameFor } from './plan/index.js';
@@ -26,7 +26,7 @@ export { checkMailAppsAllowlist } from './plan/index.js';
26
26
  export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, createZitadelAdminMinter, createZitadelProjectRoles, PLATFORM_AUTH_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, } from './plan/index.js';
27
27
  export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, PLATFORM_AUTH_INSTANCE_BEARER_REF, } from './plan/index.js';
28
28
  export { AI_MANAGEMENT_ISSUER, AI_MANAGEMENT_KEY_ID, AI_SIGNING_KEY_REF, AiManagementKeyExistsError, createAiManagementKeyMinter, PLATFORM_AI_MANAGEMENT_KEY_REF, PLATFORM_AI_MANAGEMENT_PERMISSIONS, } from './plan/index.js';
29
- export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep } from './plan/index.js';
29
+ export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep, callsSfuAppliedFingerprint, } from './plan/index.js';
30
30
  export { connectCallsSfuSsh } from './calls-sfu-ssh-client.js';
31
31
  export { UnknownCallsSfuHostKeyError, toCallsSfuHostPort, applyCallsSfuOverSession, } from './calls-sfu-ssh-adapter.js';
32
32
  export { mintLivekitCredentials } from './calls-sfu-keys.js';
@@ -0,0 +1,7 @@
1
+ -- @wtfalch/foundry: the platform's `sfu.connectionRevision`, a value a host
2
+ -- changes when the SFU VM's SSH key, host key or tunnel token changes, so the
3
+ -- platform plan shows the `sfu` step again. Declared; null until set.
4
+ -- Mirrors src/store/schema.ts's `foundryDeclaredPlatform` table addition.
5
+
6
+ alter table foundry_declared_platform
7
+ add column if not exists sfu_connection_revision text;
@@ -519,7 +519,7 @@ export interface WorkersPort {
519
519
  /**
520
520
  * One DNS record a Lettermint domain's own response says must be published --
521
521
  * `purpose` is Lettermint's own label (`return_path`, `dmarc`, `dkim_legacy`,
522
- * `dkim_primary`, `dkim_secondary`), replacing Resend's `record` field, from
522
+ * `dkim_primary`, `dkim_secondary`), replacing the earlier provider's `record` field, from
523
523
  * {@link LettermintPort.createDomain}/{@link LettermintPort.getDomain}, the
524
524
  * same shape https://lettermint.co/docs/api-reference/team/0.0.1/lettermint-team-openapi.json's
525
525
  * `DomainDnsRecordData` documents (`fqdn`/`content` renamed to this port's own
@@ -552,17 +552,17 @@ export interface LettermintProject {
552
552
  * has no Lettermint client yet, unlike Coolify/Cloudflare/ZITADEL above, so
553
553
  * this port names Lettermint's own REST shapes directly
554
554
  * (`.plans/2026-09-28-lettermint-api.md`, its own primary-source citations).
555
- * Replaces `ResendPort` (#138/#140), trimmed to what `coolifyApp`/`coolifyMail`
555
+ * Replaces the earlier mail port (#138/#140), trimmed to what `coolifyApp`/`coolifyMail`
556
556
  * below actually call.
557
557
  *
558
- * Unlike Resend's per-domain sending key, a Lettermint domain is
558
+ * Unlike the earlier provider's per-domain sending key, a Lettermint domain is
559
559
  * unrestricted by default -- any project in the team could send from it --
560
560
  * so a project's own domain access is a separate call
561
561
  * ({@link restrictDomainToProjects}), and the credential unit is a *project*,
562
562
  * not a per-domain key: Lettermint's API mints, at most, one live token per
563
563
  * project and has no call to list or revoke an individual token within one
564
564
  * (`.plans/2026-09-28-lettermint-api.md` §4), so `<slug>-app` is a project
565
- * name, never suffixed with a domain id the way Resend's key name was.
565
+ * name, never suffixed with a domain id the way the earlier provider's key name was.
566
566
  */
567
567
  export interface LettermintPort {
568
568
  /**
@@ -585,7 +585,7 @@ export interface LettermintPort {
585
585
  /**
586
586
  * Asks Lettermint to re-check DNS now. Never polled or awaited to
587
587
  * completion -- the same "do not block the plan on DNS propagation" rule
588
- * `ResendPort.verifyDomain` followed: record the status, and let the next
588
+ * the earlier mail port followed: record the status, and let the next
589
589
  * apply re-check it.
590
590
  */
591
591
  verifyDomain(id: string): Promise<{
@@ -1959,7 +1959,7 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1959
1959
  *
1960
1960
  * Mint rule ("Credential model"): the project is healthy when the app env
1961
1961
  * holds `LETTERMINT_API_TOKEN` and Lettermint lists exactly one project
1962
- * named `<slug>-app` -- unlike Resend's key, a Lettermint project's name
1962
+ * named `<slug>-app` -- unlike the earlier provider's key, a Lettermint project's name
1963
1963
  * carries no domain id, since a domain switch is handled entirely by
1964
1964
  * {@link LettermintPort.restrictDomainToProjects} below and never needs a
1965
1965
  * fresh project. Otherwise a fresh project is created and its token
@@ -2754,7 +2754,7 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2754
2754
  * upsert `publishMailDnsRecords` above uses for Stalwart's own records --
2755
2755
  * except an existing `_dmarc` record is never overwritten (a stricter
2756
2756
  * policy an org already set, `p=quarantine`/`p=reject`, should not be
2757
- * relaxed back to Lettermint's own default). Resend's own domain also
2757
+ * relaxed back to Lettermint's own default). The earlier provider's domain also
2758
2758
  * carried an apex `SPF` TXT record that had to be merged into whatever SPF
2759
2759
  * record already existed there (RFC 7208: two `v=spf1` TXT records on one
2760
2760
  * name is a permerror); Lettermint's own DNS records
@@ -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
  /**
@@ -2495,7 +2495,7 @@ export class FoundryStepExecutor {
2495
2495
  *
2496
2496
  * Mint rule ("Credential model"): the project is healthy when the app env
2497
2497
  * holds `LETTERMINT_API_TOKEN` and Lettermint lists exactly one project
2498
- * named `<slug>-app` -- unlike Resend's key, a Lettermint project's name
2498
+ * named `<slug>-app` -- unlike the earlier provider's key, a Lettermint project's name
2499
2499
  * carries no domain id, since a domain switch is handled entirely by
2500
2500
  * {@link LettermintPort.restrictDomainToProjects} below and never needs a
2501
2501
  * fresh project. Otherwise a fresh project is created and its token
@@ -5188,7 +5188,7 @@ export class FoundryStepExecutor {
5188
5188
  * upsert `publishMailDnsRecords` above uses for Stalwart's own records --
5189
5189
  * except an existing `_dmarc` record is never overwritten (a stricter
5190
5190
  * 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
5191
+ * relaxed back to Lettermint's own default). The earlier provider's domain also
5192
5192
  * carried an apex `SPF` TXT record that had to be merged into whatever SPF
5193
5193
  * record already existed there (RFC 7208: two `v=spf1` TXT records on one
5194
5194
  * name is a permerror); Lettermint's own DNS records
@@ -5,7 +5,7 @@ export { computePlanDetail, toPlan } from './diff.js';
5
5
  export type { StepSystem } from './step-system.js';
6
6
  export { stepSystemOf } from './step-system.js';
7
7
  export type { PlatformPlanDetail } from './platform-diff.js';
8
- export { planPlatform, toPlatformPlan, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, } from './platform-diff.js';
8
+ export { planPlatform, toPlatformPlan, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_SFU_APPLIED_SCOPE_KEY, } from './platform-diff.js';
9
9
  export type { HeldKeyResolver, OrgPlatformReadiness } from './platform-org.js';
10
10
  export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './platform-org.js';
11
11
  export type { PlatformOrgClientFactories } from './platform-org-ports.js';
@@ -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;
@@ -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:
@@ -12,6 +12,14 @@ export interface PlatformService {
12
12
  hostname: string;
13
13
  gitRef: string;
14
14
  }
15
+ /**
16
+ * `sfu`'s own extra field. A host that changes what the SFU VM is reached
17
+ * with (its SSH key, host key or tunnel token) sets a new `connectionRevision`
18
+ * so the platform plan shows the `sfu` step again; foundry never reads it.
19
+ */
20
+ export interface PlatformSfuService extends PlatformService {
21
+ connectionRevision?: string;
22
+ }
15
23
  /**
16
24
  * #491: `mail`'s own extra field, on top of every other service's shape.
17
25
  * Company Included (or whichever org's mail domain is the platform's own
@@ -76,7 +84,7 @@ export interface DeclaredPlatform {
76
84
  serverIpv4?: string | null;
77
85
  auth: PlatformService;
78
86
  mail: PlatformMailService;
79
- sfu: PlatformService;
87
+ sfu: PlatformSfuService;
80
88
  files: PlatformService;
81
89
  ai: PlatformService;
82
90
  jobs: PlatformService;
@@ -116,6 +124,8 @@ export declare function defaultPlatformHostname(service: PlatformServiceKey, ape
116
124
  * - `serverIpv4`, when declared, is a dotted-quad IPv4 address.
117
125
  * - Every service's `hostname` is the apex itself or a subdomain of it -- a
118
126
  * platform service does not live outside the platform's own apex.
127
+ * - `sfu.connectionRevision`, when declared, is at most 64 characters of
128
+ * `A-Za-z0-9:.-`.
119
129
  * - Every service's `gitRef` is non-empty once that service is `enabled` --
120
130
  * an enabled service with nothing to deploy is not yet declared.
121
131
  */
package/dist/platform.js CHANGED
@@ -32,6 +32,8 @@ function serviceOf(platform, key) {
32
32
  * - `serverIpv4`, when declared, is a dotted-quad IPv4 address.
33
33
  * - Every service's `hostname` is the apex itself or a subdomain of it -- a
34
34
  * platform service does not live outside the platform's own apex.
35
+ * - `sfu.connectionRevision`, when declared, is at most 64 characters of
36
+ * `A-Za-z0-9:.-`.
35
37
  * - Every service's `gitRef` is non-empty once that service is `enabled` --
36
38
  * an enabled service with nothing to deploy is not yet declared.
37
39
  */
@@ -54,6 +56,10 @@ export function validatePlatform(platform) {
54
56
  return `${key}.gitRef must not be empty once ${key}.enabled is true`;
55
57
  }
56
58
  }
59
+ const revision = platform.sfu.connectionRevision;
60
+ if (revision != null && !/^[A-Za-z0-9:.-]{0,64}$/.test(revision)) {
61
+ return 'sfu.connectionRevision must be at most 64 characters of A-Z, a-z, 0-9, ":", "." and "-"';
62
+ }
57
63
  const { uptime } = platform;
58
64
  if (Boolean(uptime.tag.trim()) !== Boolean(uptime.sha256.trim())) {
59
65
  return 'uptime.tag and uptime.sha256 must be set together: a pin is both';
@@ -606,7 +606,12 @@ function rowToPlatform(row) {
606
606
  gitRef: row.mailGitRef,
607
607
  ...(row.mailLettermintProjectId ? { lettermintProjectId: row.mailLettermintProjectId } : {}),
608
608
  },
609
- sfu: { enabled: row.sfuEnabled, hostname: row.sfuHostname, gitRef: row.sfuGitRef },
609
+ sfu: {
610
+ enabled: row.sfuEnabled,
611
+ hostname: row.sfuHostname,
612
+ gitRef: row.sfuGitRef,
613
+ ...(row.sfuConnectionRevision ? { connectionRevision: row.sfuConnectionRevision } : {}),
614
+ },
610
615
  files: { enabled: row.filesEnabled, hostname: row.filesHostname, gitRef: row.filesGitRef },
611
616
  ai: { enabled: row.aiEnabled, hostname: row.aiHostname, gitRef: row.aiGitRef },
612
617
  jobs: { enabled: row.jobsEnabled, hostname: row.jobsHostname, gitRef: row.jobsGitRef },
@@ -654,6 +659,7 @@ function platformToRow(platform) {
654
659
  sfuEnabled: platform.sfu.enabled,
655
660
  sfuHostname: platform.sfu.hostname,
656
661
  sfuGitRef: platform.sfu.gitRef,
662
+ sfuConnectionRevision: platform.sfu.connectionRevision ?? null,
657
663
  filesEnabled: platform.files.enabled,
658
664
  filesHostname: platform.files.hostname,
659
665
  filesGitRef: platform.files.gitRef,
@@ -1643,6 +1643,23 @@ export declare const foundryDeclaredPlatform: import("drizzle-orm/pg-core").PgTa
1643
1643
  identity: undefined;
1644
1644
  generated: undefined;
1645
1645
  }, {}, {}>;
1646
+ sfuConnectionRevision: import("drizzle-orm/pg-core").PgColumn<{
1647
+ name: "sfu_connection_revision";
1648
+ tableName: "foundry_declared_platform";
1649
+ dataType: "string";
1650
+ columnType: "PgText";
1651
+ data: string;
1652
+ driverParam: string;
1653
+ notNull: false;
1654
+ hasDefault: false;
1655
+ isPrimaryKey: false;
1656
+ isAutoincrement: false;
1657
+ hasRuntimeDefault: false;
1658
+ enumValues: [string, ...string[]];
1659
+ baseColumn: never;
1660
+ identity: undefined;
1661
+ generated: undefined;
1662
+ }, {}, {}>;
1646
1663
  filesEnabled: import("drizzle-orm/pg-core").PgColumn<{
1647
1664
  name: "files_enabled";
1648
1665
  tableName: "foundry_declared_platform";
@@ -165,6 +165,8 @@ export const foundryDeclaredPlatform = pgTable('foundry_declared_platform', {
165
165
  sfuEnabled: boolean('sfu_enabled').notNull().default(false),
166
166
  sfuHostname: text('sfu_hostname').notNull(),
167
167
  sfuGitRef: text('sfu_git_ref').notNull().default(''),
168
+ /** `migrations/0050_platform_sfu_connection_revision.sql`. Declared; null means unset. */
169
+ sfuConnectionRevision: text('sfu_connection_revision'),
168
170
  filesEnabled: boolean('files_enabled').notNull().default(false),
169
171
  filesHostname: text('files_hostname').notNull(),
170
172
  filesGitRef: text('files_git_ref').notNull().default(''),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "Foundry's SDK: the declared org's types, the plan and apply contracts, and the state store's interface.",
5
5
  "repository": {
6
6
  "type": "git",