@wtfalch/foundry 0.9.0 → 0.11.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 (44) hide show
  1. package/dist/apply.d.ts +16 -0
  2. package/dist/index.d.ts +4 -3
  3. package/dist/index.js +1 -1
  4. package/dist/migrations/0030_hosting_entry_hostnames.sql +24 -0
  5. package/dist/migrations/0031_adoptions.sql +37 -0
  6. package/dist/org.d.ts +49 -4
  7. package/dist/org.js +134 -1
  8. package/dist/plan/adopt.d.ts +112 -0
  9. package/dist/plan/adopt.js +252 -0
  10. package/dist/plan/adoption-vault.d.ts +9 -0
  11. package/dist/plan/adoption-vault.js +1 -0
  12. package/dist/plan/agora.d.ts +7 -0
  13. package/dist/plan/agora.js +9 -0
  14. package/dist/plan/broker-entries.d.ts +108 -0
  15. package/dist/plan/broker-entries.js +214 -0
  16. package/dist/plan/cloudflared-connector.d.ts +10 -2
  17. package/dist/plan/cloudflared-connector.js +24 -16
  18. package/dist/plan/diff.d.ts +14 -0
  19. package/dist/plan/diff.js +488 -47
  20. package/dist/plan/executor.d.ts +141 -3
  21. package/dist/plan/executor.js +1100 -149
  22. package/dist/plan/hostnames.d.ts +24 -0
  23. package/dist/plan/hostnames.js +56 -0
  24. package/dist/plan/index.d.ts +2 -0
  25. package/dist/plan/index.js +1 -0
  26. package/dist/plan/locked-hostnames.d.ts +8 -0
  27. package/dist/plan/locked-hostnames.js +26 -0
  28. package/dist/plan/login-host.d.ts +1 -1
  29. package/dist/plan/login-host.js +5 -2
  30. package/dist/plan/plan-apply.d.ts +63 -3
  31. package/dist/plan/plan-apply.js +163 -5
  32. package/dist/plan/platform-diff.d.ts +66 -13
  33. package/dist/plan/platform-diff.js +117 -15
  34. package/dist/plan/platform-providers.d.ts +95 -2
  35. package/dist/plan/platform-providers.js +393 -27
  36. package/dist/plan/providers-readers.d.ts +8 -1
  37. package/dist/plan/providers-readers.js +58 -4
  38. package/dist/plan.d.ts +6 -0
  39. package/dist/store/postgres.d.ts +6 -1
  40. package/dist/store/postgres.js +80 -5
  41. package/dist/store/schema.d.ts +162 -0
  42. package/dist/store/schema.js +19 -1
  43. package/dist/store.d.ts +37 -0
  44. package/package.json +1 -1
