@wtfalch/foundry 0.41.2 → 0.43.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 (71) hide show
  1. package/dist/bootstrap/versions.d.ts +2 -0
  2. package/dist/index.d.ts +7 -3
  3. package/dist/index.js +4 -2
  4. package/dist/migrations/0054_org_signin_in_app.sql +6 -0
  5. package/dist/migrations/0055_release_requests.sql +20 -0
  6. package/dist/migrations/0056_platform_archon_blue_green.sql +7 -0
  7. package/dist/org.d.ts +30 -0
  8. package/dist/org.js +52 -1
  9. package/dist/plan/agora.d.ts +4 -1
  10. package/dist/plan/agora.js +16 -2
  11. package/dist/plan/archon-blue-green.d.ts +54 -0
  12. package/dist/plan/archon-blue-green.js +72 -0
  13. package/dist/plan/blue-green-apply.d.ts +82 -0
  14. package/dist/plan/blue-green-apply.js +360 -0
  15. package/dist/plan/blue-green.d.ts +223 -0
  16. package/dist/plan/blue-green.js +444 -0
  17. package/dist/plan/boule.d.ts +5 -2
  18. package/dist/plan/boule.js +14 -2
  19. package/dist/plan/cloudflared-connector.d.ts +26 -0
  20. package/dist/plan/cloudflared-connector.js +37 -0
  21. package/dist/plan/coolify-previews.d.ts +10 -0
  22. package/dist/plan/coolify-previews.js +14 -0
  23. package/dist/plan/diff.d.ts +6 -0
  24. package/dist/plan/diff.js +123 -11
  25. package/dist/plan/edge-ratelimits.d.ts +5 -0
  26. package/dist/plan/edge-ratelimits.js +16 -6
  27. package/dist/plan/executor.d.ts +79 -0
  28. package/dist/plan/executor.js +527 -31
  29. package/dist/plan/login-host.d.ts +23 -2
  30. package/dist/plan/login-host.js +30 -5
  31. package/dist/plan/own-diff.js +6 -0
  32. package/dist/plan/plan-apply.d.ts +51 -0
  33. package/dist/plan/plan-apply.js +222 -0
  34. package/dist/plan/platform-diff.d.ts +9 -0
  35. package/dist/plan/platform-diff.js +28 -1
  36. package/dist/plan/platform-org.d.ts +6 -0
  37. package/dist/plan/platform-org.js +5 -0
  38. package/dist/plan/platform-providers.d.ts +31 -2
  39. package/dist/plan/platform-providers.js +345 -13
  40. package/dist/plan/providers-readers.js +6 -2
  41. package/dist/plan/proxy-labels.d.ts +2 -2
  42. package/dist/plan/proxy-labels.js +12 -4
  43. package/dist/plan/release-host.d.ts +59 -0
  44. package/dist/plan/release-host.js +239 -0
  45. package/dist/plan/remote-database.d.ts +124 -0
  46. package/dist/plan/remote-database.js +373 -0
  47. package/dist/plan/sign-in-archon.d.ts +46 -0
  48. package/dist/plan/sign-in-archon.js +71 -0
  49. package/dist/plan/sign-in-bindings.d.ts +16 -0
  50. package/dist/plan/sign-in-bindings.js +44 -0
  51. package/dist/plan/sign-in.d.ts +135 -0
  52. package/dist/plan/sign-in.js +172 -0
  53. package/dist/platform.d.ts +11 -1
  54. package/dist/platform.js +4 -1
  55. package/dist/release/decide.d.ts +95 -0
  56. package/dist/release/decide.js +242 -0
  57. package/dist/release/health.d.ts +48 -0
  58. package/dist/release/health.js +61 -0
  59. package/dist/release/run.d.ts +40 -0
  60. package/dist/release/run.js +56 -0
  61. package/dist/release/state.d.ts +64 -0
  62. package/dist/release/state.js +126 -0
  63. package/dist/release/types.d.ts +54 -0
  64. package/dist/release/types.js +4 -0
  65. package/dist/store/postgres.d.ts +8 -0
  66. package/dist/store/postgres.js +62 -3
  67. package/dist/store/schema.d.ts +171 -0
  68. package/dist/store/schema.js +14 -0
  69. package/package.json +1 -1
  70. package/versions.digests.json +2 -1
  71. 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,46 @@
