@wtfalch/foundry 0.38.0 → 0.39.1

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.
@@ -2,8 +2,7 @@
2
2
  * The estate's pinned third-party image/release refs for the platform
3
3
  * services foundry provisions (#247). One JSON file, so Renovate can read
4
4
  * and later propose bumps against it, instead of four separate string
5
- * constants it cannot see. `postgres` has no entry here: no planner step
6
- * pins a postgres image tag today, so there is no ref to record yet.
5
+ * constants it cannot see.
7
6
  */
8
7
  export interface PinnedVersions {
9
8
  zitadel: string;
@@ -17,6 +16,8 @@ export interface PinnedVersions {
17
16
  alpine: string;
18
17
  /** A static busybox, copied into the MAS image so its health check has an HTTP client. */
19
18
  busybox: string;
19
+ /** The sign-in database's image (#746). Its major version is the data directory's format: a bump past 16 needs a dump and restore, not an image change. */
20
+ postgres: string;
20
21
  }
21
22
  export declare function loadVersions(): PinnedVersions;
22
23
  export declare const VERSIONS: PinnedVersions;
@@ -28,6 +28,15 @@ export declare class NetworkMoveBackupMissingError extends Error {
28
28
  readonly reason: 'no-gate' | 'no-backup' | 'by-hand';
29
29
  constructor(reason: 'no-gate' | 'no-backup' | 'by-hand');
30
30
  }
31
+ /**
32
+ * The move of a company's sign-in database to the TLS image (#746) is refused before any step runs when
33
+ * the old database has no finished backup from the last {@link BACKUP_MAX_AGE_HOURS} hours, or the host
34
+ * has no backup gate. The message names no database, uuid or URL. There is no override.
35
+ */
36
+ export declare class SigninDbMoveBackupMissingError extends Error {
37
+ readonly reason: 'no-gate' | 'no-backup';
38
+ constructor(reason: 'no-gate' | 'no-backup');
39
+ }
31
40
  export interface BackupGatePorts {
32
41
  /** The company's recorded ids (`AppliedState.serviceIds`): database uuids and `backup_<db>` schedule uuids. */
33
42
  recordedIds: Readonly<Record<string, string>>;
@@ -67,6 +76,13 @@ export declare function requireFreshBackupsForUpgrades(plan: {
67
76
  }, confirmed: readonly string[] | undefined, ports: BackupGatePorts): Promise<{
68
77
  backupId: string | null;
69
78
  }>;
79
+ /**
80
+ * The sign-in database move's gate (#746): the company's recorded `auth` database needs a schedule and a
81
+ * `success` execution not older than {@link BACKUP_MAX_AGE_HOURS} hours, read from Coolify's executions.
82
+ * A schedule alone proves no dump ran, so a Coolify with no executions route refuses too. Returns the
83
+ * uuid of the execution that passed.
84
+ */
85
+ export declare function requireFreshBackupForSigninMove(ports: BackupGatePorts): Promise<string>;
70
86
  export interface MoveGatePorts {
71
87
  /** The company's Coolify project (`coolify_project`). */
72
88
  projectUuid: string | undefined;
@@ -44,6 +44,21 @@ export class NetworkMoveBackupMissingError extends Error {
44
44
  this.name = 'NetworkMoveBackupMissingError';
45
45
  }
46
46
  }
47
+ /**
48
+ * The move of a company's sign-in database to the TLS image (#746) is refused before any step runs when
49
+ * the old database has no finished backup from the last {@link BACKUP_MAX_AGE_HOURS} hours, or the host
50
+ * has no backup gate. The message names no database, uuid or URL. There is no override.
51
+ */
52
+ export class SigninDbMoveBackupMissingError extends Error {
53
+ reason;
54
+ constructor(reason) {
55
+ super(reason === 'no-gate'
56
+ ? 'sign-in database move refused: this host has no backup gate, so a fresh backup cannot be checked'
57
+ : `sign-in database move refused: the old sign-in database has no finished backup from the last ${BACKUP_MAX_AGE_HOURS} hours, checked in Coolify; take one now and apply again`);
58
+ this.reason = reason;
59
+ this.name = 'SigninDbMoveBackupMissingError';
60
+ }
61
+ }
47
62
  async function withinTimeout(call) {
48
63
  let timer;
49
64
  try {
@@ -184,6 +199,31 @@ export async function requireFreshBackupsForUpgrades(plan, confirmed, ports) {
184
199
  }
185
200
  return { backupId };
186
201
  }
202
+ /**
203
+ * The sign-in database move's gate (#746): the company's recorded `auth` database needs a schedule and a
204
+ * `success` execution not older than {@link BACKUP_MAX_AGE_HOURS} hours, read from Coolify's executions.
205
+ * A schedule alone proves no dump ran, so a Coolify with no executions route refuses too. Returns the
206
+ * uuid of the execution that passed.
207
+ */
208
+ export async function requireFreshBackupForSigninMove(ports) {
209
+ const now = (ports.now ?? Date.now)();
210
+ const databaseUuid = ports.recordedIds[companyDatabaseKeys({ id: ports.orgId }).auth];
211
+ const backupUuid = ports.recordedIds[backupScopeKey('auth')];
212
+ if (!databaseUuid || !backupUuid)
213
+ throw new SigninDbMoveBackupMissingError('no-backup');
214
+ let executions;
215
+ try {
216
+ executions = await readExecutions(ports.coolify, databaseUuid, backupUuid);
217
+ }
218
+ catch {
219
+ // An unreadable list is not a fresh backup. The cause stays out: it can carry a response body.
220
+ throw new SigninDbMoveBackupMissingError('no-backup');
221
+ }
222
+ const fresh = executions ? freshSuccess(executions, now) : null;
223
+ if (!fresh)
224
+ throw new SigninDbMoveBackupMissingError('no-backup');
225
+ return fresh.uuid;
226
+ }
187
227
  /**
188
228
  * The gate for a move to the company's own network, which deletes every database of the project. It
189
229
  * fails closed and is stricter than the upgrade gate: it reads the project itself, not foundry's records.
@@ -1,6 +1,8 @@
1
1
  import { BOULE_DATABASE_SCOPE_KEY } from './boule.js';
2
2
  import { CHAT_REKEY_MAS_DB_SCOPE_KEY, CHAT_REKEY_SYNAPSE_DB_SCOPE_KEY } from './executor.js';
3
3
  import { MAIL_DATABASE_NAME, MAIL_DATABASE_SCOPE_KEY } from './mail-store.js';
4
+ import { signinDbBackupMissing } from './signin-db-backup.js';
5
+ import { SIGNIN_DB_TLS_SCOPE_KEY } from './signin-db-tls.js';
4
6
  /**
5
7
  * F6: the Postgres databases of a company that get a nightly backup, and the
6
8
  * recorded id each one's uuid sits under. The keys are second, documented
@@ -101,5 +103,18 @@ export function planCompanyBackups(org, recordedIds, storageUuid) {
101
103
  },
102
104
  });
103
105
  }
106
+ const oldDatabase = recordedIds[companyDatabaseKeys(org).auth];
107
+ if (recordedIds[SIGNIN_DB_TLS_SCOPE_KEY] && oldDatabase) {
108
+ blocked.push({
109
+ description: `${org.slug}'s old Coolify sign-in database (${oldDatabase}) is no longer used; delete it by hand in Coolify when you no longer want it as a fallback, foundry never deletes it`,
110
+ key: 'signin-db-old-database',
111
+ });
112
+ }
113
+ if (signinDbBackupMissing(recordedIds)) {
114
+ blocked.push({
115
+ description: `${org.slug}'s sign-in database runs as foundry's own image, which Coolify's database backups do not cover: its nightly dump is not scheduled yet, so no backup covers it`,
116
+ key: 'backup-not-covered:signin-db',
117
+ });
118
+ }
104
119
  return { steps, blocked };
105
120
  }
@@ -69,6 +69,10 @@ export interface PlanOrgOptions {
69
69
  ownMailKeyHeld?: boolean;
70
70
  /** The executor can mint an own org's Lettermint key (`FoundryStepExecutor.ownMailKeyMintable`). */
71
71
  ownMailKeyMintable?: boolean;
72
+ /** The executor can store the sign-in database's CA and certificate (`FoundryStepExecutor.ownSigninDbKeysPuttable`). Absent: true. */
73
+ ownSigninDbKeysPuttable?: boolean;
74
+ /** The host supplies the backup bucket the sign-in database dumps into (`FoundryStepExecutor.ownSigninDbBackupConfigured`). Absent: true. */
75
+ ownSigninDbBackupConfigured?: boolean;
72
76
  /** The own jobs admin token's held-key ref holds a value. */
73
77
  ownJobsTokenHeld?: boolean;
74
78
  /** The own media server's key pair ref holds a value. */
package/dist/plan/diff.js CHANGED
@@ -16,7 +16,7 @@ import { LOGIN_BINDING_SCOPE_KEY, agoraIssuerHostFor, companyLoginTunnelHost, lo
16
16
  import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, } from './mail-copy.js';
17
17
  import { mailStoreSwitchChange } from './mail-store.js';
18
18
  import { hasAdoptedResources, networkMoveNeeded, planNetworkMove } from './network-move.js';
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
+ 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, OWN_SIGNIN_DB_MOVE_BLOCKED_KEY, planOwnAuth, planOwnFiles, planOwnFilesR2, planOwnJobs, planOwnMail, planOwnSfu, planSigninDbMove, } from './own-diff.js';
20
20
  import { ownDnsHostnames } from './own-dns.js';
21
21
  import { ownHostnames } from './own-hostnames.js';
22
22
  import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, ORG_MAIL_APPLIED_MODE_SCOPE_KEY, ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, appEnvAppliedScopeKey, appFolderDiffers, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, versionNotAppliedLine, zitadelAppIdTokenUserinfoAppliedScopeKey, } from './platform-diff.js';
@@ -197,9 +197,20 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
197
197
  oikosZoneReady: options.oikosZoneReady ?? false,
198
198
  mailKeyHeld: options.ownMailKeyHeld ?? false,
199
199
  mailKeyMintable: options.ownMailKeyMintable ?? false,
200
+ now: (options.now ?? Date.now)(),
201
+ signinDbKeysPuttable: options.ownSigninDbKeysPuttable ?? true,
202
+ signinDbBackupConfigured: options.ownSigninDbBackupConfigured ?? true,
200
203
  });
