@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 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): string | null;
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 !== '';
@@ -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,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
+ }
@@ -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;