@wtfalch/foundry 0.42.1 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/dist/bootstrap/versions.d.ts +2 -0
  2. package/dist/index.d.ts +8 -4
  3. package/dist/index.js +4 -2
  4. package/dist/migrations/0055_release_requests.sql +20 -0
  5. package/dist/migrations/0056_platform_archon_blue_green.sql +7 -0
  6. package/dist/migrations/0057_org_customers.sql +29 -0
  7. package/dist/org.d.ts +55 -0
  8. package/dist/org.js +90 -27
  9. package/dist/plan/archon-blue-green.d.ts +54 -0
  10. package/dist/plan/archon-blue-green.js +72 -0
  11. package/dist/plan/blue-green-apply.d.ts +82 -0
  12. package/dist/plan/blue-green-apply.js +360 -0
  13. package/dist/plan/blue-green.d.ts +223 -0
  14. package/dist/plan/blue-green.js +444 -0
  15. package/dist/plan/boule.d.ts +43 -2
  16. package/dist/plan/boule.js +48 -1
  17. package/dist/plan/broker-entries.d.ts +28 -0
  18. package/dist/plan/broker-entries.js +46 -1
  19. package/dist/plan/cloudflared-connector.d.ts +26 -0
  20. package/dist/plan/cloudflared-connector.js +37 -0
  21. package/dist/plan/diff.d.ts +20 -0
  22. package/dist/plan/diff.js +207 -20
  23. package/dist/plan/executor.d.ts +134 -2
  24. package/dist/plan/executor.js +517 -25
  25. package/dist/plan/hostnames.d.ts +7 -0
  26. package/dist/plan/hostnames.js +17 -2
  27. package/dist/plan/locked-hostnames.d.ts +11 -0
  28. package/dist/plan/locked-hostnames.js +13 -0
  29. package/dist/plan/mcp.d.ts +57 -0
  30. package/dist/plan/mcp.js +131 -0
  31. package/dist/plan/org-names.d.ts +2 -2
  32. package/dist/plan/org-names.js +16 -1
  33. package/dist/plan/plan-apply.d.ts +77 -0
  34. package/dist/plan/plan-apply.js +267 -5
  35. package/dist/plan/platform-diff.d.ts +9 -0
  36. package/dist/plan/platform-diff.js +24 -1
  37. package/dist/plan/platform-providers.d.ts +15 -1
  38. package/dist/plan/platform-providers.js +90 -5
  39. package/dist/plan/proxy-labels.d.ts +2 -2
  40. package/dist/plan/proxy-labels.js +14 -4
  41. package/dist/plan/release-host.d.ts +59 -0
  42. package/dist/plan/release-host.js +239 -0
  43. package/dist/plan/remote-database.d.ts +124 -0
  44. package/dist/plan/remote-database.js +373 -0
  45. package/dist/plan/worker-app-database.d.ts +148 -0
  46. package/dist/plan/worker-app-database.js +424 -0
  47. package/dist/plan/worker-app.d.ts +320 -0
  48. package/dist/plan/worker-app.js +792 -0
  49. package/dist/plan/zitadel-machine-user.d.ts +13 -1
  50. package/dist/plan/zitadel-machine-user.js +80 -0
  51. package/dist/platform.d.ts +11 -1
  52. package/dist/platform.js +4 -1
  53. package/dist/release/decide.d.ts +95 -0
  54. package/dist/release/decide.js +242 -0
  55. package/dist/release/health.d.ts +48 -0
  56. package/dist/release/health.js +61 -0
  57. package/dist/release/run.d.ts +40 -0
  58. package/dist/release/run.js +56 -0
  59. package/dist/release/state.d.ts +64 -0
  60. package/dist/release/state.js +126 -0
  61. package/dist/release/types.d.ts +54 -0
  62. package/dist/release/types.js +4 -0
  63. package/dist/requested-changes.d.ts +12 -3
  64. package/dist/requested-changes.js +22 -0
  65. package/dist/store/postgres.d.ts +8 -0
  66. package/dist/store/postgres.js +67 -8
  67. package/dist/store/schema.d.ts +188 -0
  68. package/dist/store/schema.js +15 -0
  69. package/dist/worker-app-config.d.ts +151 -0
  70. package/dist/worker-app-config.js +438 -0
  71. package/package.json +1 -1
  72. package/versions.digests.json +2 -1
  73. package/versions.json +2 -1