201
204
  if (ownAuth.step)
202
205
  plannedSteps.push(ownAuth.step);
206
+ const signinDbMove = planSigninDbMove({
207
+ org,
208
+ recordedIds,
209
+ signinDbKeysPuttable: options.ownSigninDbKeysPuttable ?? true,
210
+ signinDbBackupConfigured: options.ownSigninDbBackupConfigured ?? true,
211
+ });
212
+ if (signinDbMove.step)
213
+ plannedSteps.push(signinDbMove.step);
203
214
  // An own org's ZITADEL is the company's own instance: nothing that needs it plans before it exists.
204
215
  const ownSignInPending = ownsServices(org) && !ownAuthApplied(org, recordedIds);
205
216
  const issuer = planIssuer(org, byKey, claimed, platformReadiness.authReady);
@@ -610,6 +621,9 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
610
621
  unmanaged.push(...edgeReservedLabels.blocked);
611
622
  if (ownAuth.blocked)
612
623
  unmanaged.push({ description: ownAuth.blocked, key: OWN_AUTH_BLOCKED_KEY });
624
+ if (signinDbMove.blocked) {
625
+ unmanaged.push({ description: signinDbMove.blocked, key: OWN_SIGNIN_DB_MOVE_BLOCKED_KEY });
626
+ }
613
627
  if (ownJobs.blocked)
