@wtfalch/foundry 0.37.0 → 0.39.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.
Files changed (39) hide show
  1. package/dist/bootstrap/versions.d.ts +3 -2
  2. package/dist/index.d.ts +1 -1
  3. package/dist/plan/backup-gate.d.ts +19 -3
  4. package/dist/plan/backup-gate.js +49 -4
  5. package/dist/plan/company-backups.d.ts +7 -2
  6. package/dist/plan/company-backups.js +25 -6
  7. package/dist/plan/diff.d.ts +4 -0
  8. package/dist/plan/diff.js +19 -1
  9. package/dist/plan/executor.d.ts +13 -0
  10. package/dist/plan/executor.js +56 -13
  11. package/dist/plan/index.d.ts +2 -1
  12. package/dist/plan/index.js +1 -0
  13. package/dist/plan/mail-store.d.ts +34 -0
  14. package/dist/plan/mail-store.js +49 -0
  15. package/dist/plan/own-diff.d.ts +26 -0
  16. package/dist/plan/own-diff.js +74 -6
  17. package/dist/plan/own-services.d.ts +11 -0
  18. package/dist/plan/own-services.js +12 -0
  19. package/dist/plan/plan-apply.d.ts +4 -0
  20. package/dist/plan/plan-apply.js +30 -4
  21. package/dist/plan/platform-diff.d.ts +30 -0
  22. package/dist/plan/platform-diff.js +62 -7
  23. package/dist/plan/platform-org.d.ts +15 -0
  24. package/dist/plan/platform-org.js +17 -1
  25. package/dist/plan/platform-providers.d.ts +69 -2
  26. package/dist/plan/platform-providers.js +255 -41
  27. package/dist/plan/signin-db-backup.d.ts +105 -0
  28. package/dist/plan/signin-db-backup.js +193 -0
  29. package/dist/plan/signin-db-move.d.ts +68 -0
  30. package/dist/plan/signin-db-move.js +338 -0
  31. package/dist/plan/signin-db-tls.d.ts +69 -0
  32. package/dist/plan/signin-db-tls.js +255 -0
  33. package/dist/plan/signin-db.d.ts +114 -0
  34. package/dist/plan/signin-db.js +255 -0
  35. package/dist/plan/uptime-worker.d.ts +20 -1
  36. package/dist/plan/uptime-worker.js +121 -3
  37. package/package.json +1 -1
  38. package/versions.digests.json +2 -1
  39. package/versions.json +2 -1
@@ -17,11 +17,14 @@ 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';
26
+ import { SIGNIN_DB_ZITADEL_PENDING_SCOPE_KEY } from './signin-db-tls.js';
27
+ import { SIGNIN_DB_APP_SCOPE_KEY, SIGNIN_DB_TLS_SCOPE_KEY, dockerfileAppCreator, ensureSigninDbCaFile, provisionSigninDb, waitHealthy, withCallTimeout, } from './signin-db.js';
25
28
  export { PLATFORM_ZITADEL_ORG_SCOPE_KEY };
26
29
  import { applyCallsSfuPlatformStep, callsSfuAppliedFingerprint, } from './platform-sfu.js';
27
30
  import { heldUptimeValues, provisionPlatformUptime, } from './uptime-worker.js';
@@ -372,7 +375,7 @@ export class FoundryPlatformStepExecutor {
372
375
  if (!apiKey)
373
376
  blocked.heartbeatsApiKeyMissing = true;
374
377
  }
375
- await this.uptimeBlocked(platform, recordedIds, blocked);
378
+ await this.uptimeBlocked(platform, recordedIds, blocked, owner);
376
379
  // #604: only once edge and sign-in are applied (the planner reports
377
380
  // that case itself) and no provider is recorded yet.