@@ -0,0 +1,252 @@
1
+ import { appStepKey } from './platform-diff.js';
2
+ /** The `foundry_adoptions.resource_key` of the org's adopted tunnel: also its `serviceIds` key (#312). */
3
+ export const adoptedTunnelKey = (org) => `cloudflare:tunnel:${org.id}`;
4
+ /** The `foundry_adoptions.resource_key` of the org's adopted connector app (#312). */
5
+ export const adoptedConnectorKey = (org) => `cloudflare:connector:${org.id}`;
6
+ /** Every resource the org adopts, the database first, then the apps, the tunnel and its connector. */
7
+ export function adoptedResources(org) {
8
+ if (!org.hosting.enabled)
9
+ return [];
10
+ const database = org.hosting.adopt?.database;
11
+ const resources = [];
12
+ if (database) {
13
+ resources.push({ resourceKey: `coolify:database:${org.id}`, kind: 'database', uuid: database });
14
+ }
15
+ for (const entry of org.hosting.entries) {
16
+ if (!entry.adopt)
17
+ continue;
18
+ resources.push({
19
+ resourceKey: `coolify:app:${appStepKey(org, entry.key)}`,
20
+ kind: 'app',
21
+ uuid: entry.adopt.app,
22
+ entry,
23
+ });
24
+ }
25
+ const { tunnel, connectorApp } = org.hosting.adopt ?? {};
26
+ if (tunnel)
27
+ resources.push({ resourceKey: adoptedTunnelKey(org), kind: 'tunnel', uuid: tunnel });
28
+ if (connectorApp) {
29
+ resources.push({
30
+ resourceKey: adoptedConnectorKey(org),
31
+ kind: 'connector',
32
+ uuid: connectorApp,
33
+ });
34
+ }
35
+ return resources;
36
+ }
37
+ /**
38
+ * Every uuid the org declares as adopted, whether or not hosting is enabled
39
+ * now: a uuid in this set is never stopped or deleted by foundry (#311).
40
+ */
41
+ export function declaredAdoptUuids(org) {
42
+ const uuids = new Set();
43
+ if (org.hosting.adopt?.database)
44
+ uuids.add(org.hosting.adopt.database);
45
+ if (org.hosting.adopt?.tunnel)
46
+ uuids.add(org.hosting.adopt.tunnel);
47
+ if (org.hosting.adopt?.connectorApp)
48
+ uuids.add(org.hosting.adopt.connectorApp);
49
+ for (const entry of org.hosting.entries)
50
+ if (entry.adopt)
51
+ uuids.add(entry.adopt.app);
52
+ return uuids;
53
+ }
54
+ /**
55
+ * A snapshot is ACTIVE when its row is `adopted` or `confirmed` and the uuid
56
+ * it recorded is the uuid the org declares now (#311). Only then may foundry
57
+ * write to the live resource.
58
+ */
59
+ export function isActiveSnapshot(row, uuid) {
60
+ return (row !== null &&
61
+ row !== undefined &&
62
+ (row.state === 'adopted' || row.state === 'confirmed') &&
63
+ row.settings.uuid === uuid);
64
+ }
65
+ /**
66
+ * A row in `adopted`, `confirmed` or `restored` protects its key: foundry never
67
+ * manages, stops or deletes that resource as its own. Only `released` lets go (#311).
68
+ */
69
+ export function protectsKey(row) {
70
+ return row?.state === 'adopted' || row?.state === 'confirmed' || row?.state === 'restored';
71
+ }
72
+ /** Where the secret part of a snapshot lives in the `AdoptionVault`. */
73
+ export function adoptionVaultRef(org, resource) {
74
+ return `foundry:adopt:${org.id}:${resource.kind === 'app' ? `app:${resource.entry.key}` : resource.kind}`;
75
+ }
76
+ /** Coolify's field names for the non-secret settings an app snapshot keeps. */
77
+ const APP_SETTING_FIELDS = [
78
+ 'fqdn',
79
+ 'git_branch',
80
+ 'base_directory',
81
+ 'publish_directory',
82
+ 'build_pack',
83
+ 'ports_exposes',
84
+ 'health_check_enabled',
85
+ 'health_check_path',
86
+ 'health_check_port',
87
+ 'health_check_method',
88
+ 'health_check_return_code',
89
+ 'health_check_scheme',
90
+ 'health_check_interval',
91
+ 'health_check_timeout',
92
+ 'health_check_retries',
93
+ 'health_check_start_period',
94
+ 'custom_network_aliases',
95
+ 'is_auto_deploy_enabled',
96
+ 'limits_memory',
97
+ 'limits_memory_swap',
98
+ 'limits_memory_swappiness',
99
+ 'limits_memory_reservation',
100
+ 'limits_cpus',
101
+ 'limits_cpuset',
102
+ 'limits_cpu_shares',
103
+ ];
104
+ export function snapshotAppSettings(live) {
105
+ return Object.fromEntries(APP_SETTING_FIELDS.map((field) => [field, live[field] ?? null]));
106
+ }
107
+ export const bareName = (name) => name.trim().replace(/^https?:\/\//, '');
108
+ export const liveNames = (live) => typeof live.fqdn === 'string' ? live.fqdn.split(',').filter(Boolean).map(bareName) : [];
109
+ /**
110
+ * What an adopted entry changes on its live app: only a setting it declares
111
+ * that differs from live. The entry cannot tell a declared `branch`,
112
+ * `buildPack` or `autoDeploy` from its default, so those stay as they are; a
113
+ * `null` folder or port and an empty name list declare nothing. Never
114
+ * `custom_network_aliases`.
115
+ */
116
+ export function adoptedAppPatch(entry, names, live) {
117
+ const patch = {};
118
+ const folderField = entry.buildPack === 'static' ? 'publish_directory' : 'base_directory';
119
+ if (entry.folder !== null && (live[folderField] ?? '/') !== `/${entry.folder}`) {
120
+ patch[folderField] = `/${entry.folder}`;
121
+ }
122
+ if (entry.port !== null &&
123
+ entry.buildPack !== 'static' &&
124
+ `${live.ports_exposes}` !== `${entry.port}`) {
125
+ patch.ports_exposes = String(entry.port);
126
+ }
127
+ if (names.length > 0 && [...names].sort().join() !== liveNames(live).sort().join()) {
128
+ patch.domains = names.join(',');
129
+ }
130
+ return patch;
131
+ }
132
+ /** One line per setting the patch changes, old value beside new. */
133
+ export function adoptedPatchDetails(patch, live) {
134
+ return Object.entries(patch).map(([field, value]) => {
135
+ const old = field === 'domains' ? live.fqdn : live[field];
136
+ return `${field}: ${old ?? 'unset'} -> ${value}`;
137
+ });
138
+ }
139
+ /**
140
+ * The PATCH body that puts an app's snapshot settings back. A field is sent
141
+ * only when the entry's declaration would have changed it from the snapshot
142
+ * (`adoptedAppPatch`), so a setting foundry never touched, or a person edited
143
+ * later, stays. With no entry, a field is sent when `live` differs from the
144
+ * snapshot. Coolify reads `fqdn` but takes `domains`. A field the snapshot had
145
+ * as null is cleared the way the plain app path clears it: a folder back to
146
+ * `/`, domains to the empty list. A port is never cleared, as on that path.
147
+ */
148
+ export function restoreAppPatch(entry, names, settings, live) {
149
+ const isStatic = (entry?.buildPack ?? settings.build_pack) === 'static';
150
+ const folderField = isStatic ? 'publish_directory' : 'base_directory';
151
+ const changed = new Set(Object.keys(entry ? adoptedAppPatch(entry, names, settings) : {}));
152
+ if (!entry) {
153
+ if ((live[folderField] ?? '/') !== (settings[folderField] ?? '/'))
154
+ changed.add(folderField);
155
+ if (`${live.ports_exposes}` !== `${settings.ports_exposes}`)
156
+ changed.add('ports_exposes');
157
+ if (liveNames(settings).sort().join() !== liveNames(live).sort().join())
158
+ changed.add('domains');
159
+ }
160
+ const patch = {};
161
+ if (changed.has(folderField))
162
+ patch[folderField] = settings[folderField] ?? '/';
163
+ if (changed.has('domains'))
164
+ patch.domains = settings.fqdn ?? '';
165
+ if (!isStatic && settings.ports_exposes != null && changed.has('ports_exposes')) {
166
+ patch.ports_exposes = settings.ports_exposes;
167
+ }
168
+ return patch;
169
+ }
170
+ const isWildcard = (route) => route.hostname?.startsWith('*') ?? false;
171
+ const isCatchAll = (route) => route !== undefined && route.hostname === undefined && route.path === undefined;
172
+ /** A hostname as compared: lower case, no trailing dot (DNS names ignore both). */
173
+ export const normalizeHostname = (hostname) => hostname.toLowerCase().replace(/\.$/, '');
174
+ const sameName = (a, b) => a !== undefined && normalizeHostname(a) === normalizeHostname(b);
175
+ const hasName = (names, hostname) => hostname !== undefined && [...names].some((name) => sameName(hostname, name));
176
+ /** The route foundry writes for a name: nothing but the hostname and the service. */
177
+ const isFoundryRoute = (route, service) => route.service === service && Object.keys(route).length === 2;
178
+ /** Puts `back` before the first wildcard or hostname-less route of `rest`, so a person's order stays. */
179
+ function putBack(rest, back) {
180
+ const at = rest.findIndex((route) => route.hostname === undefined || isWildcard(route));
181
+ return at < 0 ? [...rest, ...back] : [...rest.slice(0, at), ...back, ...rest.slice(at)];
182
+ }
183
+ /** A live route foundry wrote for a name it dropped: written, no longer declared, exactly foundry's own. */
184
+ const isDroppedRoute = (route, declared, service, written) => hasName(written, route.hostname) &&
185
+ !hasName(declared, route.hostname) &&
186
+ isFoundryRoute(route, service);
187
+ /**
188
+ * What the merge leaves alone: every live route that is not for a declared
189
+ * name and not foundry's own route for a name it dropped. Whatever its shape:
190
+ * a hostname, only a `path`, or neither.
191
+ */
192
+ export function foreignRoutes(live, declared, service, written) {
193
+ return live.filter((route) => !hasName(declared, route.hostname) && !isDroppedRoute(route, declared, service, written));
194
+ }
195
+ /** The declared names whose live routes the merge replaces: there are some, and they are not already foundry's one route. */
196
+ export function replacedNames(live, declared, service) {
197
+ return declared.filter((hostname) => {
198
+ const same = live.filter((route) => sameName(route.hostname, hostname));
199
+ const [only] = same;
200
+ return only !== undefined && !(same.length === 1 && isFoundryRoute(only, service));
201
+ });
202
+ }
203
+ /** The names foundry removes from `live`: written, no longer declared, still its own route. */
204
+ export const removedNames = (live, declared, service, written) => [
205
+ ...new Set(live
206
+ .filter((route) => isDroppedRoute(route, declared, service, written))
207
+ .map((route) => route.hostname)),
208
+ ];
209
+ /**
210
+ * The ingress list an adopted tunnel gets (#312): the declared exact names,
211
+ * then every foreign live route in its live order and unchanged, with the
212
+ * snapshot's routes for a name foundry dropped put back among them, then the
213
+ * declared wildcards, then the live catch-all as it is. Only a live list that
214
+ * does not end in a catch-all gets `http_status:404`.
215
+ */
216
+ export function mergeIngress(live, declared, service, written, snapshot) {
217
+ const dropped = removedNames(live, declared, service, written);
218
+ const tail = live.at(-1);
219
+ const foreign = foreignRoutes(live, declared, service, written);
220
+ const catchAll = isCatchAll(tail) ? tail : { service: 'http_status:404' };
221
+ const middle = isCatchAll(tail) ? foreign.slice(0, -1) : foreign;
222
+ const back = snapshot.filter((route) => hasName(dropped, route.hostname));
223
+ const route = (hostname) => ({ hostname, service });
224
+ return [
225
+ ...declared.filter((hostname) => !hostname.startsWith('*')).map(route),
226
+ ...putBack(middle, back),
227
+ ...declared.filter((hostname) => hostname.startsWith('*')).map(route),
228
+ catchAll,
229
+ ];
230
+ }
231
+ /**
232
+ * The ingress list restore writes (#312): the live list without foundry's own
233
+ * route for a name it wrote (`written` accumulates for the life of the
234
+ * snapshot), plus the snapshot's routes for that name. A name with no live
235
+ * route that is exactly foundry's is left alone, as is a route a person added
236
+ * or edited, and the live catch-all. The `http_status:404` foundry appended is
237
+ * removed only while it is still last and the snapshot had no catch-all; an
238
+ * empty snapshot keeps the live catch-all, or gets one.
239
+ */
240
+ export function restoreIngress(live, snapshot, service, written) {
241
+ const isOurs = (route) => hasName(written, route.hostname) && isFoundryRoute(route, service);
242
+ const swapped = (route) => hasName(live.filter(isOurs).map((r) => r.hostname), route.hostname);
243
+ const appended = snapshot.length > 0 &&
244
+ !isCatchAll(snapshot.at(-1)) &&
245
+ isCatchAll(live.at(-1)) &&
246
+ live.at(-1)?.service === 'http_status:404';
247
+ const rest = live.filter((route, index) => !isOurs(route) && !(appended && index === live.length - 1));
248
+ const restored = putBack(rest, snapshot.filter(swapped));
249
+ return snapshot.length === 0 && !isCatchAll(restored.at(-1))
250
+ ? [...restored, { service: 'http_status:404' }]
251
+ : restored;
252
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Where an adoption keeps the secret part of its snapshot (#311): the live
3
+ * app's env values. The row in `foundry_adoptions` holds only the ref. Same
4
+ * shape as the `heldKeys` port (`../bootstrap/bootstrap.ts`).
5
+ */
6
+ export interface AdoptionVault {
7
+ put(ref: string, value: string): Promise<void>;
8
+ resolve(ref: string): Promise<string | null>;
9
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -24,6 +24,13 @@ export declare const AGORA_CLIENT_ID_SCOPE_KEY = "agora_client_id";
24
24
  * report).
25
25
  */
26
26
  export declare const APP_CLIENT_ID_SCOPE_KEY = "zitadel_app_client_id";
27
+ /**
28
+ * An entry with `uses.auth` has its own client, recorded under
29
+ * `zitadel_app_client_id.<entry key>` (a scope key holds no `:`). Not a
30
+ * secret. The entry key is also the client's name in the org's project.
31
+ */
32
+ export declare const ENTRY_CLIENT_ID_SCOPE_PREFIX = "zitadel_app_client_id.";
33
+ export declare function entryClientIdScopeKey(entryKey: string): string;
27
34
  /**
28
35
  * #139: the `cms` app's own ZITADEL web client's `clientId`, recorded
29
36
  * separately from {@link APP_CLIENT_ID_SCOPE_KEY} -- that key belongs to the
@@ -23,6 +23,15 @@ export const AGORA_CLIENT_ID_SCOPE_KEY = 'agora_client_id';
23
23
  * report).
24
24
  */
25
25
  export const APP_CLIENT_ID_SCOPE_KEY = 'zitadel_app_client_id';
26
+ /**
27
+ * An entry with `uses.auth` has its own client, recorded under
28
+ * `zitadel_app_client_id.<entry key>` (a scope key holds no `:`). Not a
29
+ * secret. The entry key is also the client's name in the org's project.
30
+ */
31
+ export const ENTRY_CLIENT_ID_SCOPE_PREFIX = `${APP_CLIENT_ID_SCOPE_KEY}.`;
32
+ export function entryClientIdScopeKey(entryKey) {
33
+ return `${ENTRY_CLIENT_ID_SCOPE_PREFIX}${entryKey}`;
34
+ }
26
35
  /**
27
36
  * #139: the `cms` app's own ZITADEL web client's `clientId`, recorded
28
37
  * separately from {@link APP_CLIENT_ID_SCOPE_KEY} -- that key belongs to the
@@ -0,0 +1,108 @@
1
+ import type { DeclaredOrg, HostingEntry } from '../org.js';
2
+ import type { PlatformStore } from '../store.js';
3
+ import type { CoolifyPort } from './executor.js';
4
+ /**
5
+ * F5 (https://github.com/wtfalch/foundry/issues/314): an entry with
6
+ * `uses.auth` gets one entry in the broker's `AUTH_APPS`. The org executor has
7
+ * no way to reach the broker, so it takes a {@link BrokerPort}; the host wires
8
+ * one (Archon, row AR2).
9
+ *
10
+ * Foundry tells its own entry from a hand-written one by `name`
11
+ * (`<slug>-<entry key>`) and the `organizationId` the entry signs in, plus the
12
+ * names it recorded when it last wrote. Every other entry is copied back as it
13
+ * was. No marker field is written: the broker's parser drops unknown fields,
14
+ * and the name match plus the recorded names is enough.
15
+ */
16
+ /** The `AUTH_APPS` name of an entry's broker entry. */
17
+ export declare function brokerEntryName(org: DeclaredOrg, entry: HostingEntry): string;
18
+ /** What the last successful broker write for an entry wrote (JSON of {@link BrokerEntryWant}). Ids, names and origins; never the key. */
19
+ export declare function brokerEntryAppliedScopeKey(entryKey: string): string;
20
+ /** The foreign entries the last write found invalid, as a JSON array of names. */
21
+ export declare function brokerEntryWarningsScopeKey(entryKey: string): string;
22
+ /** What the app's env says about who signs in, as recorded so far. Missing values are left out. */
23
+ export declare function entryAuthEnv(org: DeclaredOrg, entry: Pick<HostingEntry, 'key' | 'uses'>, recorded: Readonly<Record<string, string>>, issuer: string | null): Record<string, string>;
24
+ /** The marker `pushAppEnv` records for an entry's sign-in env; the planner compares against it. */
25
+ export declare function entryAuthEnvAppliedScopeKey(entryKey: string): string;
26
+ /** What a broker entry must say, without its key. */
27
+ export interface BrokerEntryWant {
28
+ name: string;
29
+ organizationId: string;
30
+ clientId: string;
31
+ origins: string[];
32
+ from: string;
33
+ registration: boolean;
34
+ replaces: string | null;
35
+ }
36
+ /**
37
+ * Each value a broker entry needs and the step that records it, so the
38
+ * planner can tell "not yet" (a step in this plan records it) from "blocked".
39
+ */
40
+ export type BrokerEntryInput = {
41
+ what: string;
42
+ producedBy: [system: string, kind: string];
43
+ };
44
+ export declare function wantedBrokerEntry(org: DeclaredOrg, entry: HostingEntry, recorded: Readonly<Record<string, string>>): {
45
+ want: BrokerEntryWant;
46
+ } | {
47
+ missing: BrokerEntryInput[];
48
+ };
49
+ /**
50
+ * The broker app, as the org executor sees it: Coolify env read, env write
51
+ * and deploy on the broker's app, and the platform's record of the last broker
52
+ * deploy. Foundry defines the port; Archon wires it (AR2).
53
+ */
54
+ export interface BrokerPort {
55
+ /** The broker app's Coolify id; throws when the platform has none recorded. */
56
+ appId(): Promise<string>;
57
+ listEnvs(): Promise<Array<{
58
+ key: string;
59
+ value: string;
60
+ is_preview: boolean;
61
+ }>>;
62
+ setAuthApps(raw: string): Promise<void>;
63
+ deploy(): Promise<void>;
64
+ /** The platform's `PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY` value, if any. */
65
+ recordedDeploy(): Promise<string | undefined>;
66
+ recordDeploy(value: string): Promise<void>;
67
+ }
68
+ /** {@link BrokerPort} over the platform's recorded ids and a Coolify client. */
69
+ export declare class PlatformBrokerPort implements BrokerPort {
70
+ private readonly options;
71
+ constructor(options: {
72
+ store: Pick<PlatformStore, 'getServiceIds' | 'putServiceIds'>;
73
+ coolify: Pick<CoolifyPort, 'listEnvs' | 'bulkUpsertEnvs' | 'deploy'>;
74
+ });
75
+ appId(): Promise<string>;
76
+ listEnvs(): Promise<{
77
+ uuid?: string;
78
+ key: string;
79
+ value: string;
80
+ is_preview: boolean;
81
+ is_buildtime?: boolean;
82
+ is_literal?: boolean;
83
+ is_multiline?: boolean;
84
+ }[]>;
85
+ setAuthApps(raw: string): Promise<void>;
86
+ deploy(): Promise<void>;
87
+ recordedDeploy(): Promise<string | undefined>;
88
+ recordDeploy(value: string): Promise<void>;
89
+ }
90
+ /**
91
+ * Writes one entry's broker entry beside the others and deploys the broker
92
+ * when its `AUTH_APPS` changed. Foundry's own entry is updated in place, a
93
+ * hand-written entry the entry declares it replaces is removed, and every
94
+ * other entry is copied back as it was, an invalid one included: the broker
95
+ * drops it, and one bad hand-written entry must not block sign-in. Errors
96
+ * name the entry, never a key.
97
+ *
98
+ * Returns the names of foreign entries the broker would drop, and the JSON
99
+ * of a replaced entry (what the F4a snapshot is handed).
100
+ */
101
+ export declare function writeBrokerEntry(broker: BrokerPort, input: {
102
+ want: BrokerEntryWant;
103
+ appKey: string;
104
+ previous: BrokerEntryWant | null;
105
+ }): Promise<{
106
+ invalidForeign: string[];
107
+ replaced: string | null;
108
+ }>;
@@ -0,0 +1,214 @@
1
+ import { entryClientIdScopeKey } from './agora.js';
2
+ import { entryHostnames } from './hostnames.js';
3
+ import { PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, parseBrokerDeployRecord, } from './platform-diff.js';
4
+ import { brokerDeployNeeded, brokerDeployRecordValue, missingBrokerInputs, validateBrokerAppEntry, } from './platform-providers.js';
5
+ /**
6
+ * F5 (https://github.com/wtfalch/foundry/issues/314): an entry with
7
+ * `uses.auth` gets one entry in the broker's `AUTH_APPS`. The org executor has
8
+ * no way to reach the broker, so it takes a {@link BrokerPort}; the host wires
9
+ * one (Archon, row AR2).
10
+ *
11
+ * Foundry tells its own entry from a hand-written one by `name`
12
+ * (`<slug>-<entry key>`) and the `organizationId` the entry signs in, plus the
13
+ * names it recorded when it last wrote. Every other entry is copied back as it
14
+ * was. No marker field is written: the broker's parser drops unknown fields,
15
+ * and the name match plus the recorded names is enough.
16
+ */
17
+ /** The `AUTH_APPS` name of an entry's broker entry. */
18
+ export function brokerEntryName(org, entry) {
19
+ return `${org.slug}-${entry.key}`;
20
+ }
21
+ /** What the last successful broker write for an entry wrote (JSON of {@link BrokerEntryWant}). Ids, names and origins; never the key. */
22
+ export function brokerEntryAppliedScopeKey(entryKey) {
23
+ return `broker_entry_applied.${entryKey}`;
24
+ }
25
+ /** The foreign entries the last write found invalid, as a JSON array of names. */
26
+ export function brokerEntryWarningsScopeKey(entryKey) {
27
+ return `broker_entry_warnings.${entryKey}`;
28
+ }
29
+ /** What the app's env says about who signs in, as recorded so far. Missing values are left out. */
30
+ export function entryAuthEnv(org, entry, recorded, issuer) {
31
+ const auth = entry.uses?.auth;
32
+ if (!auth)
33
+ return {};
34
+ const organizationId = auth.users === 'customers' ? recorded[`zitadel:customers:${org.id}`] : recorded.zitadel_org;
35
+ const values = {
36
+ AUTH_ORGANIZATION_ID: organizationId,
37
+ AUTH_CLIENT_ID: recorded[entryClientIdScopeKey(entry.key)],
38
+ AUTH_ISSUER: issuer ?? undefined,
39
+ };
40
+ return Object.fromEntries(Object.entries(values).filter(([, value]) => Boolean(value)));
41
+ }
42
+ /** The marker `pushAppEnv` records for an entry's sign-in env; the planner compares against it. */
43
+ export function entryAuthEnvAppliedScopeKey(entryKey) {
44
+ return `auth_env_applied.${entryKey}`;
45
+ }
46
+ export function wantedBrokerEntry(org, entry, recorded) {
47
+ const auth = entry.uses?.auth;
48
+ const organizationId = auth.users === 'customers' ? recorded[`zitadel:customers:${org.id}`] : recorded.zitadel_org;
49
+ const clientId = recorded[entryClientIdScopeKey(entry.key)];
50
+ const mailDomain = recorded.mail_domain;
51
+ const missing = [];
52
+ if (!organizationId) {
53
+ missing.push(auth.users === 'customers'
54
+ ? { what: 'the customers organisation id', producedBy: ['zitadel', 'customers'] }
55
+ : { what: "the org's ZITADEL org id", producedBy: ['zitadel', 'issuer'] });
56
+ }
57
+ if (!clientId)
58
+ missing.push({ what: 'its sign-in client id', producedBy: ['zitadel', 'app'] });
59
+ if (!mailDomain)
60
+ missing.push({ what: 'the org mail domain', producedBy: ['coolify', 'mail'] });
61
+ if (!organizationId || !clientId || !mailDomain)
62
+ return { missing };
63
+ return {
64
+ want: {
65
+ name: brokerEntryName(org, entry),
66
+ organizationId,
67
+ clientId,
68
+ origins: entryHostnames(org, entry).map((host) => `https://${host}`),
69
+ from: `${org.mail.noReplyLocalPart || 'noreply'}@${mailDomain}`,
70
+ registration: auth.registration === 'open',
71
+ replaces: auth.replacesBrokerEntry ?? null,
72
+ },
73
+ };
74
+ }
75
+ /** {@link BrokerPort} over the platform's recorded ids and a Coolify client. */
76
+ export class PlatformBrokerPort {
77
+ options;
78
+ constructor(options) {
79
+ this.options = options;
80
+ }
81
+ async appId() {
82
+ const id = (await this.options.store.getServiceIds())[PLATFORM_AUTH_BROKER_SCOPE_KEY];
83
+ if (!id)
84
+ throw new Error('the platform has not recorded its broker app yet');
85
+ return id;
86
+ }
87
+ async listEnvs() {
88
+ return this.options.coolify.listEnvs(await this.appId());
89
+ }
90
+ async setAuthApps(raw) {
91
+ await this.options.coolify.bulkUpsertEnvs(await this.appId(), [
92
+ { key: 'AUTH_APPS', value: raw },
93
+ ]);
94
+ }
95
+ async deploy() {
96
+ await this.options.coolify.deploy(await this.appId());
97
+ }
98
+ async recordedDeploy() {
99
+ return (await this.options.store.getServiceIds())[PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY];
100
+ }
101
+ async recordDeploy(value) {
102
+ const ids = await this.options.store.getServiceIds();
103
+ await this.options.store.putServiceIds({
104
+ ...ids,
105
+ [PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY]: value,
106
+ });
107
+ }
108
+ }
109
+ const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
110
+ /**
111
+ * Writes one entry's broker entry beside the others and deploys the broker
112
+ * when its `AUTH_APPS` changed. Foundry's own entry is updated in place, a
113
+ * hand-written entry the entry declares it replaces is removed, and every
114
+ * other entry is copied back as it was, an invalid one included: the broker
115
+ * drops it, and one bad hand-written entry must not block sign-in. Errors
116
+ * name the entry, never a key.
117
+ *
118
+ * Returns the names of foreign entries the broker would drop, and the JSON
119
+ * of a replaced entry (what the F4a snapshot is handed).
120
+ */
121
+ export async function writeBrokerEntry(broker, input) {
122
+ const { want, appKey, previous } = input;
123
+ const label = `broker entry '${want.name}'`;
124
+ const brokerAppId = await broker.appId();
125
+ const env = (await broker.listEnvs()).find((e) => e.key === 'AUTH_APPS' && !e.is_preview);
126
+ // A value that reads back empty is not an empty list: writing over it
127
+ // would drop every hand-written entry.
128
+ if (env && !env.value) {
129
+ throw new Error(`${label}: the broker's AUTH_APPS read back empty, so nothing was written`);
130
+ }
131
+ const raw = env?.value;
132
+ let parsed = [];
133
+ if (raw) {
134
+ try {
135
+ parsed = JSON.parse(raw);
136
+ }
137
+ catch {
138
+ throw new Error(`${label}: the broker's AUTH_APPS is not JSON, so nothing was written`);
139
+ }
140
+ }
141
+ if (!Array.isArray(parsed)) {
142
+ throw new Error(`${label}: the broker's AUTH_APPS is not a JSON array, so nothing was written`);
143
+ }
144
+ const current = parsed;
145
+ const isOwn = (entry) => isObject(entry) &&
146
+ entry.name === want.name &&
147
+ (entry.organizationId === want.organizationId ||
148
+ (previous !== null && entry.organizationId === previous.organizationId));
149
+ const isReplaced = (entry) => want.replaces !== null && isObject(entry) && entry.name === want.replaces && !isOwn(entry);
150
+ const entry = {
151
+ ...(current.find(isOwn) ?? {}),
152
+ organizationId: want.organizationId,
153
+ key: appKey,
154
+ name: want.name,
155
+ clientIds: [want.clientId],
156
+ origins: want.origins,
157
+ from: want.from,
158
+ registration: want.registration,
159
+ };
160
+ try {
161
+ validateBrokerAppEntry(entry, brokerAppId, 0);
162
+ }
163
+ catch (error) {
164
+ // The validator's message names the broker app and the entry's place in
165
+ // the list, never a value; keep only what is wrong with it.
166
+ const why = error.message.replace(/^.*AUTH_APPS\[\d+\] /, '');
167
+ throw new Error(`${label} would be dropped by the broker: it ${why}`);
168
+ }
169
+ const invalidForeign = [];
170
+ const replaced = [];
171
+ const next = [];
172
+ let placed = false;
173
+ const place = () => {
174
+ if (!placed)
175
+ next.push(entry);
176
+ placed = true;
177
+ };
178
+ current.forEach((existing, index) => {
179
+ if (isOwn(existing))
180
+ return place();
181
+ if (isReplaced(existing)) {
182
+ replaced.push(existing);
183
+ return place();
184
+ }
185
+ try {
186
+ validateBrokerAppEntry(existing, brokerAppId, index);
187
+ }
188
+ catch {
189
+ const name = isObject(existing) && typeof existing.name === 'string' ? existing.name : null;
190
+ invalidForeign.push(name === null ? `AUTH_APPS[${index}]` : name.slice(0, 100));
191
+ }
192
+ next.push(existing);
193
+ });
194
+ place();
195
+ const nextRaw = JSON.stringify(next);
196
+ const changed = JSON.stringify(current) !== nextRaw;
197
+ if (changed)
198
+ await broker.setAuthApps(nextRaw);
199
+ // The platform's own check of the broker's inputs also refuses a broker
200
+ // whose AUTH_APPS holds an invalid entry; the broker itself only drops it,
201
+ // so that one does not hold back the deploy.
202
+ const missing = (await missingBrokerInputs({ listEnvs: () => broker.listEnvs() }, brokerAppId)).filter((input) => input !== 'AUTH_APPS');
203
+ const live = changed ? nextRaw : raw;
204
+ if (brokerDeployNeeded(missing, brokerAppId, live, parseBrokerDeployRecord(await broker.recordedDeploy()))) {
205
+ await broker.deploy();
206
+ // The platform's record of what the broker last deployed, so its next
207
+ // apply does not read this write as drift and deploy the broker again.
208
+ await broker.recordDeploy(brokerDeployRecordValue(brokerAppId, live));
209
+ }
210
+ return {
211
+ invalidForeign,
212
+ replaced: replaced.length > 0 ? JSON.stringify(replaced) : null,
213
+ };
214
+ }
@@ -29,12 +29,20 @@ export interface EnsureCloudflaredConnectorInput {
29
29
  tunnelId: string;
30
30
  /** Fixed name, found before created -- so a second apply reuses this app rather than making a second one. */
31
31
  appName: string;
32
- projectUuid: string;
33
- environment: {
32
+ /** Needed to find the app by name or create it; an `adopted` connector needs neither. */
33
+ projectUuid?: string;
34
+ environment?: {
34
35
  id: number;
35
36
  name: string;
36
37
  uuid: string;
37
38
  };
39
+ /**
40
+ * #312: `recordedConnectorAppId` is a live app foundry adopted. It is never
41
+ * looked up by name or created; one that cannot be found is an error.
42
+ */
43
+ adopted?: boolean;
44
+ /** #312: called with the token just before `TUNNEL_TOKEN` is rewritten, so a later restore knows foundry wrote it. */
45
+ beforeTokenWrite?: (token: string) => Promise<void>;
38
46
  /** Called only when the app must be created -- never on the found-by-name or found-by-id paths. */
39
47
  resolveServer: () => Promise<{
40
48
  uuid: string;