@wtfalch/foundry 0.51.4 → 0.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/org.d.ts CHANGED
@@ -911,7 +911,8 @@ export interface OwnerIdentity {
911
911
  /**
912
912
  * `null` when the owner's sign-in name (the part of the work address before the
913
913
  * `@`) and display names may be used; otherwise a fixed sentence. The sign-in
914
- * name gets the broker's whole check, reserved names included. The given and
914
+ * name gets the broker's whole check except reserved names: an operator declaring
915
+ * the owner gives them the name, which is what a reserved name waits for. The given and
915
916
  * family names are free text and only refuse a slur. The sentence never holds
916
917
  * the value.
917
918
  */
package/dist/org.js CHANGED
@@ -1153,7 +1153,8 @@ const ANY_TEXT = { minLength: 0, maxLength: Number.MAX_SAFE_INTEGER, pattern: /^
1153
1153
  /**
1154
1154
  * `null` when the owner's sign-in name (the part of the work address before the
1155
1155
  * `@`) and display names may be used; otherwise a fixed sentence. The sign-in
1156
- * name gets the broker's whole check, reserved names included. The given and
1156
+ * name gets the broker's whole check except reserved names: an operator declaring
1157
+ * the owner gives them the name, which is what a reserved name waits for. The given and
1157
1158
  * family names are free text and only refuse a slur. The sentence never holds
1158
1159
  * the value.
1159
1160
  */
@@ -1161,7 +1162,7 @@ export function ownerNameProblem(mailboxAddress, givenName, familyName) {
1161
1162
  if (mailboxAddress) {
1162
1163
  const local = mailboxAddress.slice(0, mailboxAddress.lastIndexOf('@'));
1163
1164
  const verdict = checkName(local, DEFAULT_RULES);
1164
- if (!verdict.ok)
1165
+ if (!verdict.ok && verdict.reason !== 'reserved')
1165
1166
  return `the work address name before the @ is refused: ${explain(verdict.reason)}`;
1166
1167
  }
1167
1168
  for (const [label, value] of [
@@ -95,6 +95,10 @@ export interface PlanOrgOptions {
95
95
  customersDirectory?: boolean;
96
96
  /** C3: the executor can mint Boule's Archon credential (`FoundryStepExecutorPorts.mintBouleArchonCredential`). */
97
97
  bouleArchonCredential?: boolean;
98
+ /** The executor can store a Worker app database's CA and certificate (`FoundryStepExecutor.workerAppDbKeysPuttable`). Absent: true. */
99
+ workerAppDbKeysPuttable?: boolean;
100
+ /** The host supplies the backup bucket a Worker app database dumps into (`FoundryStepExecutor.workerAppDbBackupConfigured`). Absent: true. */
101
+ workerAppDbBackupConfigured?: boolean;
98
102
  /** The executor can store the sign-in database's CA and certificate (`FoundryStepExecutor.ownSigninDbKeysPuttable`). Absent: true. */
99
103
  ownSigninDbKeysPuttable?: boolean;
100
104
  /** The host supplies the backup bucket the sign-in database dumps into (`FoundryStepExecutor.ownSigninDbBackupConfigured`). Absent: true. */
package/dist/plan/diff.js CHANGED
@@ -37,6 +37,7 @@ import { planRemoteDatabase } from './remote-database.js';
37
37
  import { ZITADEL_ORG_DOMAIN_BLOCKED_SCOPE_KEY, ZITADEL_ORG_DOMAIN_SCOPE_KEY, ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY, signInDomainBlockedText, signInOrgDomain, } from './sign-in-org.js';
38
38
  import { SIGN_IN_BASE_PATH, SIGN_IN_IMAGE_TAG_SUFFIX, SIGN_IN_IN_APP_SCOPE_KEY, SIGN_IN_LOGIN_APP_SCOPE_KEY, SIGN_IN_LOGIN_INTERNAL_NAME, SIGN_IN_RATE_LIMIT_SCOPE_KEY, SIGN_IN_TRUSTED_SCOPE_KEY, inAppSignIn, inAppStatePairs, inAppTargets, } from './sign-in.js';
39
39
  import { withStepSystem } from './step-system.js';
40
+ import { WORKER_APP_DB_BLOCKED_KEY, WORKER_APP_DB_LEGACY_NOTE_KEY, WORKER_APP_DB_SCOPE_KEYS, planWorkerAppDb, } from './worker-app-db.js';
40
41
  import { brokerKeyDelivered, planWorkerApps } from './worker-app.js';
41
42
  import { planWorkers } from './worker.js';
42
43
  /** The `Plan` (#19's public contract) a `PlanDetail` carries. */
@@ -465,6 +466,16 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
465
466
  const database = planDatabase(org, byKey, claimed, recordedIds);
466
467
  if (database)
467
468
  plannedSteps.push(database);
469
+ // A Worker app's own TLS database, also before the Worker app that connects to it.
470
+ const workerAppDb = planWorkerAppDb({
471
+ org,
472
+ recordedIds,
473
+ now: (options.now ?? Date.now)(),
474
+ keysPuttable: options.workerAppDbKeysPuttable ?? true,
475
+ backupConfigured: options.workerAppDbBackupConfigured ?? true,
476
+ });
477
+ if (workerAppDb.step)
478
+ plannedSteps.push(workerAppDb.step);
468
479
  const authIssuer = 'error' in agoraHost ? null : agoraHost.issuer;
469
480
  const app = planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer, platformReadiness);
470
481
  plannedSteps.push(...app.steps);
@@ -659,8 +670,9 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
659
670
  ...(options.workerAppDatabase
660
671
  ? {
661
672
  database: {
662
- databaseReady: Boolean(recordedIds[`coolify:database:${org.id}`]) ||
663
- plannedSteps.some((s) => s.action.system === 'coolify' && s.action.kind === 'database'),
673
+ databaseReady: Boolean(recordedIds[WORKER_APP_DB_SCOPE_KEYS.app] &&
674
+ recordedIds[WORKER_APP_DB_SCOPE_KEYS.tls]) ||
675
+ plannedSteps.some((s) => s.action.system === 'coolify' && s.action.kind === 'worker-app-db'),
664
676
  tunnelReady: Boolean(recordedIds[`cloudflare:tunnel:${org.id}`]) ||
665
677
  plannedSteps.some((s) => s.action.system === 'cloudflare' && s.action.kind === 'tunnel'),
666
678
  accessReady: edgeNeeded(platformReadiness, ['access', 'tunnels', 'dns', 'zones']) === null,
@@ -777,6 +789,12 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
777
789
  unmanaged.push(...entryAiKeys.blocked);
778
790
  unmanaged.push(...edgeWorkers.blocked);
779
791
  unmanaged.push(...workerApps.blocked);
792
+ if (workerAppDb.blocked) {
793
+ unmanaged.push({ description: workerAppDb.blocked, key: WORKER_APP_DB_BLOCKED_KEY });
794
+ }
795
+ if (workerAppDb.note) {
796
+ unmanaged.push({ description: workerAppDb.note, key: WORKER_APP_DB_LEGACY_NOTE_KEY });
797
+ }
780
798
  unmanaged.push(...mcp.blocked);
781
799
  unmanaged.push(...customers.blocked);
782
800
  unmanaged.push(...entryEdgeKeys.blocked);
@@ -3635,13 +3653,16 @@ function planDatabase(org, byKey, claimed, recordedIds) {
3635
3653
  // #444: only a company whose app database is still its own Coolify database
3636
3654
  // plans one. A new company, and one that has moved, has it on its Postgres
3637
3655
  // server: recorded, or provisioned by a step earlier in this plan.
3638
- // A Worker app's database is reached through the Coolify database's id (its Hyperdrive route
3639
- // and tunnel), so a Worker app entry with a database always plans that database.
3640
- const workerAppDatabase = org.hosting.entries.some((entry) => entry.workerApp?.database);
3656
+ // A Worker app's database is foundry's own TLS Postgres (`worker-app-db.ts`), never this one:
3657
+ // an org whose entries use a database only through Worker apps plans no Coolify database, and
3658
+ // a recorded one is left as it is.
3659
+ if (org.hosting.entries.some((entry) => entry.workerApp?.database) &&
3660
+ !org.hosting.entries.some((entry) => entry.database)) {
3661
+ return null;
3662
+ }
3641
3663
  const appHome = ORG_SERVICE_SCHEMAS.find((home) => home.schema === 'app');
3642
3664
  const legacy = appHome?.legacyKey(org);
3643
- if (!workerAppDatabase &&
3644
- (!appHome || !legacy || !recordedIds[legacy] || serviceOnServer(org, appHome, recordedIds))) {
3665
+ if (!appHome || !legacy || !recordedIds[legacy] || serviceOnServer(org, appHome, recordedIds)) {
3645
3666
  return null;
3646
3667
  }
3647
3668
  return planHostingToggle('coolify', 'database', org.hosting.entries.some(entryHasDatabase), org, byKey, claimed, () => `provision a database for ${org.slug}'s app`, () => `park ${org.slug}'s database (data is retained, not deleted -- #7 corrections 1)`);
@@ -2021,6 +2021,10 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2021
2021
  bouleArchonCredentialAvailable(): boolean;
2022
2022
  /** Whether the held keys can store a new company's sign-in database CA and certificate (#746). */
2023
2023
  ownSigninDbKeysPuttable(): boolean;
2024
+ /** Whether the held keys can store a Worker app database's CA and certificate. */
2025
+ workerAppDbKeysPuttable(): boolean;
2026
+ /** Whether the host supplies the backup bucket a Worker app database dumps into (the files host's bucket and key ports). */
2027
+ workerAppDbBackupConfigured(): boolean;
2024
2028
  /** Whether the host supplies the backup bucket the sign-in database dumps into (#746). */
2025
2029
  ownSigninDbBackupConfigured(): boolean;
2026
2030
  /** What the executor's own files dependencies supply, for the planner's blocked lines. */
@@ -3318,6 +3322,8 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
3318
3322
  * platform's password, and every secret the step reads registered for redaction.
3319
3323
  */
3320
3324
  private ownAuthDeps;
3325
+ /** `plan/worker-app-db.ts`: the org's Worker app database as a TLS Postgres, and its server certificate's renewal. */
3326
+ private workerAppDb;
3321
3327
  /** `plan/signin-db-move.ts`: copies a company's sign-in data from its Coolify database to the TLS database. */
3322
3328
  private ownAuthDbMove;
3323
3329
  private ownAuth;
@@ -61,6 +61,7 @@ import { ZITADEL_ORG_DOMAIN_SCOPE_KEY, ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY
61
61
  import { SIGN_IN_BASE_PATH, SIGN_IN_ENV_LIVE_SCOPE_PREFIX, SIGN_IN_IN_APP_SCOPE_KEY, SIGN_IN_LOGIN_APP_SCOPE_KEY, SIGN_IN_RATE_LIMIT_DESCRIPTION, SIGN_IN_RATE_LIMIT_EXPRESSION, SIGN_IN_RATE_LIMIT_SCOPE_KEY, SIGN_IN_TRUSTED_SCOPE_KEY, SIGN_IN_UPSTREAM_ENV, inAppBindingFor, inAppSignIn, inAppStateHosts, inAppStatePairs, inAppTargets, signInEnvLiveHosts, } from './sign-in.js';
62
62
  import { moveSigninDatabase } from './signin-db-move.js';
63
63
  import { stageGateKey, stageHostname, } from './stage-guards.js';
64
+ import { WORKER_APP_DB_SCOPE_KEYS, provisionWorkerAppDb, workerAppDbCaPem, workerAppDbOwnerUrl, } from './worker-app-db.js';
64
65
  import { WORKER_APP_CALL_TIMEOUT_MS, applyWorkerApp, heldBrokerKey, teardownWorkerApp, withCallTimeout, } from './worker-app.js';
65
66
  import { entryWorkerAppliedScopeKey, entryWorkerExclusionsScopeKey, entryWorkerRetiredScopeKey, entryWorkerRouteIdScopeKey, entryWorkerRoutePatternScopeKey, entryWorkerRoutePendingScopeKey, entryWorkerRouteZoneScopeKey, entryWorkerUploadedScopeKey, } from './worker.js';
66
67
  /**
@@ -1012,6 +1013,14 @@ export class FoundryStepExecutor {
1012
1013
  ownSigninDbKeysPuttable() {
1013
1014
  return Boolean(this.ports.ownServices?.provision.auth.heldKeys?.put);
1014
1015
  }
1016
+ /** Whether the held keys can store a Worker app database's CA and certificate. */
1017
+ workerAppDbKeysPuttable() {
1018
+ return typeof this.ports.heldKeys?.put === 'function';
1019
+ }
1020
+ /** Whether the host supplies the backup bucket a Worker app database dumps into (the files host's bucket and key ports). */
1021
+ workerAppDbBackupConfigured() {
1022
+ return this.ownSigninDbBackupConfigured();
1023
+ }
1015
1024
  /** Whether the host supplies the backup bucket the sign-in database dumps into (#746). */
1016
1025
  ownSigninDbBackupConfigured() {
1017
1026
  const files = this.ports.ownServices?.provision.files;
@@ -1196,7 +1205,7 @@ export class FoundryStepExecutor {
1196
1205
  if (!workers) {
1197
1206
  throw new Error(`cloudflare:worker-app for ${org.slug}: this executor was built without a WorkerAppPort`);
1198
1207
  }
1199
- return applyWorkerApp(this.workerAppDeps(workers), org, action, context);
1208
+ return applyWorkerApp(this.workerAppDeps(workers, org, () => context.recordedIds()), org, action, context);
1200
1209
  }
1201
1210
  case 'files:organisation':
1202
1211
  return this.filesOrganisation(action, org, context);
@@ -1244,6 +1253,8 @@ export class FoundryStepExecutor {
1244
1253
  return this.ownAuth(action, org, context);
1245
1254
  case 'coolify:own-auth-db-move':
1246
1255
  return this.ownAuthDbMove(org, context);
1256
+ case 'coolify:worker-app-db':
1257
+ return this.workerAppDb(org, context);
1247
1258
  case 'coolify:own-jobs':
1248
1259
  return this.ownJobs(action, org, context);
1249
1260
  case 'coolify:own-mail':
@@ -1487,7 +1498,7 @@ export class FoundryStepExecutor {
1487
1498
  * "Declared changes apply" nothing is marked done until the
1488
1499
  * live state actually matches it.
1489
1500
  */
1490
- workerAppDeps(workers) {
1501
+ workerAppDeps(workers, org, recorded) {
1491
1502
  return {
1492
1503
  workers,
1493
1504
  ...(this.ports.workerAppKeys ? { keys: this.ports.workerAppKeys } : {}),
@@ -1499,7 +1510,14 @@ export class FoundryStepExecutor {
1499
1510
  access: this.accessPort(),
1500
1511
  dns: this.dnsPort(),
1501
1512
  zones: this.zonePort(),
1513
+ // The foundry-made TLS database when it is the recorded one; else the legacy Coolify database.
1514
+ ca: () => workerAppDbCaPem(this.ports.heldKeys, org.id),
1502
1515
  ownerUrl: async (databaseId) => {
1516
+ if (databaseId === recorded()[WORKER_APP_DB_SCOPE_KEYS.app]) {
1517
+ const url = await workerAppDbOwnerUrl(this.ports.coolify, org, databaseId);
1518
+ this.handledSecrets.add(url);
1519
+ return url;
1520
+ }
1503
1521
  const db = await getDatabaseStarted(this.ports.coolify, databaseId);
1504
1522
  if (!db.internal_db_url) {
1505
1523
  throw new Error('the database reports no internal connection URL yet');
@@ -7194,6 +7212,26 @@ export class FoundryStepExecutor {
7194
7212
  },
7195
7213
  };
7196
7214
  }
7215
+ /** `plan/worker-app-db.ts`: the org's Worker app database as a TLS Postgres, and its server certificate's renewal. */
7216
+ async workerAppDb(org, context) {
7217
+ const files = this.ports.ownServices?.provision.files;
7218
+ await provisionWorkerAppDb(scopeFromOrg(context, {}), {
7219
+ coolify: this.placedForCompany(this.ports.coolify, org, context),
7220
+ heldKeys: this.ports.heldKeys,
7221
+ signinDbBackup: files?.r2Buckets && files.r2Keys
7222
+ ? { r2Buckets: files.r2Buckets, r2Keys: files.r2Keys }
7223
+ : undefined,
7224
+ runtime: {
7225
+ ...(this.ports.runtime?.sleep ? { sleep: this.ports.runtime.sleep } : {}),
7226
+ ...(this.ports.runtime?.now ? { now: this.ports.runtime.now } : {}),
7227
+ onSecret: (value) => void this.handledSecrets.add(value),
7228
+ },
7229
+ }, org, {
7230
+ project: { ensure: () => this.ensureCoolifyProject(org, context) },
7231
+ label: `${org.slug}:worker-app-db`,
7232
+ });
7233
+ return {};
7234
+ }
7197
7235
  /** `plan/signin-db-move.ts`: copies a company's sign-in data from its Coolify database to the TLS database. */
7198
7236
  async ownAuthDbMove(org, context) {
7199
7237
  const own = this.ports.ownServices;
@@ -9287,7 +9325,7 @@ export class FoundryStepExecutor {
9287
9325
  }
9288
9326
  const entryKey = target.key.slice(org.id.length + 1);
9289
9327
  const at = `cloudflare:worker-app '${target.key}' for ${org.slug}`;
9290
- await teardownWorkerApp(this.workerAppDeps(workers), entryKey, target.id || undefined, recordedIds, (label, task) => withCallTimeout(WORKER_APP_CALL_TIMEOUT_MS, `${at}: ${label}`, task), org.id);
9328
+ await teardownWorkerApp(this.workerAppDeps(workers, org, () => recordedIds), entryKey, target.id || undefined, recordedIds, (label, task) => withCallTimeout(WORKER_APP_CALL_TIMEOUT_MS, `${at}: ${label}`, task), org.id);
9291
9329
  return;
9292
9330
  }
9293
9331
  case 'edge:key': {
@@ -226,6 +226,10 @@ export interface StepExecutor {
226
226
  * copy is not recorded. Optional: an executor without it runs no release.
227
227
  */
228
228
  releaseHost?(org: DeclaredOrg, subject: BlueGreenSubject, context: ReleaseContext, renew: () => Promise<void>): ReleaseHost | null;
229
+ /** Whether the executor's held keys can store a Worker app database's CA and certificate. Absent: true. */
230
+ workerAppDbKeysPuttable?(): boolean;
231
+ /** Whether the host supplies the backup bucket a Worker app database dumps into. Absent: true. */
232
+ workerAppDbBackupConfigured?(): boolean;
229
233
  /** Whether the executor's held keys can store the sign-in database's CA and certificate (#746). Absent: true. */
230
234
  ownSigninDbKeysPuttable?(): boolean;
231
235
  /** Whether the host supplies the backup bucket the sign-in database dumps into (#746). Absent: true. */
@@ -1359,6 +1359,8 @@ export class FoundryPlanApply {
1359
1359
  }),
1360
1360
  }
1361
1361
  : {}),
1362
+ workerAppDbKeysPuttable: this.executor.workerAppDbKeysPuttable?.() ?? true,
1363
+ workerAppDbBackupConfigured: this.executor.workerAppDbBackupConfigured?.() ?? true,
1362
1364
  ownSigninDbKeysPuttable: this.executor.ownSigninDbKeysPuttable?.() ?? true,
1363
1365
  ownSigninDbBackupConfigured: this.executor.ownSigninDbBackupConfigured?.() ?? true,
1364
1366
  ...(this.backupGate
@@ -2205,6 +2207,7 @@ const STEP_FIELDS = {
2205
2207
  'coolify:chat': 'chat.usesDomain',
2206
2208
  'coolify:own-auth': 'services.own',
2207
2209
  'coolify:own-auth-db-move': 'services.own',
2210
+ 'coolify:worker-app-db': 'hosting.entries.workerApp.database',
2208
2211
  'coolify:own-files': 'services.own',
2209
2212
  'coolify:own-files-r2': 'services.own',
2210
2213
  'coolify:own-jobs': 'services.own',
@@ -61,6 +61,11 @@ export declare function ensureSigninDbBackupTarget(port: SigninDbBackupPort, inp
61
61
  };
62
62
  record(key: string, id: string): Promise<void>;
63
63
  onSecret?(value: string): void;
64
+ /** Where the key's ids are recorded; the sign-in database's by default. */
65
+ keys?: {
66
+ backupKey: string;
67
+ backupKeyUndelivered: string;
68
+ };
64
69
  }): Promise<{
65
70
  target: SigninDbBackupTarget;
66
71
  tokenId: string;
@@ -77,11 +82,12 @@ export declare const SIGNIN_DB_BACKUP_ENV: {
77
82
  readonly script: "BACKUP_S3_SCRIPT";
78
83
  };
79
84
  /** Where in the bucket a company's dumps go: `signin-db/<slug>/signin-db-<UTC time>.dump`. */
80
- export declare function signinDbBackupPrefix(slug: string): string;
85
+ export declare function signinDbBackupPrefix(slug: string, base?: string): string;
81
86
  /** The env entries the backup command needs, for the database app, the script included. */
82
87
  export declare function signinDbBackupEnvs(target: SigninDbBackupTarget, slug: string, db: {
83
88
  user: string;
84
89
  database: string;
90
+ prefixBase?: string;
85
91
  }): Array<{
86
92
  key: string;
87
93
  value: string;
@@ -54,9 +54,11 @@ const usable = (value) => typeof value === 'string' && value.length > 0 && value
54
54
  * move stops anything. Errors name the label only.
55
55
  */
56
56
  export async function ensureSigninDbBackupTarget(port, input) {
57
+ const keyScope = input.keys?.backupKey ?? SIGNIN_DB_BACKUP_KEY_SCOPE_KEY;
58
+ const undeliveredScope = input.keys?.backupKeyUndelivered ?? SIGNIN_DB_BACKUP_KEY_UNDELIVERED_SCOPE_KEY;
57
59
  const { label, recorded } = input;
58
60
  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];
61
+ const kept = recorded[keyScope];
60
62
  if (kept && input.held.keyId && input.held.secret) {
61
63
  input.onSecret?.(input.held.secret);
62
64
  input.onSecret?.(input.held.keyId);
@@ -72,11 +74,8 @@ export async function ensureSigninDbBackupTarget(port, input) {
72
74
  };
73
75
  }
74
76
  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],
77
+ [undeliveredScope, recorded[undeliveredScope]],
78
+ [keyScope, kept],
80
79
  ]) {
81
80
  if (!tokenId)
82
81
  continue;
@@ -90,7 +89,7 @@ export async function ensureSigninDbBackupTarget(port, input) {
90
89
  input.onSecret?.(key.accessKeyId);
91
90
  // A token id we can read is recorded even when the rest of the answer is unusable, so it is deleted next run.
92
91
  if (usable(key?.tokenId))
93
- await input.record(SIGNIN_DB_BACKUP_KEY_UNDELIVERED_SCOPE_KEY, key.tokenId);
92
+ await input.record(undeliveredScope, key.tokenId);
94
93
  if (!usable(key?.accessKeyId) || !usable(key?.secretAccessKey) || !usable(key?.tokenId)) {
95
94
  throw new Error(`${label}: the sign-in backup key mint answered with an unusable key`);
96
95
  }
@@ -116,8 +115,8 @@ export const SIGNIN_DB_BACKUP_ENV = {
116
115
  script: 'BACKUP_S3_SCRIPT',
117
116
  };
118
117
  /** Where in the bucket a company's dumps go: `signin-db/<slug>/signin-db-<UTC time>.dump`. */
119
- export function signinDbBackupPrefix(slug) {
120
- return `signin-db/${slug}`;
118
+ export function signinDbBackupPrefix(slug, base = 'signin-db') {
119
+ return `${base}/${slug}`;
121
120
  }
122
121
  /** The env entries the backup command needs, for the database app, the script included. */
123
122
  export function signinDbBackupEnvs(target, slug, db) {
@@ -127,7 +126,7 @@ export function signinDbBackupEnvs(target, slug, db) {
127
126
  { key: SIGNIN_DB_BACKUP_ENV.region, value: target.region ?? 'auto' },
128
127
  { key: SIGNIN_DB_BACKUP_ENV.keyId, value: target.accessKeyId },
129
128
  { key: SIGNIN_DB_BACKUP_ENV.secret, value: target.secretAccessKey },
130
- { key: SIGNIN_DB_BACKUP_ENV.prefix, value: signinDbBackupPrefix(slug) },
129
+ { key: SIGNIN_DB_BACKUP_ENV.prefix, value: signinDbBackupPrefix(slug, db.prefixBase) },
131
130
  {
132
131
  key: SIGNIN_DB_BACKUP_ENV.script,
133
132
  value: Buffer.from(signinDbBackupScript(db.user, db.database), 'utf8').toString('base64'),
@@ -10,6 +10,22 @@
10
10
  */
11
11
  /** The platform's one CA, held as JSON `{ cert, key }` (PEM strings). */
12
12
  export declare const SIGNIN_DB_CA_REF = "signin-db:platform:ca";
13
+ /**
14
+ * The recorded ids of one TLS database: the app, the marker that it is in use, and the server
15
+ * certificate's expiry (milliseconds since the epoch, so the planner can renew it; not a secret).
16
+ */
17
+ export interface TlsDbScopeKeys {
18
+ app: string;
19
+ tls: string;
20
+ certNotAfter: string;
21
+ /** The id of the database's backup key (kept) and of one minted and not yet delivered. */
22
+ backupKey: string;
23
+ backupKeyUndelivered: string;
24
+ /** The id of the nightly dump's schedule. */
25
+ backupTask: string;
26
+ }
27
+ /** The scope keys of a TLS database named by `prefix`: `<prefix>_app`, `<prefix>_tls`, `<prefix>_cert_not_after`. */
28
+ export declare function tlsDbScopeKeys(prefix: string): TlsDbScopeKeys;
13
29
  /** The recorded id of the server certificate's expiry (milliseconds since the epoch), so the planner can renew it. Not a secret. */
14
30
  export declare const SIGNIN_DB_CERT_NOT_AFTER_SCOPE_KEY = "signin_db_cert_not_after";
15
31
  /** Recorded id of the database app. */
@@ -19,6 +35,8 @@ export declare const SIGNIN_DB_APP_SCOPE_KEY = "signin_db_app";
19
35
  * provisions this database and never the Coolify one.
20
36
  */
21
37
  export declare const SIGNIN_DB_TLS_SCOPE_KEY = "signin_db_tls";
38
+ /** The sign-in database's scope keys. */
39
+ export declare const SIGNIN_DB_SCOPE_KEYS: TlsDbScopeKeys;
22
40
  /**
23
41
  * Recorded while ZITADEL has been started on the TLS database and not yet seen healthy there: the
24
42
  * planner then plans the sign-in step, which starts ZITADEL again and clears it (#746).
@@ -26,6 +44,8 @@ export declare const SIGNIN_DB_TLS_SCOPE_KEY = "signin_db_tls";
26
44
  export declare const SIGNIN_DB_ZITADEL_PENDING_SCOPE_KEY = "signin_db_zitadel_pending";
27
45
  /** A server certificate with less than this left is issued again. */
28
46
  export declare const SIGNIN_DB_CERT_RENEW_WINDOW_MS: number;
47
+ /** The CA's subject unless the caller names another. */
48
+ export declare const SIGNIN_DB_CA_COMMON_NAME = "foundry sign-in database CA";
29
49
  export interface SigninDbKeys {
30
50
  resolve(ref: string): Promise<string | null | undefined>;
31
51
  put?(ref: string, value: string): Promise<void>;
@@ -47,14 +67,19 @@ export interface ServerCert extends PemPair {
47
67
  }
48
68
  /**
49
69
  * The platform's CA: the held one, else a new one stored under
50
- * {@link SIGNIN_DB_CA_REF}. A held value that cannot be read, or whose
70
+ * {@link SIGNIN_DB_CA_REF} (or `ca.ref`). A held value that cannot be read, or whose
51
71
  * certificate does not match its key, is an error and is never replaced: a
52
72
  * company's database may already trust it. The value read back after a store
53
73
  * is the one used, so two applies at once end with the same CA.
54
74
  */
55
75
  export declare function ensureSigninDbCa(keys: SigninDbKeys, runtime?: SigninDbTlsRuntime,
56
76
  /** A company already has a database or certificate made under the CA: a lost CA is then an error, never made again. */
57
- inUse?: boolean): Promise<PemPair>;
77
+ inUse?: boolean,
78
+ /** The held key of the CA and its subject; the sign-in database's by default. */
79
+ ca?: {
80
+ ref?: string;
81
+ commonName?: string;
82
+ }): Promise<PemPair>;
58
83
  /**
59
84
  * The server certificate held under `ref` for `host`, issued again when it is
60
85
  * missing, unreadable, for another host, signed by another CA, or has less
@@ -66,4 +91,6 @@ export declare function ensureSigninDbServerCert(keys: SigninDbKeys, ca: PemPair
66
91
  * Whether the recorded server certificate expires within the renew window.
67
92
  * Nothing recorded (a company not on the TLS database) is never due.
68
93
  */
69
- export declare function signinDbCertDue(recordedIds: Readonly<Record<string, string>>, now: number): boolean;
94
+ export declare function signinDbCertDue(recordedIds: Readonly<Record<string, string>>, now: number,
95
+ /** The scope keys of the database; the sign-in database's by default. */
96
+ scopeKeys?: TlsDbScopeKeys): boolean;
@@ -11,6 +11,17 @@ import { X509Certificate, createHash, createPrivateKey, createPublicKey, generat
11
11
  */
12
12
  /** The platform's one CA, held as JSON `{ cert, key }` (PEM strings). */
13
13
  export const SIGNIN_DB_CA_REF = 'signin-db:platform:ca';
14
+ /** The scope keys of a TLS database named by `prefix`: `<prefix>_app`, `<prefix>_tls`, `<prefix>_cert_not_after`. */
15
+ export function tlsDbScopeKeys(prefix) {
16
+ return {
17
+ app: `${prefix}_app`,
18
+ tls: `${prefix}_tls`,
19
+ certNotAfter: `${prefix}_cert_not_after`,
20
+ backupKey: `${prefix}_backup_key`,
21
+ backupKeyUndelivered: `${prefix}_backup_key_undelivered`,
22
+ backupTask: `${prefix}_backup_task`,
23
+ };
24
+ }
14
25
  /** The recorded id of the server certificate's expiry (milliseconds since the epoch), so the planner can renew it. Not a secret. */
15
26
  export const SIGNIN_DB_CERT_NOT_AFTER_SCOPE_KEY = 'signin_db_cert_not_after';
16
27
  /** Recorded id of the database app. */
@@ -20,6 +31,16 @@ export const SIGNIN_DB_APP_SCOPE_KEY = 'signin_db_app';
20
31
  * provisions this database and never the Coolify one.
21
32
  */
22
33
  export const SIGNIN_DB_TLS_SCOPE_KEY = 'signin_db_tls';
34
+ /** The sign-in database's scope keys. */
35
+ export const SIGNIN_DB_SCOPE_KEYS = {
36
+ app: SIGNIN_DB_APP_SCOPE_KEY,
37
+ tls: SIGNIN_DB_TLS_SCOPE_KEY,
38
+ certNotAfter: SIGNIN_DB_CERT_NOT_AFTER_SCOPE_KEY,
39
+ // The same strings as `signin-db-backup.ts` exports (that file imports this one); a test pins them.
40
+ backupKey: 'signin_db_backup_key',
41
+ backupKeyUndelivered: 'signin_db_backup_key_undelivered',
42
+ backupTask: 'signin_db_backup_task',
43
+ };
23
44
  /**
24
45
  * Recorded while ZITADEL has been started on the TLS database and not yet seen healthy there: the
25
46
  * planner then plans the sign-in step, which starts ZITADEL again and clears it (#746).
@@ -29,8 +50,9 @@ export const SIGNIN_DB_ZITADEL_PENDING_SCOPE_KEY = 'signin_db_zitadel_pending';
29
50
  export const SIGNIN_DB_CERT_RENEW_WINDOW_MS = 30 * 24 * 3_600_000;
30
51
  const SERVER_CERT_VALIDITY_MS = 365 * 24 * 3_600_000;
31
52
  const CA_VALIDITY_MS = 10 * 365 * 24 * 3_600_000;
32
- /** The CA's subject. */
33
- const CA_COMMON_NAME = 'foundry sign-in database CA';
53
+ /** The CA's subject unless the caller names another. */
54
+ export const SIGNIN_DB_CA_COMMON_NAME = 'foundry sign-in database CA';
55
+ const CA_COMMON_NAME = SIGNIN_DB_CA_COMMON_NAME;
34
56
  // ---- DER ---------------------------------------------------------------------
35
57
  const der = (tag, ...parts) => {
36
58
  const body = Buffer.concat(parts);
@@ -103,11 +125,11 @@ function newKeyPair() {
103
125
  };
104
126
  }
105
127
  // ---- the CA ------------------------------------------------------------------
106
- function newCa(now) {
128
+ function newCa(now, commonName) {
107
129
  const { publicKey, privateKey, keyPem } = newKeyPair();
108
130
  const certPem = certificate({
109
- subject: CA_COMMON_NAME,
110
- issuer: CA_COMMON_NAME,
131
+ subject: commonName,
132
+ issuer: commonName,
111
133
  publicKey,
112
134
  signingKey: privateKey,
113
135
  notBefore: now - 3_600_000,
@@ -144,15 +166,18 @@ function pairMatches(pair) {
144
166
  }
145
167
  /**
146
168
  * The platform's CA: the held one, else a new one stored under
147
- * {@link SIGNIN_DB_CA_REF}. A held value that cannot be read, or whose
169
+ * {@link SIGNIN_DB_CA_REF} (or `ca.ref`). A held value that cannot be read, or whose
148
170
  * certificate does not match its key, is an error and is never replaced: a
149
171
  * company's database may already trust it. The value read back after a store
150
172
  * is the one used, so two applies at once end with the same CA.
151
173
  */
152
174
  export async function ensureSigninDbCa(keys, runtime = {},
153
175
  /** A company already has a database or certificate made under the CA: a lost CA is then an error, never made again. */
154
- inUse = false) {
155
- const held = await keys.resolve(SIGNIN_DB_CA_REF);
176
+ inUse = false,
177
+ /** The held key of the CA and its subject; the sign-in database's by default. */
178
+ ca = {}) {
179
+ const ref = ca.ref ?? SIGNIN_DB_CA_REF;
180
+ const held = await keys.resolve(ref);
156
181
  if (held) {
157
182
  const pair = readPair(held);
158
183
  if (!pair)
@@ -164,14 +189,14 @@ inUse = false) {
164
189
  return pair;
165
190
  }
166
191
  if (inUse) {
167
- throw new Error(`the sign-in database CA (${SIGNIN_DB_CA_REF}) is not held, but a company's database already trusts it; a new CA would break every company's sign-in, so none is made. Restore the CA from the keys backup (docs/runbooks/signin-db-tls.md, "A lost CA")`);
192
+ throw new Error(`the sign-in database CA (${ref}) is not held, but a company's database already trusts it; a new CA would break every company's sign-in, so none is made. Restore the CA from the keys backup (docs/runbooks/signin-db-tls.md, "A lost CA")`);
168
193
  }
169
194
  if (!keys.put)
170
195
  throw new Error('the host cannot store the sign-in database CA: heldKeys has no put');
171
- const created = newCa((runtime.now ?? Date.now)());
196
+ const created = newCa((runtime.now ?? Date.now)(), ca.commonName ?? CA_COMMON_NAME);
172
197
  runtime.onSecret?.(created.keyPem);
173
- await keys.put(SIGNIN_DB_CA_REF, JSON.stringify({ cert: created.certPem, key: created.keyPem }));
174
- const stored = readPair(await keys.resolve(SIGNIN_DB_CA_REF));
198
+ await keys.put(ref, JSON.stringify({ cert: created.certPem, key: created.keyPem }));
199
+ const stored = readPair(await keys.resolve(ref));
175
200
  if (!stored)
176
201
  throw new Error('the sign-in database CA was stored but cannot be read back');
177
202
  runtime.onSecret?.(stored.keyPem);
@@ -182,9 +207,11 @@ function newServerCert(ca, host, now) {
182
207
  const { publicKey, keyPem } = newKeyPair();
183
208
  const caCert = new X509Certificate(ca.certPem);
184
209
  const notAfter = now + SERVER_CERT_VALIDITY_MS;
210
+ // The issuer is the CA's own subject, whatever common name it was made with.
211
+ const issuer = /^CN=(.*)$/m.exec(caCert.subject)?.[1] ?? CA_COMMON_NAME;
185
212
  const certPem = certificate({
186
213
  subject: host,
187
- issuer: CA_COMMON_NAME,
214
+ issuer,
188
215
  publicKey,
189
216
  signingKey: createPrivateKey(ca.keyPem),
190
217
  notBefore: now - 3_600_000,
@@ -249,7 +276,9 @@ export async function ensureSigninDbServerCert(keys, ca, ref, host, runtime = {}
249
276
  * Whether the recorded server certificate expires within the renew window.
250
277
  * Nothing recorded (a company not on the TLS database) is never due.
251
278
  */
252
- export function signinDbCertDue(recordedIds, now) {
253
- const notAfter = Number(recordedIds[SIGNIN_DB_CERT_NOT_AFTER_SCOPE_KEY]);
279
+ export function signinDbCertDue(recordedIds, now,
280
+ /** The scope keys of the database; the sign-in database's by default. */
281
+ scopeKeys = SIGNIN_DB_SCOPE_KEYS) {
282
+ const notAfter = Number(recordedIds[scopeKeys.certNotAfter]);
254
283
  return (Number.isFinite(notAfter) && notAfter > 0 && notAfter - now < SIGNIN_DB_CERT_RENEW_WINDOW_MS);
255
284
  }
@@ -1,6 +1,6 @@
1
1
  import type { CoolifyPort } from './executor.js';
2
2
  import { SIGNIN_DB_BACKUP_TASK_SCOPE_KEY, type SigninDbBackupPort } from './signin-db-backup.js';
3
- import { SIGNIN_DB_APP_SCOPE_KEY, SIGNIN_DB_TLS_SCOPE_KEY, type SigninDbKeys, type SigninDbTlsRuntime } from './signin-db-tls.js';
3
+ import { SIGNIN_DB_APP_SCOPE_KEY, SIGNIN_DB_TLS_SCOPE_KEY, type SigninDbKeys, type SigninDbTlsRuntime, type TlsDbScopeKeys } from './signin-db-tls.js';
4
4
  /**
5
5
  * A company's sign-in database as foundry's own image (#746). Coolify 4.4.2
6
6
  * cannot turn on a database's TLS and mounts files root-owned, so Postgres
@@ -42,9 +42,15 @@ export declare function signinDbStartScript(): string[];
42
42
  * official image); every network connection must be TLS with a password, so a client that skips
43
43
  * TLS is refused by the server, not only by the client's own setting.
44
44
  */
45
- export declare function signinDbDockerfile(): string;
45
+ export declare function signinDbDockerfile(db?: {
46
+ user?: string;
47
+ database?: string;
48
+ }): string;
46
49
  /** ZITADEL's DSN: the whole chain is checked, host name included. A secret (it holds the password). */
47
- export declare function signinDbDsn(host: string, password: string): string;
50
+ export declare function signinDbDsn(host: string, password: string, db?: {
51
+ user?: string;
52
+ database?: string;
53
+ }): string;
48
54
  type Coolify = Pick<CoolifyPort, 'findApplicationByName' | 'getApplication' | 'updateApplication' | 'bulkUpsertEnvs' | 'listEnvs' | 'deploy' | 'raw'> & Partial<Pick<CoolifyPort, 'listStorages' | 'createPersistentStorage' | 'createFileStorage'>>;
49
55
  export interface SigninDbRuntime extends SigninDbTlsRuntime {
50
56
  sleep?: (ms: number) => Promise<void>;
@@ -97,6 +103,22 @@ export interface ProvisionSigninDbInput {
97
103
  };
98
104
  record(key: string, id: string): Promise<void>;
99
105
  }
106
+ /** What differs between TLS databases; every default is the sign-in database's. */
107
+ export interface TlsDbOptions {
108
+ /** The Coolify app name (default `input.name`). */
109
+ appName?: string;
110
+ /** Postgres user (default `zitadel`). */
111
+ user?: string;
112
+ /** Postgres database (default `zitadel`). */
113
+ database?: string;
114
+ /** The held key and subject of the CA. */
115
+ caRef?: string;
116
+ caCommonName?: string;
117
+ /** Recorded-id keys. */
118
+ scopeKeys?: TlsDbScopeKeys;
119
+ /** The folder in the bucket before the slug (default `signin-db`). */
120
+ backupPrefix?: string;
121
+ }
100
122
  /**
101
123
  * Brings the TLS database up and returns ZITADEL's DSN: the CA and server certificate (held in
102
124
  * keys), the app with its data volume and no domain, its env (the password minted once and read
@@ -104,7 +126,7 @@ export interface ProvisionSigninDbInput {
104
126
  * database reports healthy. A secret (password, key) goes only to the app's env and to
105
127
  * `runtime.onSecret`.
106
128
  */
107
- export declare function provisionSigninDb(input: ProvisionSigninDbInput): Promise<{
129
+ export declare function provisionSigninDb(input: ProvisionSigninDbInput, options?: TlsDbOptions): Promise<{
108
130
  appId: string;
109
131
  dsn: string;
110
132
  ca: string;
@@ -1,8 +1,8 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { pinnedRef } from '../bootstrap/versions.js';
3
3
  import { envsDiffer, findOrCreatePlatformApp, resolvePlatformServer, } from './platform-providers.js';
4
- import { SIGNIN_DB_BACKUP_ENV, SIGNIN_DB_BACKUP_KEY_SCOPE_KEY, SIGNIN_DB_BACKUP_KEY_UNDELIVERED_SCOPE_KEY, SIGNIN_DB_BACKUP_TASK_SCOPE_KEY, ensureSigninDbBackupTarget, ensureSigninDbBackupTask, signinDbBackupEnvs, } from './signin-db-backup.js';
5
- import { SIGNIN_DB_APP_SCOPE_KEY, SIGNIN_DB_CERT_NOT_AFTER_SCOPE_KEY, SIGNIN_DB_TLS_SCOPE_KEY, ensureSigninDbCa, ensureSigninDbServerCert, } from './signin-db-tls.js';
4
+ import { SIGNIN_DB_BACKUP_ENV, SIGNIN_DB_BACKUP_TASK_SCOPE_KEY, ensureSigninDbBackupTarget, ensureSigninDbBackupTask, signinDbBackupEnvs, } from './signin-db-backup.js';
5
+ import { SIGNIN_DB_APP_SCOPE_KEY, SIGNIN_DB_SCOPE_KEYS, SIGNIN_DB_TLS_SCOPE_KEY, ensureSigninDbCa, ensureSigninDbServerCert, } from './signin-db-tls.js';
6
6
  /**
7
7
  * A company's sign-in database as foundry's own image (#746). Coolify 4.4.2
8
8
  * cannot turn on a database's TLS and mounts files root-owned, so Postgres
@@ -84,7 +84,7 @@ export function signinDbStartScript() {
84
84
  * official image); every network connection must be TLS with a password, so a client that skips
85
85
  * TLS is refused by the server, not only by the client's own setting.
86
86
  */
87
- export function signinDbDockerfile() {
87
+ export function signinDbDockerfile(db = {}) {
88
88
  const lines = signinDbStartScript();
89
89
  if (lines.some((l) => l.includes("'")))
90
90
  throw new Error('signin-db entrypoint holds a quote');
@@ -95,12 +95,12 @@ export function signinDbDockerfile() {
95
95
  `RUN printf '%s\\n' \\\n${lines.map((l) => ` '${l}'`).join(' \\\n')} > /usr/local/bin/signin-db-start \\\n && chmod 0755 /usr/local/bin/signin-db-start`,
96
96
  `EXPOSE ${SIGNIN_DB_PORT}`,
97
97
  'ENTRYPOINT ["/usr/local/bin/signin-db-start"]',
98
- `HEALTHCHECK --interval=10s --timeout=5s --start-period=60s --retries=5 CMD ["pg_isready", "-U", "${SIGNIN_DB_USER}", "-d", "${SIGNIN_DB_NAME}"]`,
98
+ `HEALTHCHECK --interval=10s --timeout=5s --start-period=60s --retries=5 CMD ["pg_isready", "-U", "${db.user ?? SIGNIN_DB_USER}", "-d", "${db.database ?? SIGNIN_DB_NAME}"]`,
99
99
  ].join('\n');
100
100
  }
101
101
  /** ZITADEL's DSN: the whole chain is checked, host name included. A secret (it holds the password). */
102
- export function signinDbDsn(host, password) {
103
- return `postgres://${SIGNIN_DB_USER}:${password}@${host}:${SIGNIN_DB_PORT}/${SIGNIN_DB_NAME}?sslmode=verify-full&sslrootcert=${SIGNIN_DB_CA_MOUNT}`;
102
+ export function signinDbDsn(host, password, db = {}) {
103
+ return `postgres://${db.user ?? SIGNIN_DB_USER}:${password}@${host}:${SIGNIN_DB_PORT}/${db.database ?? SIGNIN_DB_NAME}?sslmode=verify-full&sslrootcert=${SIGNIN_DB_CA_MOUNT}`;
104
104
  }
105
105
  /** The volume and file-mount calls, or an error naming the step: a host that cannot mount cannot run this database. */
106
106
  function storageCalls(coolify, label) {
@@ -168,7 +168,7 @@ export function dockerfileAppCreator(coolify, o) {
168
168
  * database reports healthy. A secret (password, key) goes only to the app's env and to
169
169
  * `runtime.onSecret`.
170
170
  */
171
- export async function provisionSigninDb(input) {
171
+ export async function provisionSigninDb(input, options = {}) {
172
172
  const { runtime, label } = input;
173
173
  const coolify = withCallTimeout(input.coolify);
174
174
  if (!input.keys?.put) {
@@ -177,12 +177,13 @@ export async function provisionSigninDb(input) {
177
177
  if (!input.backup) {
178
178
  throw new Error(`${label}: the host supplies no backup bucket for the sign-in database`);
179
179
  }
180
- const ca = await ensureSigninDbCa(input.keys, runtime, Boolean(input.recorded[SIGNIN_DB_TLS_SCOPE_KEY] ||
181
- input.recorded[SIGNIN_DB_APP_SCOPE_KEY] ||
182
- input.recorded[SIGNIN_DB_CERT_NOT_AFTER_SCOPE_KEY]));
180
+ const user = options.user ?? SIGNIN_DB_USER;
181
+ const database = options.database ?? SIGNIN_DB_NAME;
182
+ const scope = options.scopeKeys ?? SIGNIN_DB_SCOPE_KEYS;
183
+ const ca = await ensureSigninDbCa(input.keys, runtime, Boolean(input.recorded[scope.tls] || input.recorded[scope.app] || input.recorded[scope.certNotAfter]), { ref: options.caRef, commonName: options.caCommonName });
183
184
  const server = await ensureSigninDbServerCert(input.keys, ca, input.serverCertRef, input.internalHost, runtime);
184
- const app = await findOrCreatePlatformApp(coolify, input.name, input.environment, () => boundedCall(input.create(signinDbDockerfile()), 'Coolify createDockerfileApplication'));
185
- await input.record(SIGNIN_DB_APP_SCOPE_KEY, app.appId);
185
+ const app = await findOrCreatePlatformApp(coolify, options.appName ?? input.name, input.environment, () => boundedCall(input.create(signinDbDockerfile({ user, database })), 'Coolify createDockerfileApplication'));
186
+ await input.record(scope.app, app.appId);
186
187
  // No `ports_mappings` and no `domains`: only the Docker network reaches the port.
187
188
  await coolify.updateApplication(app.appId, {
188
189
  custom_internal_name: input.internalHost,
@@ -214,34 +215,36 @@ export async function provisionSigninDb(input) {
214
215
  },
215
216
  record: input.record,
216
217
  onSecret: runtime.onSecret,
218
+ keys: scope,
217
219
  });
218
220
  const b64 = (pem) => Buffer.from(pem, 'utf8').toString('base64');
219
221
  runtime.onSecret?.(b64(server.keyPem));
220
222
  const envs = [
221
- { key: SIGNIN_DB_ENV.user, value: SIGNIN_DB_USER },
223
+ { key: SIGNIN_DB_ENV.user, value: user },
222
224
  { key: SIGNIN_DB_ENV.password, value: password },
223
- { key: SIGNIN_DB_ENV.database, value: SIGNIN_DB_NAME },
225
+ { key: SIGNIN_DB_ENV.database, value: database },
224
226
  { key: SIGNIN_DB_ENV.cert, value: b64(server.certPem) },
225
227
  { key: SIGNIN_DB_ENV.key, value: b64(server.keyPem) },
226
228
  ...signinDbBackupEnvs(backup.target, input.slug, {
227
- user: SIGNIN_DB_USER,
228
- database: SIGNIN_DB_NAME,
229
+ user,
230
+ database,
231
+ prefixBase: options.backupPrefix,
229
232
  }),
230
233
  ];
231
234
  const changed = await envsDiffer(coolify, app.appId, envs);
232
235
  await coolify.bulkUpsertEnvs(app.appId, envs);
233
236
  if (backup.fresh) {
234
- await input.record(SIGNIN_DB_BACKUP_KEY_SCOPE_KEY, backup.tokenId);
235
- await input.record(SIGNIN_DB_BACKUP_KEY_UNDELIVERED_SCOPE_KEY, '');
237
+ await input.record(scope.backupKey, backup.tokenId);
238
+ await input.record(scope.backupKeyUndelivered, '');
236
239
  }
237
240
  if (app.justCreated || !app.running || changed) {
238
241
  await coolify.deploy(app.appId);
239
242
  await waitHealthy(coolify, app.appId, runtime, label);
240
243
  }
241
- await input.record(SIGNIN_DB_CERT_NOT_AFTER_SCOPE_KEY, String(server.notAfter));
244
+ await input.record(scope.certNotAfter, String(server.notAfter));
242
245
  // The database is not used, and ZITADEL is not stopped for a move, until its nightly dump is scheduled.
243
- await input.record(SIGNIN_DB_BACKUP_TASK_SCOPE_KEY, await ensureSigninDbBackupTask(coolify, app.appId, label));
244
- const dsn = signinDbDsn(input.internalHost, password);
246
+ await input.record(scope.backupTask, await ensureSigninDbBackupTask(coolify, app.appId, label));
247
+ const dsn = signinDbDsn(input.internalHost, password, { user, database });
245
248
  runtime.onSecret?.(dsn);
246
249
  return { appId: app.appId, dsn, ca: ca.certPem, password };
247
250
  }
@@ -28,6 +28,12 @@ export interface HyperdriveOrigin {
28
28
  password: string;
29
29
  accessClientId: string;
30
30
  accessClientSecret: string;
31
+ /** Present when the database's certificate is checked against a CA uploaded to Cloudflare. */
32
+ tls?: {
33
+ sslmode: 'verify-ca';
34
+ caCertificateId: string;
35
+ caPem: string;
36
+ };
31
37
  }
32
38
  /** One migration of a release's `migrations.json`. */
33
39
  export interface DatabaseMigration {
@@ -50,6 +56,13 @@ export interface WorkerAppDatabasePort {
50
56
  }>;
51
57
  /** A no-op when the config is gone. */
52
58
  deleteHyperdrive(name: string): Promise<void>;
59
+ /** Uploads the CA certificate `pem` under `name` unless it is already there; returns its id. */
60
+ ensureCaCertificate(input: {
61
+ name: string;
62
+ pem: string;
63
+ }): Promise<{
64
+ id: string;
65
+ }>;
53
66
  /** The text of a release asset of a wtfalch repo, or `null` when the release has no such asset. */
54
67
  fetchReleaseAsset(url: string): Promise<string | null>;
55
68
  /** The migration ids the database's ledger holds, in the order applied. */
@@ -109,6 +122,13 @@ export interface WorkerAppDatabaseDeps {
109
122
  ownerUrl(databaseId: string): Promise<string>;
110
123
  /** Registers a value for redaction before it is written anywhere. */
111
124
  secret(value: string): void;
125
+ /**
126
+ * The PEM of the CA that signs the app database's certificate. Absent or `null`: the
127
+ * connection carries no TLS settings.
128
+ */
129
+ ca?: () => Promise<{
130
+ pem: string;
131
+ } | null>;
112
132
  heldKeys?: {
113
133
  resolve(ref: string): Promise<string | null | undefined>;
114
134
  put?(ref: string, value: string): Promise<void>;
@@ -1,5 +1,6 @@
1
1
  import { createHash, randomBytes } from 'node:crypto';
2
2
  import { REMOTE_DATABASE_PORT, mergeTcpIngress, recordedHttpIngress, recordedTcpRoutes, remoteDatabaseKeys, } from './remote-database.js';
3
+ import { WORKER_APP_DB_SCOPE_KEYS } from './worker-app-db.js';
3
4
  import { WORKER_APP_CALL_TIMEOUT_MS, withCallTimeout, workerAppSecretRef } from './worker-app.js';
4
5
  export const WORKER_APP_MIGRATION_TIMEOUT_MS = 300_000;
5
6
  export const MIGRATIONS_ASSET_NAME = 'migrations.json';
@@ -141,7 +142,9 @@ export async function applyWorkerAppDatabase(deps, org, entry, scriptName, want,
141
142
  throw new Error(`${at}: needs a held-key store that can put`);
142
143
  const put = heldKeys.put;
143
144
  const keys = remoteDatabaseKeys(entry.key);
144
- const databaseId = context.recordedIds()[`coolify:database:${org.id}`];
145
+ // The foundry-made TLS database (worker-app-db) when recorded, else the legacy Coolify database.
146
+ const databaseId = context.recordedIds()[WORKER_APP_DB_SCOPE_KEYS.app] ||
147
+ context.recordedIds()[`coolify:database:${org.id}`];
145
148
  if (!databaseId)
146
149
  throw new Error(`${at}: ${org.slug}'s database is not applied yet`);
147
150
  const tunnelId = context.recordedIds()[`cloudflare:tunnel:${org.id}`];
@@ -208,6 +211,19 @@ export async function applyWorkerAppDatabase(deps, org, entry, scriptName, want,
208
211
  await call('hold the runtime password', () => put(rtRef, minted));
209
212
  }
210
213
  deps.secret(runtimePassword);
214
+ // The CA, uploaded before the first connection so both origins carry the same certificate id.
215
+ const ca = deps.ca
216
+ ? await call('read the CA certificate', () => deps.ca())
217
+ : null;
218
+ let tls;
219
+ let caName;
220
+ if (ca) {
221
+ caName = `foundry-${org.slug}-appdb-ca-${createHash('sha256').update(ca.pem).digest('hex').slice(0, 12)}`;
222
+ const pem = ca.pem;
223
+ const uploadName = caName;
224
+ const { id: caCertificateId } = await call('upload the CA certificate', () => port.ensureCaCertificate({ name: uploadName, pem }));
225
+ tls = { sslmode: 'verify-ca', caCertificateId, caPem: pem };
226
+ }
211
227
  const ownerOrigin = {
212
228
  host: hostname,
213
229
  database: owner.database,
@@ -215,6 +231,7 @@ export async function applyWorkerAppDatabase(deps, org, entry, scriptName, want,
215
231
  password: owner.password,
216
232
  accessClientId: access.clientId,
217
233
  accessClientSecret: access.clientSecret,
234
+ ...(tls ? { tls } : {}),
218
235
  };
219
236
  // 4. Migrations with the owner credentials. A failure stops the deploy; ids already applied stay.
220
237
  const text = await call('fetch the release migrations', () => port.fetchReleaseAsset(migrationsUrl(want.artifactUrl)));
@@ -259,9 +276,10 @@ export async function applyWorkerAppDatabase(deps, org, entry, scriptName, want,
259
276
  password: runtimePassword,
260
277
  accessClientId: access.clientId,
261
278
  accessClientSecret: access.clientSecret,
279
+ ...(tls ? { tls } : {}),
262
280
  },
263
281
  }));
264
- context.recordScopeId(workerAppHyperdriveScopeKey(entry.key), JSON.stringify({ name, id }));
282
+ context.recordScopeId(workerAppHyperdriveScopeKey(entry.key), JSON.stringify(caName ? { name, id, ca: caName } : { name, id }));
265
283
  await context.persistScopeIds();
266
284
  return { hyperdriveId: id };
267
285
  }
@@ -0,0 +1,78 @@
1
+ import { type DeclaredOrg } from '../org.js';
2
+ import type { DraftStep } from './diff.js';
3
+ import { type ProjectSource, type StepScope } from './own-services.js';
4
+ import type { PlatformAuthDeps } from './platform-providers.js';
5
+ import { type SigninDbKeys } from './signin-db-tls.js';
6
+ import { type SigninDbRuntime } from './signin-db.js';
7
+ /**
8
+ * A Worker app's database as foundry's own TLS Postgres (the sign-in database's image), so that
9
+ * Cloudflare Hyperdrive connects with `verify-ca`. It replaces the Coolify database that a Worker
10
+ * app entry used to force: Coolify 4.4.2 cannot turn on a database's TLS.
11
+ *
12
+ * One app `<slug>-app-db` per org, user `app_owner`, the org's `app` database. The CA is the org's
13
+ * own, held under {@link workerAppDbCaRef}; the server certificate is renewed by planning the step
14
+ * again when it is due.
15
+ */
16
+ /** Recorded ids: the app, "the step finished", and the server certificate's expiry. */
17
+ export declare const WORKER_APP_DB_SCOPE_KEYS: import("./signin-db-tls.js").TlsDbScopeKeys;
18
+ /** `unmanaged` key of the one blocked line {@link planWorkerAppDb} reports. */
19
+ export declare const WORKER_APP_DB_BLOCKED_KEY = "worker-app-db";
20
+ /** `unmanaged` key of the note that the legacy Coolify database is no longer used. */
21
+ export declare const WORKER_APP_DB_LEGACY_NOTE_KEY = "worker-app-db-legacy";
22
+ export declare const WORKER_APP_DB_USER = "app_owner";
23
+ export declare const WORKER_APP_DB_BACKUP_PREFIX = "worker-app-db";
24
+ export declare const workerAppDbCaRef: (orgId: string) => string;
25
+ export declare const workerAppDbServerRef: (orgId: string) => string;
26
+ export declare const workerAppDbName: (org: Pick<DeclaredOrg, "slug">) => string;
27
+ /** The org's Worker app entries declare a database. */
28
+ export declare const orgHasWorkerAppDatabase: (org: DeclaredOrg) => boolean;
29
+ /** The database's name on the server: the org's `app` home, as the Postgres server would name it. */
30
+ export declare function workerAppDbDatabase(org: DeclaredOrg): string;
31
+ export interface PlanWorkerAppDbOptions {
32
+ org: DeclaredOrg;
33
+ recordedIds: Readonly<Record<string, string>>;
34
+ now: number;
35
+ /** The held keys can store the CA and certificate. Default true. */
36
+ keysPuttable?: boolean;
37
+ /** The host supplies the backup bucket the database dumps into. Default true. */
38
+ backupConfigured?: boolean;
39
+ }
40
+ /**
41
+ * One `coolify:worker-app-db` step per org with a Worker app database: planned until the app and
42
+ * the "finished" marker are recorded, and again when the server certificate is due. A step that
43
+ * cannot succeed yet (no held-key put, no backup bucket) is a blocked line, not a step. The legacy
44
+ * Coolify database is never touched; a recorded one gets a note.
45
+ */
46
+ export declare function planWorkerAppDb(options: PlanWorkerAppDbOptions): {
47
+ step: DraftStep | null;
48
+ blocked: string | null;
49
+ note: string | null;
50
+ };
51
+ export interface ProvisionWorkerAppDbDeps {
52
+ coolify: PlatformAuthDeps['coolify'];
53
+ heldKeys: SigninDbKeys | undefined;
54
+ signinDbBackup: PlatformAuthDeps['signinDbBackup'];
55
+ runtime?: SigninDbRuntime;
56
+ }
57
+ /**
58
+ * Brings the org's Worker app database up (`provisionSigninDb` with the app database's names) and
59
+ * records the "finished" marker last, so a run that died halfway is planned again. Returns the CA's
60
+ * PEM (public) and the app id.
61
+ */
62
+ export declare function provisionWorkerAppDb(scope: StepScope, deps: ProvisionWorkerAppDbDeps, org: DeclaredOrg, input: {
63
+ project: ProjectSource;
64
+ label: string;
65
+ }): Promise<{
66
+ appId: string;
67
+ ca: string;
68
+ }>;
69
+ /** The org's CA certificate (public) from the held `{ cert, key }`, or null when none is held or it cannot be read. */
70
+ export declare function workerAppDbCaPem(keys: Pick<SigninDbKeys, 'resolve'> | undefined, orgId: string): Promise<{
71
+ pem: string;
72
+ } | null>;
73
+ /**
74
+ * The owner's address for foundry's own steps (migrations, roles): the app's internal host and the
75
+ * password in its env. A secret. The TLS settings are not part of the address; Hyperdrive gets the
76
+ * CA separately.
77
+ */
78
+ export declare function workerAppDbOwnerUrl(coolify: Pick<PlatformAuthDeps['coolify'], 'listEnvs'>, org: DeclaredOrg, appId: string): Promise<string>;
@@ -0,0 +1,154 @@
1
+ import { ORG_SERVICE_SCHEMAS, orgServiceDatabase } from '../org.js';
2
+ import { orgTarget } from './own-services.js';
3
+ import { signinDbCertDue, tlsDbScopeKeys } from './signin-db-tls.js';
4
+ import { SIGNIN_DB_ENV, SIGNIN_DB_PORT } from './signin-db.js';
5
+ import { dockerfileAppCreator, provisionSigninDb, withCallTimeout, } from './signin-db.js';
6
+ /**
7
+ * A Worker app's database as foundry's own TLS Postgres (the sign-in database's image), so that
8
+ * Cloudflare Hyperdrive connects with `verify-ca`. It replaces the Coolify database that a Worker
9
+ * app entry used to force: Coolify 4.4.2 cannot turn on a database's TLS.
10
+ *
11
+ * One app `<slug>-app-db` per org, user `app_owner`, the org's `app` database. The CA is the org's
12
+ * own, held under {@link workerAppDbCaRef}; the server certificate is renewed by planning the step
13
+ * again when it is due.
14
+ */
15
+ /** Recorded ids: the app, "the step finished", and the server certificate's expiry. */
16
+ export const WORKER_APP_DB_SCOPE_KEYS = tlsDbScopeKeys('workerapp_db');
17
+ /** `unmanaged` key of the one blocked line {@link planWorkerAppDb} reports. */
18
+ export const WORKER_APP_DB_BLOCKED_KEY = 'worker-app-db';
19
+ /** `unmanaged` key of the note that the legacy Coolify database is no longer used. */
20
+ export const WORKER_APP_DB_LEGACY_NOTE_KEY = 'worker-app-db-legacy';
21
+ export const WORKER_APP_DB_USER = 'app_owner';
22
+ export const WORKER_APP_DB_BACKUP_PREFIX = 'worker-app-db';
23
+ export const workerAppDbCaRef = (orgId) => `worker-app-db:${orgId}:ca`;
24
+ export const workerAppDbServerRef = (orgId) => `worker-app-db:${orgId}:server`;
25
+ export const workerAppDbName = (org) => `${org.slug}-app-db`;
26
+ /** The org's Worker app entries declare a database. */
27
+ export const orgHasWorkerAppDatabase = (org) => org.hosting.entries.some((entry) => Boolean(entry.workerApp?.database));
28
+ /** The database's name on the server: the org's `app` home, as the Postgres server would name it. */
29
+ export function workerAppDbDatabase(org) {
30
+ const home = ORG_SERVICE_SCHEMAS.find((h) => h.schema === 'app');
31
+ if (!home)
32
+ throw new Error('the org service schemas have no app home');
33
+ return orgServiceDatabase(org, home).database;
34
+ }
35
+ /**
36
+ * One `coolify:worker-app-db` step per org with a Worker app database: planned until the app and
37
+ * the "finished" marker are recorded, and again when the server certificate is due. A step that
38
+ * cannot succeed yet (no held-key put, no backup bucket) is a blocked line, not a step. The legacy
39
+ * Coolify database is never touched; a recorded one gets a note.
40
+ */
41
+ export function planWorkerAppDb(options) {
42
+ const { org, recordedIds } = options;
43
+ if (!orgHasWorkerAppDatabase(org))
44
+ return { step: null, blocked: null, note: null };
45
+ const note = recordedIds[`coolify:database:${org.id}`]
46
+ ? `${org.slug}'s Coolify database is no longer used by its Worker apps; it is left as it is`
47
+ : null;
48
+ const keys = WORKER_APP_DB_SCOPE_KEYS;
49
+ const done = Boolean(recordedIds[keys.app] && recordedIds[keys.tls]);
50
+ if (done && !signinDbCertDue(recordedIds, options.now, keys)) {
51
+ return { step: null, blocked: null, note };
52
+ }
53
+ if (options.keysPuttable === false) {
54
+ return {
55
+ step: null,
56
+ note,
57
+ blocked: `${org.slug}'s Worker app database is blocked, not planned: the host cannot store the database certificate (held keys have no put)`,
58
+ };
59
+ }
60
+ if (options.backupConfigured === false) {
61
+ return {
62
+ step: null,
63
+ note,
64
+ blocked: `${org.slug}'s Worker app database is blocked, not planned: the host supplies no backup bucket for the database`,
65
+ };
66
+ }
67
+ return {
68
+ blocked: null,
69
+ note,
70
+ step: {
71
+ action: { system: 'coolify', kind: 'worker-app-db', key: org.id, desired: {} },
72
+ step: {
73
+ description: done
74
+ ? `renew the server certificate of ${org.slug}'s Worker app database`
75
+ : `provision ${org.slug}'s Worker app database as a TLS Postgres`,
76
+ destroys: false,
77
+ migrates: false,
78
+ },
79
+ },
80
+ };
81
+ }
82
+ /**
83
+ * Brings the org's Worker app database up (`provisionSigninDb` with the app database's names) and
84
+ * records the "finished" marker last, so a run that died halfway is planned again. Returns the CA's
85
+ * PEM (public) and the app id.
86
+ */
87
+ export async function provisionWorkerAppDb(scope, deps, org, input) {
88
+ const coolify = withCallTimeout(deps.coolify);
89
+ const runtime = deps.runtime ?? {};
90
+ const name = workerAppDbName(org);
91
+ const keys = WORKER_APP_DB_SCOPE_KEYS;
92
+ const record = async (key, id) => {
93
+ scope.recordScopeId(key, id);
94
+ await scope.persist();
95
+ };
96
+ const projectUuid = await input.project.ensure(coolify, scope);
97
+ const { environment } = await coolify.findOrCreateEnvironment(projectUuid, 'production');
98
+ const db = await provisionSigninDb({
99
+ coolify,
100
+ keys: deps.heldKeys,
101
+ runtime,
102
+ name,
103
+ internalHost: name,
104
+ volumeName: `${name}-data`,
105
+ serverCertRef: workerAppDbServerRef(org.id),
106
+ slug: org.slug,
107
+ backup: deps.signinDbBackup,
108
+ backupBucket: orgTarget(org).names.signinBackupBucket,
109
+ recorded: scope.recordedIds(),
110
+ label: input.label,
111
+ environment,
112
+ record,
113
+ create: dockerfileAppCreator(coolify, {
114
+ projectUuid,
115
+ environment,
116
+ name,
117
+ description: `${org.slug}'s Worker app database with TLS`,
118
+ }),
119
+ }, {
120
+ appName: name,
121
+ user: WORKER_APP_DB_USER,
122
+ database: workerAppDbDatabase(org),
123
+ caRef: workerAppDbCaRef(org.id),
124
+ caCommonName: `foundry ${org.slug} app database CA`,
125
+ scopeKeys: keys,
126
+ backupPrefix: WORKER_APP_DB_BACKUP_PREFIX,
127
+ });
128
+ await record(keys.tls, db.appId);
129
+ return { appId: db.appId, ca: db.ca };
130
+ }
131
+ /** The org's CA certificate (public) from the held `{ cert, key }`, or null when none is held or it cannot be read. */
132
+ export async function workerAppDbCaPem(keys, orgId) {
133
+ const held = await keys?.resolve(workerAppDbCaRef(orgId));
134
+ if (!held)
135
+ return null;
136
+ try {
137
+ const cert = JSON.parse(held).cert;
138
+ return typeof cert === 'string' && cert ? { pem: cert } : null;
139
+ }
140
+ catch {
141
+ return null;
142
+ }
143
+ }
144
+ /**
145
+ * The owner's address for foundry's own steps (migrations, roles): the app's internal host and the
146
+ * password in its env. A secret. The TLS settings are not part of the address; Hyperdrive gets the
147
+ * CA separately.
148
+ */
149
+ export async function workerAppDbOwnerUrl(coolify, org, appId) {
150
+ const password = (await coolify.listEnvs(appId)).find((e) => e.key === SIGNIN_DB_ENV.password && !e.is_preview && e.value)?.value;
151
+ if (!password)
152
+ throw new Error('the database app holds no password yet');
153
+ return `postgres://${WORKER_APP_DB_USER}:${encodeURIComponent(password)}@${workerAppDbName(org)}:${SIGNIN_DB_PORT}/${workerAppDbDatabase(org)}`;
154
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.51.4",
3
+ "version": "0.52.0",
4
4
  "description": "Foundry's SDK: the declared org's types, the plan and apply contracts, and the state store's interface.",
5
5
  "repository": {
6
6
  "type": "git",