@wtfalch/foundry 0.37.0 → 0.38.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.
@@ -304,10 +304,23 @@ export interface OwnerFiles {
304
304
  organisationId: string;
305
305
  managementKeyRef: string;
306
306
  }
307
+ /**
308
+ * The owner company's chat once `coolify:chat` has applied: where the uptime
309
+ * Worker's bot is made and which people join its room.
310
+ */
311
+ export interface OwnerChat {
312
+ /** The chat-service app (Coolify uuid), whose env holds `CHAT_INTERNAL_TOKEN`. */
313
+ appId: string;
314
+ /** The company's Agora host: the public origin of the bot route and the Matrix homeserver. */
315
+ agoraHost: string;
316
+ /** ZITADEL subjects of the owner company's owners whose invite is recorded. */
317
+ subjects: readonly string[];
318
+ }
307
319
  /** What the platform plan reads about the owner company. */
308
320
  export interface PlatformOwnerInput {
309
321
  signIn?: OwnerSignIn | null;
310
322
  files?: OwnerFiles | null;
323
+ chat?: OwnerChat | null;
311
324
  }
312
325
  /**
313
326
  * The owner's sign-in from its declared org and the ids its own steps recorded.
@@ -326,3 +339,5 @@ export declare function ownerSignInFrom(owner: DeclaredOrg | null, recorded: Rea
326
339
  export declare function platformOwnerUserIdFrom(owner: DeclaredOrg | null, recorded: Readonly<Record<string, string>>): string | null;
327
340
  /** The owner's files host once its own files step has applied; `null` before. */
328
341
  export declare function ownerFilesFrom(owner: DeclaredOrg | null, recorded: Readonly<Record<string, string>>): OwnerFiles | null;
342
+ /** The owner's chat once its `coolify:chat` step has applied; `null` before, and when chat is off. */
343
+ export declare function ownerChatFrom(owner: DeclaredOrg | null, recorded: Readonly<Record<string, string>>): OwnerChat | null;
@@ -1,5 +1,5 @@
1
1
  import { ownsServices } from '../org.js';
2
- import { ZITADEL_ORG_SCOPE_KEY } from './agora.js';
2
+ import { ZITADEL_ORG_SCOPE_KEY, agoraHostnameFor } from './agora.js';
3
3
  import { ACCESS_PROVIDER_RECORD_KEY } from './gates.js';
4
4
  import { FILES_MANAGEMENT_KEY_REF, JOBS_ADMIN_TOKEN_REF, PLATFORM_AI_MANAGEMENT_KEY_REF, } from './held-key-refs.js';
5
5
  import { OWN_AUTH_APPLIED_HOSTNAME_SCOPE_KEY, ownAuthApplied } from './login-host.js';
@@ -352,3 +352,19 @@ export function ownerFilesFrom(owner, recorded) {
352
352
  managementKeyRef: orgTarget(owner).refs.filesManagement,
353
353
  };
354
354
  }
355
+ /** The recorded id of the chat-service app (`coolify_chat_service`, set by the `coolify:chat` step). */
356
+ const CHAT_SERVICE_APP_SCOPE_KEY = 'coolify_chat_service';
357
+ /** The owner's chat once its `coolify:chat` step has applied; `null` before, and when chat is off. */
358
+ export function ownerChatFrom(owner, recorded) {
359
+ const appId = recorded[CHAT_SERVICE_APP_SCOPE_KEY];
360
+ if (!owner || !owner.chat.enabled || !appId || !recorded[`coolify:chat:${owner.id}`])
361
+ return null;
362
+ return {
363
+ appId,
364
+ agoraHost: agoraHostnameFor(owner),
365
+ subjects: owner.owners.flatMap((o) => {
366
+ const id = recorded[`zitadel:owner:${owner.id}:${o.email.toLowerCase()}`];
367
+ return id ? [id] : [];
368
+ }),
369
+ };
370
+ }
@@ -354,6 +354,10 @@ export declare class FoundryPlatformStepExecutor implements PlatformStepExecutor
354
354
  access: boolean;
355
355
  vars: string;
356
356
  };
357
+ uptimeChat?: {
358
+ pending: boolean;
359
+ blocked?: string;
360
+ };
357
361
  archonRunsInsideApp?: boolean;
358
362
  archonDeployRunning?: string;
359
363
  archonIdTokenUserinfo?: boolean;
@@ -606,6 +610,16 @@ export interface PlatformMailAdminPort {
606
610
  issuerUrl: string;
607
611
  audience: string;
608
612
  };
613
+ /**
614
+ * Where Stalwart keeps its data: a Postgres reached at `host:port`. The password is not here:
615
+ * Stalwart reads it from the env var {@link MAIL_DATABASE_PASSWORD_ENV}.
616
+ */
617
+ dataStore: {
618
+ host: string;
619
+ port: number;
620
+ database: string;
621
+ user: string;
622
+ };
609
623
  }): Promise<'restart' | undefined>;