614
628
  unmanaged.push({ description: ownJobs.blocked, key: OWN_JOBS_BLOCKED_KEY });
615
629
  if (ownMail.blocked)
@@ -325,6 +325,8 @@ export interface CoolifyPort {
325
325
  description?: string;
326
326
  instant_deploy?: boolean;
327
327
  custom_docker_run_options?: string;
328
+ /** `false`: no domain is made for the app. Coolify makes one by default, which would route to the app's port. */
329
+ autogenerate_domain?: boolean;
328
330
  }): Promise<{
329
331
  uuid: string;
330
332
  }>;
@@ -1828,6 +1830,10 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1828
1830
  private executeScoped;
1829
1831
  /** Whether `ensureOwnAuthMailToken` can mint: the same two inputs it blocks on. */
1830
1832
  ownMailKeyMintable(): boolean;
1833
+ /** Whether the held keys can store a new company's sign-in database CA and certificate (#746). */
1834
+ ownSigninDbKeysPuttable(): boolean;
1835
+ /** Whether the host supplies the backup bucket the sign-in database dumps into (#746). */
1836
+ ownSigninDbBackupConfigured(): boolean;
1831
1837
  /** What the executor's own files dependencies supply, for the planner's blocked lines. */
1832
1838
  ownFilesDeps(): {
1833
1839
  keys: boolean;
@@ -2927,6 +2933,13 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2927
2933
  * declared auth ref patches both git apps' branch and redeploys them; the
2928
2934
  * applied marker comes last, so a run that died halfway is planned again.
2929
2935
  */
2936
+ /**
2937
+ * The sign-in ports of one company's steps: its own network and limits, a SMTP target without the
2938
+ * platform's password, and every secret the step reads registered for redaction.
2939
+ */
2940
+ private ownAuthDeps;
2941
+ /** `plan/signin-db-move.ts`: copies a company's sign-in data from its Coolify database to the TLS database. */
2942
+ private ownAuthDbMove;
2930
2943
  private ownAuth;
2931
2944
  /**
2932
2945
  * `plan/own-diff.ts`'s `planOwnMail`: the org's own Stalwart in the org's own
@@ -44,6 +44,7 @@ import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_
44
44
  import { AUTH_LOGIN_PORT, PLATFORM_AUTH_BROKER_MAIL_KEY_REF, PLATFORM_AUTH_LOGIN_INTERNAL_NAME, PLATFORM_AUTH_LOGIN_SCOPE_KEY, PLATFORM_MAIL_SERVER_SCOPE_KEY, defaultStalwartForInstance, filesDepsInvalid, filesShareDomains, provisionAuthStack, provisionDatabaseBackup, provisionFilesHost, provisionJobsHost, provisionMailServer, resolveOwnFilesKeys, } from './platform-providers.js';
45
45
  import { CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY, ENTRY_CONTAINER_LABELS, appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec, entryOikosHostnames, entryProxyLabels, entryRoutes, entryRoutesMarker, ownsOikosName, routesWithoutToken, sameLabelSets, } from './proxy-labels.js';
46
46
  import { ZITADEL_ORG_DOMAIN_SCOPE_KEY, ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY, closeSignInOrgRegistration, ensureSignInOrg, inviteOwnerWithLink, registerSignInDomainOrBlock, signInOrgDomain, } from './sign-in-org.js';
47
+ import { moveSigninDatabase } from './signin-db-move.js';
47
48
  import { entryWorkerAppliedScopeKey, entryWorkerExclusionsScopeKey, entryWorkerRetiredScopeKey, entryWorkerRouteIdScopeKey, entryWorkerRoutePatternScopeKey, entryWorkerRoutePendingScopeKey, entryWorkerRouteZoneScopeKey, entryWorkerUploadedScopeKey, } from './worker.js';
48
49
  /**
49
50
  * The real {@link StepExecutor} (#20 "provision Company Factory end to
@@ -940,6 +941,15 @@ export class FoundryStepExecutor {
940
941
  ownMailKeyMintable() {
941
942
  return Boolean(this.ports.lettermint && this.ports.ownServices?.provision.auth.heldKeys);
942
943
  }
944
+ /** Whether the held keys can store a new company's sign-in database CA and certificate (#746). */
945
+ ownSigninDbKeysPuttable() {
946
+ return Boolean(this.ports.ownServices?.provision.auth.heldKeys?.put);
947
+ }
948
+ /** Whether the host supplies the backup bucket the sign-in database dumps into (#746). */
949
+ ownSigninDbBackupConfigured() {
950
+ const files = this.ports.ownServices?.provision.files;
951
+ return Boolean(files?.r2Buckets && files.r2Keys);
952
+ }
943
953
  /** What the executor's own files dependencies supply, for the planner's blocked lines. */
944
954
  ownFilesDeps() {
945
955
  const files = this.ports.ownServices?.provision.files;
@@ -1132,6 +1142,8 @@ export class FoundryStepExecutor {
1132
1142
  return this.jobsRegistration(action, org, context);
1133
1143
  case 'coolify:own-auth':
1134
1144
  return this.ownAuth(action, org, context);
1145
+ case 'coolify:own-auth-db-move':
1146
+ return this.ownAuthDbMove(org, context);
1135
1147
  case 'coolify:own-jobs':
1136
1148
  return this.ownJobs(action, org, context);
1137
1149
  case 'coolify:own-mail':
@@ -5709,30 +5721,55 @@ export class FoundryStepExecutor {
5709
5721
  * declared auth ref patches both git apps' branch and redeploys them; the
5710
5722
  * applied marker comes last, so a run that died halfway is planned again.
5711
5723
  */
5712
- async ownAuth(action, org, context) {
5713
- const own = this.ports.ownServices;
5714
- if (!own)
5715
- throw new Error(OWN_SERVICES_PORT_ABSENT);
5716
- const hostname = action.desired.hostname;
5717
- const gitRef = action.desired.gitRef;
5718
- const hn = ownHostnames(org);
5719
- const scope = scopeFromOrg(context, {});
5724
+ /**
5725
+ * The sign-in ports of one company's steps: its own network and limits, a SMTP target without the
5726
+ * platform's password, and every secret the step reads registered for redaction.
5727
+ */
5728
+ ownAuthDeps(auth, org, context) {
5720
5729
  // The target (host, port, tls, user) is not secret; the platform's SMTP
5721
5730
  // password is, so the company's own token is the password instead.
5722
- const { smtp, ...authDeps } = own.provision.auth;
5723
- const deps = {
5731
+ const { smtp, signinDbBackup: _platformBackup, ...authDeps } = auth;
5732
+ const files = this.ports.ownServices?.provision.files;
5733
+ return {
5724
5734
  ...authDeps,
5735
+ // The company's own bucket and key, through the files host's ports; never a platform-wide backup key.
5736
+ signinDbBackup: files?.r2Buckets && files.r2Keys
5737
+ ? { r2Buckets: files.r2Buckets, r2Keys: files.r2Keys }
5738
+ : undefined,
5725
5739
  ...(smtp
5726
5740
  ? { smtp: { host: smtp.host, port: smtp.port, tls: smtp.tls, user: smtp.user } }
5727
5741
  : {}),
5728
- coolify: limited(this.placedForCompany(own.provision.auth.coolify, org, context), LIMITS.ownAuth),
5742
+ coolify: limited(this.placedForCompany(auth.coolify, org, context), LIMITS.ownAuth),
5729
5743
  runtime: {
5730
- ...own.provision.auth.runtime,
5744
+ ...auth.runtime,
5731
5745
  onSecret: (value) => {
5732
5746
  this.handledSecrets.add(value);
5733
5747
  },
5734
5748
  },
5735
5749
  };
5750
+ }
5751
+ /** `plan/signin-db-move.ts`: copies a company's sign-in data from its Coolify database to the TLS database. */
5752
+ async ownAuthDbMove(org, context) {
5753
+ const own = this.ports.ownServices;
5754
+ if (!own)
5755
+ throw new Error(OWN_SERVICES_PORT_ABSENT);
5756
+ const deps = this.ownAuthDeps(own.provision.auth, org, context);
5757
+ const placed = this.placedForCompany(own.provision.auth.coolify, org, context);
5758
+ await moveSigninDatabase(scopeFromOrg(context, {}), { ...deps, readOldDatabase: (uuid) => placed.getDatabase(uuid) }, orgTarget(org), {
5759
+ project: { ensure: () => this.ensureCoolifyProject(org, context) },
5760
+ label: `${org.slug}:auth`,
5761
+ });
5762
+ return {};
5763
+ }
5764
+ async ownAuth(action, org, context) {
5765
+ const own = this.ports.ownServices;
5766
+ if (!own)
5767
+ throw new Error(OWN_SERVICES_PORT_ABSENT);
5768
+ const hostname = action.desired.hostname;
5769
+ const gitRef = action.desired.gitRef;
5770
+ const hn = ownHostnames(org);
5771
+ const scope = scopeFromOrg(context, {});
5772
+ const deps = this.ownAuthDeps(own.provision.auth, org, context);
5736
5773
  const before = scope.recordedIds();
5737
5774
  const refChanged = before[OWN_AUTH_APPLIED_GIT_REF_SCOPE_KEY] !== undefined &&
5738
5775
  before[OWN_AUTH_APPLIED_GIT_REF_SCOPE_KEY] !== gitRef;
@@ -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 {
@@ -12,7 +12,10 @@ import { OWN_FILES_R2_SCOPE_KEY, OWN_SERVICES_APPLIED_SCOPE_KEY, orgTarget, } fr
12
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';
13
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';
14
14
  import { OWN_FILES_RUNNER_SCOPE_KEY } from './platform-org.js';
15
- 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';
16
19
  /** `unmanaged` key of the one line `planOwnJobs` reports. */
17
20
  export const OWN_JOBS_BLOCKED_KEY = 'own-jobs';
18
21
  /**
@@ -130,6 +133,15 @@ export function planOwnAuth(options) {
130
133
  if (!options.mailKeyHeld && !options.mailKeyMintable) {
131
134
  return blocked(`the held key ${orgTarget(org).refs.brokerMailKey} is not held yet`);
132
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
+ }
133
145
  const hostname = names.auth;
134
146
  // The broker starts only on a valid `AUTH_APPS`, and the login client is minted only once it does:
135
147
  // the first broker entry is what lets this step finish, as the platform's first registration does.
@@ -149,7 +161,11 @@ export function planOwnAuth(options) {
149
161
  recordedIds[PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY] &&
150
162
  recordedIds[PLATFORM_AUTH_SMTP_SCOPE_KEY] &&
151
163
  recordedIds[PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY] === AUTH_ZITADEL_RUN_OPTIONS &&
152
- (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]) {
153
169
  return { step: null, blocked: null };
154
170
  }
155
171
  return {
@@ -169,6 +185,47 @@ export function planOwnAuth(options) {
169
185
  },
170
186
  };
171
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
+ }
172
229
  /** `unmanaged` key of the one line `planOwnMail` reports. */
173
230
  export const OWN_MAIL_BLOCKED_KEY = 'own-mail';
174
231
  /**
@@ -20,6 +20,10 @@ 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;
@@ -34,6 +38,8 @@ export interface ServiceTarget {
34
38
  filesRunner: string;
35
39
  /** An own org's R2 bucket. The platform's is host-supplied (`PlatformFilesDeps.r2`), so its value here is never read. */
36
40
  filesBucket: string;
41
+ /** An own org's sign-in database backup bucket (#746): its own bucket, never the files bucket. */
42
+ signinBackupBucket: string;
37
43
  jobsApp: string;
38
44
  jobsDb: string;
39
45
  sfuApp: string;
@@ -41,6 +47,8 @@ export interface ServiceTarget {
41
47
  sfuCloudflared: string;
42
48
  };
43
49
  readonly internal: {
50
+ /** The hostname ZITADEL connects to, and the one name the database's certificate carries. */
51
+ signinDb: string;
44
52
  authZitadel: string;
45
53
  authLogin: string;
46
54
  authBroker: string;
@@ -62,6 +70,8 @@ export interface ServiceTarget {
62
70
  keysCredential: string;
63
71
  /** The public half the host verifies with (`KEYS_VERIFY_KEYS`). */
64
72
  keysVerifyKeys: string;
73
+ /** The sign-in database's server certificate and key, held as JSON `{ cert, key }` (#746). */
74
+ signinDbServer: string;
65
75
  };
66
76
  }
67
77
  /** The platform's shared services, with the literals the platform steps use today. */
@@ -11,6 +11,8 @@ 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',
@@ -24,6 +26,7 @@ export function platformTarget() {
24
26
  filesMigrate: 'files-migrate',
25
27
  filesRunner: 'files',
26
28
  filesBucket: 'platform-files',
29
+ signinBackupBucket: 'platform-signin-backups',
27
30
  jobsApp: 'jobs',
28
31
  jobsDb: 'jobs-scheduler-db',
29
32
  sfuApp: 'platform-sfu',
@@ -31,6 +34,7 @@ export function platformTarget() {
31
34
  sfuCloudflared: 'platform-sfu-cloudflared',
32
35
  },
33
36
  internal: {
37
+ signinDb: 'platform-signin-db',
34
38
  authZitadel: 'platform-auth-zitadel',
35
39
  authLogin: 'platform-auth-login',
36
40
  authBroker: 'platform-auth-broker',
@@ -50,6 +54,7 @@ export function platformTarget() {
50
54
  livekitKeys: 'livekit:platform:keys',
51
55
  keysCredential: 'keys:platform:credential',
52
56
  keysVerifyKeys: 'keys:platform:verifyKeys',
57
+ signinDbServer: 'signin-db:platform:server',
53
58
  },
54
59
  };
55
60
  }
@@ -61,6 +66,8 @@ export function orgTarget(org) {
61
66
  kind: 'org',
62
67
  names: {
63
68
  authDb: `${s}-auth-db`,
69
+ signinDb: `${s}-signin-db`,
70
+ signinDbMove: `${s}-signin-db-move`,
64
71
  authZitadel: `${s}-auth-zitadel`,
65
72
  authLogin: `${s}-auth-login`,
66
73
  authBroker: `${s}-auth-broker`,
@@ -74,6 +81,7 @@ export function orgTarget(org) {
74
81
  filesMigrate: `${s}-files-migrate`,
75
82
  filesRunner: `${s}-files`,
76
83
  filesBucket: `${s}-files`,
84
+ signinBackupBucket: `${s}-signin-backups`,
77
85
  jobsApp: `${s}-jobs`,
78
86
  jobsDb: `${s}-jobs-db`,
79
87
  sfuApp: `${s}-sfu`,
@@ -81,6 +89,7 @@ export function orgTarget(org) {
81
89
  sfuCloudflared: `${s}-sfu-cloudflared`,
82
90
  },
83
91
  internal: {
92
+ signinDb: `${s}-signin-db`,
84
93
  authZitadel: `${s}-auth-zitadel`,
85
94
  authLogin: `${s}-auth-login`,
86
95
  authBroker: `${s}-auth-broker`,
@@ -100,6 +109,7 @@ export function orgTarget(org) {
100
109
  livekitKeys: `livekit:org:${s}:keys`,
101
110
  keysCredential: `keys:org:${s}:credential`,
102
111
  keysVerifyKeys: `keys:org:${s}:verifyKeys`,
112
+ signinDbServer: `signin-db:org:${s}:server`,
103
113
  },
104
114
  };
105
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';
@@ -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,
@@ -1690,6 +1711,7 @@ const STEP_FIELDS = {
1690
1711
  'coolify:mail': 'mail',
1691
1712
  'coolify:chat': 'chat.usesDomain',
1692
1713
  'coolify:own-auth': 'services.own',
1714
+ 'coolify:own-auth-db-move': 'services.own',
1693
1715
  'coolify:own-files': 'services.own',
1694
1716
  'coolify:own-files-r2': 'services.own',
1695
1717
  'coolify:own-jobs': 'services.own',