@wtfalch/foundry 0.36.1 → 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.
package/dist/index.d.ts CHANGED
@@ -54,7 +54,7 @@ export { checkMailIdentityAudience } from './plan/index.js';
54
54
  export type { MailAllowlistApp } from './plan/index.js';
55
55
  export { checkMailAppsAllowlist } from './plan/index.js';
56
56
  export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, createZitadelAdminMinter, createZitadelLoginPolicy, createZitadelProjectRoles, PLATFORM_AUTH_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_RELAY_KEY, PLATFORM_MAIL_RELAY_ROUTES_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, } from './plan/index.js';
57
- export type { PlatformAuthDeps, PlatformAuthSmtp, PlatformAuthRuntime, PlatformFilesDeps, PlatformFilesRuntime, PlatformEdgeDeps, PlatformDnsFactory, PlatformAccessFactory, PlatformUptimeAccessFactory, PlatformUptimeAccessPort, PlatformUptimeDeps, PlatformWorkersFactory, PlatformWorkersPort, PlatformAccessPort, PlatformTunnelsFactory, PlatformEdgeRuntime, } from './plan/index.js';
57
+ export type { PlatformAuthDeps, PlatformAuthSmtp, PlatformAuthRuntime, PlatformFilesDeps, PlatformFilesRuntime, PlatformEdgeDeps, PlatformDnsFactory, PlatformAccessFactory, PlatformUptimeAccessFactory, PlatformUptimeAccessPort, PlatformUptimeChatDeps, PlatformUptimeDeps, PlatformWorkersFactory, PlatformWorkersPort, PlatformAccessPort, PlatformTunnelsFactory, PlatformEdgeRuntime, } from './plan/index.js';
58
58
  export type { PlatformMailDeps, PlatformMailAdminPort, PlatformMailRuntime, } from './plan/index.js';
59
59
  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';
60
60
  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';