610
624
  /**
611
625
  * `StalwartClient.createApiKey`'s own signature. The key belongs to a real
@@ -642,7 +656,7 @@ export declare function defaultStalwartForInstance(input: {
642
656
  * own shape.
643
657
  */
644
658
  export interface PlatformMailDeps {
645
- coolify: Pick<CoolifyPort, 'findProjectByName' | 'createProject' | 'findOrCreateEnvironment' | 'listServers' | 'findApplicationByName' | 'getApplication' | 'createDockerImageApplication' | 'createDockerfileApplication' | 'raw' | 'listStorages' | 'createPersistentStorage' | 'bulkUpsertEnvs' | 'listEnvs' | 'updateApplication' | 'deploy' | 'deployAndWait' | 'restart' | 'getDeployment'>;
659
+ coolify: Pick<CoolifyPort, 'findProjectByName' | 'createProject' | 'findOrCreateEnvironment' | 'listServers' | 'findApplicationByName' | 'getApplication' | 'createDockerImageApplication' | 'createDockerfileApplication' | 'createPostgresDatabase' | 'getDatabase' | 'listDatabases' | 'deleteApplication' | 'raw' | 'listStorages' | 'createPersistentStorage' | 'bulkUpsertEnvs' | 'listEnvs' | 'updateApplication' | 'deploy' | 'deployAndWait' | 'restart' | 'getDeployment'>;
646
660
  /** F4.dns: every DNS record call, through edge at its recorded address (see {@link PlatformDnsFactory}). */
647
661
  dns: PlatformDnsFactory;
648
662
  /** F4.tunnels: every tunnel call, through edge at its recorded address (see {@link PlatformTunnelsFactory}). */
@@ -684,6 +698,8 @@ export interface PlatformMailRuntime {
684
698
  readyIntervalMs?: number;
685
699
  /** Tries at handing the admin key to the host (default 3). */
686
700
  deliverAttempts?: number;
701
+ /** Called with the mail database's password, so the host can keep it out of every message it writes. */
702
+ onSecret?: (value: string) => void;
687
703
  }
688
704
  /**
689
705
  * What {@link provisionMailServer} reads besides the target's names: the
@@ -701,6 +717,12 @@ export interface MailServerInput {
701
717
  mailHost: string;
702
718
  /** The Stalwart image tag. */
703
719
  imageTag: string;
720
+ /**
721
+ * The plan's confirmed switch of a volume-backed server to Postgres: delete the Stalwart app and its
722
+ * volume, clear the ids that describe it, then provision as usual. Without it a server still on its
723
+ * volume is refused before any write.
724
+ */
725
+ switchStore?: boolean;
704
726
  /** `host:container` list; empty publishes no host port. */
705
727
  hostPorts: string;
706
728
  /** The sign-in the server's directory trusts; its `mail` project's id is the audience. */
@@ -17,10 +17,11 @@ import { LIMITS, limited } from './own-limits.js';
17
17
  export { FILES_MANAGEMENT_KEY_REF, JOBS_ADMIN_TOKEN_REF, PLATFORM_AI_MANAGEMENT_KEY_REF, } from './held-key-refs.js';
18
18
  import { coolifyDomains, coolifyTunnelDomains, filesShareHostname } from './hostnames.js';
19
19
  import { MAIL_RELAY_BLOCKED, provisionMailRelay } from './mail-relay.js';
20
+ import { MAIL_APP_SCOPE_KEY, MAIL_DATABASE_NAME, MAIL_DATABASE_PASSWORD_ENV, MAIL_DATABASE_SCOPE_KEY, MAIL_STORE_POSTGRES, MAIL_STORE_SCOPE_KEY, mailStoreIsLegacy, } from './mail-store.js';
20
21
  import { OIKOS_INGRESS_BLOCKED, PLATFORM_OIKOS_INGRESS_KEY, oikosIngressApplied, oikosIngressZoneBlocked, provisionOikosIngress, } from './oikos-ingress.js';
21
22
  import { recordOwnDnsHostname } from './own-dns.js';
22
23
  import { platformNetwork, platformTarget, scopeFromPlatform, } from './own-services.js';
23
- 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_ACCESS_ZITADEL_APP_SCOPE_KEY, PLATFORM_ACCESS_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_CONNECTOR_APP_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_AI_TUNNEL_SCOPE_KEY, PLATFORM_ARCHON_ACCESS_PIN_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_AUTH_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_AUTH_ORG_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_CI_ORG_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_FILES_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_FIRST_OWNER_LINK_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY, PLATFORM_ARCHON_APP_KEY_ORG_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_ORG_DOMAIN_BLOCKED_SCOPE_KEY, PLATFORM_ARCHON_ORG_DOMAIN_SCOPE_KEY, PLATFORM_ARCHON_ORG_POLICY_SCOPE_KEY, PLATFORM_ARCHON_ORG_REGISTRATION_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_ORG_SCOPE_KEY, PLATFORM_ARCHON_OWNER_INVITE_ORG_SCOPE_KEY, PLATFORM_ARCHON_OWNER_INVITE_SCOPE_KEY, PLATFORM_ARCHON_RETIRED_CLIENT_SCOPE_KEY, PLATFORM_ARCHON_UPTIME_TOKEN_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY, PLATFORM_ARCHON_ZITADEL_PROJECT_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_ORG_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, PLATFORM_AUTH_REGISTRATION_CLOSED_SCOPE_KEY, PLATFORM_AUTH_RESERVED_ADMIN_USERNAME_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_HEARTBEATS_HOSTED_API_KEY_REF, 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_HOST_PORTS_DIRECT, PLATFORM_MAIL_HOST_PORTS_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_RELAY_KEY, PLATFORM_MAIL_ROUTE_SCOPE_KEY, PLATFORM_MAIL_ROUTE_WANTED, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_OWNER_ORG_DOMAIN_BLOCKED_SCOPE_KEY, PLATFORM_OWNER_ORG_DOMAIN_SCOPE_KEY, PLATFORM_OWNER_ORG_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, PLATFORM_ZITADEL_ORG_SCOPE_KEY, archonFilesMarker, archonFirstOwnerLink, archonOrgDomain, archonOrgName, desiredHeartbeatCheckNames, heartbeatCheckUrlRef, parseBrokerDeployRecord, parseLoginClientRecord, platformBackupHeartbeatCheckName, platformMailHostPorts, platformMailMxHost, uptimeAccessTokenConflict, uptimeRecorded, uptimeVarsDigest, } from './platform-diff.js';
24
+ 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_ACCESS_ZITADEL_APP_SCOPE_KEY, PLATFORM_ACCESS_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_CONNECTOR_APP_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_AI_TUNNEL_SCOPE_KEY, PLATFORM_ARCHON_ACCESS_PIN_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_AUTH_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_AUTH_ORG_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_CI_ORG_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_FILES_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_FIRST_OWNER_LINK_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY, PLATFORM_ARCHON_APP_KEY_ORG_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_ORG_DOMAIN_BLOCKED_SCOPE_KEY, PLATFORM_ARCHON_ORG_DOMAIN_SCOPE_KEY, PLATFORM_ARCHON_ORG_POLICY_SCOPE_KEY, PLATFORM_ARCHON_ORG_REGISTRATION_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_ORG_SCOPE_KEY, PLATFORM_ARCHON_OWNER_INVITE_ORG_SCOPE_KEY, PLATFORM_ARCHON_OWNER_INVITE_SCOPE_KEY, PLATFORM_ARCHON_RETIRED_CLIENT_SCOPE_KEY, PLATFORM_ARCHON_UPTIME_TOKEN_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY, PLATFORM_ARCHON_ZITADEL_PROJECT_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_ORG_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, PLATFORM_AUTH_REGISTRATION_CLOSED_SCOPE_KEY, PLATFORM_AUTH_RESERVED_ADMIN_USERNAME_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_HEARTBEATS_HOSTED_API_KEY_REF, 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_HOST_PORTS_DIRECT, PLATFORM_MAIL_HOST_PORTS_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_RELAY_KEY, PLATFORM_MAIL_ROUTE_SCOPE_KEY, PLATFORM_MAIL_ROUTE_WANTED, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_OWNER_ORG_DOMAIN_BLOCKED_SCOPE_KEY, PLATFORM_OWNER_ORG_DOMAIN_SCOPE_KEY, PLATFORM_OWNER_ORG_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_CHAT_ROOM_ID_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, PLATFORM_ZITADEL_ORG_SCOPE_KEY, archonFilesMarker, archonFirstOwnerLink, archonOrgDomain, archonOrgName, desiredHeartbeatCheckNames, heartbeatCheckUrlRef, parseBrokerDeployRecord, parseLoginClientRecord, platformBackupHeartbeatCheckName, platformMailHostPorts, platformMailMxHost, uptimeAccessTokenConflict, uptimeEffectiveVars, uptimeRecorded, uptimeVarsDigest, } from './platform-diff.js';
24
25
  import { ensureSignInOrg, inviteOwnerWithLink, signInOrgDomain } from './sign-in-org.js';
