@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.
@@ -17,6 +17,7 @@ import { type FilesKeysMinted, type ProjectSource, type ServiceNetwork, type Ser
17
17
  import type { PlatformDriftedSteps, PlatformStepExecutionContext, PlatformStepExecutor } from './plan-apply.js';
18
18
  import { type BrokerDeployRecord, PLATFORM_ZITADEL_ORG_SCOPE_KEY } from './platform-diff.js';
19
19
  import type { PlatformOwnerInput } from './platform-org.js';
20
+ import type { SigninDbBackupPort } from './signin-db-backup.js';
20
21
  export { PLATFORM_ZITADEL_ORG_SCOPE_KEY };
21
22
  import { type CallsSfuPlatformDeps } from './platform-sfu.js';
22
23
  import { type PlatformUptimeDeps } from './uptime-worker.js';
@@ -132,7 +133,9 @@ export interface PlatformAuthSmtp {
132
133
  export interface PlatformAuthDeps {
133
134
  /** Overrides the Lettermint SMTP relay ZITADEL is given. Absent, Lettermint exactly as before. */
134
135
  smtp?: PlatformAuthSmtp;
135
- coolify: Pick<CoolifyPort, 'findProjectByName' | 'createProject' | 'findOrCreateEnvironment' | 'listServers' | 'findApplicationByName' | 'getApplication' | 'requireGithubApp' | 'createGithubAppApplication' | 'createDockerImageApplication' | 'createDockerfileApplication' | 'createPostgresDatabase' | 'getDatabase' | 'bulkUpsertEnvs' | 'listEnvs' | 'updateApplication' | 'deploy' | 'raw'>;
136
+ coolify: Pick<CoolifyPort, 'findProjectByName' | 'createProject' | 'findOrCreateEnvironment' | 'listServers' | 'findApplicationByName' | 'getApplication' | 'requireGithubApp' | 'createGithubAppApplication' | 'createDockerImageApplication' | 'createDockerfileApplication' | 'createPostgresDatabase' | 'getDatabase' | 'bulkUpsertEnvs' | 'listEnvs' | 'updateApplication' | 'deploy' | 'raw'> &
137
+ /** An own org's sign-in database needs a volume and the CA file mount (#746); the platform's does not. */
138
+ Partial<Pick<CoolifyPort, 'listStorages' | 'createPersistentStorage' | 'createFileStorage'>>;
136
139
  /** F4.dns: every DNS record call, through edge at its recorded address (see {@link PlatformDnsFactory}). */
137
140
  dns: PlatformDnsFactory;
138
141
  /** F4.tunnels: every tunnel call, through edge at its recorded address (see {@link PlatformTunnelsFactory}). */
@@ -172,7 +175,16 @@ export interface PlatformAuthDeps {
172
175
  */
173
176
  heldKeys?: {
174
177
  resolve(ref: string): Promise<string | null>;
178
+ /** An own org's sign-in database CA and certificate are stored through this (#746). Without it a new company's sign-in step is blocked. */
179
+ put?(ref: string, value: string): Promise<void>;
175
180
  };
181
+ /**
182
+ * The bucket and storage key ports a company's sign-in database backup uses (#746): its own bucket and
183
+ * a key scoped to it. For an own org the executor fills this from the files host's `r2Buckets` and
184
+ * `r2Keys`; the platform's backup key is never used. Without it a company's sign-in database is not
185
+ * created and an existing one is not moved.
186
+ */
187
+ signinDbBackup?: SigninDbBackupPort;
176
188
  /** Clock, sleep and fetch for the readiness wait and delivery retries; real ones by default. */
177
189
  runtime?: PlatformAuthRuntime;
178
190
  }
@@ -190,6 +202,14 @@ export interface PlatformAuthRuntime {
190
202
  readyRequestTimeoutMs?: number;
191
203
  /** Told of every secret the sign-in step reads or mints, so the host can scrub it from logs and audit rows. */
192
204
  onSecret?: (value: string) => void;
205
+ /** How long a new or redeployed sign-in database may take to report healthy (default 5 minutes). */
206
+ signinDbTimeoutMs?: number;
207
+ /** How long ZITADEL may take to report stopped before a sign-in database move copies (default 2 minutes). */
208
+ signinDbStopTimeoutMs?: number;
209
+ /** How long the copy job of a sign-in database move may run (default 30 minutes). */
210
+ signinDbMoveTimeoutMs?: number;
211
+ /** Pause between polls of the copy job (default 10 seconds). */
212
+ signinDbMovePollMs?: number;
193
213
  }
194
214
  /**
195
215
  * An own org's scheduler only: the app's uuid, recorded and persisted before
@@ -458,6 +478,7 @@ export declare const AUTH_LOGIN_PORT = "3000";
458
478
  * everything else, including the root).
459
479
  */
460
480
  export declare const AUTH_ZITADEL_PATH_PATTERN = "^/(oidc|oauth|saml|management|admin|system|auth|assets|debug/healthz|ui/console|\\.well-known|v2|v2beta|zitadel\\.[A-Za-z0-9.]+)(/|$)";
481
+ export declare const PLATFORM_AUTH_DB_SCOPE_KEY = "coolify_platform_auth_database";
461
482
  export declare const PLATFORM_AUTH_ZITADEL_SCOPE_KEY = "coolify_platform_auth_zitadel_app";
462
483
  export declare const PLATFORM_AUTH_LOGIN_SCOPE_KEY = "coolify_platform_auth_login_app";
463
484
  /**
@@ -1172,6 +1193,25 @@ export declare function createAiManagementKeyMinter(options: {
1172
1193
  fetchImpl?: typeof fetch;
1173
1194
  now?: () => number;
1174
1195
  }): AiManagementKeyMinter;
1196
+ export declare function resolvePlatformServer(coolify: Pick<CoolifyPort, 'listServers'>): Promise<{
1197
+ uuid: string;
1198
+ ip?: string;
1199
+ }>;
1200
+ /**
1201
+ * `executor.ts`'s `coolifyCalls`/`findOrCreateCallsApp`, restated here rather
1202
+ * than imported: that method is private to `FoundryStepExecutor`, and this
1203
+ * file holds no instance of that class to call it on. `running` says whether
1204
+ * the app was already running or starting; the caller deploys it when not.
1205
+ */
1206
+ export declare function findOrCreatePlatformApp(coolify: Pick<CoolifyPort, 'findApplicationByName' | 'getApplication'>, name: string, environment: {
1207
+ id: number;
1208
+ }, create: () => Promise<{
1209
+ uuid: string;
1210
+ }>): Promise<{
1211
+ appId: string;
1212
+ justCreated: boolean;
1213
+ running: boolean;
1214
+ }>;
1175
1215
  /**
1176
1216
  * #211: names of the envs the platform's sign-in broker (`wtfalch/auth`'s
1177
1217
  * `broker/server.ts`) refuses to start without -- `ZITADEL_SERVICE_USER_TOKEN`/
@@ -1218,6 +1258,11 @@ export declare function brokerDeployNeeded(missingInputs: readonly string[], bro
1218
1258
  * wrote there. Paired with {@link brokerDeployNeeded}, which reads it back.
1219
1259
  */
1220
1260
  export declare function brokerDeployRecordValue(brokerAppId: string, authAppsRaw: string | undefined): string;
1261
+ /** Whether any desired env is missing from the app, or set to another value. */
1262
+ export declare function envsDiffer(coolify: Pick<CoolifyPort, 'listEnvs'>, appId: string, desired: Array<{
1263
+ key: string;
1264
+ value: string;
1265
+ }>): Promise<boolean>;
1221
1266
  /**
1222
1267
  * #171: everything {@link provisionPlatformFiles} needs beyond
1223
1268
  * {@link PlatformStepExecutionContext}, bundled for the same reason as
@@ -23,6 +23,8 @@ import { recordOwnDnsHostname } from './own-dns.js';
23
23
  import { platformNetwork, platformTarget, scopeFromPlatform, } from './own-services.js';
24
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';
25
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';
26
28
  export { PLATFORM_ZITADEL_ORG_SCOPE_KEY };
27
29
  import { applyCallsSfuPlatformStep, callsSfuAppliedFingerprint, } from './platform-sfu.js';
28
30
  import { heldUptimeValues, provisionPlatformUptime, } from './uptime-worker.js';
@@ -1094,7 +1096,7 @@ export const AUTH_ZITADEL_PATH_PATTERN = '^/(oidc|oauth|saml|management|admin|sy
1094
1096
  */
1095
1097
  const AUTH_BROKER_PATH_PATTERN = '^/api(/|$)';
1096
1098
  const PLATFORM_AUTH_PROJECT_SCOPE_KEY = 'coolify_project_platform_auth';
1097
- const PLATFORM_AUTH_DB_SCOPE_KEY = 'coolify_platform_auth_database';
1099
+ export const PLATFORM_AUTH_DB_SCOPE_KEY = 'coolify_platform_auth_database';
1098
1100
  export const PLATFORM_AUTH_ZITADEL_SCOPE_KEY = 'coolify_platform_auth_zitadel_app';
1099
1101
  export const PLATFORM_AUTH_LOGIN_SCOPE_KEY = 'coolify_platform_auth_login_app';
1100
1102
  const PLATFORM_AUTH_TUNNEL_SCOPE_KEY = 'cloudflare_platform_auth_tunnel';
@@ -1429,33 +1431,70 @@ export async function provisionAuthStack(scope, deps, target, input) {
1429
1431
  const projectUuid = await input.project.ensure(deps.coolify, scope);
1430
1432
  const { environment } = await deps.coolify.findOrCreateEnvironment(projectUuid, 'production');
1431
1433
  // ---- database ----------------------------------------------------------
1432
- 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;
1433
1439
  // A database ZITADEL has already run against may hold data encrypted with a
1434
1440
  // masterkey this run cannot see (see `ensureMasterkey`). A database that
1435
1441
  // exists but was never deployed against (a throw between its create and the
1436
1442
  // first deploy) is still empty, so a retry may generate the key.
1437
- const databaseInUse = Boolean(dbId && recorded[PLATFORM_AUTH_ZITADEL_DEPLOYED_SCOPE_KEY]);
1438
- if (!dbId) {
1439
- const server = await resolvePlatformServer(deps.coolify);
1440
- const created = await deps.coolify.createPostgresDatabase({
1441
- project_uuid: projectUuid,
1442
- server_uuid: server.uuid,
1443
- environment_name: environment.name,
1444
- environment_uuid: environment.uuid,
1445
- name: target.names.authDb,
1446
- postgres_db: 'zitadel',
1447
- 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
+ }),
1448
1469
  });
1449
- dbId = created.uuid;
1450
- 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);
1451
1473
  }
1452
- const db = await getDatabaseStarted(deps.coolify, dbId);
1453
- if (!db.internal_db_url) {
1454
- // Static config check before either app is touched (`diff.ts`'s
1455
- // `coolifyCalls` follows the same rule for its own databases): Coolify
1456
- // can take a moment to report a freshly created database's
1457
- // `internal_db_url`, so the next apply retries from a clean state.
1458
- 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;
1459
1498
  }
1460
1499
  // ---- the ZITADEL app itself ---------------------------------------------
1461
1500
  const zitadelApp = await findOrCreatePlatformApp(deps.coolify, target.names.authZitadel, environment, async () => {
@@ -1473,6 +1512,8 @@ export async function provisionAuthStack(scope, deps, target, input) {
1473
1512
  });
1474
1513
  const zitadelAppId = zitadelApp.appId;
1475
1514
  await record(PLATFORM_AUTH_ZITADEL_SCOPE_KEY, zitadelAppId);
1515
+ if (signinDbCa)
1516
+ await ensureSigninDbCaFile(deps.coolify, zitadelAppId, signinDbCa, label);
1476
1517
  // Set on every run, not only after a create: a throw right after the
1477
1518
  // create must not leave the app unnamed for good, and the tunnel addresses
1478
1519
  // the app by this name. (The port cannot read the current name back, and
@@ -1575,7 +1616,7 @@ export async function provisionAuthStack(scope, deps, target, input) {
1575
1616
  { key: 'ZITADEL_EXTERNALPORT', value: '443' },
1576
1617
  { key: 'ZITADEL_EXTERNALSECURE', value: 'true' },
1577
1618
  { key: 'ZITADEL_TLS_ENABLED', value: 'false' },
1578
- { key: 'ZITADEL_DATABASE_POSTGRES_DSN', value: db.internal_db_url },
1619
+ { key: 'ZITADEL_DATABASE_POSTGRES_DSN', value: databaseUrl },
1579
1620
  { key: 'ZITADEL_FIRSTINSTANCE_SKIP', value: 'true' },
1580
1621
  { key: 'ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED', value: 'true' },
1581
1622
  // A username is unique per organisation, and the login name is `name@<org domain>`. Without it a
@@ -1665,13 +1706,24 @@ export async function provisionAuthStack(scope, deps, target, input) {
1665
1706
  await deps.coolify.bulkUpsertEnvs(brokerAppId, brokerEnvs);
1666
1707
  // Deploy an app that was just created, is not running (a throw after the
1667
1708
  // create left it never deployed, or it stopped), or whose envs changed.
1668
- 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) {
1669
1717
  await deps.coolify.deploy(zitadelAppId);
1670
1718
  await record(PLATFORM_AUTH_ZITADEL_DEPLOYED_SCOPE_KEY, zitadelAppId);
1671
1719
  // Only now: the options are live once the deploy went out. A throw between
1672
1720
  // the PATCH and here leaves the marker unset, so the next run deploys.
1673
1721
  await record(PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, AUTH_ZITADEL_RUN_OPTIONS);
1674
1722
  }
1723
+ if (zitadelUnconfirmed) {
1724
+ await waitHealthy(withCallTimeout(deps.coolify), zitadelAppId, runtime, label, 'ZITADEL');
1725
+ await record(SIGNIN_DB_ZITADEL_PENDING_SCOPE_KEY, '');
1726
+ }
1675
1727
  if (loginApp.justCreated || !loginApp.running || loginEnvsChanged) {
1676
1728
  await deps.coolify.deploy(loginAppId);
1677
1729
  }
@@ -6229,7 +6281,7 @@ async function waitForInstance(url, runtime, sleep, label = 'platform:auth') {
6229
6281
  await sleep(interval);
6230
6282
  }
6231
6283
  }
6232
- async function resolvePlatformServer(coolify) {
6284
+ export async function resolvePlatformServer(coolify) {
6233
6285
  const servers = await coolify.listServers();
6234
6286
  const server = servers.find((s) => s.is_usable && s.is_reachable) ?? servers[0];
6235
6287
  if (!server)
@@ -6242,7 +6294,7 @@ async function resolvePlatformServer(coolify) {
6242
6294
  * file holds no instance of that class to call it on. `running` says whether
6243
6295
  * the app was already running or starting; the caller deploys it when not.
6244
6296
  */
6245
- async function findOrCreatePlatformApp(coolify, name, environment, create) {
6297
+ export async function findOrCreatePlatformApp(coolify, name, environment, create) {
6246
6298
  const existing = await coolify.findApplicationByName(name, {
6247
6299
  environmentIds: [environment.id],
6248
6300
  });
@@ -6397,7 +6449,7 @@ export function brokerDeployRecordValue(brokerAppId, authAppsRaw) {
6397
6449
  return JSON.stringify(record);
6398
6450
  }
6399
6451
  /** Whether any desired env is missing from the app, or set to another value. */
6400
- async function envsDiffer(coolify, appId, desired) {
6452
+ export async function envsDiffer(coolify, appId, desired) {
6401
6453
  const live = new Map((await coolify.listEnvs(appId)).filter((e) => !e.is_preview).map((e) => [e.key, e.value]));
6402
6454
  return desired.some((e) => live.get(e.key) !== e.value);
6403
6455
  }
@@ -7498,7 +7550,9 @@ async function provisionPlatformHc(context, deps) {
7498
7550
  { key: 'DB_USER', value: dbUser },
7499
7551
  { key: 'DB_PASSWORD', value: dbPassword },
7500
7552
  { key: 'SITE_ROOT', value: `https://${hostname}` },
7501
- { key: 'ALLOWED_HOSTS', value: hostname },
7553
+ // Django answers 400 to any other Host: foundry's readiness poll comes in as
7554
+ // `platform-hc:8000` and Coolify's own health check as `localhost`.
7555
+ { key: 'ALLOWED_HOSTS', value: `${hostname},${HC_INTERNAL_NAME},localhost` },
7502
7556
  // Healthchecks defaults this to `true` -- see this function's own doc
7503
7557
  // comment. `hostname` is public and Cloudflare-proxied, so sign-up stays
7504
7558
  // closed until a real invite mechanism exists.
@@ -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;
@@ -0,0 +1,193 @@
1
+ import { SIGNIN_DB_TLS_SCOPE_KEY } from './signin-db-tls.js';
2
+ /**
3
+ * The nightly backup of a company's sign-in database (#746). Coolify's database backups cover Coolify
4
+ * databases only, and the TLS database is an application, so the schedule is a Coolify scheduled task of
5
+ * that application (`POST /applications/{uuid}/scheduled-tasks`, read from Coolify 4.4.2's
6
+ * `ScheduledTasksController`). Coolify runs the task's command with `docker exec` in the database's
7
+ * container at the task's frequency. The command dumps with `pg_dump` and puts the file in the company's
8
+ * own backup bucket, with a storage key that reaches that bucket alone (minted through the same host
9
+ * ports as the company's files bucket key, `ensureSigninDbBackupTarget`). The platform's backup key never
10
+ * reaches a company's container. It keeps nothing: Coolify's default of keeping every dump applies.
11
+ */
12
+ /** Recorded id of the scheduled task (set only after Coolify lists it back as enabled). */
13
+ export const SIGNIN_DB_BACKUP_TASK_SCOPE_KEY = 'signin_db_backup_task';
14
+ export const SIGNIN_DB_BACKUP_TASK_NAME = 'signin-db-backup';
15
+ /** Coolify's own name for "once a day", the frequency the platform's database backups use. */
16
+ export const SIGNIN_DB_BACKUP_FREQUENCY = 'daily';
17
+ /** How long Coolify lets one run take, in seconds. */
18
+ export const SIGNIN_DB_BACKUP_TIMEOUT_SECONDS = 3600;
19
+ /** How long the upload may take, in seconds: the dump has to leave time inside the task's own limit. */
20
+ const UPLOAD_MAX_SECONDS = 1800;
21
+ /** The token id of the company's sign-in backup key, recorded once it is in the database app's env. */
22
+ export const SIGNIN_DB_BACKUP_KEY_SCOPE_KEY = 'signin_db_backup_key';
23
+ /** The token id minted and not yet in the env: deleted before any new mint. */
24
+ export const SIGNIN_DB_BACKUP_KEY_UNDELIVERED_SCOPE_KEY = 'signin_db_backup_key_undelivered';
25
+ /** How long one call to the bucket or key port may take. */
26
+ export const SIGNIN_DB_BACKUP_PORT_TIMEOUT_MS = 30_000;
27
+ async function portCall(work, label, what) {
28
+ let timer;
29
+ try {
30
+ return await Promise.race([
31
+ work,
32
+ new Promise((_, reject) => {
33
+ timer = setTimeout(() => reject(new Error('timeout')), SIGNIN_DB_BACKUP_PORT_TIMEOUT_MS);
34
+ }),
35
+ ]);
36
+ }
37
+ catch (error) {
38
+ // The cause is dropped: a port's message is not ours to repeat, and may hold the secret.
39
+ throw new Error(`${label}: ${what} ${error instanceof Error && error.message === 'timeout'
40
+ ? `did not answer within ${SIGNIN_DB_BACKUP_PORT_TIMEOUT_MS / 1000} seconds`
41
+ : 'failed'}`);
42
+ }
43
+ finally {
44
+ clearTimeout(timer);
45
+ }
46
+ }
47
+ const usable = (value) => typeof value === 'string' && value.length > 0 && value.length <= 200;
48
+ /**
49
+ * The company's own bucket and a storage key that names that bucket alone. A key already in the
50
+ * database app's env whose token id is recorded is kept. Otherwise any earlier token of this company
51
+ * (recorded, or minted and never confirmed) is deleted first, so there are never two live tokens, then
52
+ * one is minted, registered as a secret and recorded as undelivered; the caller writes it to the env and
53
+ * then records it as delivered. A failed bucket or mint throws before the database is deployed or the
54
+ * move stops anything. Errors name the label only.
55
+ */
56
+ export async function ensureSigninDbBackupTarget(port, input) {
57
+ const { label, recorded } = input;
58
+ const bucket = await portCall(port.r2Buckets.ensureBucket(input.bucketName), label, `the sign-in backup bucket ${input.bucketName}`);
59
+ const kept = recorded[SIGNIN_DB_BACKUP_KEY_SCOPE_KEY];
60
+ if (kept && input.held.keyId && input.held.secret) {
61
+ input.onSecret?.(input.held.secret);
62
+ input.onSecret?.(input.held.keyId);
63
+ return {
64
+ target: {
65
+ endpoint: bucket.endpoint,
66
+ bucket: bucket.bucket,
67
+ accessKeyId: input.held.keyId,
68
+ secretAccessKey: input.held.secret,
69
+ },
70
+ tokenId: kept,
71
+ fresh: false,
72
+ };
73
+ }
74
+ for (const [scopeKey, tokenId] of [
75
+ [
76
+ SIGNIN_DB_BACKUP_KEY_UNDELIVERED_SCOPE_KEY,
77
+ recorded[SIGNIN_DB_BACKUP_KEY_UNDELIVERED_SCOPE_KEY],
78
+ ],
79
+ [SIGNIN_DB_BACKUP_KEY_SCOPE_KEY, kept],
80
+ ]) {
81
+ if (!tokenId)
82
+ continue;
83
+ await portCall(port.r2Keys.deleteBucketKey(tokenId), label, `deleting the earlier sign-in backup key ${tokenId}, so no new one was made:`);
84
+ await input.record(scopeKey, '');
85
+ }
86
+ const key = await portCall(port.r2Keys.mintBucketKey(bucket.bucket), label, 'the sign-in backup key mint');
87
+ if (usable(key?.secretAccessKey))
88
+ input.onSecret?.(key.secretAccessKey);
89
+ if (usable(key?.accessKeyId))
90
+ input.onSecret?.(key.accessKeyId);
91
+ // A token id we can read is recorded even when the rest of the answer is unusable, so it is deleted next run.
92
+ if (usable(key?.tokenId))
93
+ await input.record(SIGNIN_DB_BACKUP_KEY_UNDELIVERED_SCOPE_KEY, key.tokenId);
94
+ if (!usable(key?.accessKeyId) || !usable(key?.secretAccessKey) || !usable(key?.tokenId)) {
95
+ throw new Error(`${label}: the sign-in backup key mint answered with an unusable key`);
96
+ }
97
+ return {
98
+ target: {
99
+ endpoint: bucket.endpoint,
100
+ bucket: bucket.bucket,
101
+ accessKeyId: key.accessKeyId,
102
+ secretAccessKey: key.secretAccessKey,
103
+ },
104
+ tokenId: key.tokenId,
105
+ fresh: true,
106
+ };
107
+ }
108
+ /** The env of the database app that the backup command reads. The command itself holds no value. */
109
+ export const SIGNIN_DB_BACKUP_ENV = {
110
+ endpoint: 'BACKUP_S3_ENDPOINT',
111
+ bucket: 'BACKUP_S3_BUCKET',
112
+ region: 'BACKUP_S3_REGION',
113
+ keyId: 'BACKUP_S3_KEY_ID',
114
+ secret: 'BACKUP_S3_SECRET',
115
+ prefix: 'BACKUP_S3_PREFIX',
116
+ };
117
+ /** Where in the bucket a company's dumps go: `signin-db/<slug>/signin-db-<UTC time>.dump`. */
118
+ export function signinDbBackupPrefix(slug) {
119
+ return `signin-db/${slug}`;
120
+ }
121
+ /** The env entries the backup command needs, for the database app. */
122
+ export function signinDbBackupEnvs(target, slug) {
123
+ return [
124
+ { key: SIGNIN_DB_BACKUP_ENV.endpoint, value: target.endpoint.replace(/\/+$/, '') },
125
+ { key: SIGNIN_DB_BACKUP_ENV.bucket, value: target.bucket },
126
+ { key: SIGNIN_DB_BACKUP_ENV.region, value: target.region ?? 'auto' },
127
+ { key: SIGNIN_DB_BACKUP_ENV.keyId, value: target.accessKeyId },
128
+ { key: SIGNIN_DB_BACKUP_ENV.secret, value: target.secretAccessKey },
129
+ { key: SIGNIN_DB_BACKUP_ENV.prefix, value: signinDbBackupPrefix(slug) },
130
+ ];
131
+ }
132
+ /**
133
+ * The command Coolify runs in the container. A custom-format dump goes to a temporary file (an upload
134
+ * needs a length), is signed with SigV4 and put with curl, and the file is removed on every exit. The
135
+ * credentials reach curl on its standard input as a config, never in an argument. Every failure ends the
136
+ * run non-zero, and curl's error text names the HTTP status only. No single quote: Coolify wraps the
137
+ * command in single quotes.
138
+ */
139
+ export function signinDbBackupCommand(user, database) {
140
+ const e = SIGNIN_DB_BACKUP_ENV;
141
+ return [
142
+ 'set -eu',
143
+ 'f=/tmp/signin-db-$(date -u +%Y%m%dT%H%M%SZ).dump',
144
+ 'trap "rm -f $f" EXIT',
145
+ `pg_dump -U ${user} -d ${database} -Fc -f "$f"`,
146
+ `printf "user = \\"%s:%s\\"\\n" "$${e.keyId}" "$${e.secret}" | curl -sS --fail --max-time ${UPLOAD_MAX_SECONDS} --aws-sigv4 "aws:amz:$${e.region}:s3" -K - -T "$f" "$${e.endpoint}/$${e.bucket}/$${e.prefix}/\${f##*/}"`,
147
+ ].join('; ');
148
+ }
149
+ function rows(body) {
150
+ if (!Array.isArray(body))
151
+ throw new Error('scheduled tasks list has an unexpected shape');
152
+ return body.map((row) => (row && typeof row === 'object' ? row : {}));
153
+ }
154
+ /**
155
+ * Makes sure the database app has its backup task, and returns its uuid. The task is found by name
156
+ * before it is created, and updated to the full wanted body every time (Coolify hides the stored
157
+ * command from a token without the sensitive-read ability, so it cannot be compared). The task is then
158
+ * read back and must be listed enabled with the wanted frequency and timeout, or this throws: a
159
+ * schedule that cannot be seen is not a backup. The caller records the uuid. Errors name the label only.
160
+ */
161
+ export async function ensureSigninDbBackupTask(coolify, appId, label, db) {
162
+ const base = `/applications/${appId}/scheduled-tasks`;
163
+ const body = {
164
+ name: SIGNIN_DB_BACKUP_TASK_NAME,
165
+ command: signinDbBackupCommand(db.user, db.database),
166
+ frequency: SIGNIN_DB_BACKUP_FREQUENCY,
167
+ timeout: SIGNIN_DB_BACKUP_TIMEOUT_SECONDS,
168
+ enabled: true,
169
+ };
170
+ const found = rows(await coolify.raw('GET', base)).find((t) => t.name === SIGNIN_DB_BACKUP_TASK_NAME && typeof t.uuid === 'string');
171
+ let uuid = found?.uuid;
172
+ if (found) {
173
+ await coolify.raw('PATCH', `${base}/${found.uuid}`, body);
174
+ }
175
+ else {
176
+ uuid = (await coolify.raw('POST', base, body))?.uuid;
177
+ }
178
+ if (typeof uuid !== 'string' || !uuid) {
179
+ throw new Error(`${label}: Coolify did not return the sign-in database's backup schedule`);
180
+ }
181
+ const listed = rows(await coolify.raw('GET', base)).find((t) => t.uuid === uuid);
182
+ if (!listed ||
183
+ listed.enabled !== true ||
184
+ listed.frequency !== SIGNIN_DB_BACKUP_FREQUENCY ||
185
+ listed.timeout !== SIGNIN_DB_BACKUP_TIMEOUT_SECONDS) {
186
+ throw new Error(`${label}: the sign-in database's backup schedule is not listed as enabled in Coolify; nothing was moved`);
187
+ }
188
+ return uuid;
189
+ }
190
+ /** A company on the TLS database whose nightly dump is not scheduled yet: the sign-in step plans again to schedule it. */
191
+ export function signinDbBackupMissing(recordedIds) {
192
+ return (Boolean(recordedIds[SIGNIN_DB_TLS_SCOPE_KEY]) && !recordedIds[SIGNIN_DB_BACKUP_TASK_SCOPE_KEY]);
193
+ }