1
+ import type { CoolifyPort, ZitadelPort } from './executor.js';
2
+ /**
3
+ * Archon in-app sign-in: Archon signs people in on its own address under `/sign-in`, through the owner
4
+ * company's in-app login app. Its sign-in client lives in the owner company's ZITADEL, in Archon's own
5
+ * organisation, so this is the platform's step and not the company's login-host step.
6
+ */
7
+ /** Recorded `<host>=<clientId>` once Archon's address is trusted, bound, proxied by the app and pointed at; `''` once given up again, in the reverse order. */
8
+ export declare const PLATFORM_ARCHON_IN_APP_SCOPE_KEY = "platform_archon_in_app";
9
+ /** The host a recorded {@link PLATFORM_ARCHON_IN_APP_SCOPE_KEY} names; `''` when none. */
10
+ export declare function archonInAppHost(recorded: Readonly<Record<string, string>>): string;
11
+ /** The ZITADEL calls Archon's in-app sign-in makes, on the owner company's own instance. */
12
+ export type ArchonInAppZitadel = Pick<ZitadelPort, 'ensureTrustedDomain' | 'deleteTrustedDomain' | 'updateOidcAppLoginBaseUri'> & Partial<Pick<ZitadelPort, 'allowPerAppLoginBaseUri'>>;
13
+ interface ArchonInAppInput {
14
+ zitadel: ArchonInAppZitadel;
15
+ coolify: Pick<CoolifyPort, 'listEnvs' | 'bulkUpsertEnvs' | 'deploy'>;
16
+ /** The owner company's in-app login app, whose host table holds Archon's binding. Giving the address up without it leaves no table to edit. */
17
+ loginAppId?: string;
18
+ /** The owner company's login host, the instance's own: Archon's default base URI. */
19
+ loginHost: string;
20
+ /** Archon's address. */
21
+ host: string;
22
+ /** Archon's ZITADEL organisation, project and sign-in client (app id and client id). */
23
+ organisationId: string;
24
+ projectId: string;
25
+ appId: string;
26
+ }
27
+ /**
28
+ * Archon's address becomes one the login serves, in two halves with the app's own env between them
29
+ * (`SIGNIN_UPSTREAM`, written and deployed by the step): first trusted in the company's ZITADEL and bound
30
+ * to Archon's organisation and client in the in-app login's host table; only after the app proxies
31
+ * `/sign-in/*`, the client's Login V2 base URI is set to the address ({@link archonInAppPoint}).
32
+ */
33
+ export declare function archonInAppBind(input: ArchonInAppInput & {
34
+ clientId: string;
35
+ loginAppId: string;
36
+ }): Promise<void>;
37
+ /** Sets the client's Login V2 base URI to Archon's address. Only after the app proxies `/sign-in/*`. */
38
+ export declare function archonInAppPoint(input: ArchonInAppInput): Promise<void>;
39
+ /** First half of giving the address up: the client goes back to the company's login host. */
40
+ export declare function archonInAppBack(input: ArchonInAppInput): Promise<void>;
41
+ /**
42
+ * Last half: the binding and the trusted domain are removed, after the app stopped proxying. A domain or
43
+ * binding already gone counts as done.
44
+ */
45
+ export declare function archonInAppUnbind(input: ArchonInAppInput): Promise<void>;
46
+ export {};
@@ -0,0 +1,71 @@
1
+ import { isNotFoundError, setOidcAppLoginBaseUri } from './executor.js';
2
+ import { editSignInBindings } from './sign-in-bindings.js';
3
+ import { SIGN_IN_BASE_PATH } from './sign-in.js';
4
+ /**
5
+ * Archon in-app sign-in: Archon signs people in on its own address under `/sign-in`, through the owner
6
+ * company's in-app login app. Its sign-in client lives in the owner company's ZITADEL, in Archon's own
7
+ * organisation, so this is the platform's step and not the company's login-host step.
8
+ */
9
+ /** Recorded `<host>=<clientId>` once Archon's address is trusted, bound, proxied by the app and pointed at; `''` once given up again, in the reverse order. */
10
+ export const PLATFORM_ARCHON_IN_APP_SCOPE_KEY = 'platform_archon_in_app';
11
+ /** The host a recorded {@link PLATFORM_ARCHON_IN_APP_SCOPE_KEY} names; `''` when none. */
12
+ export function archonInAppHost(recorded) {
13
+ return (recorded[PLATFORM_ARCHON_IN_APP_SCOPE_KEY] ?? '').split('=')[0] ?? '';
14
+ }
15
+ /**
16
+ * Archon's address becomes one the login serves, in two halves with the app's own env between them
17
+ * (`SIGNIN_UPSTREAM`, written and deployed by the step): first trusted in the company's ZITADEL and bound
18
+ * to Archon's organisation and client in the in-app login's host table; only after the app proxies
19
+ * `/sign-in/*`, the client's Login V2 base URI is set to the address ({@link archonInAppPoint}).
20
+ */
21
+ export async function archonInAppBind(input) {
22
+ const { zitadel, host } = input;
23
+ await zitadel.ensureTrustedDomain(host);
24
+ await editSignInBindings(input.coolify, {
25
+ appId: input.loginAppId,
26
+ instanceHost: input.loginHost,
27
+ upsert: [
28
+ {
29
+ host,
30
+ organizationId: input.organisationId,
31
+ clientIds: [input.clientId],
32
+ apps: { [input.clientId]: 'archon' },
33
+ basePath: SIGN_IN_BASE_PATH,
34
+ },
35
+ ],
36
+ });
37
+ }
38
+ /** Sets the client's Login V2 base URI to Archon's address. Only after the app proxies `/sign-in/*`. */
39
+ export async function archonInAppPoint(input) {
40
+ const { zitadel, host } = input;
41
+ if (!zitadel.allowPerAppLoginBaseUri) {
42
+ throw new Error("platform:archon: the owner company's ZITADEL port cannot allow a per-app login base URI, so Archon cannot sign in on its own address");
43
+ }
44
+ await zitadel.allowPerAppLoginBaseUri(`https://${input.loginHost}/`);
45
+ await setOidcAppLoginBaseUri(zitadel, input.organisationId, input.projectId, input.appId, `https://${host}${SIGN_IN_BASE_PATH}/`);
46
+ }
47
+ /** First half of giving the address up: the client goes back to the company's login host. */
48
+ export async function archonInAppBack(input) {
49
+ await setOidcAppLoginBaseUri(input.zitadel, input.organisationId, input.projectId, input.appId, `https://${input.loginHost}/`);
50
+ }
51
+ /**
52
+ * Last half: the binding and the trusted domain are removed, after the app stopped proxying. A domain or
53
+ * binding already gone counts as done.
54
+ */
55
+ export async function archonInAppUnbind(input) {
56
+ const { zitadel, host } = input;
57
+ if (input.loginAppId) {
58
+ await editSignInBindings(input.coolify, {
59
+ appId: input.loginAppId,
60
+ instanceHost: input.loginHost,
61
+ remove: [host],
62
+ });
63
+ }
64
+ try {
65
+ await zitadel.deleteTrustedDomain(host);
66
+ }
67
+ catch (error) {
68
+ if (!isNotFoundError(error))
69
+ throw error;
70
+ }
71
+ }
@@ -0,0 +1,16 @@
1
+ import type { CoolifyPort } from './executor.js';
2
+ import type { LoginHostBinding } from './login-host.js';
3
+ /** The login's host table env, and the document it holds (`auth/docs/company-login-hosts.md`). */
4
+ export declare const LOGIN_HOST_BINDINGS_ENV = "LOGIN_HOST_BINDINGS";
5
+ /**
6
+ * Adds, replaces and removes hosts in the host table of one in-app login app, then redeploys it when the
7
+ * document changed. Hosts not named stay as they are, so two steps that each own some hosts (the
8
+ * company's login-host step, Archon's step) never undo each other. Entries are sorted by host and the
9
+ * document is written compact, so a repeat writes the same text and deploys nothing.
10
+ */
11
+ export declare function editSignInBindings(coolify: Pick<CoolifyPort, 'listEnvs' | 'bulkUpsertEnvs' | 'deploy'>, input: {
12
+ appId: string;
13
+ instanceHost: string;
14
+ upsert?: readonly LoginHostBinding[];
15
+ remove?: readonly string[];
16
+ }): Promise<void>;
@@ -0,0 +1,44 @@
1
+ /** The login's host table env, and the document it holds (`auth/docs/company-login-hosts.md`). */
2
+ export const LOGIN_HOST_BINDINGS_ENV = 'LOGIN_HOST_BINDINGS';
3
+ /**
4
+ * The host table on a company's in-app login app. The company's own login stack has none of its own:
5
+ * its login host is the instance host, every in-app host is a binding. Parsed with a fixed error that
6
+ * never echoes the live value.
7
+ */
8
+ function readDoc(raw, instanceHost, appId) {
9
+ if (!raw)
10
+ return { version: 1, instanceHost, sharedHosts: [instanceHost], bindings: [] };
11
+ let doc;
12
+ try {
13
+ doc = JSON.parse(raw);
14
+ }
15
+ catch {
16
+ throw new Error(`${LOGIN_HOST_BINDINGS_ENV} on app ${appId} is not JSON; left as it is`);
17
+ }
18
+ const d = doc;
19
+ if (!d || d.version !== 1 || !Array.isArray(d.bindings) || !Array.isArray(d.sharedHosts)) {
20
+ throw new Error(`${LOGIN_HOST_BINDINGS_ENV} on app ${appId} has an unknown shape; left as it is`);
21
+ }
22
+ return d;
23
+ }
24
+ /**
25
+ * Adds, replaces and removes hosts in the host table of one in-app login app, then redeploys it when the
26
+ * document changed. Hosts not named stay as they are, so two steps that each own some hosts (the
27
+ * company's login-host step, Archon's step) never undo each other. Entries are sorted by host and the
28
+ * document is written compact, so a repeat writes the same text and deploys nothing.
29
+ */
30
+ export async function editSignInBindings(coolify, input) {
31
+ const { appId, instanceHost } = input;
32
+ const live = (await coolify.listEnvs(appId)).find((e) => e.key === LOGIN_HOST_BINDINGS_ENV && !e.is_preview);
33
+ const doc = readDoc(live?.value || undefined, instanceHost, appId);
34
+ const touched = new Set([...(input.upsert ?? []).map((b) => b.host), ...(input.remove ?? [])]);
35
+ doc.bindings = [
36
+ ...doc.bindings.filter((b) => !touched.has(b.host)),
37
+ ...(input.upsert ?? []).map((b) => ({ ...b, clientIds: [...b.clientIds].sort() })),
38
+ ].sort((a, b) => (a.host < b.host ? -1 : 1));
39
+ const next = JSON.stringify(doc);
40
+ if (live?.value === next)
41
+ return;
42
+ await coolify.bulkUpsertEnvs(appId, [{ key: LOGIN_HOST_BINDINGS_ENV, value: next }]);
43
+ await coolify.deploy(appId);
44
+ }