@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.
@@ -0,0 +1,255 @@
1
+ import { X509Certificate, createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomBytes, sign, } from 'node:crypto';
2
+ /**
3
+ * The certificate authority and server certificate of a company's sign-in
4
+ * database (#746). One CA serves the whole platform and is created once; each
5
+ * company's database gets its own server certificate, signed by it, for the
6
+ * database's internal hostname. Both are held in `@wtfalch/keys` as one JSON
7
+ * value each (`{ cert, key }`), so a CA is never half written.
8
+ *
9
+ * Node has no certificate builder, so the DER is written here: ECDSA P-256,
10
+ * SHA-256, X.509 v3, only the extensions Postgres clients check.
11
+ */
12
+ /** The platform's one CA, held as JSON `{ cert, key }` (PEM strings). */
13
+ export const SIGNIN_DB_CA_REF = 'signin-db:platform:ca';
14
+ /** The recorded id of the server certificate's expiry (milliseconds since the epoch), so the planner can renew it. Not a secret. */
15
+ export const SIGNIN_DB_CERT_NOT_AFTER_SCOPE_KEY = 'signin_db_cert_not_after';
16
+ /** Recorded id of the database app. */
17
+ export const SIGNIN_DB_APP_SCOPE_KEY = 'signin_db_app';
18
+ /**
19
+ * Recorded once ZITADEL's DSN names the TLS database: from then on the sign-in step
20
+ * provisions this database and never the Coolify one.
21
+ */
22
+ export const SIGNIN_DB_TLS_SCOPE_KEY = 'signin_db_tls';
23
+ /**
24
+ * Recorded while ZITADEL has been started on the TLS database and not yet seen healthy there: the
25
+ * planner then plans the sign-in step, which starts ZITADEL again and clears it (#746).
26
+ */
27
+ export const SIGNIN_DB_ZITADEL_PENDING_SCOPE_KEY = 'signin_db_zitadel_pending';
28
+ /** A server certificate with less than this left is issued again. */
29
+ export const SIGNIN_DB_CERT_RENEW_WINDOW_MS = 30 * 24 * 3_600_000;
30
+ const SERVER_CERT_VALIDITY_MS = 365 * 24 * 3_600_000;
31
+ 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';
34
+ // ---- DER ---------------------------------------------------------------------
35
+ const der = (tag, ...parts) => {
36
+ const body = Buffer.concat(parts);
37
+ const n = body.length;
38
+ const len = n < 0x80
39
+ ? Buffer.from([n])
40
+ : n < 0x100
41
+ ? Buffer.from([0x81, n])
42
+ : Buffer.from([0x82, n >> 8, n & 0xff]);
43
+ return Buffer.concat([Buffer.from([tag]), len, body]);
44
+ };
45
+ const seq = (...p) => der(0x30, ...p);
46
+ const set = (...p) => der(0x31, ...p);
47
+ const octets = (b) => der(0x04, b);
48
+ const bool = (v) => der(0x01, Buffer.from([v ? 0xff : 0x00]));
49
+ const int = (n) => {
50
+ const raw = typeof n === 'number' ? Buffer.from([n]) : n;
51
+ return der(0x02, raw[0] !== undefined && raw[0] >= 0x80 ? Buffer.concat([Buffer.from([0]), raw]) : raw);
52
+ };
53
+ const oid = (dotted) => {
54
+ const [a = 0, b = 0, ...rest] = dotted.split('.').map(Number);
55
+ const bytes = [a * 40 + b];
56
+ for (const n of rest) {
57
+ const chunk = [n & 0x7f];
58
+ for (let v = n >> 7; v > 0; v >>= 7)
59
+ chunk.unshift((v & 0x7f) | 0x80);
60
+ bytes.push(...chunk);
61
+ }
62
+ return der(0x06, Buffer.from(bytes));
63
+ };
64
+ const utf8 = (s) => der(0x0c, Buffer.from(s, 'utf8'));
65
+ /** RFC 5280 4.1.2.5: UTCTime up to the year 2049, GeneralizedTime from 2050. */
66
+ const utc = (ms) => {
67
+ const d = new Date(ms);
68
+ const p = (n) => String(n).padStart(2, '0');
69
+ const year = d.getUTCFullYear();
70
+ const rest = `${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}Z`;
71
+ return year >= 2050
72
+ ? der(0x18, Buffer.from(`${year}${rest}`))
73
+ : der(0x17, Buffer.from(`${p(year % 100)}${rest}`));
74
+ };
75
+ const name = (cn) => seq(set(seq(oid('2.5.4.3'), utf8(cn))));
76
+ const extension = (id, critical, value) => seq(oid(id), ...(critical ? [bool(true)] : []), octets(value));
77
+ const ECDSA_SHA256 = '1.2.840.10045.4.3.2';
78
+ const pem = (label, derBytes) => {
79
+ const lines = derBytes.toString('base64').match(/.{1,64}/g) ?? [];
80
+ return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`;
81
+ };
82
+ /** The hash of the 65-byte public point, the way RFC 5280 names a key. */
83
+ const keyId = (publicKey) => createHash('sha1')
84
+ .update(publicKey.export({ type: 'spki', format: 'der' }).subarray(-65))
85
+ .digest();
86
+ function certificate(input) {
87
+ const serial = randomBytes(16);
88
+ // 0x40 bit set, 0x80 clear: positive, and no leading zero byte (DER rejects a padded INTEGER).
89
+ serial[0] = ((serial[0] ?? 0) & 0x3f) | 0x40;
90
+ const sigAlg = seq(oid(ECDSA_SHA256));
91
+ const tbs = seq(der(0xa0, int(2)), int(serial), sigAlg, name(input.issuer), seq(utc(input.notBefore), utc(input.notAfter)), name(input.subject), Buffer.from(input.publicKey.export({ type: 'spki', format: 'der' })), der(0xa3, seq(...input.extensions)));
92
+ const signature = sign('sha256', tbs, input.signingKey);
93
+ return pem('CERTIFICATE', seq(tbs, sigAlg, der(0x03, Buffer.concat([Buffer.from([0]), signature]))));
94
+ }
95
+ const subjectKeyId = (publicKey) => extension('2.5.29.14', false, octets(keyId(publicKey)));
96
+ const authorityKeyId = (publicKey) => extension('2.5.29.35', false, seq(der(0x80, keyId(publicKey))));
97
+ function newKeyPair() {
98
+ const { publicKey, privateKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
99
+ return {
100
+ publicKey,
101
+ privateKey,
102
+ keyPem: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
103
+ };
104
+ }
105
+ // ---- the CA ------------------------------------------------------------------
106
+ function newCa(now) {
107
+ const { publicKey, privateKey, keyPem } = newKeyPair();
108
+ const certPem = certificate({
109
+ subject: CA_COMMON_NAME,
110
+ issuer: CA_COMMON_NAME,
111
+ publicKey,
112
+ signingKey: privateKey,
113
+ notBefore: now - 3_600_000,
114
+ notAfter: now + CA_VALIDITY_MS,
115
+ extensions: [
116
+ extension('2.5.29.19', true, seq(bool(true), int(0))),
117
+ extension('2.5.29.15', true, der(0x03, Buffer.from([0x01, 0x06]))),
118
+ subjectKeyId(publicKey),
119
+ ],
120
+ });
121
+ return { certPem, keyPem };
122
+ }
123
+ function readPair(held) {
124
+ if (!held)
125
+ return null;
126
+ try {
127
+ const parsed = JSON.parse(held);
128
+ if (typeof parsed.cert !== 'string' || typeof parsed.key !== 'string')
129
+ return null;
130
+ return { certPem: parsed.cert, keyPem: parsed.key };
131
+ }
132
+ catch {
133
+ return null;
134
+ }
135
+ }
136
+ /** True when `pair` is a certificate with its own private key. */
137
+ function pairMatches(pair) {
138
+ try {
139
+ return new X509Certificate(pair.certPem).checkPrivateKey(createPrivateKey(pair.keyPem));
140
+ }
141
+ catch {
142
+ return false;
143
+ }
144
+ }
145
+ /**
146
+ * 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
148
+ * certificate does not match its key, is an error and is never replaced: a
149
+ * company's database may already trust it. The value read back after a store
150
+ * is the one used, so two applies at once end with the same CA.
151
+ */
152
+ export async function ensureSigninDbCa(keys, runtime = {},
153
+ /** 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);
156
+ if (held) {
157
+ const pair = readPair(held);
158
+ if (!pair)
159
+ throw new Error('the held sign-in database CA cannot be read; it is not replaced');
160
+ runtime.onSecret?.(pair.keyPem);
161
+ if (!pairMatches(pair)) {
162
+ throw new Error('the held sign-in database CA is not a certificate with its own key; it is not replaced');
163
+ }
164
+ return pair;
165
+ }
166
+ 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")`);
168
+ }
169
+ if (!keys.put)
170
+ throw new Error('the host cannot store the sign-in database CA: heldKeys has no put');
171
+ const created = newCa((runtime.now ?? Date.now)());
172
+ 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));
175
+ if (!stored)
176
+ throw new Error('the sign-in database CA was stored but cannot be read back');
177
+ runtime.onSecret?.(stored.keyPem);
178
+ return stored;
179
+ }
180
+ // ---- the server certificate ----------------------------------------------------
181
+ function newServerCert(ca, host, now) {
182
+ const { publicKey, keyPem } = newKeyPair();
183
+ const caCert = new X509Certificate(ca.certPem);
184
+ const notAfter = now + SERVER_CERT_VALIDITY_MS;
185
+ const certPem = certificate({
186
+ subject: host,
187
+ issuer: CA_COMMON_NAME,
188
+ publicKey,
189
+ signingKey: createPrivateKey(ca.keyPem),
190
+ notBefore: now - 3_600_000,
191
+ notAfter,
192
+ extensions: [
193
+ extension('2.5.29.19', true, seq()),
194
+ extension('2.5.29.15', true, der(0x03, Buffer.from([0x07, 0x80]))),
195
+ extension('2.5.29.37', false, seq(oid('1.3.6.1.5.5.7.3.1'))),
196
+ extension('2.5.29.17', false, seq(der(0x82, Buffer.from(host, 'ascii')))),
197
+ subjectKeyId(publicKey),
198
+ authorityKeyId(caCert.publicKey),
199
+ ],
200
+ });
201
+ return { certPem, keyPem, notAfter };
202
+ }
203
+ /** Whether a held server certificate can still be used: right host, our CA, a matching key, and more than the renew window left. */
204
+ function reusable(pair, ca, host, now) {
205
+ try {
206
+ const cert = new X509Certificate(pair.certPem);
207
+ const notAfter = cert.validToDate.getTime();
208
+ const ok = cert.checkPrivateKey(createPrivateKey(pair.keyPem)) &&
209
+ cert.checkHost(host) === host &&
210
+ cert.verify(createPublicKey(ca.certPem)) &&
211
+ notAfter - now > SIGNIN_DB_CERT_RENEW_WINDOW_MS &&
212
+ cert.validFromDate.getTime() <= now;
213
+ return ok ? notAfter : null;
214
+ }
215
+ catch {
216
+ return null;
217
+ }
218
+ }
219
+ /**
220
+ * The server certificate held under `ref` for `host`, issued again when it is
221
+ * missing, unreadable, for another host, signed by another CA, or has less
222
+ * than {@link SIGNIN_DB_CERT_RENEW_WINDOW_MS} left. The read-back after a
223
+ * store is the one used.
224
+ */
225
+ export async function ensureSigninDbServerCert(keys, ca, ref, host, runtime = {}) {
226
+ const now = (runtime.now ?? Date.now)();
227
+ const held = readPair(await keys.resolve(ref));
228
+ if (held) {
229
+ runtime.onSecret?.(held.keyPem);
230
+ const notAfter = reusable(held, ca, host, now);
231
+ if (notAfter !== null)
232
+ return { ...held, notAfter, issued: false };
233
+ }
234
+ if (!keys.put)
235
+ throw new Error('the host cannot store the sign-in database certificate: heldKeys has no put');
236
+ const created = newServerCert(ca, host, now);
237
+ runtime.onSecret?.(created.keyPem);
238
+ await keys.put(ref, JSON.stringify({ cert: created.certPem, key: created.keyPem }));
239
+ const stored = readPair(await keys.resolve(ref));
240
+ if (!stored)
241
+ throw new Error('the sign-in database certificate was stored but cannot be read back');
242
+ runtime.onSecret?.(stored.keyPem);
243
+ const notAfter = reusable(stored, ca, host, now);
244
+ if (notAfter === null)
245
+ throw new Error('the stored sign-in database certificate is not usable');
246
+ return { ...stored, notAfter, issued: true };
247
+ }
248
+ /**
249
+ * Whether the recorded server certificate expires within the renew window.
250
+ * Nothing recorded (a company not on the TLS database) is never due.
251
+ */
252
+ export function signinDbCertDue(recordedIds, now) {
253
+ const notAfter = Number(recordedIds[SIGNIN_DB_CERT_NOT_AFTER_SCOPE_KEY]);
254
+ return (Number.isFinite(notAfter) && notAfter > 0 && notAfter - now < SIGNIN_DB_CERT_RENEW_WINDOW_MS);
255
+ }
@@ -0,0 +1,114 @@
1
+ import type { CoolifyPort } from './executor.js';
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';
4
+ /**
5
+ * A company's sign-in database as foundry's own image (#746). Coolify 4.4.2
6
+ * cannot turn on a database's TLS and mounts files root-owned, so Postgres
7
+ * would refuse a mounted key. The database is therefore a Dockerfile app: the
8
+ * pinned `postgres` image, an entrypoint that writes the certificate and key
9
+ * from env to files owned by `postgres` with mode 0600, and Postgres started
10
+ * with `ssl=on`. ZITADEL connects with `sslmode=verify-full` and the CA.
11
+ */
12
+ export { SIGNIN_DB_APP_SCOPE_KEY, SIGNIN_DB_BACKUP_TASK_SCOPE_KEY, SIGNIN_DB_TLS_SCOPE_KEY };
13
+ export declare const SIGNIN_DB_PORT = "5432";
14
+ export declare const SIGNIN_DB_USER = "zitadel";
15
+ export declare const SIGNIN_DB_NAME = "zitadel";
16
+ /** Where the official image keeps its data (`PGDATA`); the volume is mounted here. */
17
+ export declare const SIGNIN_DB_DATA_DIR = "/var/lib/postgresql/data";
18
+ /** Where ZITADEL's container finds the CA (a Coolify file mount: the CA is public, and its image has no shell to write it). */
19
+ export declare const SIGNIN_DB_CA_MOUNT = "/etc/signin-db/ca.crt";
20
+ export declare const SIGNIN_DB_ENV: {
21
+ readonly user: "POSTGRES_USER";
22
+ readonly password: "POSTGRES_PASSWORD";
23
+ readonly database: "POSTGRES_DB";
24
+ readonly cert: "SIGNIN_DB_TLS_CERT_B64";
25
+ readonly key: "SIGNIN_DB_TLS_KEY_B64";
26
+ };
27
+ /** How long one Coolify call of these steps may take: the client has no timeout of its own. */
28
+ export declare const SIGNIN_DB_CALL_TIMEOUT_MS = 60000;
29
+ /** `work`, or an error naming `what` after {@link SIGNIN_DB_CALL_TIMEOUT_MS}. The call itself is not cancelled. */
30
+ export declare function boundedCall<T>(work: Promise<T>, what: string): Promise<T>;
31
+ /** The port with every method bounded by {@link boundedCall}. */
32
+ export declare function withCallTimeout<T extends object>(port: T): T;
33
+ /**
34
+ * The entrypoint, one line each. No line holds a single quote: the Dockerfile
35
+ * writes each one inside single quotes. The key file is written by `printf` to
36
+ * a path only root can reach and then handed to `postgres` with mode 0600, the
37
+ * check Postgres makes before it loads a key.
38
+ */
39
+ export declare function signinDbStartScript(): string[];
40
+ /**
41
+ * The inline Dockerfile. `local` connections stay trusted (inside the container only, as in the
42
+ * official image); every network connection must be TLS with a password, so a client that skips
43
+ * TLS is refused by the server, not only by the client's own setting.
44
+ */
45
+ export declare function signinDbDockerfile(): string;
46
+ /** 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;
48
+ type Coolify = Pick<CoolifyPort, 'findApplicationByName' | 'getApplication' | 'updateApplication' | 'bulkUpsertEnvs' | 'listEnvs' | 'deploy' | 'raw'> & Partial<Pick<CoolifyPort, 'listStorages' | 'createPersistentStorage' | 'createFileStorage'>>;
49
+ export interface SigninDbRuntime extends SigninDbTlsRuntime {
50
+ sleep?: (ms: number) => Promise<void>;
51
+ /** How long a new or redeployed database may take to report healthy (default 5 minutes). */
52
+ signinDbTimeoutMs?: number;
53
+ }
54
+ /**
55
+ * Polls until Coolify reports the app `running:healthy`. A healthy status read before the app was
56
+ * ever seen leaving it could be the container being replaced, so it counts only after two polls in
57
+ * a row. Throws at the deadline; the message names the app and its last status.
58
+ */
59
+ export declare function waitHealthy(coolify: Pick<CoolifyPort, 'getApplication'>, appId: string, runtime: SigninDbRuntime, label: string, what?: string): Promise<void>;
60
+ /** Makes a Dockerfile app on the first usable server, without a domain: the caller's `create`. */
61
+ export declare function dockerfileAppCreator(coolify: Pick<CoolifyPort, 'listServers' | 'createDockerfileApplication'>, o: {
62
+ projectUuid: string;
63
+ environment: {
64
+ name: string;
65
+ uuid: string;
66
+ };
67
+ name: string;
68
+ description: string;
69
+ }): (dockerfile: string) => Promise<{
70
+ uuid: string;
71
+ }>;
72
+ export interface ProvisionSigninDbInput {
73
+ coolify: Coolify;
74
+ keys: SigninDbKeys | undefined;
75
+ runtime: SigninDbRuntime;
76
+ /** The Coolify name and internal hostname of the database app. */
77
+ name: string;
78
+ internalHost: string;
79
+ /** The volume's name. */
80
+ volumeName: string;
81
+ /** The ref the server certificate is held under. */
82
+ serverCertRef: string;
83
+ /** The company's slug: the nightly dump's folder in the bucket. */
84
+ slug: string;
85
+ /** The host's bucket and key ports. Without them the database is not created. */
86
+ backup: SigninDbBackupPort | undefined;
87
+ /** The company's own backup bucket. */
88
+ backupBucket: string;
89
+ /** The company's recorded ids when the step started. */
90
+ recorded: Readonly<Record<string, string>>;
91
+ label: string;
92
+ create(dockerfile: string): Promise<{
93
+ uuid: string;
94
+ }>;
95
+ environment: {
96
+ id: number;
97
+ };
98
+ record(key: string, id: string): Promise<void>;
99
+ }
100
+ /**
101
+ * Brings the TLS database up and returns ZITADEL's DSN: the CA and server certificate (held in
102
+ * keys), the app with its data volume and no domain, its env (the password minted once and read
103
+ * back after), and a deploy when the app is new, stopped or its env changed. Returns after the
104
+ * database reports healthy. A secret (password, key) goes only to the app's env and to
105
+ * `runtime.onSecret`.
106
+ */
107
+ export declare function provisionSigninDb(input: ProvisionSigninDbInput): Promise<{
108
+ appId: string;
109
+ dsn: string;
110
+ ca: string;
111
+ password: string;
112
+ }>;
113
+ /** Mounts the CA on ZITADEL's app, once. The CA is public and never changes, so an existing mount is left alone. */
114
+ export declare function ensureSigninDbCaFile(port: Coolify, zitadelAppId: string, caPem: string, label: string): Promise<void>;
@@ -0,0 +1,255 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { pinnedRef } from '../bootstrap/versions.js';
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';
6
+ /**
7
+ * A company's sign-in database as foundry's own image (#746). Coolify 4.4.2
8
+ * cannot turn on a database's TLS and mounts files root-owned, so Postgres
9
+ * would refuse a mounted key. The database is therefore a Dockerfile app: the
10
+ * pinned `postgres` image, an entrypoint that writes the certificate and key
11
+ * from env to files owned by `postgres` with mode 0600, and Postgres started
12
+ * with `ssl=on`. ZITADEL connects with `sslmode=verify-full` and the CA.
13
+ */
14
+ export { SIGNIN_DB_APP_SCOPE_KEY, SIGNIN_DB_BACKUP_TASK_SCOPE_KEY, SIGNIN_DB_TLS_SCOPE_KEY };
15
+ export const SIGNIN_DB_PORT = '5432';
16
+ export const SIGNIN_DB_USER = 'zitadel';
17
+ export const SIGNIN_DB_NAME = 'zitadel';
18
+ /** Where the official image keeps its data (`PGDATA`); the volume is mounted here. */
19
+ export const SIGNIN_DB_DATA_DIR = '/var/lib/postgresql/data';
20
+ /** Where ZITADEL's container finds the CA (a Coolify file mount: the CA is public, and its image has no shell to write it). */
21
+ export const SIGNIN_DB_CA_MOUNT = '/etc/signin-db/ca.crt';
22
+ /** Inside the database container: the certificate, key and host rules the entrypoint writes. */
23
+ const TLS_DIR = '/etc/signin-db';
24
+ export const SIGNIN_DB_ENV = {
25
+ user: 'POSTGRES_USER',
26
+ password: 'POSTGRES_PASSWORD',
27
+ database: 'POSTGRES_DB',
28
+ cert: 'SIGNIN_DB_TLS_CERT_B64',
29
+ key: 'SIGNIN_DB_TLS_KEY_B64',
30
+ };
31
+ /** How long one Coolify call of these steps may take: the client has no timeout of its own. */
32
+ export const SIGNIN_DB_CALL_TIMEOUT_MS = 60_000;
33
+ /** `work`, or an error naming `what` after {@link SIGNIN_DB_CALL_TIMEOUT_MS}. The call itself is not cancelled. */
34
+ export async function boundedCall(work, what) {
35
+ let timer;
36
+ try {
37
+ return await Promise.race([
38
+ work,
39
+ new Promise((_, reject) => {
40
+ timer = setTimeout(() => reject(new Error(`${what} did not answer within ${SIGNIN_DB_CALL_TIMEOUT_MS / 1000}s`)), SIGNIN_DB_CALL_TIMEOUT_MS);
41
+ }),
42
+ ]);
43
+ }
44
+ finally {
45
+ clearTimeout(timer);
46
+ }
47
+ }
48
+ /** The port with every method bounded by {@link boundedCall}. */
49
+ export function withCallTimeout(port) {
50
+ return new Proxy(port, {
51
+ get(target, prop) {
52
+ const value = Reflect.get(target, prop, target);
53
+ if (typeof value !== 'function')
54
+ return value;
55
+ return (...args) => boundedCall(Promise.resolve(value.apply(target, args)), `Coolify ${String(prop)}`);
56
+ },
57
+ });
58
+ }
59
+ /** How long a new or redeployed database may take to report healthy. */
60
+ const DB_READY_TIMEOUT_MS = 300_000;
61
+ const DB_READY_INTERVAL_MS = 5_000;
62
+ /**
63
+ * The entrypoint, one line each. No line holds a single quote: the Dockerfile
64
+ * writes each one inside single quotes. The key file is written by `printf` to
65
+ * a path only root can reach and then handed to `postgres` with mode 0600, the
66
+ * check Postgres makes before it loads a key.
67
+ */
68
+ export function signinDbStartScript() {
69
+ const files = `${TLS_DIR}/server.crt ${TLS_DIR}/server.key ${TLS_DIR}/pg_hba.conf`;
70
+ return [
71
+ '#!/bin/sh',
72
+ 'set -eu',
73
+ `mkdir -p ${TLS_DIR}`,
74
+ `printf %s "$${SIGNIN_DB_ENV.cert}" | base64 -d > ${TLS_DIR}/server.crt`,
75
+ `printf %s "$${SIGNIN_DB_ENV.key}" | base64 -d > ${TLS_DIR}/server.key`,
76
+ `printf "%s\\n" "local all all trust" "hostssl all all all scram-sha-256" > ${TLS_DIR}/pg_hba.conf`,
77
+ `chown postgres:postgres ${files}`,
78
+ `chmod 0600 ${files}`,
79
+ `exec docker-entrypoint.sh postgres -c ssl=on -c ssl_min_protocol_version=TLSv1.2 -c ssl_cert_file=${TLS_DIR}/server.crt -c ssl_key_file=${TLS_DIR}/server.key -c hba_file=${TLS_DIR}/pg_hba.conf`,
80
+ ];
81
+ }
82
+ /**
83
+ * The inline Dockerfile. `local` connections stay trusted (inside the container only, as in the
84
+ * official image); every network connection must be TLS with a password, so a client that skips
85
+ * TLS is refused by the server, not only by the client's own setting.
86
+ */
87
+ export function signinDbDockerfile() {
88
+ const lines = signinDbStartScript();
89
+ if (lines.some((l) => l.includes("'")))
90
+ throw new Error('signin-db entrypoint holds a quote');
91
+ return [
92
+ `FROM postgres:${pinnedRef('postgres')}`,
93
+ // curl signs and puts the nightly dump into the backup bucket (`signin-db-backup.ts`).
94
+ 'RUN apk add --no-cache curl',
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
+ `EXPOSE ${SIGNIN_DB_PORT}`,
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}"]`,
99
+ ].join('\n');
100
+ }
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}`;
104
+ }
105
+ /** The volume and file-mount calls, or an error naming the step: a host that cannot mount cannot run this database. */
106
+ function storageCalls(coolify, label) {
107
+ const { listStorages, createPersistentStorage, createFileStorage } = coolify;
108
+ if (!listStorages || !createPersistentStorage || !createFileStorage) {
109
+ throw new Error(`${label}: the host's Coolify port cannot mount the sign-in database's volume`);
110
+ }
111
+ return {
112
+ listStorages: listStorages.bind(coolify),
113
+ createPersistentStorage: createPersistentStorage.bind(coolify),
114
+ createFileStorage: createFileStorage.bind(coolify),
115
+ };
116
+ }
117
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
118
+ /**
119
+ * Polls until Coolify reports the app `running:healthy`. A healthy status read before the app was
120
+ * ever seen leaving it could be the container being replaced, so it counts only after two polls in
121
+ * a row. Throws at the deadline; the message names the app and its last status.
122
+ */
123
+ export async function waitHealthy(coolify, appId, runtime, label, what = 'the sign-in database') {
124
+ const now = runtime.now ?? Date.now;
125
+ const sleep = runtime.sleep ?? defaultSleep;
126
+ const timeout = runtime.signinDbTimeoutMs ?? DB_READY_TIMEOUT_MS;
127
+ const deadline = now() + timeout;
128
+ let left = false;
129
+ let healthyPolls = 0;
130
+ for (;;) {
131
+ const { status } = await coolify.getApplication(appId);
132
+ if (status === 'running:healthy') {
133
+ healthyPolls += 1;
134
+ if (left || healthyPolls >= 2)
135
+ return;
136
+ }
137
+ else {
138
+ left = true;
139
+ healthyPolls = 0;
140
+ }
141
+ if (now() >= deadline) {
142
+ throw new Error(`${label}: ${what} (${appId}) did not report healthy within ${Math.round(timeout / 1000)}s (last status: ${status ?? 'unknown'}); check its logs and apply again`);
143
+ }
144
+ await sleep(DB_READY_INTERVAL_MS);
145
+ }
146
+ }
147
+ /** Makes a Dockerfile app on the first usable server, without a domain: the caller's `create`. */
148
+ export function dockerfileAppCreator(coolify, o) {
149
+ return async (dockerfile) => {
150
+ const server = await resolvePlatformServer(coolify);
151
+ return coolify.createDockerfileApplication({
152
+ project_uuid: o.projectUuid,
153
+ server_uuid: server.uuid,
154
+ environment_name: o.environment.name,
155
+ environment_uuid: o.environment.uuid,
156
+ dockerfile,
157
+ name: o.name,
158
+ description: o.description,
159
+ instant_deploy: false,
160
+ autogenerate_domain: false,
161
+ });
162
+ };
163
+ }
164
+ /**
165
+ * Brings the TLS database up and returns ZITADEL's DSN: the CA and server certificate (held in
166
+ * keys), the app with its data volume and no domain, its env (the password minted once and read
167
+ * back after), and a deploy when the app is new, stopped or its env changed. Returns after the
168
+ * database reports healthy. A secret (password, key) goes only to the app's env and to
169
+ * `runtime.onSecret`.
170
+ */
171
+ export async function provisionSigninDb(input) {
172
+ const { runtime, label } = input;
173
+ const coolify = withCallTimeout(input.coolify);
174
+ if (!input.keys?.put) {
175
+ throw new Error(`${label}: the host's held keys cannot store the sign-in database certificate`);
176
+ }
177
+ if (!input.backup) {
178
+ throw new Error(`${label}: the host supplies no backup bucket for the sign-in database`);
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]));
183
+ 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);
186
+ // No `ports_mappings` and no `domains`: only the Docker network reaches the port.
187
+ await coolify.updateApplication(app.appId, {
188
+ custom_internal_name: input.internalHost,
189
+ ports_exposes: SIGNIN_DB_PORT,
190
+ health_check_enabled: false,
191
+ });
192
+ const calls = storageCalls(coolify, label);
193
+ const storages = await calls.listStorages(app.appId);
194
+ if (!(storages.persistent_storages ?? []).some((s) => s.mount_path === SIGNIN_DB_DATA_DIR)) {
195
+ await calls.createPersistentStorage(app.appId, {
196
+ name: input.volumeName,
197
+ mount_path: SIGNIN_DB_DATA_DIR,
198
+ });
199
+ }
200
+ // The password is minted once: the data directory keeps the one it was started with.
201
+ const heldEnvs = await coolify.listEnvs(app.appId);
202
+ const existing = heldEnvs.find((e) => e.key === SIGNIN_DB_ENV.password && !e.is_preview && e.value);
203
+ const heldValue = (key) => heldEnvs.find((e) => e.key === key && !e.is_preview && e.value)?.value;
204
+ const password = existing?.value ?? randomBytes(24).toString('base64url');
205
+ runtime.onSecret?.(password);
206
+ // The company's own bucket and a key that reaches it alone, never the platform's.
207
+ const backup = await ensureSigninDbBackupTarget(input.backup, {
208
+ bucketName: input.backupBucket,
209
+ label,
210
+ recorded: input.recorded,
211
+ held: {
212
+ keyId: heldValue(SIGNIN_DB_BACKUP_ENV.keyId),
213
+ secret: heldValue(SIGNIN_DB_BACKUP_ENV.secret),
214
+ },
215
+ record: input.record,
216
+ onSecret: runtime.onSecret,
217
+ });
218
+ const b64 = (pem) => Buffer.from(pem, 'utf8').toString('base64');
219
+ runtime.onSecret?.(b64(server.keyPem));
220
+ const envs = [
221
+ { key: SIGNIN_DB_ENV.user, value: SIGNIN_DB_USER },
222
+ { key: SIGNIN_DB_ENV.password, value: password },
223
+ { key: SIGNIN_DB_ENV.database, value: SIGNIN_DB_NAME },
224
+ { key: SIGNIN_DB_ENV.cert, value: b64(server.certPem) },
225
+ { key: SIGNIN_DB_ENV.key, value: b64(server.keyPem) },
226
+ ...signinDbBackupEnvs(backup.target, input.slug),
227
+ ];
228
+ const changed = await envsDiffer(coolify, app.appId, envs);
229
+ await coolify.bulkUpsertEnvs(app.appId, envs);
230
+ if (backup.fresh) {
231
+ await input.record(SIGNIN_DB_BACKUP_KEY_SCOPE_KEY, backup.tokenId);
232
+ await input.record(SIGNIN_DB_BACKUP_KEY_UNDELIVERED_SCOPE_KEY, '');
233
+ }
234
+ if (app.justCreated || !app.running || changed) {
235
+ await coolify.deploy(app.appId);
236
+ await waitHealthy(coolify, app.appId, runtime, label);
237
+ }
238
+ await input.record(SIGNIN_DB_CERT_NOT_AFTER_SCOPE_KEY, String(server.notAfter));
239
+ // The database is not used, and ZITADEL is not stopped for a move, until its nightly dump is scheduled.
240
+ await input.record(SIGNIN_DB_BACKUP_TASK_SCOPE_KEY, await ensureSigninDbBackupTask(coolify, app.appId, label, {
241
+ user: SIGNIN_DB_USER,
242
+ database: SIGNIN_DB_NAME,
243
+ }));
244
+ const dsn = signinDbDsn(input.internalHost, password);
245
+ runtime.onSecret?.(dsn);
246
+ return { appId: app.appId, dsn, ca: ca.certPem, password };
247
+ }
248
+ /** Mounts the CA on ZITADEL's app, once. The CA is public and never changes, so an existing mount is left alone. */
249
+ export async function ensureSigninDbCaFile(port, zitadelAppId, caPem, label) {
250
+ const calls = storageCalls(withCallTimeout(port), label);
251
+ const storages = await calls.listStorages(zitadelAppId);
252
+ if ((storages.file_storages ?? []).some((s) => s.mount_path === SIGNIN_DB_CA_MOUNT))
253
+ return;
254
+ await calls.createFileStorage(zitadelAppId, { mount_path: SIGNIN_DB_CA_MOUNT, content: caPem });
255
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.38.0",
3
+ "version": "0.39.1",
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",
@@ -7,5 +7,6 @@
7
7
  "stalwart": "sha256:ec011be228596e37e65f41aab17deed573859614430472f7eeb42178c50d87b7",
8
8
  "hc": "sha256:aa08a61b0dcfd83f1332eab195d5cec1b595ae076eb8388cadf8a3d597114f9b",
9
9
  "alpine": "sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8c44de311d1",
10
- "busybox": "sha256:5cec3fc171c87218698e85a52af7087de727372aae264a787b8112901a5b0092"
10
+ "busybox": "sha256:5cec3fc171c87218698e85a52af7087de727372aae264a787b8112901a5b0092",
11
+ "postgres": "sha256:786dab398303b8ce7cb76b407bb21ef2e4dfbbbd4c6abcf3d29b3130467ffdbc"
11
12
  }
package/versions.json CHANGED
@@ -7,5 +7,6 @@
7
7
  "stalwart": "v0.16.24",
8
8
  "hc": "v4.4",
9
9
  "alpine": "3.22.1",
10
- "busybox": "1.37.0-musl"
10
+ "busybox": "1.37.0-musl",
11
+ "postgres": "16.14-alpine3.22"
11
12
  }