@wtfalch/foundry 0.8.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.
@@ -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;