@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
@@ -45,6 +45,12 @@ export interface PlanOwnAuthOptions {
45
45
  mailKeyHeld: boolean;
46
46
  /** The host can mint that key itself (a Lettermint port and a held-key reader): the step plans without it. */
47
47
  mailKeyMintable?: boolean;
48
+ /** The clock for the sign-in database certificate's renewal window (#746). Default `Date.now`. */
49
+ now?: number;
50
+ /** The held keys can store the sign-in database's CA and certificate. Default true. */
51
+ signinDbKeysPuttable?: boolean;
52
+ /** The host supplies the backup bucket the sign-in database dumps into. Default true. */
53
+ signinDbBackupConfigured?: boolean;
48
54
  }
49
55
  /**
50
56
  * An own org (`services.own`) runs its own sign-in: ZITADEL, the login app
@@ -59,6 +65,26 @@ export declare function planOwnAuth(options: PlanOwnAuthOptions): {
59
65
  step: DraftStep | null;
60
66
  blocked: string | null;
61
67
  };
68
+ /** `unmanaged` key of the one line `planSigninDbMove` reports. */
69
+ export declare const OWN_SIGNIN_DB_MOVE_BLOCKED_KEY = "own-signin-db-move";
70
+ export interface PlanSigninDbMoveOptions {
71
+ org: DeclaredOrg;
72
+ recordedIds: Readonly<Record<string, string>>;
73
+ /** The held keys can store the sign-in database's CA and certificate. Default true. */
74
+ signinDbKeysPuttable?: boolean;
75
+ /** The host supplies the backup bucket the sign-in database dumps into. Default true. */
76
+ signinDbBackupConfigured?: boolean;
77
+ }
78
+ /**
79
+ * One step for an own org whose sign-in is applied and whose ZITADEL still uses the Coolify database
80
+ * (#746): `signin-db-move.ts` copies it to foundry's TLS image. Never planned for a company on the TLS
81
+ * database, a new one, or one whose sign-in is not applied. The apply refuses it without a fresh
82
+ * backup of the old database (`backup-gate.ts`).
83
+ */
84
+ export declare function planSigninDbMove(options: PlanSigninDbMoveOptions): {
85
+ step: DraftStep | null;
86
+ blocked: string | null;
87
+ };
62
88
  /** `unmanaged` key of the one line `planOwnMail` reports. */
63
89
  export declare const OWN_MAIL_BLOCKED_KEY = "own-mail";