@@ -52,8 +52,8 @@ export interface BackupGatePorts {
52
52
  * `unconfirmedUpgrades` and before any step. A service whose database the
53
53
  * company does not have passes (there is nothing to migrate); a database that
54
54
  * exists with no recorded schedule, a disabled or non-S3 schedule, a failed or
55
- * old dump, or an unreadable list all refuse. `mail` has no Postgres and is
56
- * not covered here (plan note `backup-not-covered:mail`).
55
+ * old dump, or an unreadable list all refuse. `mail` is covered like the rest:
56
+ * Stalwart's data store is a Postgres database.
57
57
  *
58
58
  * Where this Coolify has no executions route, the fallback is that the
59
59
  * schedule exists, is enabled and saves to S3; that proves a dump is
@@ -88,6 +88,6 @@ export declare function requireFreshBackupsForMove(ports: MoveGatePorts): Promis
88
88
  /**
89
89
  * The platform databases an upgrade of `service` can migrate. Postgres is all three. archon, ai and
90
90
  * integrations each migrate their own database when the new container starts. The owner company runs its own
91
- * sign-in, files and jobs, so the platform has no database for them; mail, edge and hc have no Postgres.
91
+ * sign-in, files and jobs, so the platform has no database for them; edge and hc have no Postgres.
92
92
  */
93
93
  export declare function platformDatabasesForService(service: string): readonly string[] | 'all';
@@ -123,8 +123,8 @@ function freshSuccess(executions, now, maxAgeMs = BACKUP_MAX_AGE_HOURS * 3_600_0
123
123
  * `unconfirmedUpgrades` and before any step. A service whose database the
124
124
  * company does not have passes (there is nothing to migrate); a database that
125
125
  * exists with no recorded schedule, a disabled or non-S3 schedule, a failed or
126
- * old dump, or an unreadable list all refuse. `mail` has no Postgres and is
127
- * not covered here (plan note `backup-not-covered:mail`).
126
+ * old dump, or an unreadable list all refuse. `mail` is covered like the rest:
127
+ * Stalwart's data store is a Postgres database.
128
128
  *
129
129
  * Where this Coolify has no executions route, the fallback is that the
130
130
  * schedule exists, is enabled and saves to S3; that proves a dump is
@@ -252,10 +252,15 @@ export async function requireFreshBackupsForMove(ports) {
252
252
  /**
253
253
  * The platform databases an upgrade of `service` can migrate. Postgres is all three. archon, ai and
254
254
  * integrations each migrate their own database when the new container starts. The owner company runs its own
255
- * sign-in, files and jobs, so the platform has no database for them; mail, edge and hc have no Postgres.
255
+ * sign-in, files and jobs, so the platform has no database for them; edge and hc have no Postgres.
256
256
  */
257
257
  export function platformDatabasesForService(service) {
258
258
  if (service === 'postgres')
259
259
  return 'all';
260
- return service === 'archon' || service === 'ai' || service === 'integrations' ? [service] : [];
260
+ return service === 'archon' ||
261
+ service === 'ai' ||
262
+ service === 'integrations' ||
263
+ service === 'mail'
264
+ ? [service]
265
+ : [];
261
266
  }
@@ -11,6 +11,11 @@ import type { DraftStep } from './diff.js';
11
11
  * `app` is the hosting entry's database, recorded as a resource id.
12
12
  */
13
13
  export declare function companyDatabaseKeys(org: Pick<DeclaredOrg, 'id'>): Readonly<Record<string, string>>;
14
+ /**
15
+ * The database a company backup schedule must name when the Coolify resource
16
+ * is not left to pick its first database. Absent: the resource's first database.
17
+ */
18
+ export declare const COMPANY_BACKUP_DATABASE_NAMES: Readonly<Record<string, string>>;
14
19
  /** What every recorded-id key of a company database's backup schedule starts with. */
15
20
  export declare const BACKUP_SCOPE_PREFIX = "backup_";
16
21
  /** The recorded-id key a company database's backup schedule uuid is stored under. */
@@ -22,8 +27,8 @@ export declare function databasesForService(service: string): readonly string[]
22
27
  * recorded uuid and no recorded backup yet. `storageUuid` undefined means the
23
28
  * host did not wire backups, so nothing is planned and nothing is said; null
24
29
  * means the platform's backup storage is not registered, so each database is
25
- * reported as blocked instead (rule 2). A company that runs its own mail
26
- * server carries a note that the Stalwart volume has no Postgres to dump.
30
+ * reported as blocked instead (rule 2). A company's mail server is one of
31
+ * these databases (Stalwart's data store), dumped like the rest.
27
32
  */
28
33
  export declare function planCompanyBackups(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, storageUuid: string | null | undefined): {
29
34
  steps: DraftStep[];
@@ -1,6 +1,6 @@
1
- import { ownsServices } from '../org.js';
2
1
  import { BOULE_DATABASE_SCOPE_KEY } from './boule.js';
3
2
  import { CHAT_REKEY_MAS_DB_SCOPE_KEY, CHAT_REKEY_SYNAPSE_DB_SCOPE_KEY } from './executor.js';
3
+ import { MAIL_DATABASE_NAME, MAIL_DATABASE_SCOPE_KEY } from './mail-store.js';
4
4
  /**
5
5
  * F6: the Postgres databases of a company that get a nightly backup, and the
6
6
  * recorded id each one's uuid sits under. The keys are second, documented
@@ -14,6 +14,7 @@ export function companyDatabaseKeys(org) {
14
14
  return {
15
15
  app: `coolify:database:${org.id}`,
16
16
  cms: 'coolify_cms_database',
17
+ mail: MAIL_DATABASE_SCOPE_KEY,
17
18
  auth: 'coolify_platform_auth_database',
18
19
  files: 'coolify_platform_files_database',
19
20
  jobs: 'coolify_jobs_database',
@@ -28,6 +29,13 @@ export function companyDatabaseKeys(org) {
28
29
  chat_rekey_mas: CHAT_REKEY_MAS_DB_SCOPE_KEY,
29
30
  };
30
31
  }
32
+ /**
33
+ * The database a company backup schedule must name when the Coolify resource
34
+ * is not left to pick its first database. Absent: the resource's first database.
35
+ */
36
+ export const COMPANY_BACKUP_DATABASE_NAMES = {
37
+ mail: MAIL_DATABASE_NAME,
38
+ };
31
39
  /** What every recorded-id key of a company database's backup schedule starts with. */
32
40
  export const BACKUP_SCOPE_PREFIX = 'backup_';
33
41
  /** The recorded-id key a company database's backup schedule uuid is stored under. */
@@ -43,6 +51,8 @@ export function databasesForService(service) {
43
51
  return ['files'];
44
52
  case 'jobs':
45
53
  return ['jobs'];
54
+ case 'mail':
55
+ return ['mail'];
46
56
  case 'synapse':
47
57
  return ['synapse', 'chat_synapse'];
48
58
  case 'mas':
@@ -58,8 +68,8 @@ export function databasesForService(service) {
58
68
  * recorded uuid and no recorded backup yet. `storageUuid` undefined means the
59
69
  * host did not wire backups, so nothing is planned and nothing is said; null
60
70
  * means the platform's backup storage is not registered, so each database is
61
- * reported as blocked instead (rule 2). A company that runs its own mail
62
- * server carries a note that the Stalwart volume has no Postgres to dump.
71
+ * reported as blocked instead (rule 2). A company's mail server is one of
72
+ * these databases (Stalwart's data store), dumped like the rest.
63
73
  */
64
74
  export function planCompanyBackups(org, recordedIds, storageUuid) {
65
75
  const steps = [];
@@ -91,11 +101,5 @@ export function planCompanyBackups(org, recordedIds, storageUuid) {
91
101
  },
92
102
  });
93
103
  }
94
- if (ownsServices(org)) {
95
- blocked.push({
96
- description: `${org.slug}'s mail server keeps its data on a volume, not in Postgres: no backup covers it yet`,
97
- key: 'backup-not-covered:mail',
98
- });
99
- }
100
104
  return { steps, blocked };
101
105
  }
package/dist/plan/diff.js CHANGED
@@ -14,6 +14,7 @@ import { appHostname, tunnelDnsHostnames } from './hostnames.js';
14
14
  import { lockedHostnames } from './locked-hostnames.js';
15
15
  import { LOGIN_BINDING_SCOPE_KEY, agoraIssuerHostFor, companyLoginTunnelHost, loginBindingState, loginHostClientIds, loginHostServesAgora, loginHostnameFor, ownAuthApplied, pendingRemovalHosts, } from './login-host.js';
16
16
  import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, } from './mail-copy.js';
17
+ import { mailStoreSwitchChange } from './mail-store.js';
17
18
  import { hasAdoptedResources, networkMoveNeeded, planNetworkMove } from './network-move.js';
18
19
  import { OWN_AUTH_BLOCKED_KEY, OWN_FILES_BLOCKED_KEY, OWN_FILES_R2_BLOCKED_KEY, OWN_JOBS_BLOCKED_KEY, OWN_MAIL_BLOCKED_KEY, OWN_SFU_BLOCKED_KEY, OWN_SFU_FIREWALL_KEY, planOwnAuth, planOwnFiles, planOwnFilesR2, planOwnJobs, planOwnMail, planOwnSfu, } from './own-diff.js';
19
20
  import { ownDnsHostnames } from './own-dns.js';
@@ -319,6 +320,9 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
319
320
  // uses, never `versionChangeLine`'s "X -> Y (redeploy)" -- nothing here
320
321
  // ever performs that redeploy.
321
322
  const versionChanges = [];
323
+ if (ownMail.step?.action.desired.switchMailStore) {
324
+ versionChanges.push(mailStoreSwitchChange(org.slug));
325
+ }
322
326
  if (org.calls.enabled && !calls.blocked) {
323
327
  const callsAppLive = Boolean(byKey.get(resourceKey('coolify', 'calls', org.id)));
324
328
  const synapseChange = versionNotAppliedLine('synapse', callsAppLive, recordedIds[CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY], versionPin?.calls ?? VERSIONS.synapse);
@@ -15,6 +15,7 @@ import { type MailCopyPort } from './mail-copy.js';
15
15
  import { type OwnServicesPort } from './own-services.js';
16
16
  import type { DestroyTarget, StepDestroyer, StepExecutionContext, StepExecutor } from './plan-apply.js';
17
17
  import type { EdgePortName } from './platform-org.js';
18
+ import type { RequestHeaderPort } from './platform-providers.js';
18
19
  export declare function coolifyProjectMark(org: Pick<DeclaredOrg, 'id'>): string;
19
20
  /**
20
21
  * The Coolify calls this executor makes, shaped to match
@@ -1595,6 +1596,13 @@ export interface FoundryStepExecutorPorts {
1595
1596
  * `edge.tunnels` (see {@link EdgePort.tunnels}). Kept for a host or a test that supplies its own.
1596
1597
  */
1597
1598
  tunnels?: TunnelPort;
1599
+ /**
1600
+ * #745: the Cloudflare request header rule call, edge's `platform.requestHeaders`
1601
+ * (`@wtfalch/edge` 0.14.0). A company's sign-in hostname gets its header strip rule
1602
+ * through it before its DNS record; without it the step logs one fixed line and
1603
+ * writes no rule. Mail and SFU hostnames never use it.
1604
+ */
1605
+ requestHeaders?: RequestHeaderPort;
1598
1606
  /**
1599
1607
  * F4.access: overrides the Access calls, which otherwise go through
1600
1608
  * `edge.access` (see {@link EdgePort.access}). Kept for a host or a test that supplies its own.
@@ -13,7 +13,7 @@ import { recordBuiltCommit } from './build-record.js';
13
13
  import { MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, masDockerfile, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
14
14
  import { CHAT_MAS_PORT, CHAT_REPO, CHAT_SERVICE_DOCKERFILE, CHAT_SERVICE_PORT, CHAT_SYNAPSE_DOCKERFILE, CHAT_SYNAPSE_PORT, assertChatServerName, chatInternalNames, chatMasConfigYaml, chatRekeyedInternalNames, chatServerName, chatServiceEnvs, chatSynapseHomeserverYaml, chatUpstreams, } from './chat-matrix.js';
15
15
  import { ensureCloudflaredConnector } from './cloudflared-connector.js';
16
- import { BACKUP_SCOPE_PREFIX, backupScopeKey } from './company-backups.js';
16
+ import { BACKUP_SCOPE_PREFIX, COMPANY_BACKUP_DATABASE_NAMES, backupScopeKey, } from './company-backups.js';
17
17
  import { CORE_FILES_COLLECTION_NAME, FILES_CORE_COLLECTION_SCOPE_KEY, ZITADEL_FILES_PROJECT_SCOPE_KEY, } from './core-files.js';
18
18
  import { waitForNewDeployment, } from './deploy-health.js';
19
19
  import { PLATFORM_NETWORK, companyNetworkName, ensureDestination, findDestination, onPlatformNetwork, placed, placedByScope, } from './destinations.js';
@@ -2633,6 +2633,7 @@ export class FoundryStepExecutor {
2633
2633
  databaseUuid: databaseId,
2634
2634
  label: `${org.slug} ${database}`,
2635
2635
  storageUuid,
2636
+ databaseName: COMPANY_BACKUP_DATABASE_NAMES[database],
2636
2637
  });
2637
2638
  context.recordScopeId(backupScopeKey(database), uuid);
2638
2639
  return undefined;
@@ -5749,7 +5750,12 @@ export class FoundryStepExecutor {
5749
5750
  apex: hn.zone,
5750
5751
  gitRef,
5751
5752
  project: { ensure: () => this.ensureCoolifyProject(org, context) },
5752
- network: orgNetwork({ dns: this.dnsPort(), tunnels: this.tunnelPort(), zones: this.zonePort() }, scope, `${org.slug}:auth`),
5753
+ network: orgNetwork({
5754
+ dns: this.dnsPort(),
5755
+ tunnels: this.tunnelPort(),
5756
+ requestHeaders: this.ports.requestHeaders,
5757
+ zones: this.zonePort(),
5758
+ }, scope, `${org.slug}:auth`),
5753
5759
  smtpSender: `noreply@${hn.mailDomain}`,
5754
5760
  defaultRedirectUri: `https://${agoraHostnameFor(org)}`,
5755
5761
  ownerCompany: null,
@@ -5808,12 +5814,17 @@ export class FoundryStepExecutor {
5808
5814
  },
5809
5815
  zitadel: own.zitadelFor({ issuerUrl, bearerRef: target.refs.instanceBearer }),
5810
5816
  zitadelForInstance: undefined,
5817
+ runtime: {
5818
+ ...own.provision.mail.runtime,
5819
+ onSecret: (value) => this.handledSecrets.add(value),
5820
+ },
5811
5821
  }, target, {
5812
5822
  hostname: action.desired.hostname,
5813
5823
  apex: hn.mailDomain,
5814
5824
  zone: hn.zone,
5815
5825
  mailHost: action.desired.mailHost,
5816
5826
  imageTag: action.desired.gitRef,
5827
+ switchStore: action.desired.switchMailStore === true,
5817
5828
  hostPorts: bind ? `${bind}:${smtpPort}:25` : `${smtpPort}:25`,
5818
5829
  issuerUrl,
5819
5830
  mxRecord: null,
@@ -23,6 +23,7 @@ export { AdoptRefusedError } from './adopt.js';
23
23
  export type { AuditActor, AuditLog, AuthzGate, StepExecutor, StepDestroyer, DestroyTarget, StepExecutionContext, PlatformStepExecutor, PlatformStepExecutionContext, FoundryPlanApplyOptions, FoundryApplyOptions, } from './plan-apply.js';
24
24
  export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, UpgradeConfirmationRequiredError, planNeedsConfirmation, } from './plan-apply.js';
25
25
  export type { UnconfirmedUpgrade } from './confirm-upgrade.js';
26
+ export { MAIL_STORE_SWITCH_SERVICE } from './mail-store.js';
26
27
  export { NetworkMoveBackupMissingError, UpgradeBackupMissingError } from './backup-gate.js';
27
28
  export type { BackupGatePorts, MoveGatePorts } from './backup-gate.js';
28
29
  export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, filesPublicHostsValue, filesShareDomains, provisionFilesHost, provisionJobsHost, provisionMailServer, } from './platform-providers.js';
@@ -63,7 +64,7 @@ export { createEdgeAccess } from './edge-access.js';
63
64
  export type { AccessAppSummary, AccessDecision, AccessPolicyRule, AccessPort, EdgePlatformAccess, } from './edge-access.js';
64
65
  export { createEdgeWorkers } from './edge-workers.js';
65
66
  export type { EdgePlatformWorkers, EdgeWorkersOptions, PlatformWorkersPort, } from './edge-workers.js';
66
- export type { PlatformUptimeAccessFactory, PlatformUptimeAccessPort, PlatformUptimeDeps, PlatformWorkersFactory, } from './uptime-worker.js';
67
+ export type { PlatformUptimeAccessFactory, PlatformUptimeAccessPort, PlatformUptimeChatDeps, PlatformUptimeDeps, PlatformWorkersFactory, } from './uptime-worker.js';
67
68
  export { createEdgeRateLimits } from './edge-ratelimits.js';
68
69
  export type { EdgePlatformRateLimits, EdgeRateLimitsOptions, RateLimitPort, RateLimitRuleInput, } from './edge-ratelimits.js';
69
70
  export { createEdgeZones } from './edge-zones.js';
@@ -10,6 +10,7 @@ export { OWN_FILES_R2_SCOPE_KEY, OWN_FILES_R2_UNDELIVERED_SCOPE_KEY, OWN_SERVICE
10
10
  export { PlatformOrgBlockedError, PlatformOrgPorts } from './platform-org-ports.js';
11
11
  export { AdoptRefusedError } from './adopt.js';
12
12
  export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, UpgradeConfirmationRequiredError, planNeedsConfirmation, } from './plan-apply.js';
13
+ export { MAIL_STORE_SWITCH_SERVICE } from './mail-store.js';
13
14
  export { NetworkMoveBackupMissingError, UpgradeBackupMissingError } from './backup-gate.js';
14
15
  export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, filesPublicHostsValue, filesShareDomains, provisionFilesHost, provisionJobsHost, provisionMailServer, } from './platform-providers.js';
15
16
  // #171: how a host signs files management requests with the delivered key.
@@ -0,0 +1,34 @@
1
+ import { type VersionChangeItem } from '../plan.js';
2
+ /**
3
+ * Stalwart keeps its data (mail, accounts, settings, DKIM keys) in a Coolify
4
+ * Postgres, so the nightly dump of that database is the off-server backup.
5
+ * These keys are shared by the planners (which must not import
6
+ * `platform-providers.ts`) and by `provisionMailServer`.
7
+ */
8
+ /** The recorded uuid of the mail Postgres, under the same name for the platform and every company. */
9
+ export declare const MAIL_DATABASE_SCOPE_KEY = "coolify_mail_database";
10
+ /** Recorded as {@link MAIL_STORE_POSTGRES} once the `Bootstrap` write has pointed Stalwart at the database. */
11
+ export declare const MAIL_STORE_SCOPE_KEY = "mail_store";
12
+ export declare const MAIL_STORE_POSTGRES = "postgres";
13
+ /** The recorded uuid of the Stalwart app (`platform-providers.ts` re-exports it under its old name). */
14
+ export declare const MAIL_APP_SCOPE_KEY = "coolify_platform_mail_app";
15
+ /** The database and its owner, and the env var Stalwart reads the password from. */
16
+ export declare const MAIL_DATABASE_NAME = "stalwart";
17
+ export declare const MAIL_DATABASE_USER = "stalwart";
18
+ export declare const MAIL_DATABASE_PASSWORD_ENV = "STALWART_DB_PASSWORD";
19
+ /** The `confirmUpgrade` name that confirms the switch (`VersionChangeItem.service`). */
20
+ export declare const MAIL_STORE_SWITCH_SERVICE = "mail-store";
21
+ /**
22
+ * A Stalwart app is recorded and its data store was never moved to Postgres
23
+ * (no mail database recorded either: a fresh instance records that before it
24
+ * creates the app, so a crash halfway never reads as a volume-backed server).
25
+ */
26
+ export declare function mailStoreIsLegacy(recorded: Readonly<Record<string, string>>): boolean;
27
+ /** The one step description for the switch. `owner` is `the platform` or the company's slug. */
28
+ export declare function mailStoreSwitchDescription(owner: string): string;
29
+ /**
30
+ * The version-change line that makes `apply` refuse the plan until
31
+ * `confirmUpgrade` names `mail-store`. Fixed text: it is part of the
32
+ * stale-plan comparison.
33
+ */
34
+ export declare function mailStoreSwitchChange(owner: string): VersionChangeItem;
@@ -0,0 +1,49 @@
1
+ import { VERSION_CHANGE_KIND } from '../plan.js';
2
+ /**
3
+ * Stalwart keeps its data (mail, accounts, settings, DKIM keys) in a Coolify
4
+ * Postgres, so the nightly dump of that database is the off-server backup.
5
+ * These keys are shared by the planners (which must not import
6
+ * `platform-providers.ts`) and by `provisionMailServer`.
7
+ */
8
+ /** The recorded uuid of the mail Postgres, under the same name for the platform and every company. */
9
+ export const MAIL_DATABASE_SCOPE_KEY = 'coolify_mail_database';
10
+ /** Recorded as {@link MAIL_STORE_POSTGRES} once the `Bootstrap` write has pointed Stalwart at the database. */
11
+ export const MAIL_STORE_SCOPE_KEY = 'mail_store';
12
+ export const MAIL_STORE_POSTGRES = 'postgres';
13
+ /** The recorded uuid of the Stalwart app (`platform-providers.ts` re-exports it under its old name). */
14
+ export const MAIL_APP_SCOPE_KEY = 'coolify_platform_mail_app';
15
+ /** The database and its owner, and the env var Stalwart reads the password from. */
16
+ export const MAIL_DATABASE_NAME = 'stalwart';
17
+ export const MAIL_DATABASE_USER = 'stalwart';
18
+ export const MAIL_DATABASE_PASSWORD_ENV = 'STALWART_DB_PASSWORD';
19
+ /** The `confirmUpgrade` name that confirms the switch (`VersionChangeItem.service`). */
20
+ export const MAIL_STORE_SWITCH_SERVICE = 'mail-store';
21
+ /**
22
+ * A Stalwart app is recorded and its data store was never moved to Postgres
23
+ * (no mail database recorded either: a fresh instance records that before it
24
+ * creates the app, so a crash halfway never reads as a volume-backed server).
25
+ */
26
+ export function mailStoreIsLegacy(recorded) {
27
+ return (Boolean(recorded[MAIL_APP_SCOPE_KEY]) &&
28
+ !recorded[MAIL_DATABASE_SCOPE_KEY] &&
29
+ recorded[MAIL_STORE_SCOPE_KEY] !== MAIL_STORE_POSTGRES);
30
+ }
31
+ /** The one step description for the switch. `owner` is `the platform` or the company's slug. */
32
+ export function mailStoreSwitchDescription(owner) {
33
+ return `switch ${owner}'s mail store to Postgres (recreates the mail server; mail stored there is deleted)`;
34
+ }
35
+ /**
36
+ * The version-change line that makes `apply` refuse the plan until
37
+ * `confirmUpgrade` names `mail-store`. Fixed text: it is part of the
38
+ * stale-plan comparison.
39
+ */
40
+ export function mailStoreSwitchChange(owner) {
41
+ return {
42
+ kind: VERSION_CHANGE_KIND,
43
+ service: MAIL_STORE_SWITCH_SERVICE,
44
+ description: `${owner}'s mail server still stores its data on a volume: moving it to Postgres recreates the server and deletes the mail stored there; confirm with confirmUpgrade "${MAIL_STORE_SWITCH_SERVICE}"`,
45
+ from: 'volume',
46
+ to: 'postgres',
47
+ needsConfirmation: true,
48
+ };
49
+ }
@@ -6,6 +6,7 @@ import { brokerEntryAppliedScopeKey } from './broker-entries.js';
6
6
  import { ZITADEL_FILES_PROJECT_SCOPE_KEY } from './core-files.js';
7
7
  import { OWN_AUTH_APPLIED_HOSTNAME_SCOPE_KEY, ownAuthApplied } from './login-host.js';
8
8
  import { OWN_MAIL_SMTP_INTERNAL_SCOPE_KEY, OWN_MAIL_SMTP_PORT_BASE, OWN_MAIL_SMTP_PORT_SCOPE_KEY, isOwnMailSmtpPort, } from './mail-relay.js';
9
+ import { mailStoreIsLegacy, mailStoreSwitchDescription } from './mail-store.js';
9
10
  import { ownHostnames } from './own-hostnames.js';
10
11
  import { OWN_FILES_R2_SCOPE_KEY, OWN_SERVICES_APPLIED_SCOPE_KEY, orgTarget, } from './own-services.js';
11
12
  import { OWN_SFU_APPLIED_HOSTNAME_SCOPE_KEY, OWN_SFU_APPLIED_SLOT_SCOPE_KEY, OWN_SFU_APPLIED_VERSION_SCOPE_KEY, OWN_SFU_DEPLOY_PENDING_SCOPE_KEY, OWN_SFU_KEYS_UNDELIVERED_SCOPE_KEY, SFU_INTERNAL_URL_SCOPE_KEY, SFU_PUBLIC_URL_SCOPE_KEY, } from './own-sfu.js';
@@ -212,7 +213,9 @@ export function planOwnMail(options) {
212
213
  const smtpPort = options.smtpPort ?? OWN_MAIL_SMTP_PORT_BASE;
213
214
  if (!isOwnMailSmtpPort(smtpPort))
214
215
  return blocked('its mail port is outside the range foundry uses');
215
- if (recordedIds[`coolify:own-mail:${org.id}`] &&
216
+ const storeSwitch = mailStoreIsLegacy(recordedIds);
217
+ if (!storeSwitch &&
218
+ recordedIds[`coolify:own-mail:${org.id}`] &&
216
219
  recordedIds[OWN_MAIL_SMTP_PORT_SCOPE_KEY] === String(smtpPort) &&
217
220
  recordedIds[PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY] === hostname &&
218
221
  recordedIds[PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY] === gitRef &&
@@ -227,11 +230,19 @@ export function planOwnMail(options) {
227
230
  system: 'coolify',
228
231
  kind: 'own-mail',
229
232
  key: org.id,
230
- desired: { hostname, gitRef, mailHost: platformMailMxHost(platformApex), smtpPort },
233
+ desired: {
234
+ hostname,
235
+ gitRef,
236
+ mailHost: platformMailMxHost(platformApex),
237
+ smtpPort,
238
+ ...(storeSwitch ? { switchMailStore: true } : {}),
239
+ },
231
240
  },
232
241
  step: {
233
- description: `provision ${org.slug}'s own mail server`,
234
- destroys: false,
242
+ description: storeSwitch
243
+ ? mailStoreSwitchDescription(org.slug)
244
+ : `provision ${org.slug}'s own mail server`,
245
+ destroys: storeSwitch,
235
246
  migrates: false,
236
247
  },
237
248
  },
@@ -6,7 +6,7 @@ import type { DnsPort } from './edge-dns.js';
6
6
  import type { ZonePort } from './edge-zones.js';
7
7
  import type { CallsSfuPort, CloudflarePort, CoolifyPort, FilesOrganisationPort, FoundryStepExecutorPorts, JobsSchedulerPort, MailPort, ZitadelPort } from './executor.js';
8
8
  import type { PlatformStepExecutionContext, StepExecutionContext } from './plan-apply.js';
9
- import type { FoundryPlatformStepExecutorOptions, PlatformAuthDeps, PlatformDnsFactory, PlatformFilesDeps, PlatformMailDeps, PlatformTunnelPort, PlatformTunnelsFactory } from './platform-providers.js';
9
+ import type { FoundryPlatformStepExecutorOptions, PlatformAuthDeps, PlatformDnsFactory, PlatformFilesDeps, PlatformMailDeps, PlatformRequestHeadersFactory, PlatformTunnelPort, PlatformTunnelsFactory, RequestHeaderPort } from './platform-providers.js';
10
10
  /** Applied-state key under which an own org's provisioned-services marker is recorded. */
11
11
  export declare const OWN_SERVICES_APPLIED_SCOPE_KEY = "own_services_applied";
12
12
  /**
@@ -26,6 +26,7 @@ export interface ServiceTarget {
26
26
  authTunnel: string;
27
27
  authCloudflared: string;
28
28
  mailApp: string;
29
+ mailDb: string;
29
30
  mailTunnel: string;
30
31
  mailCloudflared: string;
31
32
  filesDb: string;
@@ -82,10 +83,13 @@ export interface ServiceNetwork {
82
83
  dns(): Promise<DnsPort>;
83
84
  tunnels(): Promise<PlatformTunnelPort>;
84
85
  zoneId(apex: string): Promise<string | undefined>;
86
+ /** Undefined when the host supplies no way to write Cloudflare request header rules. */
87
+ requestHeaders(): Promise<RequestHeaderPort | undefined>;
85
88
  }
86
89
  export declare function platformNetwork(deps: {
87
90
  dns: PlatformDnsFactory;
88
91
  tunnels: PlatformTunnelsFactory;
92
+ requestHeaders?: PlatformRequestHeadersFactory;
89
93
  cloudflare: Pick<CloudflarePort, 'findZoneByApex'>;
90
94
  }, scope: StepScope, step: string): ServiceNetwork;
91
95
  /**
@@ -96,6 +100,7 @@ export declare function platformNetwork(deps: {
96
100
  export declare function orgNetwork(ports: {
97
101
  dns?: DnsPort;
98
102
  tunnels?: PlatformTunnelPort;
103
+ requestHeaders?: RequestHeaderPort;
99
104
  zones?: Pick<ZonePort, 'findZoneByApex'>;
100
105
  }, scope: StepScope, step: string): ServiceNetwork;
101
106
  /** Where the project comes from: the platform's 'platform' project, or the org's own. */
@@ -1,5 +1,5 @@
1
1
  import { NO_DOMAIN_ZONE } from './own-hostnames.js';
2
- import { apexZoneKey, exactZone, platformDns, platformTunnels } from './platform-providers.js';
2
+ import { apexZoneKey, exactZone, platformDns, platformRequestHeaders, platformTunnels, } from './platform-providers.js';
3
3
  /** The key the org's zone step records its Cloudflare zone id under. */
4
4
  const ORG_ZONE_SCOPE_KEY = 'cloudflare_zone';
5
5
  /** Applied-state key under which an own org's provisioned-services marker is recorded. */
@@ -17,6 +17,7 @@ export function platformTarget() {
17
17
  authTunnel: 'platform-auth-tunnel',
18
18
  authCloudflared: 'platform-auth-cloudflared',
19
19
  mailApp: 'platform-mail-stalwart',
20
+ mailDb: 'platform-mail-postgres',
20
21
  mailTunnel: 'platform-mail-tunnel',
21
22
  mailCloudflared: 'platform-mail-cloudflared',
22
23
  filesDb: 'platform-files-db',
@@ -66,6 +67,7 @@ export function orgTarget(org) {
66
67
  authTunnel: `${s}-auth-tunnel`,
67
68
  authCloudflared: `${s}-auth-cloudflared`,
68
69
  mailApp: `${s}-mail-stalwart`,
70
+ mailDb: `${s}-mail-postgres`,
69
71
  mailTunnel: `${s}-mail-tunnel`,
70
72
  mailCloudflared: `${s}-mail-cloudflared`,
71
73
  filesDb: `${s}-files-db`,
@@ -123,6 +125,7 @@ export function platformNetwork(deps, scope, step) {
123
125
  return {
124
126
  dns: () => platformDns(deps, scope.recordedIds(), step),
125
127
  tunnels: () => platformTunnels(deps, scope.recordedIds(), step),
128
+ requestHeaders: () => platformRequestHeaders(deps, scope.recordedIds()),
126
129
  zoneId: async (apex) => scope.recordedIds()[apexZoneKey(apex)] ?? (await exactZone(deps.cloudflare, apex))?.id,
127
130
  };
128
131
  }
@@ -143,6 +146,7 @@ export function orgNetwork(ports, scope, step) {
143
146
  throw new Error(`${step}: the host supplies no tunnel port for this org`);
144
147
  return ports.tunnels;
145
148
  },
149
+ requestHeaders: async () => ports.requestHeaders,
146
150
  zoneId: async (apex) => {
147
151
  // The recorded zone is the company's own; `oikos.host` is never it.
148
152
  const recorded = apex === NO_DOMAIN_ZONE ? undefined : scope.recordedIds()[ORG_ZONE_SCOPE_KEY];
@@ -22,7 +22,7 @@ import { findNameClashes, nameClashMessage } from './org-names.js';
22
22
  import { ownHostnames } from './own-hostnames.js';
23
23
  import { OWN_FILES_R2_UNDELIVERED_SCOPE_KEY, orgTarget } from './own-services.js';
24
24
  import { PLATFORM_ARCHON_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_KEY, appStepKey, planPlatform as computePlatformPlanDetail, toPlatformPlan, } from './platform-diff.js';
25
- import { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesForOwn, agoraHostValuesFromPlatform, dnsBlockedReason, heldOwnKeyRefs, heldPlatformKeyRefs, orgPlatformReadinessFor, orgPlatformReadinessFrom, ownerFilesFrom, ownerSignInFrom, platformOwnerUserIdFrom, serviceGitRefsOf, } from './platform-org.js';
25
+ import { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesForOwn, agoraHostValuesFromPlatform, dnsBlockedReason, heldOwnKeyRefs, heldPlatformKeyRefs, orgPlatformReadinessFor, orgPlatformReadinessFrom, ownerChatFrom, ownerFilesFrom, ownerSignInFrom, platformOwnerUserIdFrom, serviceGitRefsOf, } from './platform-org.js';
26
26
  import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF, PLATFORM_BACKUP_S3_STORAGE_SCOPE_KEY, } from './platform-providers.js';
27
27
  import { CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY, } from './proxy-labels.js';
28
28
  import { liveWorkers, workerTraceScopeKeys } from './worker.js';
@@ -1330,9 +1330,13 @@ export class FoundryPlanApply {
1330
1330
  async platformOwnerInput(platform) {
1331
1331
  const owner = await this.store.get(platform.ownerOrgId);
1332
1332
  if (!owner)
1333
- return { signIn: null, files: null };
1333
+ return { signIn: null, files: null, chat: null };
1334
1334
  const recorded = (await this.store.getAppliedState(owner.id))?.serviceIds ?? {};
1335
- return { signIn: ownerSignInFrom(owner, recorded), files: ownerFilesFrom(owner, recorded) };
1335
+ return {
1336
+ signIn: ownerSignInFrom(owner, recorded),
1337
+ files: ownerFilesFrom(owner, recorded),
1338
+ chat: ownerChatFrom(owner, recorded),
1339
+ };
1336
1340
  }
1337
1341
  /**
1338
1342
  * What the shared mail relay needs from the orgs. A route exists for an org
@@ -1030,6 +1030,25 @@ export declare const PLATFORM_UPTIME_APPLIED_SECRETS_SCOPE_KEY = "uptime_applied
1030
1030
  export declare const PLATFORM_UPTIME_APPLIED_VARS_SCOPE_KEY = "uptime_applied_vars";
1031
1031
  /** `'1'` once the step removed the Worker because the platform no longer enables it; cleared when it deploys again. */
1032
1032
  export declare const PLATFORM_UPTIME_REMOVED_SCOPE_KEY = "uptime_removed";
1033
+ /**
1034
+ * The chat bot's room (`!id:server`), recorded as soon as chat-service names it.
1035
+ * Recorded before the bot's token is held, so a crash between the two leaves the room known.
1036
+ */
1037
+ export declare const PLATFORM_UPTIME_CHAT_ROOM_ID_SCOPE_KEY = "platform_uptime_chat_room_id";
1038
+ /**
1039
+ * A random id, recorded and persisted before the chat route is called (it mints the bot's token) and
1040
+ * cleared once that token is held. Set while the token is not held, it means a token was minted that may
1041
+ * be lost, so the next call asks the route to rotate it.
1042
+ */
1043
+ export declare const PLATFORM_UPTIME_CHAT_UNDELIVERED_SCOPE_KEY = "platform_uptime_chat_token_undelivered";
1044
+ /**
1045
+ * The Worker's plain text variables: the chat homeserver and room foundry made, under whatever the
1046
+ * host configured (a host value that is set wins). Unset values are left out.
1047
+ */
1048
+ export declare function uptimeEffectiveVars(hostVars: Readonly<Record<string, string | undefined>> | undefined, chat: {
1049
+ homeserverUrl: string;
1050
+ roomId: string;
1051
+ } | null): Record<string, string | undefined>;
1033
1052
  /** The digest of the Worker's plain text variables that are set; empty when none is. */
1034
1053
  export declare function uptimeVarsDigest(vars?: Readonly<Record<string, string | undefined>>): string;
1035
1054
  /** The Worker secret names the step sets: the three it mints, and each held one. */
@@ -1164,6 +1183,16 @@ export interface PlanPlatformOptions {
1164
1183
  access: boolean;
1165
1184
  vars: string;
1166
1185
  };
1186
+ /**
1187
+ * The uptime Worker's chat bot, when the host wired one. `pending`: the bot's token is not held and
1188
+ * Company Included's chat is applied, so the step makes it. `blocked`: why it cannot yet (a fixed
1189
+ * line, reported without stopping the rest of the Worker). Absent: no bot path, so the plan keeps
1190
+ * asking for a hand-held credential.
1191
+ */
1192
+ uptimeChat?: {
1193
+ pending: boolean;
1194
+ blocked?: string;
1195
+ };
1167
1196
  /** Why Archon's step must wait: the deployment it triggered is still running. Blocks even an applied step. */
1168
1197
  archonDeployRunning?: string;
1169
1198
  /**
@@ -1279,6 +1308,7 @@ export declare const PLATFORM_BACKUP_DATABASE_SCOPE_KEYS: {
1279
1308
  readonly archon: "coolify_platform_archon_database";
1280
1309
  readonly ai: "coolify_platform_ai_database";
1281
1310
  readonly integrations: "coolify_integrations_database";
1311
+ readonly mail: "coolify_mail_database";
1282
1312
  };
1283
1313
  export type PlatformBackupServiceKey = keyof typeof PLATFORM_BACKUP_DATABASE_SCOPE_KEYS;
1284
1314
  export declare const PLATFORM_BACKUP_SERVICE_KEYS: readonly PlatformBackupServiceKey[];