@@ -0,0 +1,373 @@
1
+ import { pinnedRef } from '../bootstrap/versions.js';
2
+ import { PRIMARY_SERVER_SCOPE_KEY, splitServers } from './blue-green.js';
3
+ import { isNotFoundError } from './executor.js';
4
+ import { appNameFor } from './platform-diff.js';
5
+ /**
6
+ * A blue/green app on a server other than the database's reaches its one database through a
7
+ * Cloudflare tunnel TCP route behind an Access service token (William, 2026-10-15). The tunnel has a
8
+ * `tcp://<org>-db:5432` rule on an internal hostname; Access lets only the service token in; a
9
+ * `cloudflared access tcp` sidecar on each other server listens on 5432 under the name
10
+ * {@link remoteDatabaseHost}.
11
+ */
12
+ export const REMOTE_DATABASE_PORT = 5432;
13
+ const DELIVERY_ATTEMPTS = 3;
14
+ /** The sidecar's name on the Docker network of `serverUuid`, or `null` on the database's own server. */
15
+ export function remoteDatabaseHost(entryKey, serverUuid, primaryUuid) {
16
+ return serverUuid === primaryUuid ? null : `${entryKey}-db-access`;
17
+ }
18
+ /** The `host:port` a color on a server other than the database's connects to. */
19
+ export const remoteDatabaseAddress = (host) => `${host}:${REMOTE_DATABASE_PORT}`;
20
+ /**
21
+ * The facts for {@link planRemoteDatabase}, from what the planner already read. The zone is the one the org's
22
+ * tunnel names live in (`org.domain`); `access` says the host can reach Access, tunnels, DNS and zones.
23
+ */
24
+ export function remoteDatabaseFactsFrom(input) {
25
+ const { up } = splitServers(input.servers);
26
+ return {
27
+ serverUuids: up,
28
+ primaryUuid: input.recorded[PRIMARY_SERVER_SCOPE_KEY] ?? up[0] ?? null,
29
+ zone: input.org.domain ?? null,
30
+ access: input.access,
31
+ };
32
+ }
33
+ /** The scope ids one entry's step keeps. None holds a secret. */
34
+ export const remoteDatabaseKeys = (entryKey) => ({
35
+ /** The service token's id, from its mint until its secret reached every sidecar. No `:`. */
36
+ undelivered: `bgdb.${entryKey}.undelivered`,
37
+ /** The service token's id once delivered. */
38
+ token: `bgdb.${entryKey}.token`,
39
+ access: `bgdb.${entryKey}.access`,
40
+ route: `bgdb.${entryKey}.route`,
41
+ applied: `bgdb.${entryKey}.applied`,
42
+ sidecar: (serverUuid) => `bgdb.${entryKey}.sidecar.${serverUuid}`,
43
+ });
44
+ /** The tunnel's HTTP rules as the org tunnel step last wrote them (JSON), so a later write keeps them. */
45
+ export const TUNNEL_HTTP_INGRESS_SCOPE_KEY = 'cloudflare_tunnel_http_ingress';
46
+ const ROUTE_KEY = /^bgdb\.[^.:]+\.route$/;
47
+ /** Every TCP route recorded for the org, in key order. */
48
+ export function recordedTcpRoutes(recorded) {
49
+ const routes = [];
50
+ for (const key of Object.keys(recorded).sort()) {
51
+ if (!ROUTE_KEY.test(key) || !recorded[key])
52
+ continue;
53
+ try {
54
+ const parsed = JSON.parse(recorded[key]);
55
+ if (typeof parsed.hostname === 'string' && typeof parsed.service === 'string') {
56
+ routes.push({ hostname: parsed.hostname, service: parsed.service });
57
+ }
58
+ }
59
+ catch {
60
+ // A record that is not JSON is ignored; the next apply of the step writes it again.
61
+ }
62
+ }
63
+ return routes;
64
+ }
65
+ /** The HTTP rules recorded by the tunnel step, catch-all included; `[]` if none or unreadable. */
66
+ export function recordedHttpIngress(recorded) {
67
+ try {
68
+ const parsed = JSON.parse(recorded[TUNNEL_HTTP_INGRESS_SCOPE_KEY] ?? '[]');
69
+ return Array.isArray(parsed) ? parsed : [];
70
+ }
71
+ catch {
72
+ return [];
73
+ }
74
+ }
75
+ /**
76
+ * The tunnel's whole ingress: the TCP rules first (their names are exact), then every HTTP rule, the
77
+ * catch-all last. `putTunnelConfig` replaces the whole list, so every writer builds it through this.
78
+ */
79
+ export function mergeTcpIngress(http, tcp) {
80
+ const names = new Set(tcp.map((r) => r.hostname));
81
+ const rules = http.filter((r) => !(r.hostname && names.has(r.hostname)));
82
+ const catchAll = rules.filter((r) => !r.hostname);
83
+ return [
84
+ ...tcp.map((r) => ({ hostname: r.hostname, service: r.service })),
85
+ ...rules.filter((r) => r.hostname),
86
+ ...(catchAll.length > 0 ? catchAll : [{ service: 'http_status:404' }]),
87
+ ];
88
+ }
89
+ /** The entry that gets a remote route: blue/green with the org's database. */
90
+ function routedEntry(org) {
91
+ if (!org.hosting.enabled)
92
+ return undefined;
93
+ return org.hosting.entries.find((e) => e.database && e.blueGreen);
94
+ }
95
+ const appliedMarker = (d) => JSON.stringify({ hostname: d.hostname, database: d.database, servers: d.servers });
96
+ /** Whether the route step has been applied for `serverUuid`: its marker names the server. */
97
+ export function remoteDatabaseAppliedFor(recorded, entryKey, serverUuid) {
98
+ try {
99
+ const marker = JSON.parse(recorded[remoteDatabaseKeys(entryKey).applied] ?? 'null');
100
+ return Array.isArray(marker?.servers) && marker.servers.includes(serverUuid);
101
+ }
102
+ catch {
103
+ return false;
104
+ }
105
+ }
106
+ /**
107
+ * Plans the remote database route for the org's blue/green database entry. One server, or no facts,
108
+ * plans nothing. A missing zone, port, tunnel or primary is a blocked line (rule 2), and so is a token
109
+ * that was minted and never delivered (rule 1): it is never minted again.
110
+ */
111
+ export function planRemoteDatabase(org, facts, recorded) {
112
+ const entry = routedEntry(org);
113
+ if (!entry || !facts || facts.serverUuids.length < 2)
114
+ return { step: null, blocked: null };
115
+ const key = `cloudflare:db-route:${org.id}`;
116
+ const block = (reason) => ({
117
+ step: null,
118
+ blocked: {
119
+ description: `${org.slug}'s remote database route for ${entry.key} is blocked, not planned: ${reason}`,
120
+ key,
121
+ },
122
+ });
123
+ const keys = remoteDatabaseKeys(entry.key);
124
+ const lost = recorded[keys.undelivered];
125
+ if (lost) {
126
+ return block(`its Access service token ${lost} was minted and its secret never reached every sidecar; delete the token in Cloudflare and clear the recorded id before minting again`);
127
+ }
128
+ if (!facts.access)
129
+ return block('the host supplies no Access, tunnel, DNS or zone port');
130
+ if (!facts.zone)
131
+ return block("the platform's edge has no internal zone");
132
+ if (!facts.primaryUuid)
133
+ return block('no primary server is recorded');
134
+ if (!recorded[`cloudflare:tunnel:${org.id}`]) {
135
+ return block(`${org.slug}'s Cloudflare tunnel is not applied yet`);
136
+ }
137
+ const database = recorded[`coolify:database:${org.id}`];
138
+ if (!database)
139
+ return block(`${org.slug}'s database is not applied yet`);
140
+ const app = appNameFor(org, entry);
141
+ const desired = {
142
+ entry: entry.key,
143
+ app,
144
+ hostname: `${app}-db.${facts.zone}`,
145
+ database,
146
+ primary: facts.primaryUuid,
147
+ servers: facts.serverUuids.filter((s) => s !== facts.primaryUuid).sort(),
148
+ zone: facts.zone,
149
+ };
150
+ // A server added after the token was minted gets its sidecar's secret copied from another sidecar;
151
+ // with no sidecar to copy from there is nothing to give it.
152
+ if (recorded[keys.token] && desired.servers.every((server) => !recorded[keys.sidecar(server)])) {
153
+ return block(`service token ${recorded[keys.token]} cannot be shown again and no sidecar holds it to copy; delete the token in Cloudflare and clear the recorded ids to mint it again`);
154
+ }
155
+ if (recorded[keys.applied] === appliedMarker(desired))
156
+ return { step: null, blocked: null };
157
+ return {
158
+ blocked: null,
159
+ step: {
160
+ action: {
161
+ system: 'cloudflare',
162
+ kind: 'db-route',
163
+ key: org.id,
164
+ desired: { ...desired },
165
+ },
166
+ step: {
167
+ description: `reach ${org.slug}'s database from ${desired.servers.length} other server(s) through a tunnel route behind an Access service token`,
168
+ destroys: false,
169
+ migrates: false,
170
+ },
171
+ },
172
+ };
173
+ }
174
+ /** The Dockerfile of the sidecar: the pinned `cloudflared`, listening on 5432 for the Access-protected route. */
175
+ export function remoteDatabaseDockerfile(hostname) {
176
+ return [
177
+ `FROM cloudflare/cloudflared:${pinnedRef('cloudflared')}`,
178
+ `CMD ["access", "tcp", "--hostname", "${hostname}", "--url", "0.0.0.0:${REMOTE_DATABASE_PORT}"]`,
179
+ // The image is distroless (no shell, no nc): the exec-form check proves only that the binary runs.
180
+ 'HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD ["cloudflared", "version"]',
181
+ '',
182
+ ].join('\n');
183
+ }
184
+ const TOKEN_ID_ENV = 'TUNNEL_SERVICE_TOKEN_ID';
185
+ const TOKEN_SECRET_ENV = 'TUNNEL_SERVICE_TOKEN_SECRET';
186
+ /**
187
+ * Applies one entry's route. The Access app and its service-token policy come first, so the tunnel route
188
+ * and the DNS record never exist without the gate in front of them. The service token is minted once
189
+ * (rule 1): its id is recorded and persisted before delivery, the secret goes only to the sidecars' env
190
+ * (rule 6), the delivered id replaces it, and the applied marker is the last write. A failure halfway
191
+ * leaves a repair for the next run: the Access app, sidecars, route and DNS are found again; a minted
192
+ * token is never minted again. A sidecar made after the mint takes its secret from an existing sidecar's
193
+ * env. Every outside call is bounded by the ports' own timeouts (edge SDK and Coolify client).
194
+ */
195
+ export async function applyRemoteDatabase(deps, run) {
196
+ const { desired, org } = run;
197
+ const keys = remoteDatabaseKeys(desired.entry);
198
+ const tunnelId = run.recorded()[`cloudflare:tunnel:${org.id}`];
199
+ if (!tunnelId)
200
+ throw new Error(`remote database for ${org.slug}: the Cloudflare tunnel is not applied`);
201
+ const zone = await deps.zones.findZoneByApex(desired.zone);
202
+ if (!zone)
203
+ throw new Error(`remote database for ${org.slug}: zone ${desired.zone} is not live`);
204
+ let accessAppId = run.recorded()[keys.access];
205
+ if (!accessAppId) {
206
+ const live = (await deps.access.listAccessApps()).find((a) => a.domain === desired.hostname);
207
+ accessAppId = (live ??
208
+ (await deps.access.createAccessApp({
209
+ name: `${desired.app}-db-access`,
210
+ domain: desired.hostname,
211
+ type: 'self_hosted',
212
+ }))).id;
213
+ run.record(keys.access, accessAppId);
214
+ await run.persist();
215
+ }
216
+ // Sidecars exist before the secret does, so the secret has somewhere to go the moment it is minted.
217
+ const { environment } = await deps.coolify.findOrCreateEnvironment(run.projectUuid, 'production');
218
+ const sidecars = [];
219
+ for (const server of desired.servers) {
220
+ const host = remoteDatabaseHost(desired.app, server, desired.primary);
221
+ if (!host)
222
+ continue;
223
+ const recordedId = run.recorded()[keys.sidecar(server)];
224
+ let appId = null;
225
+ if (recordedId) {
226
+ try {
227
+ await deps.coolify.getApplication(recordedId);
228
+ appId = recordedId;
229
+ }
230
+ catch (error) {
231
+ if (!isNotFoundError(error))
232
+ throw error;
233
+ }
234
+ }
235
+ let fresh = false;
236
+ if (!appId) {
237
+ const created = await deps.coolify.createDockerfileApplication({
238
+ project_uuid: run.projectUuid,
239
+ server_uuid: server,
240
+ environment_name: environment.name,
241
+ environment_uuid: environment.uuid,
242
+ dockerfile: remoteDatabaseDockerfile(desired.hostname),
243
+ name: host,
244
+ description: `cloudflared access sidecar: ${org.slug}'s database over the tunnel`,
245
+ instant_deploy: false,
246
+ autogenerate_domain: false,
247
+ });
248
+ appId = created.uuid;
249
+ fresh = true;
250
+ // The internal name is what `<app>-db-access` resolves to on the server's Docker network.
251
+ await deps.coolify.updateApplication(appId, {
252
+ custom_internal_name: host,
253
+ health_check_enabled: false,
254
+ });
255
+ run.record(keys.sidecar(server), appId);
256
+ await run.persist();
257
+ }
258
+ sidecars.push({ server, appId, fresh });
259
+ }
260
+ // The token: found by recorded id first. A token of the name that foundry has no id for is not
261
+ // minted over; the step fails with fixed text.
262
+ const lost = run.recorded()[keys.undelivered];
263
+ if (lost) {
264
+ throw new Error(`remote database for ${org.slug}: service token ${lost} was minted and not delivered; delete it in Cloudflare and clear the recorded id before minting again`);
265
+ }
266
+ let tokenId = run.recorded()[keys.token];
267
+ let secrets = null;
268
+ let deliverTo = sidecars;
269
+ if (!tokenId) {
270
+ const name = `${desired.app}-db-access`;
271
+ if (await deps.access.findServiceTokenByName(name)) {
272
+ throw new Error(`remote database for ${org.slug}: a service token named ${name} exists and foundry holds no id for it; delete it in Cloudflare before applying again`);
273
+ }
274
+ const minted = await deps.access.createServiceToken(name);
275
+ deps.secret(minted.clientSecret);
276
+ deps.secret(minted.clientId);
277
+ run.record(keys.undelivered, minted.id);
278
+ await run.persist();
279
+ tokenId = minted.id;
280
+ secrets = { clientId: minted.clientId, clientSecret: minted.clientSecret };
281
+ }
282
+ else {
283
+ // A sidecar made after the token was minted takes the secret from a sidecar that already holds it.
284
+ deliverTo = sidecars.filter((sidecar) => sidecar.fresh);
285
+ if (deliverTo.length > 0) {
286
+ secrets = await secretsOf(deps, sidecars.filter((sidecar) => !sidecar.fresh));
287
+ if (!secrets) {
288
+ throw new Error(`remote database for ${org.slug}: the sidecar on server ${deliverTo[0]?.server} is new and no existing sidecar holds service token ${tokenId} to copy; delete the token in Cloudflare and clear the recorded ids to mint it again`);
289
+ }
290
+ }
291
+ }
292
+ if (secrets) {
293
+ const failed = [];
294
+ for (const sidecar of deliverTo) {
295
+ if (!(await deliver(deps, sidecar.appId, secrets)))
296
+ failed.push(sidecar.server);
297
+ }
298
+ if (failed.length > 0) {
299
+ // Ids only: the cause of a failed write can echo the request body.
300
+ throw new Error(`remote database for ${org.slug}: service token ${tokenId} could not be delivered to the sidecar on server ${failed.join(', ')} after ${DELIVERY_ATTEMPTS} attempts; delete the token in Cloudflare and clear the recorded id before minting again`);
301
+ }
302
+ if (run.recorded()[keys.undelivered]) {
303
+ run.record(keys.token, tokenId);
304
+ run.record(keys.undelivered, '');
305
+ await run.persist();
306
+ }
307
+ }
308
+ const policies = await deps.access.listAccessPolicies(accessAppId);
309
+ const hasPolicy = policies.some((p) => p.decision === 'non_identity' &&
310
+ (p.include ?? []).some((r) => r.service_token?.token_id === tokenId));
311
+ if (!hasPolicy) {
312
+ await deps.access.createAccessPolicy(accessAppId, {
313
+ name: 'database sidecars',
314
+ decision: 'non_identity',
315
+ include: [{ service_token: { token_id: tokenId } }],
316
+ });
317
+ }
318
+ // The gate exists: now the route and the name that reach the database.
319
+ run.record(keys.route, JSON.stringify({
320
+ hostname: desired.hostname,
321
+ service: `tcp://${run.databaseHost}:${REMOTE_DATABASE_PORT}`,
322
+ }));
323
+ await deps.tunnels.putTunnelConfig(tunnelId, {
324
+ ingress: mergeTcpIngress(recordedHttpIngress(run.recorded()), recordedTcpRoutes(run.recorded())),
325
+ });
326
+ if ((await deps.dns.findDnsRecord(zone.name, desired.hostname, 'CNAME')).length === 0) {
327
+ await deps.dns.createDnsRecord(zone.name, {
328
+ type: 'CNAME',
329
+ name: desired.hostname,
330
+ content: `${tunnelId}.cfargotunnel.com`,
331
+ proxied: true,
332
+ });
333
+ }
334
+ for (const sidecar of sidecars) {
335
+ const live = await deps.coolify.getApplication(sidecar.appId);
336
+ const up = Boolean(live.status?.startsWith('running') || live.status?.startsWith('starting'));
337
+ if (secrets || sidecar.fresh || !up)
338
+ await deps.coolify.deploy(sidecar.appId);
339
+ }
340
+ run.record(keys.applied, appliedMarker(desired));
341
+ await run.persist();
342
+ }
343
+ /** The service token an existing sidecar already holds in its env, registered for redaction; `null` when none does. */
344
+ async function secretsOf(deps, sidecars) {
345
+ for (const sidecar of sidecars) {
346
+ const envs = await deps.coolify.listEnvs(sidecar.appId);
347
+ const envValue = (key) => envs.find((e) => e.key === key && !e.is_preview)?.value;
348
+ const clientId = envValue(TOKEN_ID_ENV);
349
+ const clientSecret = envValue(TOKEN_SECRET_ENV);
350
+ if (clientId && clientSecret) {
351
+ deps.secret(clientId);
352
+ deps.secret(clientSecret);
353
+ return { clientId, clientSecret };
354
+ }
355
+ }
356
+ return null;
357
+ }
358
+ /** Writes the token to one sidecar's env, up to {@link DELIVERY_ATTEMPTS} times; the error is dropped. */
359
+ async function deliver(deps, appId, secrets) {
360
+ for (let attempt = 1; attempt <= DELIVERY_ATTEMPTS; attempt++) {
361
+ try {
362
+ await deps.coolify.bulkUpsertEnvs(appId, [
363
+ { key: TOKEN_ID_ENV, value: secrets.clientId, literal: true },
364
+ { key: TOKEN_SECRET_ENV, value: secrets.clientSecret, literal: true },
365
+ ]);
366
+ return true;
367
+ }
368
+ catch {
369
+ // Retried; the error is dropped in case it echoes the secret.
370
+ }
371
+ }
372
+ return false;
373
+ }
@@ -0,0 +1,148 @@
1
+ import type { DeclaredOrg, HostingEntry } from '../org.js';
2
+ import type { AccessPort } from './edge-access.js';
3
+ import type { DnsPort } from './edge-dns.js';
4
+ import type { TunnelPort } from './edge-tunnels.js';
5
+ import type { ZonePort } from './edge-zones.js';
6
+ import type { StepExecutionContext } from './plan-apply.js';
7
+ /**
8
+ * C2 (campaign 2026-10-15): a Worker app with `workerApp.database` reaches the org's Coolify
9
+ * Postgres through a Hyperdrive config. The database host is Docker-internal, so Hyperdrive
10
+ * connects over the org's Cloudflare tunnel (a `tcp://<db container>:5432` rule on an internal
11
+ * hostname) behind an Access service token, the route `plan/remote-database.ts` already uses
12
+ * between servers.
13
+ *
14
+ * Order on apply, inside the `cloudflare:worker-app` step and before the upload, so a failure at
15
+ * any point leaves a state the next apply repairs and the Worker is never deployed against a
16
+ * database it cannot use:
17
+ * Access app and service token -> tunnel rule and DNS -> `<db>_rt` password -> release
18
+ * `migrations.json` (its sha256 checked against `database.migrationsSha256`) applied with the owner credentials -> runtime role (UPDATE, DELETE and
19
+ * TRUNCATE revoked on `appendOnly`) -> Hyperdrive config with the runtime credentials.
20
+ * A failed migration throws, so the Worker is not uploaded.
21
+ */
22
+ /** Everything needed to open a connection to the org's database over the tunnel. */
23
+ export interface HyperdriveOrigin {
24
+ /** The tunnel hostname that fronts the database (Access protected). */
25
+ host: string;
26
+ database: string;
27
+ user: string;
28
+ password: string;
29
+ accessClientId: string;
30
+ accessClientSecret: string;
31
+ }
32
+ /** One migration of a release's `migrations.json`. */
33
+ export interface DatabaseMigration {
34
+ id: string;
35
+ sql: string;
36
+ }
37
+ /**
38
+ * The calls a Worker app database needs beyond Cloudflare's Access, tunnel and DNS. Edge's
39
+ * Hyperdrive SDK (`client.platform.hyperdrive`) and the SQL runner behind the tunnel
40
+ * (`@wtfalch/db`'s migrate and `ensureRuntimeRole`) are the host's to implement; foundry only
41
+ * calls this port. Every method is idempotent.
42
+ */
43
+ export interface WorkerAppDatabasePort {
44
+ /** Creates or updates the Hyperdrive config `name`; returns its id. */
45
+ upsertHyperdrive(input: {
46
+ name: string;
47
+ origin: HyperdriveOrigin;
48
+ }): Promise<{
49
+ id: string;
50
+ }>;
51
+ /** A no-op when the config is gone. */
52
+ deleteHyperdrive(name: string): Promise<void>;
53
+ /** The text of a release asset of a wtfalch repo, or `null` when the release has no such asset. */
54
+ fetchReleaseAsset(url: string): Promise<string | null>;
55
+ /** The migration ids the database's ledger holds, in the order applied. */
56
+ appliedMigrationIds(owner: HyperdriveOrigin): Promise<string[]>;
57
+ /** Runs `migration.sql` and records `migration.id` in the ledger in one transaction. */
58
+ applyMigration(owner: HyperdriveOrigin, migration: DatabaseMigration): Promise<void>;
59
+ /**
60
+ * Creates or updates the `runtime.user` role with `runtime.password`, grants it DML on the
61
+ * tables and revokes UPDATE, DELETE and TRUNCATE from it on each `appendOnly` table.
62
+ */
63
+ ensureRuntimeRole(input: {
64
+ owner: HyperdriveOrigin;
65
+ runtime: {
66
+ user: string;
67
+ password: string;
68
+ };
69
+ appendOnly: string[];
70
+ }): Promise<void>;
71
+ }
72
+ export declare const WORKER_APP_MIGRATION_TIMEOUT_MS = 300000;
73
+ export declare const MIGRATIONS_ASSET_NAME = "migrations.json";
74
+ /** The Hyperdrive config, `{name, id}` as JSON. No secret. */
75
+ export declare const workerAppHyperdriveScopeKey: (entryKey: string) => string;
76
+ /** The migration ids applied, a JSON list, for the plan and the operator. The database's ledger is the fact. */
77
+ export declare const workerAppMigrationsScopeKey: (entryKey: string) => string;
78
+ /** The applied route marker (`{hostname, database}`). */
79
+ export declare const workerAppDbRouteMarkerScopeKey: (entryKey: string) => string;
80
+ /** The Hyperdrive config name of an entry: the Worker's script name plus `-db`. */
81
+ export declare const hyperdriveName: (scriptName: string) => string;
82
+ /** The tunnel hostname of an entry's database route: `<8 hex of org id>-<key>-hd.<zone>`. */
83
+ export declare const hyperdriveHostname: (org: DeclaredOrg, entryKey: string, zone: string) => string;
84
+ /**
85
+ * Reads a release's `migrations.json`. `null` text is an absent file: no migrations. A file that is
86
+ * not a list of `{id, sql}` with unique ids is refused, naming no SQL.
87
+ */
88
+ export declare function parseMigrations(text: string | null): DatabaseMigration[];
89
+ /**
90
+ * A present `migrations.json` runs as the database owner, so its bytes must be the ones the
91
+ * declaration pinned. No file needs no digest; a file with no declared digest, or a different
92
+ * one, is refused before it is parsed. The message names neither the SQL nor the wanted digest.
93
+ */
94
+ export declare function verifyMigrationsDigest(text: string | null, declared: string | undefined): void;
95
+ /** The URL of `migrations.json` beside the module: the same release, another file. */
96
+ export declare const migrationsUrl: (artifactUrl: string) => string;
97
+ /**
98
+ * The migrations the database does not hold, in order. The applied ledger must be the first
99
+ * migrations of the file: a missing or reordered one is refused (migrations are append-only).
100
+ */
101
+ export declare function unappliedDatabaseMigrations(all: readonly DatabaseMigration[], applied: readonly string[]): DatabaseMigration[];
102
+ export interface WorkerAppDatabaseDeps {
103
+ port: WorkerAppDatabasePort;
104
+ tunnels: Pick<TunnelPort, 'putTunnelConfig'>;
105
+ access: Pick<AccessPort, 'listAccessApps' | 'createAccessApp' | 'listAccessPolicies' | 'createAccessPolicy' | 'findServiceTokenByName' | 'createServiceToken' | 'deleteAccessApp' | 'removeServiceToken'>;
106
+ dns: Pick<DnsPort, 'findDnsRecord' | 'createDnsRecord' | 'deleteDnsRecord'>;
107
+ zones: Pick<ZonePort, 'findZoneByApex'>;
108
+ /** The owner connection string of the org's database, as Coolify reports it. Registered for redaction by the host. */
109
+ ownerUrl(databaseId: string): Promise<string>;
110
+ /** Registers a value for redaction before it is written anywhere. */
111
+ secret(value: string): void;
112
+ heldKeys?: {
113
+ resolve(ref: string): Promise<string | null | undefined>;
114
+ put?(ref: string, value: string): Promise<void>;
115
+ };
116
+ /** Test hook: the timeout of a call, in ms. */
117
+ timeoutMs?: number;
118
+ /** Test hook: the timeout of one migration, in ms. */
119
+ migrationTimeoutMs?: number;
120
+ }
121
+ type Call = <T>(label: string, task: () => Promise<T>, ms?: number) => Promise<T>;
122
+ /**
123
+ * Makes the database true and returns the Hyperdrive config id for the `HYPERDRIVE` binding.
124
+ * Secrets (the owner and runtime passwords, the Access service token) go only to the port and the
125
+ * held-key store, never into an error, a recorded id or a return value. Each outside call is
126
+ * bounded by a timeout; the error names the call, never the cause.
127
+ */
128
+ export declare function applyWorkerAppDatabase(deps: WorkerAppDatabaseDeps, org: DeclaredOrg, entry: HostingEntry, scriptName: string, want: {
129
+ artifactUrl: string;
130
+ database: {
131
+ appendOnly: string[];
132
+ migrationsSha256?: string;
133
+ };
134
+ }, context: StepExecutionContext, zoneApex: string): Promise<{
135
+ hyperdriveId: string;
136
+ }>;
137
+ /** Deletes the entry's Hyperdrive config, found by the recorded name, and clears the record. */
138
+ export declare function deleteWorkerAppHyperdrive(port: Pick<WorkerAppDatabasePort, 'deleteHyperdrive'> | undefined, entryKey: string, recorded: Readonly<Record<string, string>>, context: Pick<StepExecutionContext, 'recordScopeId' | 'persistScopeIds'> | null, call: Call): Promise<void>;
139
+ /**
140
+ * Removes the Cloudflare state the database route made, in the order that never leaves a name
141
+ * pointing at nothing: the DNS record, the tunnel rule (the tunnel config is rewritten without
142
+ * it), the Access app, the service token (also one minted and never delivered), then the held
143
+ * token and runtime password and the recorded ids. Each step tolerates "already gone", and the
144
+ * ids are cleared last, so a failed run is repeated by the next one. With `context` null (org
145
+ * destroy) the ids are not cleared: destroy drops them with the org.
146
+ */
147
+ export declare function deleteWorkerAppDatabaseRoute(database: WorkerAppDatabaseDeps | undefined, heldKeys: Pick<NonNullable<WorkerAppDatabaseDeps['heldKeys']>, 'put'> | undefined, orgId: string, entryKey: string, recorded: Readonly<Record<string, string>>, context: Pick<StepExecutionContext, 'recordScopeId' | 'persistScopeIds'> | null, call: Call): Promise<void>;
148
+ export {};