25
26
  export { PLATFORM_ZITADEL_ORG_SCOPE_KEY };
26
27
  import { applyCallsSfuPlatformStep, callsSfuAppliedFingerprint, } from './platform-sfu.js';
@@ -372,7 +373,7 @@ export class FoundryPlatformStepExecutor {
372
373
  if (!apiKey)
373
374
  blocked.heartbeatsApiKeyMissing = true;
374
375
  }
375
- await this.uptimeBlocked(platform, recordedIds, blocked);
376
+ await this.uptimeBlocked(platform, recordedIds, blocked, owner);
376
377
  // #604: only once edge and sign-in are applied (the planner reports
377
378
  // that case itself) and no provider is recorded yet.
378
379
  if (platform.edge.enabled &&
@@ -440,7 +441,7 @@ export class FoundryPlatformStepExecutor {
440
441
  * checks the pin and Archon first, so none of the reads here happens before
441
442
  * they hold.
442
443
  */
443
- async uptimeBlocked(platform, recordedIds, blocked) {
444
+ async uptimeBlocked(platform, recordedIds, blocked, owner = {}) {
444
445
  const deployed = Boolean(recordedIds[`cloudflare:worker:${PLATFORM_UPTIME_KEY}`]) &&
445
446
  !recordedIds[PLATFORM_UPTIME_REMOVED_SCOPE_KEY];
446
447
  if (!platform.uptime.enabled && !uptimeRecorded(recordedIds))
@@ -453,15 +454,41 @@ export class FoundryPlatformStepExecutor {
453
454
  }
454
455
  if (!platform.uptime.enabled)
455
456
  return;
456
- blocked.uptimeSecretsHeld = Object.keys(await heldUptimeValues(deps));
457
+ const heldValues = await heldUptimeValues(deps);
458
+ blocked.uptimeSecretsHeld = Object.keys(heldValues);
457
459
  const accessTokenId = recordedIds[PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY];
458
460
  const accessHeld = Boolean((await deps.heldKeys.resolve(PLATFORM_UPTIME_ACCESS_CLIENT_ID_REF)) &&
459
461
  (await deps.heldKeys.resolve(PLATFORM_UPTIME_ACCESS_CLIENT_SECRET_REF)));
460
462
  blocked.uptimeHeld = {
461
463
  token: Boolean(await deps.heldKeys.resolve(PLATFORM_UPTIME_TOKEN_REF)),
462
464
  access: accessHeld,
463
- vars: uptimeVarsDigest(deps.vars),
465
+ vars: uptimeVarsDigest(uptimeEffectiveVars(deps.vars, deps.chat && owner.chat && recordedIds[PLATFORM_UPTIME_CHAT_ROOM_ID_SCOPE_KEY]
466
+ ? {
467
+ homeserverUrl: `https://${owner.chat.agoraHost}`,
468
+ roomId: recordedIds[PLATFORM_UPTIME_CHAT_ROOM_ID_SCOPE_KEY],
469
+ }
470
+ : null)),
464
471
  };
472
+ if (deps.chat && !heldValues.CHAT_ACCESS_TOKEN) {
473
+ if (!owner.chat) {
474
+ blocked.uptimeChat = {
475
+ pending: false,
476
+ blocked: "the uptime Worker's chat alerts are blocked, not planned: the owner company's chat is not applied yet",
477
+ };
478
+ }
479
+ else if (owner.chat.subjects.length === 0) {
480
+ blocked.uptimeChat = {
481
+ pending: false,
482
+ blocked: "the uptime Worker's chat alerts are blocked, not planned: the owner company has no owner with a recorded sign-in user to join the alerts room",
483
+ };
484
+ }
485
+ else {
486
+ blocked.uptimeChat = { pending: true };
487
+ }
488
+ }
489
+ else if (deps.chat) {
490
+ blocked.uptimeChat = { pending: false };
491
+ }
465
492
  const edgeUrl = recordedIds[PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY];
466
493
  // A token of the name with no id recorded cannot be minted over, and
467
494
  // foundry does not remove what it did not make. With an id recorded the
@@ -667,7 +694,7 @@ export class FoundryPlatformStepExecutor {
667
694
  if (!this.mail) {
668
695
  throw new Error('platform:mail needs FoundryPlatformStepExecutor to be constructed with its mail dependencies (#169)');
669
696
  }
670
- return provisionPlatformMail(context, this.limitedOnPlatform(this.mail, LIMITS.platformMail));
697
+ return provisionPlatformMail(context, this.limitedOnPlatform(this.mail, LIMITS.platformMail), action.desired.switchMailStore === true);
671
698
  }
672
699
  if (action.system === 'coolify' &&
673
700
  action.kind === 'app' &&
@@ -2361,7 +2388,7 @@ const STALWART_READY_PATH = '/healthz/ready';
2361
2388
  /** Where the server the app was created on is recorded, so the MX record follows the app, not a later pick. */
2362
2389
  export const PLATFORM_MAIL_SERVER_SCOPE_KEY = 'coolify_platform_mail_server';
2363
2390
  const PLATFORM_MAIL_PROJECT_SCOPE_KEY = 'coolify_project_platform_mail';
2364
- const PLATFORM_MAIL_APP_SCOPE_KEY = 'coolify_platform_mail_app';
2391
+ const PLATFORM_MAIL_APP_SCOPE_KEY = MAIL_APP_SCOPE_KEY;
2365
2392
  const PLATFORM_MAIL_RELAY_APP_SCOPE_KEY = 'coolify_platform_mail_relay_app';
2366
2393
  const PLATFORM_MAIL_TUNNEL_SCOPE_KEY = 'cloudflare_platform_mail_tunnel';
2367
2394
  /**
@@ -2410,7 +2437,7 @@ export function defaultStalwartForInstance(input) {
2410
2437
  user: input.user,
2411
2438
  });
2412
2439
  return {
2413
- async applySettings({ mailHost, apex, oidc }) {
2440
+ async applySettings({ mailHost, apex, oidc, dataStore }) {
2414
2441
  // A fresh Stalwart (0.16) is in bootstrap mode and refuses every write
2415
2442
  // but the `Bootstrap` object, and loads that object only on its next
2416
2443
  // start, so write it and hand the restart to the caller. A server
@@ -2418,9 +2445,29 @@ export function defaultStalwartForInstance(input) {
2418
2445
  // carries the generated `admin@<apex>` secret: this step signs in with
2419
2446
  // the recovery admin and never needs it.
2420
2447
  if ((await instance.client.probe()) === 'bootstrap') {
2448
+ if (!dataStore.host ||
2449
+ !dataStore.database ||
2450
+ !dataStore.user ||
2451
+ !Number.isInteger(dataStore.port) ||
2452
+ dataStore.port < 1 ||
2453
+ dataStore.port > 65535) {
2454
+ throw new Error('platform:mail: the mail data store is not a usable Postgres address');
2455
+ }
2421
2456
  await instance.client.update('Bootstrap', 'singleton', {
2422
2457
  serverHostname: mailHost,
2423
2458
  defaultDomain: apex,
2459
+ // The password is read from the app's env, never written into Stalwart's settings.
2460
+ dataStore: {
2461
+ '@type': 'PostgreSql',
2462
+ host: dataStore.host,
2463
+ port: dataStore.port,
2464
+ database: dataStore.database,
2465
+ authUsername: dataStore.user,
2466
+ authSecret: {
2467
+ '@type': 'EnvironmentVariable',
2468
+ variableName: MAIL_DATABASE_PASSWORD_ENV,
2469
+ },
2470
+ },
2424
2471
  // The server sits behind a tunnel; no ACME challenge can reach it.
2425
2472
  requestTlsCertificate: false,
2426
2473
  });
@@ -2535,7 +2582,7 @@ export async function provisionMailServer(scope, deps, target, input) {
2535
2582
  const zone = input.zone ?? apex;
2536
2583
  const label = target.kind === 'org' ? `own step coolify:own-mail for ${target.id}` : 'platform:mail';
2537
2584
  const keyDescription = target.kind === 'org' ? `foundry:org:${target.id}:admin` : PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION;
2538
- const recorded = scope.recordedIds();
2585
+ let recorded = scope.recordedIds();
2539
2586
  const runtime = deps.runtime ?? {};
2540
2587
  const sleep = runtime.sleep ?? defaultSleep;
2541
2588
  const record = async (key, id) => {
@@ -2552,6 +2599,15 @@ export async function provisionMailServer(scope, deps, target, input) {
2552
2599
  const imageTagBlocked = mailImageTagBlocked(imageTag);
2553
2600
  if (imageTagBlocked)
2554
2601
  throw new Error(imageTagBlocked);
2602
+ // A server whose data is still on its volume is never provisioned over: the plan's confirmed switch
2603
+ // deletes it first, and without that this step stops before any write.
2604
+ if (mailStoreIsLegacy(recorded)) {
2605
+ if (!input.switchStore) {
2606
+ throw new Error(`${label}: the mail server still stores its data on a volume; confirm the mail-store switch in the plan before applying`);
2607
+ }
2608
+ await switchMailStoreToPostgres(scope, deps.coolify, target, environment.id, label);
2609
+ recorded = scope.recordedIds();
2610
+ }
2555
2611
  // One server for both the app and the MX record: recorded the first time,
2556
2612
  // read back after, so a later run never points `mx.<apex>` somewhere the
2557
2613
  // app is not.
@@ -2562,6 +2618,32 @@ export async function provisionMailServer(scope, deps, target, input) {
2562
2618
  const serverIpBlocked = mxRecord ? mailServerIpBlocked(server.ip, mxRecord.serverIpv4) : null;
2563
2619
  if (serverIpBlocked)
2564
2620
  throw new Error(serverIpBlocked);
2621
+ // ---- the data store: a Postgres, created and recorded before the app ----------
2622
+ // A crash after the create resumes with the recorded id and never makes a second database. A crash
2623
+ // before the app exists leaves only this empty database behind; the next run uses it.
2624
+ let mailDbId = recorded[MAIL_DATABASE_SCOPE_KEY];
2625
+ if (!mailDbId) {
2626
+ const found = (await deps.coolify.listDatabases()).find((d) => d.name === target.names.mailDb && d.environment_id === environment.id);
2627
+ if (found) {
2628
+ mailDbId = found.uuid;
2629
+ }
2630
+ else {
2631
+ const created = await deps.coolify.createPostgresDatabase({
2632
+ project_uuid: projectUuid,
2633
+ server_uuid: server.uuid,
2634
+ environment_name: environment.name,
2635
+ environment_uuid: environment.uuid,
2636
+ name: target.names.mailDb,
2637
+ postgres_db: MAIL_DATABASE_NAME,
2638
+ postgres_user: MAIL_DATABASE_NAME,
2639
+ });
2640
+ mailDbId = created.uuid;
2641
+ }
2642
+ await record(MAIL_DATABASE_SCOPE_KEY, mailDbId);
2643
+ }
2644
+ const mailDb = await getDatabaseStarted(deps.coolify, mailDbId);
2645
+ const dataStore = mailDataStore(mailDb.internal_db_url, label);
2646
+ runtime.onSecret?.(dataStore.password);
2565
2647
  const app = await findOrCreatePlatformApp(deps.coolify, target.names.mailApp, environment, async () => {
2566
2648
  return deps.coolify.createDockerImageApplication({
2567
2649
  project_uuid: projectUuid,
@@ -2621,6 +2703,7 @@ export async function provisionMailServer(scope, deps, target, input) {
2621
2703
  const recoveryGenerated = await ensureGeneratedEnv(deps.coolify, appId, 'STALWART_RECOVERY_ADMIN', () => `${STALWART_RECOVERY_USER}:${randomBytes(18).toString('base64')}`);
2622
2704
  const envs = [
2623
2705
  { key: 'STALWART_PUBLIC_URL', value: `https://${hostname}` },
2706
+ { key: MAIL_DATABASE_PASSWORD_ENV, value: dataStore.password },
2624
2707
  ];
2625
2708
  if (recoveryGenerated)
2626
2709
  envs.push({ key: 'STALWART_RECOVERY_ADMIN', value: recoveryGenerated });
@@ -2810,13 +2893,15 @@ export async function provisionMailServer(scope, deps, target, input) {
2810
2893
  mailHost: hostname,
2811
2894
  apex,
2812
2895
  oidc: { issuerUrl: issuer, audience: project.id },
2896
+ dataStore: dataStore.address,
2813
2897
  },
2814
2898
  });
2899
+ await record(MAIL_STORE_SCOPE_KEY, MAIL_STORE_POSTGRES);
2815
2900
  await record(PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, project.id);
2816
2901
  await record(PLATFORM_MAIL_ROUTE_SCOPE_KEY, PLATFORM_MAIL_ROUTE_WANTED);
2817
2902
  }
2818
2903
  catch (error) {
2819
- throw redactedMailError(error, [password]);
2904
+ throw redactedMailError(error, [password, dataStore.password]);
2820
2905
  }
2821
2906
  }
2822
2907
  else if (!recordedProjectId) {
@@ -2851,8 +2936,10 @@ export async function provisionMailServer(scope, deps, target, input) {
2851
2936
  mailHost: hostname,
2852
2937
  apex,
2853
2938
  oidc: { issuerUrl: issuer, audience: project.id },
2939
+ dataStore: dataStore.address,
2854
2940
  },
2855
2941
  });
2942
+ await record(MAIL_STORE_SCOPE_KEY, MAIL_STORE_POSTGRES);
2856
2943
  await record(PLATFORM_MAIL_ROUTE_SCOPE_KEY, PLATFORM_MAIL_ROUTE_WANTED);
2857
2944
  // Recorded and persisted before the secret leaves this function: if
2858
2945
  // delivery fails for good, the next plan reports the lost key rather
@@ -2875,7 +2962,7 @@ export async function provisionMailServer(scope, deps, target, input) {
2875
2962
  await record(PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, appId);
2876
2963
  }
2877
2964
  catch (error) {
2878
- throw redactedMailError(error, [password]);
2965
+ throw redactedMailError(error, [password, dataStore.password]);
2879
2966
  }
2880
2967
  }
2881
2968
  // Last, after every write the applied values depend on, so a run that
@@ -2884,6 +2971,79 @@ export async function provisionMailServer(scope, deps, target, input) {
2884
2971
  await record(PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, imageTag);
2885
2972
  return { resourceId: appId };
2886
2973
  }
2974
+ /**
2975
+ * Where Stalwart reaches its database, and the password it reads from its env. Parsed from Coolify's
2976
+ * `internal_db_url` inside a try: Node's parse error carries the whole input, credential included, so it
2977
+ * is replaced with a fixed message.
2978
+ */
2979
+ function mailDataStore(internalDbUrl, label) {
2980
+ if (!internalDbUrl) {
2981
+ throw new Error(`${label}: the mail database has no internal_db_url yet -- retry this apply once Coolify reports one`);
2982
+ }
2983
+ try {
2984
+ const url = new URL(internalDbUrl);
2985
+ const address = {
2986
+ host: url.hostname,
2987
+ port: url.port ? Number(url.port) : 5432,
2988
+ database: decodeURIComponent(url.pathname.replace(/^\//, '')),
2989
+ user: decodeURIComponent(url.username),
2990
+ };
2991
+ const password = decodeURIComponent(url.password);
2992
+ if (!address.host ||
2993
+ !address.database ||
2994
+ !address.user ||
2995
+ !password ||
2996
+ !Number.isInteger(address.port)) {
2997
+ throw new Error('incomplete');
2998
+ }
2999
+ return { address, password };
3000
+ }
3001
+ catch {
3002
+ throw new Error(`${label}: the mail database's internal_db_url could not be parsed`);
3003
+ }
3004
+ }
3005
+ /**
3006
+ * The recorded ids a recreated Stalwart makes stale: the app and what was deployed or applied to it, and the
3007
+ * admin key minted in it (the new server has none, so it is minted and delivered again). Everything else
3008
+ * stays: the project, the server, the tunnel and its connector, the DNS names, the internal address and the
3009
+ * published ports all describe things the new app reuses.
3010
+ */
3011
+ // A function, not a constant: `platform-diff.ts` and this file import each other, so its keys are not
3012
+ // all initialised yet when this module is evaluated.
3013
+ const mailStoreSwitchClearedKeys = () => [
3014
+ PLATFORM_MAIL_APP_SCOPE_KEY,
3015
+ PLATFORM_MAIL_DEPLOYED_SCOPE_KEY,
3016
+ PLATFORM_MAIL_DEPLOYED_HOSTNAME_SCOPE_KEY,
3017
+ PLATFORM_MAIL_DEPLOYED_GIT_REF_SCOPE_KEY,
3018
+ PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY,
3019
+ PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY,
3020
+ PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY,
3021
+ PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY,
3022
+ PLATFORM_MAIL_ROUTE_SCOPE_KEY,
3023
+ ];
3024
+ /**
3025
+ * Deletes the volume-backed Stalwart app (Coolify's delete removes its volumes) and clears the ids above.
3026
+ * The delete comes first: an app left behind after the ids are cleared would be found by name and reused,
3027
+ * still on its volume. A run that dies between the two finds no app by that name on the next run and only
3028
+ * clears. An app of that name whose uuid is not the recorded one is not deleted.
3029
+ */
3030
+ async function switchMailStoreToPostgres(scope, coolify, target, environmentId, label) {
3031
+ const recorded = scope.recordedIds();
3032
+ const live = await coolify.findApplicationByName(target.names.mailApp, {
3033
+ environmentIds: [environmentId],
3034
+ });
3035
+ if (live) {
3036
+ if (live.uuid !== recorded[PLATFORM_MAIL_APP_SCOPE_KEY]) {
3037
+ throw new Error(`${label}: the app named ${target.names.mailApp} is not the recorded mail server; not deleting it`);
3038
+ }
3039
+ await coolify.deleteApplication(live.uuid);
3040
+ }
3041
+ for (const key of mailStoreSwitchClearedKeys()) {
3042
+ if (recorded[key])
3043
+ scope.recordScopeId(key, '');
3044
+ }
3045
+ await scope.persist();
3046
+ }
2887
3047
  /** The platform's mail project: the shared `platform` project, its id recorded by the mail step under its own key. */
2888
3048
  const platformMailProject = {
2889
3049
  async ensure(coolify) {
@@ -2892,7 +3052,7 @@ const platformMailProject = {
2892
3052
  },
2893
3053
  };
2894
3054
  /** The platform's mail server: {@link provisionMailServer} with {@link platformTarget} and the platform's own inputs. */
2895
- async function provisionPlatformMail(context, deps) {
3055
+ async function provisionPlatformMail(context, deps, switchStore = false) {
2896
3056
  const { platform } = context;
2897
3057
  const scope = scopeFromPlatform(context);
2898
3058
  return provisionMailServer(scope, deps, platformTarget(), {
@@ -2900,6 +3060,7 @@ async function provisionPlatformMail(context, deps) {
2900
3060
  apex: platform.apex,
2901
3061
  mailHost: platformMailMxHost(platform.apex),
2902
3062
  imageTag: platform.mail.gitRef,
3063
+ switchStore,
2903
3064
  hostPorts: platformMailHostPorts(platform),
2904
3065
  issuerUrl: `https://${platform.auth.hostname}`,
2905
3066
  mxRecord: { serverIpv4: platform.serverIpv4 },
@@ -8177,11 +8338,12 @@ const PLATFORM_BACKUP_DEFAULT_FREQUENCY = 'daily';
8177
8338
  * The database a service's schedule must name. A schedule that names none
8178
8339
  * dumps the Coolify resource's first database; Archon's is named here anyway,
8179
8340
  * so a resource whose first database is not {@link ARCHON_POSTGRES_DB} is still
8180
- * dumped right. Every other service runs on its resource's first database and
8181
- * needs no entry.
8341
+ * dumped right. Mail names its database the same way. Every other service runs on its
8342
+ * resource's first database and needs no entry.
8182
8343
  */
8183
8344
  const PLATFORM_BACKUP_DATABASE_NAMES = {
8184
8345
  archon: ARCHON_POSTGRES_DB,
8346
+ mail: MAIL_DATABASE_NAME,
8185
8347
  };
8186
8348
  /**
8187
8349
  * The one schedule both the reader and {@link provisionPlatformBackup} treat
@@ -1,5 +1,6 @@
1
1
  import type { AccessPort } from './edge-access.js';
2
2
  import type { PlatformWorkersPort } from './edge-workers.js';
3
+ import type { CoolifyPort } from './executor.js';
3
4
  import type { PlatformStepExecutionContext } from './plan-apply.js';
4
5
  /** The optional plain text variables the Worker reads, supplied by the host (not secrets). */
5
6
  export declare const UPTIME_OPTIONAL_VARS: readonly ["CHAT_HOMESERVER_URL", "CHAT_ROOM_ID", "ALERT_EMAIL_FROM", "ALERT_EMAIL_TO"];
@@ -9,6 +10,19 @@ export type PlatformWorkersFactory = (edgeUrl: string) => PlatformWorkersPort |
9
10
  export type PlatformUptimeAccessPort = Pick<AccessPort, 'createServiceToken' | 'removeServiceToken' | 'findServiceTokenByName'>;
10
11
  /** Builds the Access port at edge's recorded address, the {@link PlatformWorkersFactory} way. */
11
12
  export type PlatformUptimeAccessFactory = (edgeUrl: string) => PlatformUptimeAccessPort | Promise<PlatformUptimeAccessPort>;
13
+ /**
14
+ * What the uptime step needs to make its chat bot through chat-service. The bearer for
15
+ * `/_chat/internal/*` is `CHAT_INTERNAL_TOKEN` on Company Included's chat-service app, read here and
16
+ * never held, logged or put in an error.
17
+ */
18
+ export interface PlatformUptimeChatDeps {
19
+ /** Reads that app's env (`OwnerChat.appId`). */
20
+ coolify: Pick<CoolifyPort, 'listEnvs'>;
21
+ /** The HTTP client; the global `fetch` by default. */
22
+ fetch?: typeof fetch;
23
+ /** Milliseconds one call may take (default 10 seconds). */
24
+ timeoutMs?: number;
25
+ }
12
26
  /**
13
27
  * Dependencies of the uptime Worker step. `deliverSecret` and `heldKeys` are
14
28
  * the host's: the minted token goes only to `deliverSecret`, and every secret
@@ -26,8 +40,13 @@ export interface PlatformUptimeDeps {
26
40
  heldKeys: {
27
41
  resolve(ref: string): Promise<string | null>;
28
42
  };
29
- /** Host config for the Worker's optional plain text variables; an absent one is left unset. */
43
+ /**
44
+ * Host config for the Worker's optional plain text variables; an absent one is left unset. A set
45
+ * `CHAT_HOMESERVER_URL` or `CHAT_ROOM_ID` overrides the one foundry made.
46
+ */
30
47
  vars?: Partial<Record<(typeof UPTIME_OPTIONAL_VARS)[number], string>>;
48
+ /** Makes the Worker's chat bot and its token. Absent: the chat credential is held by hand. */
49
+ chat?: PlatformUptimeChatDeps;
31
50
  }
32
51
  /**
33
52
  * What is held now: the optional secrets, and `UPTIME_HEARTBEAT_URL` (a ping