64
90
  export interface PlanOwnMailOptions {
@@ -6,12 +6,16 @@ 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';
12
13
  import { AUTH_ZITADEL_RUN_OPTIONS, JOBS_DEPLOY_PENDING_SCOPE_KEY, JOBS_INTERNAL_URL_SCOPE_KEY, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_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_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_SMTP_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_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_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_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_INTERNAL_URL_SCOPE_KEY, platformMailMxHost, } from './platform-diff.js';
13
14
  import { OWN_FILES_RUNNER_SCOPE_KEY } from './platform-org.js';
14
- import { JOBS_ADMIN_TOKEN_UNDELIVERED_SCOPE_KEY, JOBS_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_SCOPE_KEY, PLATFORM_FILES_SHARE_DOMAINS_SCOPE_KEY, filesShareDomains, } from './platform-providers.js';
15
+ import { JOBS_ADMIN_TOKEN_UNDELIVERED_SCOPE_KEY, JOBS_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_AUTH_DB_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_SCOPE_KEY, PLATFORM_FILES_SHARE_DOMAINS_SCOPE_KEY, filesShareDomains, } from './platform-providers.js';
16
+ import { signinDbBackupMissing } from './signin-db-backup.js';
17
+ import { SIGNIN_DB_ZITADEL_PENDING_SCOPE_KEY, signinDbCertDue } from './signin-db-tls.js';
18
+ import { SIGNIN_DB_TLS_SCOPE_KEY } from './signin-db.js';
15
19
  /** `unmanaged` key of the one line `planOwnJobs` reports. */
16
20
  export const OWN_JOBS_BLOCKED_KEY = 'own-jobs';
17
21
  /**
@@ -129,6 +133,15 @@ export function planOwnAuth(options) {
129
133
  if (!options.mailKeyHeld && !options.mailKeyMintable) {
130
134
  return blocked(`the held key ${orgTarget(org).refs.brokerMailKey} is not held yet`);
131
135
  }
136
+ // A company still on the Coolify database keeps running without the keys until it moves.
137
+ if (!recordedIds[PLATFORM_AUTH_DB_SCOPE_KEY]) {
138
+ if (options.signinDbKeysPuttable === false && !recordedIds[SIGNIN_DB_TLS_SCOPE_KEY]) {
139
+ return blocked('the host cannot store the sign-in database certificate (held keys have no put)');
140
+ }
141
+ if (options.signinDbBackupConfigured === false) {
142
+ return blocked('the host supplies no backup bucket for the sign-in database');
143
+ }
144
+ }
132
145
  const hostname = names.auth;
133
146
  // The broker starts only on a valid `AUTH_APPS`, and the login client is minted only once it does:
134
147
  // the first broker entry is what lets this step finish, as the platform's first registration does.
@@ -148,7 +161,11 @@ export function planOwnAuth(options) {
148
161
  recordedIds[PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY] &&
149
162
  recordedIds[PLATFORM_AUTH_SMTP_SCOPE_KEY] &&
150
163
  recordedIds[PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY] === AUTH_ZITADEL_RUN_OPTIONS &&
151
- (Boolean(recordedIds[PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY]) || !brokerHoldsAnEntry)) {
164
+ (Boolean(recordedIds[PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY]) || !brokerHoldsAnEntry) &&
165
+ !signinDbCertDue(recordedIds, options.now ?? Date.now()) &&
166
+ !signinDbBackupMissing(recordedIds) &&
167
+ // ZITADEL was started on the TLS database and never seen healthy there: the step starts it again.
168
+ !recordedIds[SIGNIN_DB_ZITADEL_PENDING_SCOPE_KEY]) {
152
169
  return { step: null, blocked: null };
153
170
  }
154
171
  return {
@@ -168,6 +185,47 @@ export function planOwnAuth(options) {
168
185
  },
169
186
  };
170
187
  }
188
+ /** `unmanaged` key of the one line `planSigninDbMove` reports. */
189
+ export const OWN_SIGNIN_DB_MOVE_BLOCKED_KEY = 'own-signin-db-move';
190
+ /**
191
+ * One step for an own org whose sign-in is applied and whose ZITADEL still uses the Coolify database
192
+ * (#746): `signin-db-move.ts` copies it to foundry's TLS image. Never planned for a company on the TLS
193
+ * database, a new one, or one whose sign-in is not applied. The apply refuses it without a fresh
194
+ * backup of the old database (`backup-gate.ts`).
195
+ */
196
+ export function planSigninDbMove(options) {
197
+ const { org, recordedIds } = options;
198
+ if (!ownsServices(org) ||
199
+ !ownAuthApplied(org, recordedIds) ||
200
+ !recordedIds[PLATFORM_AUTH_DB_SCOPE_KEY] ||
201
+ !recordedIds[PLATFORM_AUTH_ZITADEL_SCOPE_KEY] ||
202
+ recordedIds[SIGNIN_DB_TLS_SCOPE_KEY]) {
203
+ return { step: null, blocked: null };
204
+ }
205
+ if (options.signinDbKeysPuttable === false) {
206
+ return {
207
+ step: null,
208
+ blocked: `${org.slug}'s sign-in database move is blocked, not planned: the host cannot store the sign-in database certificate (held keys have no put)`,
209
+ };
210
+ }
211
+ if (options.signinDbBackupConfigured === false) {
212
+ return {
213
+ step: null,
214
+ blocked: `${org.slug}'s sign-in database move is blocked, not planned: the host supplies no backup bucket for the sign-in database`,
215
+ };
216
+ }
217
+ return {
218
+ blocked: null,
219
+ step: {
220
+ action: { system: 'coolify', kind: 'own-auth-db-move', key: org.id, desired: {} },
221
+ step: {
222
+ description: `move ${org.slug}'s sign-in database to foundry's TLS image: ZITADEL is stopped while the data is copied, the old database is kept`,
223
+ destroys: false,
224
+ migrates: true,
225
+ },
226
+ },
227
+ };
228
+ }
171
229
  /** `unmanaged` key of the one line `planOwnMail` reports. */
172
230
  export const OWN_MAIL_BLOCKED_KEY = 'own-mail';
173
231
  /**
@@ -212,7 +270,9 @@ export function planOwnMail(options) {
212
270
  const smtpPort = options.smtpPort ?? OWN_MAIL_SMTP_PORT_BASE;
213
271
  if (!isOwnMailSmtpPort(smtpPort))
214
272
  return blocked('its mail port is outside the range foundry uses');
215
- if (recordedIds[`coolify:own-mail:${org.id}`] &&
273
+ const storeSwitch = mailStoreIsLegacy(recordedIds);
274
+ if (!storeSwitch &&
275
+ recordedIds[`coolify:own-mail:${org.id}`] &&
216
276
  recordedIds[OWN_MAIL_SMTP_PORT_SCOPE_KEY] === String(smtpPort) &&
217
277
  recordedIds[PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY] === hostname &&
218
278
  recordedIds[PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY] === gitRef &&
@@ -227,11 +287,19 @@ export function planOwnMail(options) {
227
287
  system: 'coolify',
228
288
  kind: 'own-mail',
229
289
  key: org.id,
230
- desired: { hostname, gitRef, mailHost: platformMailMxHost(platformApex), smtpPort },
290
+ desired: {
291
+ hostname,
292
+ gitRef,
293
+ mailHost: platformMailMxHost(platformApex),
294
+ smtpPort,
295
+ ...(storeSwitch ? { switchMailStore: true } : {}),
296
+ },
231
297
  },
232
298
  step: {
233
- description: `provision ${org.slug}'s own mail server`,
234
- destroys: false,
299
+ description: storeSwitch
300
+ ? mailStoreSwitchDescription(org.slug)
301
+ : `provision ${org.slug}'s own mail server`,
302
+ destroys: storeSwitch,
235
303
  migrates: false,
236
304
  },
237
305
  },
@@ -20,12 +20,17 @@ export interface ServiceTarget {
20
20
  readonly kind: 'platform' | 'org';
21
21
  readonly names: {
22
22
  authDb: string;
23
+ /** The sign-in database as foundry's own TLS image (#746). Only an org uses it; the platform's value is never read. */
24
+ signinDb: string;
25
+ /** The one-shot job that copies the old database into it (#746). */
26
+ signinDbMove: string;
23
27
  authZitadel: string;
24
28
  authLogin: string;
25
29
  authBroker: string;
26
30
  authTunnel: string;
27
31
  authCloudflared: string;
28
32
  mailApp: string;
33
+ mailDb: string;
29
34
  mailTunnel: string;
30
35
  mailCloudflared: string;
31
36
  filesDb: string;
@@ -33,6 +38,8 @@ export interface ServiceTarget {
33
38
  filesRunner: string;
34
39
  /** An own org's R2 bucket. The platform's is host-supplied (`PlatformFilesDeps.r2`), so its value here is never read. */
35
40
  filesBucket: string;
41
+ /** An own org's sign-in database backup bucket (#746): its own bucket, never the files bucket. */
42
+ signinBackupBucket: string;
36
43
  jobsApp: string;
37
44
  jobsDb: string;
38
45
  sfuApp: string;
@@ -40,6 +47,8 @@ export interface ServiceTarget {
40
47
  sfuCloudflared: string;
41
48
  };
42
49
  readonly internal: {
50
+ /** The hostname ZITADEL connects to, and the one name the database's certificate carries. */
51
+ signinDb: string;
43
52
  authZitadel: string;
44
53
  authLogin: string;
45
54
  authBroker: string;
@@ -61,6 +70,8 @@ export interface ServiceTarget {
61
70
  keysCredential: string;
62
71
  /** The public half the host verifies with (`KEYS_VERIFY_KEYS`). */
63
72
  keysVerifyKeys: string;
73
+ /** The sign-in database's server certificate and key, held as JSON `{ cert, key }` (#746). */
74
+ signinDbServer: string;
64
75
  };
65
76
  }
66
77
  /** The platform's shared services, with the literals the platform steps use today. */
@@ -11,18 +11,22 @@ export function platformTarget() {
11
11
  kind: 'platform',
12
12
  names: {
13
13
  authDb: 'platform-auth-db',
14
+ signinDb: 'platform-signin-db',
15
+ signinDbMove: 'platform-signin-db-move',
14
16
  authZitadel: 'platform-auth-zitadel',
15
17
  authLogin: 'platform-auth-login',
16
18
  authBroker: 'platform-auth-broker',
17
19
  authTunnel: 'platform-auth-tunnel',
18
20
  authCloudflared: 'platform-auth-cloudflared',
19
21
  mailApp: 'platform-mail-stalwart',
22
+ mailDb: 'platform-mail-postgres',
20
23
  mailTunnel: 'platform-mail-tunnel',
21
24
  mailCloudflared: 'platform-mail-cloudflared',
22
25
  filesDb: 'platform-files-db',
23
26
  filesMigrate: 'files-migrate',
24
27
  filesRunner: 'files',
25
28
  filesBucket: 'platform-files',
29
+ signinBackupBucket: 'platform-signin-backups',
26
30
  jobsApp: 'jobs',
27
31
  jobsDb: 'jobs-scheduler-db',
28
32
  sfuApp: 'platform-sfu',
@@ -30,6 +34,7 @@ export function platformTarget() {
30
34
  sfuCloudflared: 'platform-sfu-cloudflared',
31
35
  },
32
36
  internal: {
37
+ signinDb: 'platform-signin-db',
33
38
  authZitadel: 'platform-auth-zitadel',
34
39
  authLogin: 'platform-auth-login',
35
40
  authBroker: 'platform-auth-broker',
@@ -49,6 +54,7 @@ export function platformTarget() {
49
54
  livekitKeys: 'livekit:platform:keys',
50
55
  keysCredential: 'keys:platform:credential',
51
56
  keysVerifyKeys: 'keys:platform:verifyKeys',
57
+ signinDbServer: 'signin-db:platform:server',
52
58
  },
53
59
  };
54
60
  }
@@ -60,18 +66,22 @@ export function orgTarget(org) {
60
66
  kind: 'org',
61
67
  names: {
62
68
  authDb: `${s}-auth-db`,
69
+ signinDb: `${s}-signin-db`,
70
+ signinDbMove: `${s}-signin-db-move`,
63
71
  authZitadel: `${s}-auth-zitadel`,
64
72
  authLogin: `${s}-auth-login`,
65
73
  authBroker: `${s}-auth-broker`,
66
74
  authTunnel: `${s}-auth-tunnel`,
67
75
  authCloudflared: `${s}-auth-cloudflared`,
68
76
  mailApp: `${s}-mail-stalwart`,
77
+ mailDb: `${s}-mail-postgres`,
69
78
  mailTunnel: `${s}-mail-tunnel`,
70
79
  mailCloudflared: `${s}-mail-cloudflared`,
71
80
  filesDb: `${s}-files-db`,
72
81
  filesMigrate: `${s}-files-migrate`,
73
82
  filesRunner: `${s}-files`,
74
83
  filesBucket: `${s}-files`,
84
+ signinBackupBucket: `${s}-signin-backups`,
75
85
  jobsApp: `${s}-jobs`,
76
86
  jobsDb: `${s}-jobs-db`,
77
87
  sfuApp: `${s}-sfu`,
@@ -79,6 +89,7 @@ export function orgTarget(org) {
79
89
  sfuCloudflared: `${s}-sfu-cloudflared`,
80
90
  },
81
91
  internal: {
92
+ signinDb: `${s}-signin-db`,
82
93
  authZitadel: `${s}-auth-zitadel`,
83
94
  authLogin: `${s}-auth-login`,
84
95
  authBroker: `${s}-auth-broker`,
@@ -98,6 +109,7 @@ export function orgTarget(org) {
98
109
  livekitKeys: `livekit:org:${s}:keys`,
99
110
  keysCredential: `keys:org:${s}:credential`,
100
111
  keysVerifyKeys: `keys:org:${s}:verifyKeys`,
112
+ signinDbServer: `signin-db:org:${s}:server`,
101
113
  },
102
114
  };
103
115
  }
@@ -199,6 +199,10 @@ export interface StepExecutor {
199
199
  }>;
200
200
  /** Whether the executor can mint an own org's Lettermint key: a Lettermint port and a reader for the held ref. */
201
201
  ownMailKeyMintable?(): boolean;
202
+ /** Whether the executor's held keys can store the sign-in database's CA and certificate (#746). Absent: true. */
203
+ ownSigninDbKeysPuttable?(): boolean;
204
+ /** Whether the host supplies the backup bucket the sign-in database dumps into (#746). Absent: true. */
205
+ ownSigninDbBackupConfigured?(): boolean;
202
206
  /**
203
207
  * Whether the host's scoped ports for this own org include a files-organisation
204
208
  * port. The planner plans no `files:organisation` step for an own org while
@@ -7,7 +7,7 @@ import { AlreadyRunningError, StepFailedError } from '../runner/errors.js';
7
7
  import { StaleLeaseError, } from '../store.js';
8
8
  import { AdoptRefusedError, adoptCandidates, adoptedKeysUnreadable, adoptedScopeKey, parseAdoptSnapshot, planAdoptFrom, } from './adopt.js';
9
9
  import { NO_AGORA_HOST_VALUES, readAgoraHost, } from './agora.js';
10
- import { NetworkMoveBackupMissingError, UpgradeBackupMissingError, platformDatabasesForService, requireFreshBackupsForMove, requireFreshBackupsForUpgrades, } from './backup-gate.js';
10
+ import { NetworkMoveBackupMissingError, SigninDbMoveBackupMissingError, UpgradeBackupMissingError, platformDatabasesForService, requireFreshBackupForSigninMove, requireFreshBackupsForMove, requireFreshBackupsForUpgrades, } from './backup-gate.js';
11
11
  import { readLiveBuilds } from './build-record.js';
12
12
  import { unconfirmedUpgrades } from './confirm-upgrade.js';
13
13
  import { AI_KEY_UNDELIVERED_SCOPE_KEY, JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY, computePlanDetail, toPlan, } from './diff.js';
@@ -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';
@@ -443,6 +443,25 @@ export class FoundryPlanApply {
443
443
  throw e;
444
444
  }
445
445
  }
446
+ // The move of a sign-in database reads the old one, and the old one stays; a fresh dump is the way back.
447
+ if (detail.plannedSteps.some((s) => s.action.system === 'coolify' && s.action.kind === 'own-auth-db-move')) {
448
+ try {
449
+ if (!this.backupGate)
450
+ throw new SigninDbMoveBackupMissingError('no-gate');
451
+ await requireFreshBackupForSigninMove({
452
+ coolify: this.backupGate.coolify,
453
+ orgId,
454
+ recordedIds: (await this.store.getAppliedState(orgId))?.serviceIds ?? {},
455
+ now: () => this.now().getTime(),
456
+ });
457
+ }
458
+ catch (e) {
459
+ if (e instanceof SigninDbMoveBackupMissingError) {
460
+ await this.refuse(plan, 'sign-in database move without a fresh backup');
461
+ }
462
+ throw e;
463
+ }
464
+ }
446
465
  if (planNeedsConfirmation(plan.steps) &&
447
466
  confirmedOrgName.trim() !== org.displayName.trim()) {
448
467
  await this.refuse(plan, "destructive or migrating step without the org's name typed correctly");
@@ -1044,6 +1063,8 @@ export class FoundryPlanApply {
1044
1063
  oikosZoneReady: platformReadiness.oikosIngressApplied === true,
1045
1064
  ownMailKeyHeld: await this.ownMailKeyHeld(org),
1046
1065
  ownMailKeyMintable: this.executor.ownMailKeyMintable?.() ?? false,
1066
+ ownSigninDbKeysPuttable: this.executor.ownSigninDbKeysPuttable?.() ?? true,
1067
+ ownSigninDbBackupConfigured: this.executor.ownSigninDbBackupConfigured?.() ?? true,
1047
1068
  ...(this.backupGate
1048
1069
  ? {
1049
1070
  backupStorageUuid: (await this.platformDeps?.store.getServiceIds())?.[PLATFORM_BACKUP_S3_STORAGE_SCOPE_KEY] || null,
@@ -1330,9 +1351,13 @@ export class FoundryPlanApply {
1330
1351
  async platformOwnerInput(platform) {
1331
1352
  const owner = await this.store.get(platform.ownerOrgId);
1332
1353
  if (!owner)
1333
- return { signIn: null, files: null };
1354
+ return { signIn: null, files: null, chat: null };
1334
1355
  const recorded = (await this.store.getAppliedState(owner.id))?.serviceIds ?? {};
1335
- return { signIn: ownerSignInFrom(owner, recorded), files: ownerFilesFrom(owner, recorded) };
1356
+ return {
1357
+ signIn: ownerSignInFrom(owner, recorded),
1358
+ files: ownerFilesFrom(owner, recorded),
1359
+ chat: ownerChatFrom(owner, recorded),
1360
+ };
1336
1361
  }
1337
1362
  /**
1338
1363
  * What the shared mail relay needs from the orgs. A route exists for an org
@@ -1686,6 +1711,7 @@ const STEP_FIELDS = {
1686
1711
  'coolify:mail': 'mail',
1687
1712
  'coolify:chat': 'chat.usesDomain',
1688
1713
  'coolify:own-auth': 'services.own',
1714
+ 'coolify:own-auth-db-move': 'services.own',
1689
1715
  'coolify:own-files': 'services.own',
1690
1716
  'coolify:own-files-r2': 'services.own',
1691
1717
  'coolify:own-jobs': 'services.own',
@@ -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[];
@@ -7,6 +7,7 @@ import { versionChangeNeedsConfirmation } from './confirm-upgrade.js';
7
7
  import { ACCESS_PROVIDER_RECORD_KEY } from './gates.js';
8
8
  import { legacyAppHostname } from './hostnames.js';
9
9
  import { PLATFORM_MAIL_RELAY_KEY, planMailRelay } from './mail-relay.js';
10
+ import { MAIL_DATABASE_SCOPE_KEY, mailStoreIsLegacy, mailStoreSwitchChange, mailStoreSwitchDescription, } from './mail-store.js';
10
11
  import { planOikosIngress } from './oikos-ingress.js';
11
12
  import { callsSfuAppliedFingerprint } from './platform-sfu.js';
12
13
  import { signInDomainBlockedText } from './sign-in-org.js';
@@ -1155,6 +1156,28 @@ export const PLATFORM_UPTIME_APPLIED_SECRETS_SCOPE_KEY = 'uptime_applied_secrets
1155
1156
  export const PLATFORM_UPTIME_APPLIED_VARS_SCOPE_KEY = 'uptime_applied_vars';
1156
1157
  /** `'1'` once the step removed the Worker because the platform no longer enables it; cleared when it deploys again. */
1157
1158
  export const PLATFORM_UPTIME_REMOVED_SCOPE_KEY = 'uptime_removed';
1159
+ /**
1160
+ * The chat bot's room (`!id:server`), recorded as soon as chat-service names it.
1161
+ * Recorded before the bot's token is held, so a crash between the two leaves the room known.
1162
+ */
1163
+ export const PLATFORM_UPTIME_CHAT_ROOM_ID_SCOPE_KEY = 'platform_uptime_chat_room_id';
1164
+ /**
1165
+ * A random id, recorded and persisted before the chat route is called (it mints the bot's token) and
1166
+ * cleared once that token is held. Set while the token is not held, it means a token was minted that may
1167
+ * be lost, so the next call asks the route to rotate it.
1168
+ */
1169
+ export const PLATFORM_UPTIME_CHAT_UNDELIVERED_SCOPE_KEY = 'platform_uptime_chat_token_undelivered';
1170
+ /**
1171
+ * The Worker's plain text variables: the chat homeserver and room foundry made, under whatever the
1172
+ * host configured (a host value that is set wins). Unset values are left out.
1173
+ */
1174
+ export function uptimeEffectiveVars(hostVars, chat) {
1175
+ const host = Object.fromEntries(Object.entries(hostVars ?? {}).filter(([, v]) => Boolean(v)));
1176
+ return {
1177
+ ...(chat ? { CHAT_HOMESERVER_URL: chat.homeserverUrl, CHAT_ROOM_ID: chat.roomId } : {}),
1178
+ ...host,
1179
+ };
1180
+ }
1158
1181
  /** The digest of the Worker's plain text variables that are set; empty when none is. */
1159
1182
  export function uptimeVarsDigest(vars = {}) {
1160
1183
  const set = Object.entries(vars)
@@ -1332,7 +1355,7 @@ export function planPlatform(platform, live, options = {}) {
1332
1355
  plannedSteps.push(heartbeats.step);
1333
1356
  // foundry#542: after Archon is applied (never merely planned): the Worker
1334
1357
  // probes Archon, and Archon's env takes the token this step mints.
1335
- const uptime = planUptime(platform, recordedIds, blocked.uptime, blocked.uptimeSecretsHeld ?? [], Boolean(recordedIds[resourceKey('coolify', 'app', PLATFORM_ARCHON_KEY)]), blocked.uptimeHeld);
1358
+ const uptime = planUptime(platform, recordedIds, blocked.uptime, blocked.uptimeSecretsHeld ?? [], Boolean(recordedIds[resourceKey('coolify', 'app', PLATFORM_ARCHON_KEY)]), blocked.uptimeHeld, blocked.uptimeChat);
1336
1359
  if (uptime.step)
1337
1360
  plannedSteps.push(uptime.step);
1338
1361
  if (archon.step && blocked.archonRunsInsideApp)
@@ -1353,6 +1376,9 @@ export function planPlatform(platform, live, options = {}) {
1353
1376
  // read for, is premature, not informational.
1354
1377
  const versionChanges = [];
1355
1378
  for (const change of [
1379
+ mail.blocked || !mail.step?.action.desired.switchMailStore
1380
+ ? null
1381
+ : mailStoreSwitchChange('the platform'),
1356
1382
  mail.blocked
1357
1383
  ? null
1358
1384
  : versionChangeLine('mail', recordedIds[PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY], platform.mail.gitRef, 'image, redeploy'),
@@ -1593,11 +1619,13 @@ export const PLATFORM_BACKUP_DATABASE_SCOPE_KEYS = {
1593
1619
  archon: 'coolify_platform_archon_database',
1594
1620
  ai: 'coolify_platform_ai_database',
1595
1621
  integrations: 'coolify_integrations_database',
1622
+ mail: MAIL_DATABASE_SCOPE_KEY,
1596
1623
  };
1597
1624
  export const PLATFORM_BACKUP_SERVICE_KEYS = [
1598
1625
  'archon',
1599
1626
  'ai',
1600
1627
  'integrations',
1628
+ 'mail',
1601
1629
  ];
1602
1630
  /**
1603
1631
  * #257: a nightly backup for each platform database foundry itself creates,
@@ -2047,7 +2075,9 @@ function planMail(platform, recordedIds, executorBlocked, authReady) {
2047
2075
  if (!platform.mail.enabled)
2048
2076
  return { step: null, blocked: null };
2049
2077
  const key = resourceKey('coolify', 'app', PLATFORM_MAIL_KEY);
2050
- if (recordedIds[key] &&
2078
+ const storeSwitch = mailStoreIsLegacy(recordedIds);
2079
+ if (!storeSwitch &&
2080
+ recordedIds[key] &&
2051
2081
  recordedIds[PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY] &&
2052
2082
  recordedIds[PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY] === platform.mail.hostname &&
2053
2083
  recordedIds[PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY] === platform.mail.gitRef &&
@@ -2083,6 +2113,28 @@ function planMail(platform, recordedIds, executorBlocked, authReady) {
2083
2113
  blocked: "the platform's mail server is blocked, not planned: its OIDC directory uses the platform's own sign-in, which is neither applied nor planned",
2084
2114
  };
2085
2115
  }
2116
+ if (storeSwitch) {
2117
+ return {
2118
+ step: {
2119
+ action: {
2120
+ system: 'coolify',
2121
+ kind: 'app',
2122
+ key: PLATFORM_MAIL_KEY,
2123
+ desired: {
2124
+ hostname: platform.mail.hostname,
2125
+ gitRef: platform.mail.gitRef,
2126
+ switchMailStore: true,
2127
+ },
2128
+ },
2129
+ step: {
2130
+ description: mailStoreSwitchDescription('the platform'),
2131
+ destroys: true,
2132
+ migrates: false,
2133
+ },
2134
+ },
2135
+ blocked: null,
2136
+ };
2137
+ }
2086
2138
  return {
2087
2139
  step: {
2088
2140
  action: {
@@ -2684,7 +2736,7 @@ function planUptime(platform, recordedIds, executorBlocked, heldOptional, archon
2684
2736
  token: true,
2685
2737
  access: true,
2686
2738
  vars: '',
2687
- }) {
2739
+ }, chat) {
2688
2740
  const { uptime } = platform;
2689
2741
  const deployed = Boolean(recordedIds[resourceKey('cloudflare', 'worker', PLATFORM_UPTIME_KEY)]) &&
2690
2742
  !recordedIds[PLATFORM_UPTIME_REMOVED_SCOPE_KEY];
@@ -2733,7 +2785,7 @@ function planUptime(platform, recordedIds, executorBlocked, heldOptional, archon
2733
2785
  if (executorBlocked)
2734
2786
  return { step: null, blocked: [executorBlocked], notes: [] };
2735
2787
  const notes = [];
2736
- if (!heldOptional.includes('CHAT_ACCESS_TOKEN')) {
2788
+ if (!chat && !heldOptional.includes('CHAT_ACCESS_TOKEN')) {
2737
2789
  notes.push({
2738
2790
  key: 'uptime:CHAT_ACCESS_TOKEN',
2739
2791
  service: 'uptime',
@@ -2759,9 +2811,12 @@ function planUptime(platform, recordedIds, executorBlocked, heldOptional, archon
2759
2811
  recordedIds[PLATFORM_UPTIME_APPLIED_SHA256_SCOPE_KEY] === uptime.sha256 &&
2760
2812
  recordedIds[PLATFORM_UPTIME_APPLIED_HOSTNAME_SCOPE_KEY] === hostname &&
2761
2813
  (recordedIds[PLATFORM_UPTIME_APPLIED_VARS_SCOPE_KEY] ?? '') === uptimeHeld.vars &&
2762
- recordedIds[PLATFORM_UPTIME_APPLIED_SECRETS_SCOPE_KEY] === uptimeSecretNames(heldOptional);
2814
+ recordedIds[PLATFORM_UPTIME_APPLIED_SECRETS_SCOPE_KEY] === uptimeSecretNames(heldOptional) &&
2815
+ !chat?.pending;
2816
+ // Reported beside the step, not instead of it: the Worker deploys without chat alerts meanwhile.
2817
+ const blockedLines = chat?.blocked ? [chat.blocked] : [];
2763
2818
  if (applied)
2764
- return { step: null, blocked: [], notes };
2819
+ return { step: null, blocked: blockedLines, notes };
2765
2820
  return {
2766
2821
  step: {
2767
2822
  action: action({ hostname, gitRef: uptime.tag, sha256: uptime.sha256 }),
@@ -2771,7 +2826,7 @@ function planUptime(platform, recordedIds, executorBlocked, heldOptional, archon
2771
2826
  migrates: false,
2772
2827
  },
2773
2828
  },
2774
- blocked: [],
2829
+ blocked: blockedLines,
2775
2830
  notes,
2776
2831
  };
2777
2832
  }
@@ -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
+ }