@wtfalch/foundry 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +1 -1
- package/dist/migrations/0030_hosting_entry_hostnames.sql +24 -0
- package/dist/org.d.ts +30 -4
- package/dist/org.js +82 -1
- package/dist/plan/agora.d.ts +7 -0
- package/dist/plan/agora.js +9 -0
- package/dist/plan/broker-entries.d.ts +104 -0
- package/dist/plan/broker-entries.js +214 -0
- package/dist/plan/diff.d.ts +2 -0
- package/dist/plan/diff.js +260 -42
- package/dist/plan/executor.d.ts +44 -0
- package/dist/plan/executor.js +219 -90
- package/dist/plan/hostnames.d.ts +24 -0
- package/dist/plan/hostnames.js +56 -0
- package/dist/plan/index.d.ts +2 -0
- package/dist/plan/index.js +1 -0
- package/dist/plan/locked-hostnames.d.ts +8 -0
- package/dist/plan/locked-hostnames.js +26 -0
- package/dist/plan/login-host.d.ts +1 -1
- package/dist/plan/login-host.js +5 -2
- package/dist/plan/plan-apply.d.ts +16 -0
- package/dist/plan/plan-apply.js +42 -2
- package/dist/plan/platform-diff.d.ts +60 -13
- package/dist/plan/platform-diff.js +109 -15
- package/dist/plan/platform-providers.d.ts +95 -2
- package/dist/plan/platform-providers.js +393 -27
- package/dist/store/postgres.js +11 -3
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type { OrgId, MailMode, MailConfig, ChatConfig, CallsConfig, IssuerConfig, SiteConfig, IssuerAppKind, IssuerApp, HostingConfig, HostingEntry, EnvValue, SsoConfig, GuestsConfig, SoloConfig, StorageConfig, AiConfig, BuyerAddress, BuyerIdentity, AdoptedAppForm, AdoptedApp, OwnerIdentity, DeclaredOrg, } from './org.js';
|
|
1
|
+
export type { OrgId, MailMode, MailConfig, ChatConfig, CallsConfig, IssuerConfig, SiteConfig, IssuerAppKind, IssuerApp, HostingConfig, HostingEntry, HostingEntryUses, EntryAuth, EnvValue, SsoConfig, GuestsConfig, SoloConfig, StorageConfig, AiConfig, BuyerAddress, BuyerIdentity, AdoptedAppForm, AdoptedApp, OwnerIdentity, DeclaredOrg, } from './org.js';
|
|
2
2
|
export { APP_ENTRY_KEY, DEFAULT_MAIL_APPS, appEntry, validateBuyerIdentity, validateHosting, validateMail, validateOwner, validateSite, } from './org.js';
|
|
3
3
|
export type { DeclaredPlatform, PlatformService, PlatformServiceKey } from './platform.js';
|
|
4
4
|
export { PLATFORM_SERVICE_KEYS, defaultPlatformHostname, validatePlatform, } from './platform.js';
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
-- @wtfalch/foundry: each hosting entry names where it is served (org.ts's
|
|
2
|
+
-- `HostingEntry.hostnames`). Mirrors `src/store/schema.ts`'s `hostingEntries`.
|
|
3
|
+
--
|
|
4
|
+
-- The `app` entry of an org with hosting.usesDomain on is served at the org's
|
|
5
|
+
-- domain, so it gets ['@']; every other entry, and the `app` entry of an org
|
|
6
|
+
-- with usesDomain off (served at <slug>.oikos.host), gets []. An org planned
|
|
7
|
+
-- before this migration therefore plans the same names after it.
|
|
8
|
+
--
|
|
9
|
+
-- Copies and never drops: no column changes, only entries without a
|
|
10
|
+
-- `hostnames` field gain one. Safe to run twice: an entry that has the field
|
|
11
|
+
-- is left as it is.
|
|
12
|
+
|
|
13
|
+
update foundry_declared_orgs o
|
|
14
|
+
set hosting_entries = (
|
|
15
|
+
select coalesce(jsonb_agg(
|
|
16
|
+
case when e ? 'hostnames' then e
|
|
17
|
+
else e || jsonb_build_object(
|
|
18
|
+
'hostnames',
|
|
19
|
+
case when e ->> 'key' = 'app' and o.hosting_uses_domain
|
|
20
|
+
then '["@"]'::jsonb else '[]'::jsonb end)
|
|
21
|
+
end order by t.ord), '[]'::jsonb)
|
|
22
|
+
from jsonb_array_elements(o.hosting_entries) with ordinality as t(e, ord))
|
|
23
|
+
where exists (
|
|
24
|
+
select 1 from jsonb_array_elements(o.hosting_entries) e where not (e ? 'hostnames'));
|
package/dist/org.d.ts
CHANGED
|
@@ -196,9 +196,6 @@ export type HostingBuildPack = 'dockerfile' | 'nixpacks' | 'static' | 'dockercom
|
|
|
196
196
|
* its apps. Each app is one of `entries`. `tunnel` is meaningless without the
|
|
197
197
|
* apps running, so `validateHosting` below requires `enabled` before it can
|
|
198
198
|
* be true.
|
|
199
|
-
*
|
|
200
|
-
* Until the planner reads more than one entry, only the entry keyed
|
|
201
|
-
* {@link APP_ENTRY_KEY} is planned.
|
|
202
199
|
*/
|
|
203
200
|
export interface HostingConfig {
|
|
204
201
|
/** The org's apps run on Coolify. */
|
|
@@ -234,6 +231,15 @@ export interface HostingEntry {
|
|
|
234
231
|
port: number | null;
|
|
235
232
|
/** The path that answers 200 when the app is healthy. `null` is none. */
|
|
236
233
|
healthPath: string | null;
|
|
234
|
+
/**
|
|
235
|
+
* The names the entry is served at: `'@'` (the org's domain), a label under
|
|
236
|
+
* the domain, or `'*'` (reserved: a later change acts on it). Empty means an
|
|
237
|
+
* internal app, reached as `<slug>-<key>` on the Coolify network. Read only
|
|
238
|
+
* when `HostingConfig.usesDomain` is on and the org has a domain; otherwise
|
|
239
|
+
* the `app` entry is served at `<slug>.oikos.host` and no other entry may
|
|
240
|
+
* set any.
|
|
241
|
+
*/
|
|
242
|
+
hostnames: string[];
|
|
237
243
|
/** A Postgres database beside the app. At most one entry per org. */
|
|
238
244
|
database: boolean;
|
|
239
245
|
/** Deploy on every push to `branch`. Default: `true`. */
|
|
@@ -251,6 +257,23 @@ export interface HostingEntry {
|
|
|
251
257
|
* this map.
|
|
252
258
|
*/
|
|
253
259
|
env: Record<string, EnvValue>;
|
|
260
|
+
/** What the entry signs in through. Absent means it uses nothing. */
|
|
261
|
+
uses?: HostingEntryUses;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Sign-in for one entry: one ZITADEL client and one broker entry, signing in
|
|
265
|
+
* ONE organisation. `staff` is the org's own ZITADEL org, `customers` the
|
|
266
|
+
* `<slug> customers` org. `registration` is the broker entry's sign-up switch.
|
|
267
|
+
* `replacesBrokerEntry` is the `name` of the hand-written broker `AUTH_APPS`
|
|
268
|
+
* entry this entry's own entry replaces; the first write removes it.
|
|
269
|
+
*/
|
|
270
|
+
export interface EntryAuth {
|
|
271
|
+
users: 'staff' | 'customers';
|
|
272
|
+
registration: 'open' | 'closed';
|
|
273
|
+
replacesBrokerEntry?: string;
|
|
274
|
+
}
|
|
275
|
+
export interface HostingEntryUses {
|
|
276
|
+
auth?: EntryAuth;
|
|
254
277
|
}
|
|
255
278
|
/**
|
|
256
279
|
* A plain string is pushed as it is, so `validateHosting` refuses one that
|
|
@@ -282,7 +305,10 @@ export declare const APP_ENV_SOURCES: ReadonlySet<AppEnvSource>;
|
|
|
282
305
|
* unique, and at most one entry has a `database`, because the one
|
|
283
306
|
* `<slug>_rt` runtime role cannot serve two apps.
|
|
284
307
|
*/
|
|
285
|
-
export declare function validateHosting(hosting: HostingConfig
|
|
308
|
+
export declare function validateHosting(hosting: HostingConfig, names?: {
|
|
309
|
+
domain: string | null;
|
|
310
|
+
locked: ReadonlySet<string>;
|
|
311
|
+
}): string | null;
|
|
286
312
|
export declare function validateDomain(domain: string): string | null;
|
|
287
313
|
/**
|
|
288
314
|
* `null` when `site` is valid; otherwise the reason it is not (#101).
|
package/dist/org.js
CHANGED
|
@@ -32,7 +32,7 @@ const HOSTING_BUILD_PACKS = new Set([
|
|
|
32
32
|
* unique, and at most one entry has a `database`, because the one
|
|
33
33
|
* `<slug>_rt` runtime role cannot serve two apps.
|
|
34
34
|
*/
|
|
35
|
-
export function validateHosting(hosting) {
|
|
35
|
+
export function validateHosting(hosting, names) {
|
|
36
36
|
if (hosting.tunnel && !hosting.enabled) {
|
|
37
37
|
return 'hosting.tunnel requires hosting.enabled';
|
|
38
38
|
}
|
|
@@ -69,12 +69,53 @@ export function validateHosting(hosting) {
|
|
|
69
69
|
const invalid = validateHostingEntry(entry, hosting.enabled);
|
|
70
70
|
if (invalid)
|
|
71
71
|
return invalid;
|
|
72
|
+
const invalidAuth = validateEntryAuth(entry, hosting, names?.domain);
|
|
73
|
+
if (invalidAuth)
|
|
74
|
+
return invalidAuth;
|
|
72
75
|
}
|
|
76
|
+
const invalidNames = validateEntryHostnames(hosting, names);
|
|
77
|
+
if (invalidNames)
|
|
78
|
+
return invalidNames;
|
|
73
79
|
if (hosting.entries.filter((entry) => entry.database).length > 1) {
|
|
74
80
|
return 'at most one hosting entry may set database: the one runtime database role cannot serve two apps';
|
|
75
81
|
}
|
|
76
82
|
return null;
|
|
77
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* The rules for the names of every entry: each is `@`, `*` or a DNS label, no
|
|
86
|
+
* name twice, none on a name foundry owns (`plan/locked-hostnames.ts`), and
|
|
87
|
+
* none at all on a non-`app` entry while `usesDomain` is off. `names` is what
|
|
88
|
+
* the caller knows of the org; without it only the checks that need no domain
|
|
89
|
+
* run.
|
|
90
|
+
*/
|
|
91
|
+
function validateEntryHostnames(hosting, names) {
|
|
92
|
+
const taken = new Set();
|
|
93
|
+
for (const entry of hosting.entries) {
|
|
94
|
+
const at = `hosting.entries '${entry.key}'`;
|
|
95
|
+
if (!hosting.usesDomain && entry.key !== APP_ENTRY_KEY && entry.hostnames.length > 0) {
|
|
96
|
+
return `${at} hostnames need hosting.usesDomain: without it only the '${APP_ENTRY_KEY}' entry is served, at its own address`;
|
|
97
|
+
}
|
|
98
|
+
const reads = hosting.usesDomain && names?.domain;
|
|
99
|
+
if (reads && entry.key === APP_ENTRY_KEY && !entry.hostnames.some((n) => n !== '*')) {
|
|
100
|
+
return `${at} needs a hostname ('@' or a label) while hosting.usesDomain is on: '*' alone names nothing, and the app would fall back to the bare domain`;
|
|
101
|
+
}
|
|
102
|
+
for (const name of entry.hostnames) {
|
|
103
|
+
if (name !== '@' && name !== '*' && !ENTRY_KEY.test(name)) {
|
|
104
|
+
return `${at} hostname '${name}' must be '@', '*' or a DNS label`;
|
|
105
|
+
}
|
|
106
|
+
if (taken.has(name))
|
|
107
|
+
return `hostname '${name}' is held by two entries or listed twice`;
|
|
108
|
+
taken.add(name);
|
|
109
|
+
if (names?.domain &&
|
|
110
|
+
name !== '@' &&
|
|
111
|
+
name !== '*' &&
|
|
112
|
+
names.locked.has(`${name}.${names.domain}`)) {
|
|
113
|
+
return `${at} hostname '${name}' is a name foundry owns: ${name}.${names.domain}`;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
78
119
|
const ENTRY_KEY = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
79
120
|
const REPO = /^wtfalch\/[A-Za-z0-9._-]+$/;
|
|
80
121
|
// The env names `pushAppEnv` and `pushDatabaseUrl` set themselves.
|
|
@@ -141,6 +182,46 @@ function validateHostingEntry(entry, enabled) {
|
|
|
141
182
|
}
|
|
142
183
|
return null;
|
|
143
184
|
}
|
|
185
|
+
/**
|
|
186
|
+
* `uses.auth`: its two switches are known values, sign-up is never open
|
|
187
|
+
* into the staff organisation, the entry has an exact
|
|
188
|
+
* hostname to sign in at (a `*` or an internal entry has none), and the
|
|
189
|
+
* client's name, which is the entry key, is not one another client holds.
|
|
190
|
+
*/
|
|
191
|
+
function validateEntryAuth(entry, hosting, domain) {
|
|
192
|
+
const auth = entry.uses?.auth;
|
|
193
|
+
if (!auth)
|
|
194
|
+
return null;
|
|
195
|
+
const at = `hosting.entries '${entry.key}' uses.auth`;
|
|
196
|
+
if (!hosting.enabled)
|
|
197
|
+
return `${at} requires hosting.enabled`;
|
|
198
|
+
if (auth.users !== 'staff' && auth.users !== 'customers') {
|
|
199
|
+
return `${at} users must be 'staff' or 'customers'`;
|
|
200
|
+
}
|
|
201
|
+
if (auth.registration !== 'open' && auth.registration !== 'closed') {
|
|
202
|
+
return `${at} registration must be 'open' or 'closed'`;
|
|
203
|
+
}
|
|
204
|
+
// The staff organisation holds the org's own people and its project.
|
|
205
|
+
if (auth.users === 'staff' && auth.registration === 'open') {
|
|
206
|
+
return `${at} registration 'open' needs users 'customers': nobody signs themselves up as staff`;
|
|
207
|
+
}
|
|
208
|
+
if (auth.replacesBrokerEntry !== undefined &&
|
|
209
|
+
(typeof auth.replacesBrokerEntry !== 'string' || auth.replacesBrokerEntry === '')) {
|
|
210
|
+
return `${at} replacesBrokerEntry must be the non-empty name of a broker entry`;
|
|
211
|
+
}
|
|
212
|
+
// With no domain the hostnames are not read (`entryHostnames`).
|
|
213
|
+
const hasName = hosting.usesDomain && domain !== null
|
|
214
|
+
? entry.hostnames.some((name) => name !== '*')
|
|
215
|
+
: entry.key === APP_ENTRY_KEY;
|
|
216
|
+
if (!hasName)
|
|
217
|
+
return `${at} needs an exact hostname to sign in at`;
|
|
218
|
+
if (entry.key === 'agora' ||
|
|
219
|
+
entry.key === 'cms' ||
|
|
220
|
+
hosting.issuerApps.some((app) => app.name === entry.key)) {
|
|
221
|
+
return `${at} client name '${entry.key}' is already held by another ZITADEL client`;
|
|
222
|
+
}
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
144
225
|
function urlHasPassword(value) {
|
|
145
226
|
try {
|
|
146
227
|
return new URL(value).password !== '';
|
package/dist/plan/agora.d.ts
CHANGED
|
@@ -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
|
package/dist/plan/agora.js
CHANGED
|
@@ -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,104 @@
|
|
|
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: HostingEntry, 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
|
+
key: string;
|
|
78
|
+
value: string;
|
|
79
|
+
is_preview: boolean;
|
|
80
|
+
}[]>;
|
|
81
|
+
setAuthApps(raw: string): Promise<void>;
|
|
82
|
+
deploy(): Promise<void>;
|
|
83
|
+
recordedDeploy(): Promise<string | undefined>;
|
|
84
|
+
recordDeploy(value: string): Promise<void>;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Writes one entry's broker entry beside the others and deploys the broker
|
|
88
|
+
* when its `AUTH_APPS` changed. Foundry's own entry is updated in place, a
|
|
89
|
+
* hand-written entry the entry declares it replaces is removed, and every
|
|
90
|
+
* other entry is copied back as it was, an invalid one included: the broker
|
|
91
|
+
* drops it, and one bad hand-written entry must not block sign-in. Errors
|
|
92
|
+
* name the entry, never a key.
|
|
93
|
+
*
|
|
94
|
+
* Returns the names of foreign entries the broker would drop, and the JSON
|
|
95
|
+
* of a replaced entry (what the F4a snapshot is handed).
|
|
96
|
+
*/
|
|
97
|
+
export declare function writeBrokerEntry(broker: BrokerPort, input: {
|
|
98
|
+
want: BrokerEntryWant;
|
|
99
|
+
appKey: string;
|
|
100
|
+
previous: BrokerEntryWant | null;
|
|
101
|
+
}): Promise<{
|
|
102
|
+
invalidForeign: string[];
|
|
103
|
+
replaced: string | null;
|
|
104
|
+
}>;
|
|
@@ -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
|
+
}
|
package/dist/plan/diff.d.ts
CHANGED
|
@@ -34,6 +34,8 @@ export interface PlannedStep {
|
|
|
34
34
|
export interface PlanOrgOptions {
|
|
35
35
|
/** #267: the executor was built with a `loginHostBindings` port. */
|
|
36
36
|
loginHostBindings?: boolean;
|
|
37
|
+
/** F5: the executor was built with a `broker` port. */
|
|
38
|
+
broker?: boolean;
|
|
37
39
|
}
|
|
38
40
|
export interface PlanDetail {
|
|
39
41
|
orgId: OrgId;
|