378
381
  if (platform.edge.enabled &&
@@ -440,7 +443,7 @@ export class FoundryPlatformStepExecutor {
440
443
  * checks the pin and Archon first, so none of the reads here happens before
441
444
  * they hold.
442
445
  */
443
- async uptimeBlocked(platform, recordedIds, blocked) {
446
+ async uptimeBlocked(platform, recordedIds, blocked, owner = {}) {
444
447
  const deployed = Boolean(recordedIds[`cloudflare:worker:${PLATFORM_UPTIME_KEY}`]) &&
445
448
  !recordedIds[PLATFORM_UPTIME_REMOVED_SCOPE_KEY];
446
449
  if (!platform.uptime.enabled && !uptimeRecorded(recordedIds))
@@ -453,15 +456,41 @@ export class FoundryPlatformStepExecutor {
453
456
  }
454
457
  if (!platform.uptime.enabled)
455
458
  return;
456
- blocked.uptimeSecretsHeld = Object.keys(await heldUptimeValues(deps));
459
+ const heldValues = await heldUptimeValues(deps);
460
+ blocked.uptimeSecretsHeld = Object.keys(heldValues);
457
461
  const accessTokenId = recordedIds[PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY];
458
462
  const accessHeld = Boolean((await deps.heldKeys.resolve(PLATFORM_UPTIME_ACCESS_CLIENT_ID_REF)) &&
459
463
  (await deps.heldKeys.resolve(PLATFORM_UPTIME_ACCESS_CLIENT_SECRET_REF)));
460
464
  blocked.uptimeHeld = {
461
465
  token: Boolean(await deps.heldKeys.resolve(PLATFORM_UPTIME_TOKEN_REF)),
462
466
  access: accessHeld,
463
- vars: uptimeVarsDigest(deps.vars),
467
+ vars: uptimeVarsDigest(uptimeEffectiveVars(deps.vars, deps.chat && owner.chat && recordedIds[PLATFORM_UPTIME_CHAT_ROOM_ID_SCOPE_KEY]
468
+ ? {
469
+ homeserverUrl: `https://${owner.chat.agoraHost}`,
470
+ roomId: recordedIds[PLATFORM_UPTIME_CHAT_ROOM_ID_SCOPE_KEY],
471
+ }
472
+ : null)),
464
473
  };
474
+ if (deps.chat && !heldValues.CHAT_ACCESS_TOKEN) {
475
+ if (!owner.chat) {
476
+ blocked.uptimeChat = {
477
+ pending: false,
478
+ blocked: "the uptime Worker's chat alerts are blocked, not planned: the owner company's chat is not applied yet",
479
+ };
480
+ }
481
+ else if (owner.chat.subjects.length === 0) {
482
+ blocked.uptimeChat = {
483
+ pending: false,
484
+ 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",
485
+ };
486
+ }
487
+ else {
488
+ blocked.uptimeChat = { pending: true };
489
+ }
490
+ }
491
+ else if (deps.chat) {
492
+ blocked.uptimeChat = { pending: false };
493
+ }
465
494
  const edgeUrl = recordedIds[PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY];
466
495
  // A token of the name with no id recorded cannot be minted over, and
467
496
  // foundry does not remove what it did not make. With an id recorded the
@@ -667,7 +696,7 @@ export class FoundryPlatformStepExecutor {
667
696
  if (!this.mail) {
668
697
  throw new Error('platform:mail needs FoundryPlatformStepExecutor to be constructed with its mail dependencies (#169)');
669
698
  }
670
- return provisionPlatformMail(context, this.limitedOnPlatform(this.mail, LIMITS.platformMail));
699
+ return provisionPlatformMail(context, this.limitedOnPlatform(this.mail, LIMITS.platformMail), action.desired.switchMailStore === true);
671
700
  }
672
701
  if (action.system === 'coolify' &&
673
702
  action.kind === 'app' &&
@@ -1067,7 +1096,7 @@ export const AUTH_ZITADEL_PATH_PATTERN = '^/(oidc|oauth|saml|management|admin|sy
1067
1096
  */
1068
1097
  const AUTH_BROKER_PATH_PATTERN = '^/api(/|$)';
1069
1098
  const PLATFORM_AUTH_PROJECT_SCOPE_KEY = 'coolify_project_platform_auth';
1070
- const PLATFORM_AUTH_DB_SCOPE_KEY = 'coolify_platform_auth_database';
1099
+ export const PLATFORM_AUTH_DB_SCOPE_KEY = 'coolify_platform_auth_database';
1071
1100
  export const PLATFORM_AUTH_ZITADEL_SCOPE_KEY = 'coolify_platform_auth_zitadel_app';
1072
1101
  export const PLATFORM_AUTH_LOGIN_SCOPE_KEY = 'coolify_platform_auth_login_app';
1073
1102
  const PLATFORM_AUTH_TUNNEL_SCOPE_KEY = 'cloudflare_platform_auth_tunnel';
@@ -1402,33 +1431,70 @@ export async function provisionAuthStack(scope, deps, target, input) {
1402
1431
  const projectUuid = await input.project.ensure(deps.coolify, scope);
1403
1432
  const { environment } = await deps.coolify.findOrCreateEnvironment(projectUuid, 'production');
1404
1433
  // ---- database ----------------------------------------------------------
1405
- let dbId = recorded[PLATFORM_AUTH_DB_SCOPE_KEY];
1434
+ // A company's sign-in database is foundry's own TLS image (#746). A company still on the Coolify
1435
+ // database keeps it until its move step has run (`signin-db-move.ts`); the platform's stays on it.
1436
+ const legacyDbId = recorded[PLATFORM_AUTH_DB_SCOPE_KEY];
1437
+ const tlsDatabase = target.kind === 'org' && Boolean(recorded[SIGNIN_DB_TLS_SCOPE_KEY] || !legacyDbId);
1438
+ let dbId = legacyDbId;
1406
1439
  // A database ZITADEL has already run against may hold data encrypted with a
1407
1440
  // masterkey this run cannot see (see `ensureMasterkey`). A database that
1408
1441
  // exists but was never deployed against (a throw between its create and the
1409
1442
  // first deploy) is still empty, so a retry may generate the key.
1410
- const databaseInUse = Boolean(dbId && recorded[PLATFORM_AUTH_ZITADEL_DEPLOYED_SCOPE_KEY]);
1411
- if (!dbId) {
1412
- const server = await resolvePlatformServer(deps.coolify);
1413
- const created = await deps.coolify.createPostgresDatabase({
1414
- project_uuid: projectUuid,
1415
- server_uuid: server.uuid,
1416
- environment_name: environment.name,
1417
- environment_uuid: environment.uuid,
1418
- name: target.names.authDb,
1419
- postgres_db: 'zitadel',
1420
- postgres_user: 'zitadel',
1443
+ const databaseInUse = Boolean((tlsDatabase ? recorded[SIGNIN_DB_APP_SCOPE_KEY] : dbId) &&
1444
+ recorded[PLATFORM_AUTH_ZITADEL_DEPLOYED_SCOPE_KEY]);
1445
+ let databaseUrl;
1446
+ let signinDbCa;
1447
+ if (tlsDatabase) {
1448
+ const provisioned = await provisionSigninDb({
1449
+ coolify: deps.coolify,
1450
+ keys: deps.heldKeys,
1451
+ runtime,
1452
+ name: target.names.signinDb,
1453
+ internalHost: target.internal.signinDb,
1454
+ volumeName: `${target.names.signinDb}-data`,
1455
+ serverCertRef: target.refs.signinDbServer,
1456
+ slug: target.id,
1457
+ backup: deps.signinDbBackup,
1458
+ backupBucket: target.names.signinBackupBucket,
1459
+ recorded,
1460
+ label,
1461
+ environment,
1462
+ record,
1463
+ create: dockerfileAppCreator(deps.coolify, {
1464
+ projectUuid,
1465
+ environment,
1466
+ name: target.names.signinDb,
1467
+ description: `${target.id}'s sign-in database with TLS (#746)`,
1468
+ }),
1421
1469
  });
1422
- dbId = created.uuid;
1423
- await record(PLATFORM_AUTH_DB_SCOPE_KEY, dbId);
1470
+ databaseUrl = provisioned.dsn;
1471
+ signinDbCa = provisioned.ca;
1472
+ await record(SIGNIN_DB_TLS_SCOPE_KEY, provisioned.appId);
1424
1473
  }
1425
- const db = await getDatabaseStarted(deps.coolify, dbId);
1426
- if (!db.internal_db_url) {
1427
- // Static config check before either app is touched (`diff.ts`'s
1428
- // `coolifyCalls` follows the same rule for its own databases): Coolify
1429
- // can take a moment to report a freshly created database's
1430
- // `internal_db_url`, so the next apply retries from a clean state.
1431
- throw new Error(`coolify:app for ${subject} (auth): the database has no internal_db_url yet -- retry this apply once Coolify reports one`);
1474
+ else {
1475
+ if (!dbId) {
1476
+ const server = await resolvePlatformServer(deps.coolify);
1477
+ const created = await deps.coolify.createPostgresDatabase({
1478
+ project_uuid: projectUuid,
1479
+ server_uuid: server.uuid,
1480
+ environment_name: environment.name,
1481
+ environment_uuid: environment.uuid,
1482
+ name: target.names.authDb,
1483
+ postgres_db: 'zitadel',
1484
+ postgres_user: 'zitadel',
1485
+ });
1486
+ dbId = created.uuid;
1487
+ await record(PLATFORM_AUTH_DB_SCOPE_KEY, dbId);
1488
+ }
1489
+ const db = await getDatabaseStarted(deps.coolify, dbId);
1490
+ if (!db.internal_db_url) {
1491
+ // Static config check before either app is touched (`diff.ts`'s
1492
+ // `coolifyCalls` follows the same rule for its own databases): Coolify
1493
+ // can take a moment to report a freshly created database's
1494
+ // `internal_db_url`, so the next apply retries from a clean state.
1495
+ throw new Error(`coolify:app for ${subject} (auth): the database has no internal_db_url yet -- retry this apply once Coolify reports one`);
1496
+ }
1497
+ databaseUrl = db.internal_db_url;
1432
1498
  }
1433
1499
  // ---- the ZITADEL app itself ---------------------------------------------
1434
1500
  const zitadelApp = await findOrCreatePlatformApp(deps.coolify, target.names.authZitadel, environment, async () => {
@@ -1446,6 +1512,8 @@ export async function provisionAuthStack(scope, deps, target, input) {
1446
1512
  });
1447
1513
  const zitadelAppId = zitadelApp.appId;
1448
1514
  await record(PLATFORM_AUTH_ZITADEL_SCOPE_KEY, zitadelAppId);
1515
+ if (signinDbCa)
1516
+ await ensureSigninDbCaFile(deps.coolify, zitadelAppId, signinDbCa, label);
1449
1517
  // Set on every run, not only after a create: a throw right after the
1450
1518
  // create must not leave the app unnamed for good, and the tunnel addresses
1451
1519
  // the app by this name. (The port cannot read the current name back, and
@@ -1548,7 +1616,7 @@ export async function provisionAuthStack(scope, deps, target, input) {
1548
1616
  { key: 'ZITADEL_EXTERNALPORT', value: '443' },
1549
1617
  { key: 'ZITADEL_EXTERNALSECURE', value: 'true' },
1550
1618
  { key: 'ZITADEL_TLS_ENABLED', value: 'false' },
1551
- { key: 'ZITADEL_DATABASE_POSTGRES_DSN', value: db.internal_db_url },
1619
+ { key: 'ZITADEL_DATABASE_POSTGRES_DSN', value: databaseUrl },
1552
1620
  { key: 'ZITADEL_FIRSTINSTANCE_SKIP', value: 'true' },
1553
1621
  { key: 'ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED', value: 'true' },
1554
1622
  // A username is unique per organisation, and the login name is `name@<org domain>`. Without it a
@@ -1638,13 +1706,24 @@ export async function provisionAuthStack(scope, deps, target, input) {
1638
1706
  await deps.coolify.bulkUpsertEnvs(brokerAppId, brokerEnvs);
1639
1707
  // Deploy an app that was just created, is not running (a throw after the
1640
1708
  // create left it never deployed, or it stopped), or whose envs changed.
1641
- if (zitadelApp.justCreated || !zitadelApp.running || zitadelEnvsChanged || zitadelRunOptionsNew) {
1709
+ // A move that started ZITADEL on the TLS database and never saw it healthy there (#746): start it
1710
+ // again and wait, so sign-in is not left down by a plan that finds nothing to do.
1711
+ const zitadelUnconfirmed = Boolean(recorded[SIGNIN_DB_ZITADEL_PENDING_SCOPE_KEY]);
1712
+ if (zitadelApp.justCreated ||
1713
+ !zitadelApp.running ||
1714
+ zitadelEnvsChanged ||
1715
+ zitadelRunOptionsNew ||
1716
+ zitadelUnconfirmed) {
1642
1717
  await deps.coolify.deploy(zitadelAppId);
1643
1718
  await record(PLATFORM_AUTH_ZITADEL_DEPLOYED_SCOPE_KEY, zitadelAppId);
1644
1719
  // Only now: the options are live once the deploy went out. A throw between
1645
1720
  // the PATCH and here leaves the marker unset, so the next run deploys.
1646
1721
  await record(PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, AUTH_ZITADEL_RUN_OPTIONS);
1647
1722
  }
1723
+ if (zitadelUnconfirmed) {
1724
+ await waitHealthy(withCallTimeout(deps.coolify), zitadelAppId, runtime, label, 'ZITADEL');
1725
+ await record(SIGNIN_DB_ZITADEL_PENDING_SCOPE_KEY, '');
1726
+ }
1648
1727
  if (loginApp.justCreated || !loginApp.running || loginEnvsChanged) {
1649
1728
  await deps.coolify.deploy(loginAppId);
1650
1729
  }
@@ -2361,7 +2440,7 @@ const STALWART_READY_PATH = '/healthz/ready';
2361
2440
  /** Where the server the app was created on is recorded, so the MX record follows the app, not a later pick. */
2362
2441
  export const PLATFORM_MAIL_SERVER_SCOPE_KEY = 'coolify_platform_mail_server';
2363
2442
  const PLATFORM_MAIL_PROJECT_SCOPE_KEY = 'coolify_project_platform_mail';
2364
- const PLATFORM_MAIL_APP_SCOPE_KEY = 'coolify_platform_mail_app';
2443
+ const PLATFORM_MAIL_APP_SCOPE_KEY = MAIL_APP_SCOPE_KEY;
2365
2444
  const PLATFORM_MAIL_RELAY_APP_SCOPE_KEY = 'coolify_platform_mail_relay_app';
2366
2445
  const PLATFORM_MAIL_TUNNEL_SCOPE_KEY = 'cloudflare_platform_mail_tunnel';
2367
2446
  /**
@@ -2410,7 +2489,7 @@ export function defaultStalwartForInstance(input) {
2410
2489
  user: input.user,
2411
2490
  });
2412
2491
  return {
2413
- async applySettings({ mailHost, apex, oidc }) {
2492
+ async applySettings({ mailHost, apex, oidc, dataStore }) {
2414
2493
  // A fresh Stalwart (0.16) is in bootstrap mode and refuses every write
2415
2494
  // but the `Bootstrap` object, and loads that object only on its next
2416
2495
  // start, so write it and hand the restart to the caller. A server
@@ -2418,9 +2497,29 @@ export function defaultStalwartForInstance(input) {
2418
2497
  // carries the generated `admin@<apex>` secret: this step signs in with
2419
2498
  // the recovery admin and never needs it.
2420
2499
  if ((await instance.client.probe()) === 'bootstrap') {
2500
+ if (!dataStore.host ||
2501
+ !dataStore.database ||
2502
+ !dataStore.user ||
2503
+ !Number.isInteger(dataStore.port) ||
2504
+ dataStore.port < 1 ||
2505
+ dataStore.port > 65535) {
2506
+ throw new Error('platform:mail: the mail data store is not a usable Postgres address');
2507
+ }
2421
2508
  await instance.client.update('Bootstrap', 'singleton', {
2422
2509
  serverHostname: mailHost,
2423
2510
  defaultDomain: apex,
2511
+ // The password is read from the app's env, never written into Stalwart's settings.
2512
+ dataStore: {
2513
+ '@type': 'PostgreSql',
2514
+ host: dataStore.host,
2515
+ port: dataStore.port,
2516
+ database: dataStore.database,
2517
+ authUsername: dataStore.user,
2518
+ authSecret: {
2519
+ '@type': 'EnvironmentVariable',
2520
+ variableName: MAIL_DATABASE_PASSWORD_ENV,
2521
+ },
2522
+ },
2424
2523
  // The server sits behind a tunnel; no ACME challenge can reach it.
2425
2524
  requestTlsCertificate: false,
2426
2525
  });
@@ -2535,7 +2634,7 @@ export async function provisionMailServer(scope, deps, target, input) {
2535
2634
  const zone = input.zone ?? apex;
2536
2635
  const label = target.kind === 'org' ? `own step coolify:own-mail for ${target.id}` : 'platform:mail';
2537
2636
  const keyDescription = target.kind === 'org' ? `foundry:org:${target.id}:admin` : PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION;
2538
- const recorded = scope.recordedIds();
2637
+ let recorded = scope.recordedIds();
2539
2638
  const runtime = deps.runtime ?? {};
2540
2639
  const sleep = runtime.sleep ?? defaultSleep;
2541
2640
  const record = async (key, id) => {
@@ -2552,6 +2651,15 @@ export async function provisionMailServer(scope, deps, target, input) {
2552
2651
  const imageTagBlocked = mailImageTagBlocked(imageTag);
2553
2652
  if (imageTagBlocked)
2554
2653
  throw new Error(imageTagBlocked);
2654
+ // A server whose data is still on its volume is never provisioned over: the plan's confirmed switch
2655
+ // deletes it first, and without that this step stops before any write.
2656
+ if (mailStoreIsLegacy(recorded)) {
2657
+ if (!input.switchStore) {
2658
+ throw new Error(`${label}: the mail server still stores its data on a volume; confirm the mail-store switch in the plan before applying`);
2659
+ }
2660
+ await switchMailStoreToPostgres(scope, deps.coolify, target, environment.id, label);
2661
+ recorded = scope.recordedIds();
2662
+ }
2555
2663
  // One server for both the app and the MX record: recorded the first time,
2556
2664
  // read back after, so a later run never points `mx.<apex>` somewhere the
2557
2665
  // app is not.
@@ -2562,6 +2670,32 @@ export async function provisionMailServer(scope, deps, target, input) {
2562
2670
  const serverIpBlocked = mxRecord ? mailServerIpBlocked(server.ip, mxRecord.serverIpv4) : null;
2563
2671
  if (serverIpBlocked)
2564
2672
  throw new Error(serverIpBlocked);
2673
+ // ---- the data store: a Postgres, created and recorded before the app ----------
2674
+ // A crash after the create resumes with the recorded id and never makes a second database. A crash
2675
+ // before the app exists leaves only this empty database behind; the next run uses it.
2676
+ let mailDbId = recorded[MAIL_DATABASE_SCOPE_KEY];
2677
+ if (!mailDbId) {
2678
+ const found = (await deps.coolify.listDatabases()).find((d) => d.name === target.names.mailDb && d.environment_id === environment.id);
2679
+ if (found) {
2680
+ mailDbId = found.uuid;
2681
+ }
2682
+ else {
2683
+ const created = await deps.coolify.createPostgresDatabase({
2684
+ project_uuid: projectUuid,
2685
+ server_uuid: server.uuid,
2686
+ environment_name: environment.name,
2687
+ environment_uuid: environment.uuid,
2688
+ name: target.names.mailDb,
2689
+ postgres_db: MAIL_DATABASE_NAME,
2690
+ postgres_user: MAIL_DATABASE_NAME,
2691
+ });
2692
+ mailDbId = created.uuid;
2693
+ }
2694
+ await record(MAIL_DATABASE_SCOPE_KEY, mailDbId);
2695
+ }
2696
+ const mailDb = await getDatabaseStarted(deps.coolify, mailDbId);
2697
+ const dataStore = mailDataStore(mailDb.internal_db_url, label);
2698
+ runtime.onSecret?.(dataStore.password);
2565
2699
  const app = await findOrCreatePlatformApp(deps.coolify, target.names.mailApp, environment, async () => {
2566
2700
  return deps.coolify.createDockerImageApplication({
2567
2701
  project_uuid: projectUuid,
@@ -2621,6 +2755,7 @@ export async function provisionMailServer(scope, deps, target, input) {
2621
2755
  const recoveryGenerated = await ensureGeneratedEnv(deps.coolify, appId, 'STALWART_RECOVERY_ADMIN', () => `${STALWART_RECOVERY_USER}:${randomBytes(18).toString('base64')}`);
2622
2756
  const envs = [
2623
2757
  { key: 'STALWART_PUBLIC_URL', value: `https://${hostname}` },
2758
+ { key: MAIL_DATABASE_PASSWORD_ENV, value: dataStore.password },
2624
2759
  ];
2625
2760
  if (recoveryGenerated)
2626
2761
  envs.push({ key: 'STALWART_RECOVERY_ADMIN', value: recoveryGenerated });
@@ -2810,13 +2945,15 @@ export async function provisionMailServer(scope, deps, target, input) {
2810
2945
  mailHost: hostname,
2811
2946
  apex,
2812
2947
  oidc: { issuerUrl: issuer, audience: project.id },
2948
+ dataStore: dataStore.address,
2813
2949
  },
2814
2950
  });
2951
+ await record(MAIL_STORE_SCOPE_KEY, MAIL_STORE_POSTGRES);
2815
2952
  await record(PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, project.id);
2816
2953
  await record(PLATFORM_MAIL_ROUTE_SCOPE_KEY, PLATFORM_MAIL_ROUTE_WANTED);
2817
2954
  }
2818
2955
  catch (error) {
2819
- throw redactedMailError(error, [password]);
2956
+ throw redactedMailError(error, [password, dataStore.password]);
2820
2957
  }
2821
2958
  }
2822
2959
  else if (!recordedProjectId) {
@@ -2851,8 +2988,10 @@ export async function provisionMailServer(scope, deps, target, input) {
2851
2988
  mailHost: hostname,
2852
2989
  apex,
2853
2990
  oidc: { issuerUrl: issuer, audience: project.id },
2991
+ dataStore: dataStore.address,
2854
2992
  },
2855
2993
  });
2994
+ await record(MAIL_STORE_SCOPE_KEY, MAIL_STORE_POSTGRES);
2856
2995
  await record(PLATFORM_MAIL_ROUTE_SCOPE_KEY, PLATFORM_MAIL_ROUTE_WANTED);
2857
2996
  // Recorded and persisted before the secret leaves this function: if
2858
2997
  // delivery fails for good, the next plan reports the lost key rather
@@ -2875,7 +3014,7 @@ export async function provisionMailServer(scope, deps, target, input) {
2875
3014
  await record(PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, appId);
2876
3015
  }
2877
3016
  catch (error) {
2878
- throw redactedMailError(error, [password]);
3017
+ throw redactedMailError(error, [password, dataStore.password]);
2879
3018
  }
2880
3019
  }
2881
3020
  // Last, after every write the applied values depend on, so a run that
@@ -2884,6 +3023,79 @@ export async function provisionMailServer(scope, deps, target, input) {
2884
3023
  await record(PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, imageTag);
2885
3024
  return { resourceId: appId };
2886
3025
  }
3026
+ /**
3027
+ * Where Stalwart reaches its database, and the password it reads from its env. Parsed from Coolify's
3028
+ * `internal_db_url` inside a try: Node's parse error carries the whole input, credential included, so it
3029
+ * is replaced with a fixed message.
3030
+ */
3031
+ function mailDataStore(internalDbUrl, label) {
3032
+ if (!internalDbUrl) {
3033
+ throw new Error(`${label}: the mail database has no internal_db_url yet -- retry this apply once Coolify reports one`);
3034
+ }
3035
+ try {
3036
+ const url = new URL(internalDbUrl);
3037
+ const address = {
3038
+ host: url.hostname,
3039
+ port: url.port ? Number(url.port) : 5432,
3040
+ database: decodeURIComponent(url.pathname.replace(/^\//, '')),
3041
+ user: decodeURIComponent(url.username),
3042
+ };
3043
+ const password = decodeURIComponent(url.password);
3044
+ if (!address.host ||
3045
+ !address.database ||
3046
+ !address.user ||
3047
+ !password ||
3048
+ !Number.isInteger(address.port)) {
3049
+ throw new Error('incomplete');
3050
+ }
3051
+ return { address, password };
3052
+ }
3053
+ catch {
3054
+ throw new Error(`${label}: the mail database's internal_db_url could not be parsed`);
3055
+ }
3056
+ }
3057
+ /**
3058
+ * The recorded ids a recreated Stalwart makes stale: the app and what was deployed or applied to it, and the
3059
+ * admin key minted in it (the new server has none, so it is minted and delivered again). Everything else
3060
+ * stays: the project, the server, the tunnel and its connector, the DNS names, the internal address and the
3061
+ * published ports all describe things the new app reuses.
3062
+ */
3063
+ // A function, not a constant: `platform-diff.ts` and this file import each other, so its keys are not
3064
+ // all initialised yet when this module is evaluated.
3065
+ const mailStoreSwitchClearedKeys = () => [
3066
+ PLATFORM_MAIL_APP_SCOPE_KEY,
3067
+ PLATFORM_MAIL_DEPLOYED_SCOPE_KEY,
3068
+ PLATFORM_MAIL_DEPLOYED_HOSTNAME_SCOPE_KEY,
3069
+ PLATFORM_MAIL_DEPLOYED_GIT_REF_SCOPE_KEY,
3070
+ PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY,
3071
+ PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY,
3072
+ PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY,
3073
+ PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY,
3074
+ PLATFORM_MAIL_ROUTE_SCOPE_KEY,
3075
+ ];
3076
+ /**
3077
+ * Deletes the volume-backed Stalwart app (Coolify's delete removes its volumes) and clears the ids above.
3078
+ * The delete comes first: an app left behind after the ids are cleared would be found by name and reused,
3079
+ * still on its volume. A run that dies between the two finds no app by that name on the next run and only
3080
+ * clears. An app of that name whose uuid is not the recorded one is not deleted.
3081
+ */
3082
+ async function switchMailStoreToPostgres(scope, coolify, target, environmentId, label) {
3083
+ const recorded = scope.recordedIds();
3084
+ const live = await coolify.findApplicationByName(target.names.mailApp, {
3085
+ environmentIds: [environmentId],
3086
+ });
3087
+ if (live) {
3088
+ if (live.uuid !== recorded[PLATFORM_MAIL_APP_SCOPE_KEY]) {
3089
+ throw new Error(`${label}: the app named ${target.names.mailApp} is not the recorded mail server; not deleting it`);
3090
+ }
3091
+ await coolify.deleteApplication(live.uuid);
3092
+ }
3093
+ for (const key of mailStoreSwitchClearedKeys()) {
3094
+ if (recorded[key])
3095
+ scope.recordScopeId(key, '');
3096
+ }
3097
+ await scope.persist();
3098
+ }
2887
3099
  /** The platform's mail project: the shared `platform` project, its id recorded by the mail step under its own key. */
2888
3100
  const platformMailProject = {
2889
3101
  async ensure(coolify) {
@@ -2892,7 +3104,7 @@ const platformMailProject = {
2892
3104
  },
2893
3105
  };
2894
3106
  /** The platform's mail server: {@link provisionMailServer} with {@link platformTarget} and the platform's own inputs. */
2895
- async function provisionPlatformMail(context, deps) {
3107
+ async function provisionPlatformMail(context, deps, switchStore = false) {
2896
3108
  const { platform } = context;
2897
3109
  const scope = scopeFromPlatform(context);
2898
3110
  return provisionMailServer(scope, deps, platformTarget(), {
@@ -2900,6 +3112,7 @@ async function provisionPlatformMail(context, deps) {
2900
3112
  apex: platform.apex,
2901
3113
  mailHost: platformMailMxHost(platform.apex),
2902
3114
  imageTag: platform.mail.gitRef,
3115
+ switchStore,
2903
3116
  hostPorts: platformMailHostPorts(platform),
2904
3117
  issuerUrl: `https://${platform.auth.hostname}`,
2905
3118
  mxRecord: { serverIpv4: platform.serverIpv4 },
@@ -6068,7 +6281,7 @@ async function waitForInstance(url, runtime, sleep, label = 'platform:auth') {
6068
6281
  await sleep(interval);
6069
6282
  }
6070
6283
  }
6071
- async function resolvePlatformServer(coolify) {
6284
+ export async function resolvePlatformServer(coolify) {
6072
6285
  const servers = await coolify.listServers();
6073
6286
  const server = servers.find((s) => s.is_usable && s.is_reachable) ?? servers[0];
6074
6287
  if (!server)
@@ -6081,7 +6294,7 @@ async function resolvePlatformServer(coolify) {
6081
6294
  * file holds no instance of that class to call it on. `running` says whether
6082
6295
  * the app was already running or starting; the caller deploys it when not.
6083
6296
  */
6084
- async function findOrCreatePlatformApp(coolify, name, environment, create) {
6297
+ export async function findOrCreatePlatformApp(coolify, name, environment, create) {
6085
6298
  const existing = await coolify.findApplicationByName(name, {
6086
6299
  environmentIds: [environment.id],
6087
6300
  });
@@ -6236,7 +6449,7 @@ export function brokerDeployRecordValue(brokerAppId, authAppsRaw) {
6236
6449
  return JSON.stringify(record);
6237
6450
  }
6238
6451
  /** Whether any desired env is missing from the app, or set to another value. */
6239
- async function envsDiffer(coolify, appId, desired) {
6452
+ export async function envsDiffer(coolify, appId, desired) {
6240
6453
  const live = new Map((await coolify.listEnvs(appId)).filter((e) => !e.is_preview).map((e) => [e.key, e.value]));
6241
6454
  return desired.some((e) => live.get(e.key) !== e.value);
6242
6455
  }
@@ -8177,11 +8390,12 @@ const PLATFORM_BACKUP_DEFAULT_FREQUENCY = 'daily';
8177
8390
  * The database a service's schedule must name. A schedule that names none
8178
8391
  * dumps the Coolify resource's first database; Archon's is named here anyway,
8179
8392
  * 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.
8393
+ * dumped right. Mail names its database the same way. Every other service runs on its
8394
+ * resource's first database and needs no entry.
8182
8395
  */
8183
8396
  const PLATFORM_BACKUP_DATABASE_NAMES = {
8184
8397
  archon: ARCHON_POSTGRES_DB,
8398
+ mail: MAIL_DATABASE_NAME,
8185
8399
  };
8186
8400
  /**
8187
8401
  * The one schedule both the reader and {@link provisionPlatformBackup} treat
@@ -0,0 +1,105 @@
1
+ import type { CoolifyPort } from './executor.js';
2
+ import type { OwnFilesR2Keys } from './own-services.js';
3
+ /**
4
+ * The nightly backup of a company's sign-in database (#746). Coolify's database backups cover Coolify
5
+ * databases only, and the TLS database is an application, so the schedule is a Coolify scheduled task of
6
+ * that application (`POST /applications/{uuid}/scheduled-tasks`, read from Coolify 4.4.2's
7
+ * `ScheduledTasksController`). Coolify runs the task's command with `docker exec` in the database's
8
+ * container at the task's frequency. The command dumps with `pg_dump` and puts the file in the company's
9
+ * own backup bucket, with a storage key that reaches that bucket alone (minted through the same host
10
+ * ports as the company's files bucket key, `ensureSigninDbBackupTarget`). The platform's backup key never
11
+ * reaches a company's container. It keeps nothing: Coolify's default of keeping every dump applies.
12
+ */
13
+ /** Recorded id of the scheduled task (set only after Coolify lists it back as enabled). */
14
+ export declare const SIGNIN_DB_BACKUP_TASK_SCOPE_KEY = "signin_db_backup_task";
15
+ export declare const SIGNIN_DB_BACKUP_TASK_NAME = "signin-db-backup";
16
+ /** Coolify's own name for "once a day", the frequency the platform's database backups use. */
17
+ export declare const SIGNIN_DB_BACKUP_FREQUENCY = "daily";
18
+ /** How long Coolify lets one run take, in seconds. */
19
+ export declare const SIGNIN_DB_BACKUP_TIMEOUT_SECONDS = 3600;
20
+ /** The company's own backup bucket and the storage key scoped to it. The key is a secret. */
21
+ export interface SigninDbBackupTarget {
22
+ endpoint: string;
23
+ bucket: string;
24
+ /** R2 takes `auto`. */
25
+ region?: string;
26
+ accessKeyId: string;
27
+ secretAccessKey: string;
28
+ }
29
+ /** The host's bucket and storage key ports, the same ones the company's files bucket uses. */
30
+ export interface SigninDbBackupPort {
31
+ r2Buckets: {
32
+ ensureBucket(name: string): Promise<{
33
+ bucket: string;
34
+ endpoint: string;
35
+ }>;
36
+ };
37
+ r2Keys: OwnFilesR2Keys;
38
+ }
39
+ /** The token id of the company's sign-in backup key, recorded once it is in the database app's env. */
40
+ export declare const SIGNIN_DB_BACKUP_KEY_SCOPE_KEY = "signin_db_backup_key";
41
+ /** The token id minted and not yet in the env: deleted before any new mint. */
42
+ export declare const SIGNIN_DB_BACKUP_KEY_UNDELIVERED_SCOPE_KEY = "signin_db_backup_key_undelivered";
43
+ /** How long one call to the bucket or key port may take. */
44
+ export declare const SIGNIN_DB_BACKUP_PORT_TIMEOUT_MS = 30000;
45
+ /**
46
+ * The company's own bucket and a storage key that names that bucket alone. A key already in the
47
+ * database app's env whose token id is recorded is kept. Otherwise any earlier token of this company
48
+ * (recorded, or minted and never confirmed) is deleted first, so there are never two live tokens, then
49
+ * one is minted, registered as a secret and recorded as undelivered; the caller writes it to the env and
50
+ * then records it as delivered. A failed bucket or mint throws before the database is deployed or the
51
+ * move stops anything. Errors name the label only.
52
+ */
53
+ export declare function ensureSigninDbBackupTarget(port: SigninDbBackupPort, input: {
54
+ bucketName: string;
55
+ label: string;
56
+ recorded: Readonly<Record<string, string>>;
57
+ /** What the database app's env holds now. */
58
+ held: {
59
+ keyId?: string;
60
+ secret?: string;
61
+ };
62
+ record(key: string, id: string): Promise<void>;
63
+ onSecret?(value: string): void;
64
+ }): Promise<{
65
+ target: SigninDbBackupTarget;
66
+ tokenId: string;
67
+ fresh: boolean;
68
+ }>;
69
+ /** The env of the database app that the backup command reads. The command itself holds no value. */
70
+ export declare const SIGNIN_DB_BACKUP_ENV: {
71
+ readonly endpoint: "BACKUP_S3_ENDPOINT";
72
+ readonly bucket: "BACKUP_S3_BUCKET";
73
+ readonly region: "BACKUP_S3_REGION";
74
+ readonly keyId: "BACKUP_S3_KEY_ID";
75
+ readonly secret: "BACKUP_S3_SECRET";
76
+ readonly prefix: "BACKUP_S3_PREFIX";
77
+ };
78
+ /** Where in the bucket a company's dumps go: `signin-db/<slug>/signin-db-<UTC time>.dump`. */
79
+ export declare function signinDbBackupPrefix(slug: string): string;
80
+ /** The env entries the backup command needs, for the database app. */
81
+ export declare function signinDbBackupEnvs(target: SigninDbBackupTarget, slug: string): Array<{
82
+ key: string;
83
+ value: string;
84
+ }>;
85
+ /**
86
+ * The command Coolify runs in the container. A custom-format dump goes to a temporary file (an upload
87
+ * needs a length), is signed with SigV4 and put with curl, and the file is removed on every exit. The
88
+ * credentials reach curl on its standard input as a config, never in an argument. Every failure ends the
89
+ * run non-zero, and curl's error text names the HTTP status only. No single quote: Coolify wraps the
90
+ * command in single quotes.
91
+ */
92
+ export declare function signinDbBackupCommand(user: string, database: string): string;
93
+ /**
94
+ * Makes sure the database app has its backup task, and returns its uuid. The task is found by name
95
+ * before it is created, and updated to the full wanted body every time (Coolify hides the stored
96
+ * command from a token without the sensitive-read ability, so it cannot be compared). The task is then
97
+ * read back and must be listed enabled with the wanted frequency and timeout, or this throws: a
98
+ * schedule that cannot be seen is not a backup. The caller records the uuid. Errors name the label only.
99
+ */
100
+ export declare function ensureSigninDbBackupTask(coolify: Pick<CoolifyPort, 'raw'>, appId: string, label: string, db: {
101
+ user: string;
102
+ database: string;
103
+ }): Promise<string>;
104
+ /** A company on the TLS database whose nightly dump is not scheduled yet: the sign-in step plans again to schedule it. */
105
+ export declare function signinDbBackupMissing(recordedIds: Readonly<Record<string, string>>): boolean;