@wtfalch/foundry 0.1.0 → 0.3.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/apply.d.ts +14 -0
- package/dist/calls-sfu-deploy-support.d.ts +57 -0
- package/dist/calls-sfu-deploy-support.js +82 -0
- package/dist/calls-sfu.d.ts +175 -0
- package/dist/calls-sfu.js +239 -0
- package/dist/index.d.ts +11 -6
- package/dist/index.js +7 -4
- package/dist/migrate.d.ts +38 -0
- package/dist/migrate.js +47 -0
- package/dist/migrations/0008_hosting.sql +24 -0
- package/dist/migrations/0009_hosting_git_branch_build_pack.sql +14 -0
- package/dist/migrations/0010_owner.sql +13 -0
- package/dist/migrations/0011_test_org.sql +12 -0
- package/dist/migrations/0012_audit.sql +21 -0
- package/dist/migrations/0013_access_emails.sql +6 -0
- package/dist/org.d.ts +130 -0
- package/dist/org.js +102 -0
- package/dist/plan/diff.d.ts +20 -5
- package/dist/plan/diff.js +533 -9
- package/dist/plan/executor.d.ts +823 -0
- package/dist/plan/executor.js +1242 -0
- package/dist/plan/index.d.ts +6 -4
- package/dist/plan/index.js +3 -2
- package/dist/plan/live.d.ts +8 -4
- package/dist/plan/plan-apply.d.ts +114 -4
- package/dist/plan/plan-apply.js +266 -14
- package/dist/plan/providers-readers.d.ts +102 -14
- package/dist/plan/providers-readers.js +245 -16
- package/dist/provision.d.ts +56 -0
- package/dist/provision.js +69 -0
- package/dist/slug.d.ts +12 -18
- package/dist/slug.js +66 -0
- package/dist/state.d.ts +26 -5
- package/dist/store/audit.d.ts +8 -0
- package/dist/store/audit.js +43 -0
- package/dist/store/postgres.d.ts +3 -1
- package/dist/store/postgres.js +84 -2
- package/dist/store/schema.d.ts +378 -0
- package/dist/store/schema.js +32 -1
- package/dist/store.d.ts +12 -0
- package/package.json +15 -11
|
@@ -0,0 +1,823 @@
|
|
|
1
|
+
import type { OrgMailOptions, OrgMailOrg, OrgMailResult } from '@wtfalch/email/postmaster';
|
|
2
|
+
import type { DeclaredOrg, HostingBuildPack, OrgId } from '../org.js';
|
|
3
|
+
import type { StepAction } from './diff.js';
|
|
4
|
+
import type { DestroyTarget, StepDestroyer, StepExecutionContext, StepExecutor } from './plan-apply.js';
|
|
5
|
+
/**
|
|
6
|
+
* The real {@link StepExecutor} (#20 "provision Company Factory end to
|
|
7
|
+
* end"): makes each {@link StepAction} `diff.ts` plans true against a real
|
|
8
|
+
* Coolify, Cloudflare and ZITADEL, plus per-org organisations, spend limits
|
|
9
|
+
* and (for AI) offers on Files and AI (#80). Scope and design are exactly
|
|
10
|
+
* what `.claude/decisions-2026-09-24-executor.md`'s "Executor decisions"
|
|
11
|
+
* section settled, referenced by name at each dispatch branch below.
|
|
12
|
+
*
|
|
13
|
+
* Built entirely from narrow ports (interfaces), never a concrete
|
|
14
|
+
* `@wtfalch/providers` client: this package holds no credential and never
|
|
15
|
+
* will (executor decisions: "the executor is constructed with narrow ports
|
|
16
|
+
* ... a host [archon] supplies real clients. How the host gets provider
|
|
17
|
+
* tokens from the keys Worker is the host's job, not this campaign's").
|
|
18
|
+
* Every port method here is named and shaped to match a real method on
|
|
19
|
+
* `@wtfalch/providers`' `CoolifyClient`/`CloudflareClient`/`ZitadelClient`
|
|
20
|
+
* (checked against `providers` `origin/main`, 2026-09-24), or on
|
|
21
|
+
* `@wtfalch/files`'/`@wtfalch/ai`'s own SDK clients (#80, checked against
|
|
22
|
+
* each repo's own `main`, 2026-09-25), so a real client satisfies the port
|
|
23
|
+
* structurally, with no adapter needed — see each port's own doc comment for
|
|
24
|
+
* the one real method it does not yet have.
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* The Coolify calls this executor makes, shaped to match
|
|
28
|
+
* `@wtfalch/providers/coolify`'s `CoolifyClient` method-for-method — a real
|
|
29
|
+
* `CoolifyClient` instance satisfies this port with no adapter.
|
|
30
|
+
*
|
|
31
|
+
* There is no single `findOrCreateProject`/stop/start on `CoolifyClient`
|
|
32
|
+
* today (unlike ZITADEL's `findOrCreateOrg`/`findOrCreateProject`, which
|
|
33
|
+
* already exist): `findProjectByName` + `createProject` compose into
|
|
34
|
+
* "find or create" inside this executor instead of the port, and `raw` —
|
|
35
|
+
* `CoolifyClient`'s own documented escape hatch for "use — or add — a typed
|
|
36
|
+
* method instead" — carries the stop/start calls Coolify's REST API has
|
|
37
|
+
* (`POST /applications/{uuid}/stop`, `POST /databases/{uuid}/stop`) that
|
|
38
|
+
* `CoolifyClient` has not wrapped yet. Logged as a Decision in this
|
|
39
|
+
* campaign's report rather than inventing port methods (`findOrCreateProject`,
|
|
40
|
+
* `stop`, `start`) no real client could structurally satisfy.
|
|
41
|
+
*/
|
|
42
|
+
export interface CoolifyPort {
|
|
43
|
+
findProjectByName(name: string): Promise<{
|
|
44
|
+
uuid: string;
|
|
45
|
+
name: string;
|
|
46
|
+
} | null>;
|
|
47
|
+
createProject(name: string, description?: string): Promise<{
|
|
48
|
+
uuid: string;
|
|
49
|
+
}>;
|
|
50
|
+
findOrCreateEnvironment(projectUuid: string, name: string): Promise<{
|
|
51
|
+
environment: {
|
|
52
|
+
id: number;
|
|
53
|
+
uuid: string;
|
|
54
|
+
name: string;
|
|
55
|
+
};
|
|
56
|
+
created: boolean;
|
|
57
|
+
}>;
|
|
58
|
+
listServers(): Promise<Array<{
|
|
59
|
+
uuid: string;
|
|
60
|
+
is_usable: boolean;
|
|
61
|
+
is_reachable: boolean;
|
|
62
|
+
}>>;
|
|
63
|
+
requireGithubApp(preferredUuid?: string): Promise<{
|
|
64
|
+
uuid: string;
|
|
65
|
+
name: string;
|
|
66
|
+
}>;
|
|
67
|
+
findApplicationByName(name: string, scope?: {
|
|
68
|
+
environmentIds: number[];
|
|
69
|
+
}): Promise<{
|
|
70
|
+
uuid: string;
|
|
71
|
+
} | null>;
|
|
72
|
+
/**
|
|
73
|
+
* One application's own live status (`` `running:healthy`, `exited:unhealthy`
|
|
74
|
+
* `` -- `@wtfalch/providers`' `CoolifyApplication.status` doc comment) --
|
|
75
|
+
* `findOrCreateCmsApp`'s own check of whether an app it found by name
|
|
76
|
+
* (rather than just created) needs starting again (#116 review round 3,
|
|
77
|
+
* Finding 1).
|
|
78
|
+
*/
|
|
79
|
+
getApplication(uuid: string): Promise<{
|
|
80
|
+
uuid: string;
|
|
81
|
+
status?: string;
|
|
82
|
+
}>;
|
|
83
|
+
createGithubAppApplication(input: {
|
|
84
|
+
project_uuid: string;
|
|
85
|
+
server_uuid: string;
|
|
86
|
+
environment_name: string;
|
|
87
|
+
environment_uuid: string;
|
|
88
|
+
github_app_uuid: string;
|
|
89
|
+
git_repository: string;
|
|
90
|
+
git_branch: string;
|
|
91
|
+
build_pack: HostingBuildPack;
|
|
92
|
+
name?: string;
|
|
93
|
+
domains?: string;
|
|
94
|
+
/** Selects which of two Dockerfiles in one repo to build (`cmsSite`'s own site/cms apps). */
|
|
95
|
+
dockerfile_location?: string;
|
|
96
|
+
}): Promise<{
|
|
97
|
+
uuid: string;
|
|
98
|
+
}>;
|
|
99
|
+
createPostgresDatabase(input: {
|
|
100
|
+
project_uuid: string;
|
|
101
|
+
server_uuid: string;
|
|
102
|
+
environment_name: string;
|
|
103
|
+
environment_uuid: string;
|
|
104
|
+
name: string;
|
|
105
|
+
postgres_db: string;
|
|
106
|
+
postgres_user: string;
|
|
107
|
+
}): Promise<{
|
|
108
|
+
uuid: string;
|
|
109
|
+
internal_db_url: string;
|
|
110
|
+
}>;
|
|
111
|
+
/**
|
|
112
|
+
* Coolify's own record of a database, re-read here (rather than trusted
|
|
113
|
+
* from `createPostgresDatabase`'s return value) so the app's env is wired
|
|
114
|
+
* from the database's live state whether this run just created it or found
|
|
115
|
+
* it already there (`coolifyApp`/`coolifyDatabase` below, hosting-wiring
|
|
116
|
+
* campaign; `cmsSite`'s own cms database, #117). `internal_db_url` is
|
|
117
|
+
* `postgres://user:password@host/db` — a
|
|
118
|
+
* live credential, present in the raw Coolify API response and reachable
|
|
119
|
+
* only from inside the Coolify Docker network (`@wtfalch/providers`'
|
|
120
|
+
* `CoolifyClient.createPostgresDatabase` doc comment).
|
|
121
|
+
*/
|
|
122
|
+
getDatabase(uuid: string): Promise<{
|
|
123
|
+
uuid: string;
|
|
124
|
+
internal_db_url?: string;
|
|
125
|
+
}>;
|
|
126
|
+
/**
|
|
127
|
+
* Every database Coolify holds. `coolifyDatabase` finds its own by name and
|
|
128
|
+
* environment, so a crash between create and persist does not make a second
|
|
129
|
+
* one on the next apply. Only `uuid`, `name` and `environment_id` are read;
|
|
130
|
+
* the raw response also carries `internal_db_url`, which is never used here.
|
|
131
|
+
*/
|
|
132
|
+
listDatabases(): Promise<Array<{
|
|
133
|
+
uuid: string;
|
|
134
|
+
name: string;
|
|
135
|
+
environment_id?: number;
|
|
136
|
+
}>>;
|
|
137
|
+
/**
|
|
138
|
+
* Upsert environment variables on an application in one call — used here to
|
|
139
|
+
* push the database's `internal_db_url` in as the app's `DATABASE_URL`
|
|
140
|
+
* (hosting-wiring campaign), and, for the cms app, its `DATABASE_URL_OWNER`
|
|
141
|
+
* plus a freshly minted `DATABASE_URL` runtime-role credential (#117). Each
|
|
142
|
+
* value is a live credential: it must reach this call and nothing else —
|
|
143
|
+
* never a step's return value, `serviceIds` (hence the store), an audit
|
|
144
|
+
* record, a log or a thrown error. See `coolifyApp`'s `pushDatabaseUrl` and
|
|
145
|
+
* `cmsSite`'s `ensureCmsRuntimeUrl`.
|
|
146
|
+
*/
|
|
147
|
+
bulkUpsertEnvs(applicationUuid: string, envs: Array<{
|
|
148
|
+
key: string;
|
|
149
|
+
value: string;
|
|
150
|
+
buildTime?: boolean;
|
|
151
|
+
literal?: boolean;
|
|
152
|
+
multiline?: boolean;
|
|
153
|
+
}>): Promise<unknown>;
|
|
154
|
+
/**
|
|
155
|
+
* Every environment variable currently on the application — read before
|
|
156
|
+
* generating `AUTH_COOKIE_SECRET` (`ensureAuthCookieSecret`, level-1
|
|
157
|
+
* app-env campaign) so that value is only ever generated once, never
|
|
158
|
+
* overwritten. Every key comes back twice (a production row and an
|
|
159
|
+
* `is_preview` duplicate, `@wtfalch/providers`' `CoolifyClient.listEnvs`
|
|
160
|
+
* doc comment), so a caller checking "does this key already exist" filters
|
|
161
|
+
* `is_preview === false` the same way a deployed-config reader must.
|
|
162
|
+
*/
|
|
163
|
+
listEnvs(applicationUuid: string): Promise<Array<{
|
|
164
|
+
key: string;
|
|
165
|
+
value: string;
|
|
166
|
+
is_preview: boolean;
|
|
167
|
+
}>>;
|
|
168
|
+
/** Escape hatch (`CoolifyClient.raw`) — used for stop/start, which have no typed method yet. */
|
|
169
|
+
raw(method: string, path: string, body?: unknown): Promise<unknown>;
|
|
170
|
+
/**
|
|
171
|
+
* Persistent (named volume) and file storages already attached to an
|
|
172
|
+
* application (#75) — `ensureLogVolume` reads this before creating one, so
|
|
173
|
+
* a re-run never mounts a second volume at the same path.
|
|
174
|
+
*/
|
|
175
|
+
listStorages(applicationUuid: string): Promise<{
|
|
176
|
+
persistent_storages?: Array<{
|
|
177
|
+
uuid: string;
|
|
178
|
+
name?: string;
|
|
179
|
+
mount_path: string;
|
|
180
|
+
}>;
|
|
181
|
+
file_storages?: Array<{
|
|
182
|
+
uuid: string;
|
|
183
|
+
mount_path: string;
|
|
184
|
+
}>;
|
|
185
|
+
}>;
|
|
186
|
+
/** Mounts a named volume on an application (#75) — `ensureLogVolume`'s create path. */
|
|
187
|
+
createPersistentStorage(applicationUuid: string, input: {
|
|
188
|
+
name: string;
|
|
189
|
+
mount_path: string;
|
|
190
|
+
}): Promise<unknown>;
|
|
191
|
+
/**
|
|
192
|
+
* Restarts the running container without a rebuild (#75) — `ensureLogVolume`
|
|
193
|
+
* calls this after mounting a volume on an app that was already running,
|
|
194
|
+
* since Coolify only binds a newly created storage into the container at
|
|
195
|
+
* its next start.
|
|
196
|
+
*/
|
|
197
|
+
restart(applicationUuid: string): Promise<unknown>;
|
|
198
|
+
/** `FoundryPlanApply.destroy`'s teardown of the org's own app. */
|
|
199
|
+
deleteApplication(uuid: string): Promise<unknown>;
|
|
200
|
+
/** `FoundryPlanApply.destroy`'s teardown of the org's own database. */
|
|
201
|
+
deleteDatabase(uuid: string): Promise<unknown>;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* The Cloudflare calls this executor makes, shaped to match
|
|
205
|
+
* `@wtfalch/providers/cloudflare`'s `CloudflareClient` method-for-method.
|
|
206
|
+
*/
|
|
207
|
+
export interface CloudflarePort {
|
|
208
|
+
findZoneByApex(domain: string): Promise<{
|
|
209
|
+
id: string;
|
|
210
|
+
name: string;
|
|
211
|
+
} | undefined>;
|
|
212
|
+
createZone(name: string): Promise<{
|
|
213
|
+
id: string;
|
|
214
|
+
name: string;
|
|
215
|
+
}>;
|
|
216
|
+
findTunnelByName(name: string): Promise<{
|
|
217
|
+
id: string;
|
|
218
|
+
name: string;
|
|
219
|
+
} | undefined>;
|
|
220
|
+
createTunnel(name: string): Promise<{
|
|
221
|
+
id: string;
|
|
222
|
+
name: string;
|
|
223
|
+
}>;
|
|
224
|
+
putTunnelConfig(tunnelId: string, config: unknown): Promise<unknown>;
|
|
225
|
+
/** `FoundryPlanApply.destroy`'s teardown of the org's own Cloudflare tunnel. */
|
|
226
|
+
deleteTunnel(tunnelId: string): Promise<void>;
|
|
227
|
+
/** `cloudflareAccess` finds the org's Access app by domain before creating one (#84). */
|
|
228
|
+
listAccessApps(): Promise<Array<{
|
|
229
|
+
id: string;
|
|
230
|
+
name: string;
|
|
231
|
+
domain: string;
|
|
232
|
+
}>>;
|
|
233
|
+
createAccessApp(app: {
|
|
234
|
+
name: string;
|
|
235
|
+
domain: string;
|
|
236
|
+
type: string;
|
|
237
|
+
}): Promise<{
|
|
238
|
+
id: string;
|
|
239
|
+
}>;
|
|
240
|
+
listAccessPolicies(appId: string): Promise<Array<{
|
|
241
|
+
id: string;
|
|
242
|
+
name: string;
|
|
243
|
+
}>>;
|
|
244
|
+
createAccessPolicy(appId: string, policy: {
|
|
245
|
+
name: string;
|
|
246
|
+
decision: string;
|
|
247
|
+
include: unknown[];
|
|
248
|
+
}): Promise<{
|
|
249
|
+
id: string;
|
|
250
|
+
}>;
|
|
251
|
+
deleteAccessApp(appId: string): Promise<void>;
|
|
252
|
+
/**
|
|
253
|
+
* `FoundryPlanApply.destroy`'s teardown of any DNS record foundry created
|
|
254
|
+
* for the org. Nothing in this executor creates or records one yet (Finding,
|
|
255
|
+
* this campaign's report: no `cloudflare:dns` resource kind exists in
|
|
256
|
+
* `diff.ts`/`executor.ts` today), so `destroy` has no dispatch target for
|
|
257
|
+
* it either -- this port method exists ready for whichever step starts
|
|
258
|
+
* creating one.
|
|
259
|
+
*/
|
|
260
|
+
deleteDnsRecord(zoneId: string, recordId: string): Promise<void>;
|
|
261
|
+
/** `coolify:mail`'s lookup of an existing MX/TXT record before upserting it, filtered by type so a stale record of a different type is never mistaken for a match. */
|
|
262
|
+
findDnsRecord(zoneId: string, name: string, type?: string): Promise<Array<{
|
|
263
|
+
id: string;
|
|
264
|
+
type: string;
|
|
265
|
+
content: string;
|
|
266
|
+
name: string;
|
|
267
|
+
}>>;
|
|
268
|
+
createDnsRecord(zoneId: string, rec: {
|
|
269
|
+
type: string;
|
|
270
|
+
name: string;
|
|
271
|
+
content: string;
|
|
272
|
+
proxied?: boolean;
|
|
273
|
+
ttl?: number;
|
|
274
|
+
}): Promise<{
|
|
275
|
+
id: string;
|
|
276
|
+
name: string;
|
|
277
|
+
content: string;
|
|
278
|
+
}>;
|
|
279
|
+
updateDnsRecord(zoneId: string, recordId: string, rec: {
|
|
280
|
+
type: string;
|
|
281
|
+
name: string;
|
|
282
|
+
content: string;
|
|
283
|
+
proxied?: boolean;
|
|
284
|
+
ttl?: number;
|
|
285
|
+
}): Promise<{
|
|
286
|
+
id: string;
|
|
287
|
+
name: string;
|
|
288
|
+
content: string;
|
|
289
|
+
}>;
|
|
290
|
+
/**
|
|
291
|
+
* Escape hatch (mirrors `CoolifyPort.raw`'s own precedent above) --
|
|
292
|
+
* `@wtfalch/providers` 0.5.0's `CloudflareClient` has no typed method for
|
|
293
|
+
* Health Checks or zone rulesets/rate-limiting rules
|
|
294
|
+
* (https://github.com/wtfalch/foundry/issues/43), only this public,
|
|
295
|
+
* generic `request<T>(method, path, body?, missingOkay?)`, which
|
|
296
|
+
* this port names directly rather than inventing a narrower method no real
|
|
297
|
+
* client could structurally satisfy. `cloudflareRateLimit` below is its one
|
|
298
|
+
* caller today. `missingOkay` makes a 404 resolve to `undefined` instead of
|
|
299
|
+
* throwing, matching `CloudflareClient`'s own real behavior exactly.
|
|
300
|
+
*/
|
|
301
|
+
request<T>(method: string, path: string, body?: unknown, missingOkay?: boolean): Promise<T>;
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* The Stalwart calls this executor makes for one org's mail, shaped to match
|
|
305
|
+
* `@wtfalch/email/postmaster`'s `applyOrgMail`/`parkOrgMail` method-for-method
|
|
306
|
+
* with the `StalwartClient` bound, the same way {@link CloudflarePort} mirrors
|
|
307
|
+
* `CloudflareClient`.
|
|
308
|
+
*/
|
|
309
|
+
export interface MailPort {
|
|
310
|
+
applyOrgMail(org: OrgMailOrg, options: OrgMailOptions): Promise<OrgMailResult>;
|
|
311
|
+
parkOrgMail(org: OrgMailOrg, options: OrgMailOptions): Promise<void>;
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* The ZITADEL calls this executor makes. `findOrCreateOrg`/`findOrCreateProject`
|
|
315
|
+
* already exist on `@wtfalch/providers/zitadel`'s `ZitadelClient` (returning
|
|
316
|
+
* `{org|project, created}`, not the bare `{id}` this campaign's brief
|
|
317
|
+
* sketched — matched to the real shape here, logged as a Decision).
|
|
318
|
+
* `createOidcApp`/`createApiApp`/`deleteApp` do not exist yet — "being added
|
|
319
|
+
* to providers in parallel" (task brief) — so this port states the shapes
|
|
320
|
+
* they need to land with; a `ZitadelClient` that gains exactly these three
|
|
321
|
+
* methods satisfies this port with no adapter.
|
|
322
|
+
*/
|
|
323
|
+
export interface ZitadelPort {
|
|
324
|
+
findOrCreateOrg(name: string): Promise<{
|
|
325
|
+
org: {
|
|
326
|
+
id: string;
|
|
327
|
+
name: string;
|
|
328
|
+
};
|
|
329
|
+
created: boolean;
|
|
330
|
+
}>;
|
|
331
|
+
findOrCreateProject(orgId: string, name: string): Promise<{
|
|
332
|
+
project: {
|
|
333
|
+
id: string;
|
|
334
|
+
name: string;
|
|
335
|
+
};
|
|
336
|
+
created: boolean;
|
|
337
|
+
}>;
|
|
338
|
+
createOidcApp(orgId: string, projectId: string, input: {
|
|
339
|
+
name: string;
|
|
340
|
+
appType: 'web' | 'native';
|
|
341
|
+
redirectUris: string[];
|
|
342
|
+
postLogoutRedirectUris: string[];
|
|
343
|
+
}): Promise<{
|
|
344
|
+
appId: string;
|
|
345
|
+
clientId: string;
|
|
346
|
+
}>;
|
|
347
|
+
/**
|
|
348
|
+
* Returns `clientSecret` alongside `appId`/`clientId` — ZITADEL mints it
|
|
349
|
+
* once, on creation, and never shows it again (`providers`' `ZitadelClient.
|
|
350
|
+
* createApiApp`, commit 1d0fec2). The executor hands it straight to
|
|
351
|
+
* {@link FoundryStepExecutorPorts.deliverSecret} and never returns, stores
|
|
352
|
+
* or logs it itself (see that port's doc comment).
|
|
353
|
+
*/
|
|
354
|
+
createApiApp(orgId: string, projectId: string, input: {
|
|
355
|
+
name: string;
|
|
356
|
+
}): Promise<{
|
|
357
|
+
appId: string;
|
|
358
|
+
clientId: string;
|
|
359
|
+
clientSecret: string;
|
|
360
|
+
}>;
|
|
361
|
+
deleteApp(orgId: string, projectId: string, appId: string): Promise<void>;
|
|
362
|
+
/**
|
|
363
|
+
* The org-owner flow (goal line): find-or-create the human user by email in
|
|
364
|
+
* the org, then have ZITADEL email them an invite to set up a passkey.
|
|
365
|
+
* Idempotent on the user; the invite itself is not (a re-run always sends a
|
|
366
|
+
* fresh code) — `plan/diff.ts`'s `planOwner` is what makes a *re-run of
|
|
367
|
+
* foundry's own apply* idempotent, by planning nothing once the returned
|
|
368
|
+
* `userId` is recorded. Returns only ids, matching `createApiApp`'s own
|
|
369
|
+
* rule for anything invite/credential-shaped: the email itself is a
|
|
370
|
+
* parameter here, never part of the return value.
|
|
371
|
+
*/
|
|
372
|
+
inviteOrgOwner(orgId: string, owner: {
|
|
373
|
+
givenName: string;
|
|
374
|
+
familyName: string;
|
|
375
|
+
email: string;
|
|
376
|
+
}): Promise<{
|
|
377
|
+
userId: string;
|
|
378
|
+
created: boolean;
|
|
379
|
+
}>;
|
|
380
|
+
/** Lets anyone sign up to the org (plan gap A9). Idempotent. */
|
|
381
|
+
allowSelfRegistration(orgId: string): Promise<{
|
|
382
|
+
changed: boolean;
|
|
383
|
+
}>;
|
|
384
|
+
/** Grants another ZITADEL org access to a project (plan gap A9). Idempotent. */
|
|
385
|
+
findOrCreateProjectGrant(orgId: string, projectId: string, grantedOrgId: string): Promise<{
|
|
386
|
+
created: boolean;
|
|
387
|
+
}>;
|
|
388
|
+
/** `FoundryPlanApply.destroy`'s teardown of the org's ZITADEL project, once every app inside it is gone. */
|
|
389
|
+
deleteProject(orgId: string, projectId: string): Promise<void>;
|
|
390
|
+
/** `FoundryPlanApply.destroy`'s last step: removes the whole ZITADEL org and everything foundry made inside it. */
|
|
391
|
+
deleteOrg(orgId: string): Promise<void>;
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* One org's spend limit on Files, matching `files/packages/sdk/src/client.ts:154-166`'s
|
|
395
|
+
* `setQuota`/`setBudget` (the caller's own organisation, resolved from its
|
|
396
|
+
* signed request — no organisation id parameter, hence a factory per org
|
|
397
|
+
* rather than one flat port).
|
|
398
|
+
*/
|
|
399
|
+
export interface FilesOrgLimits {
|
|
400
|
+
setQuota(limitBytes: string | null): Promise<unknown>;
|
|
401
|
+
setBudget(limitMicros: string | null): Promise<unknown>;
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* One org's budget on AI, matching `ai/packages/sdk/src/client.ts:94-101`'s
|
|
405
|
+
* `updateBudget` (`UpdateBudget` from `ai/packages/sdk/src/types.ts:134-137`).
|
|
406
|
+
*/
|
|
407
|
+
export interface AiOrgLimits {
|
|
408
|
+
updateBudget(input: {
|
|
409
|
+
keyId: string | null;
|
|
410
|
+
limitMicros: string;
|
|
411
|
+
}): Promise<unknown>;
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Provisions one org's organisation on Files (#80), matching
|
|
415
|
+
* `files/packages/sdk/src/client.ts:136-145`'s
|
|
416
|
+
* `createFilesManagementClient().provisionOrganisation`
|
|
417
|
+
* (`ProvisionOrganisationInput`/`ProvisionOrganisationResult`,
|
|
418
|
+
* `files/packages/sdk/src/types.ts:146-156`). Flat, not a factory, like
|
|
419
|
+
* {@link ZitadelPort}: `organisationId` is an explicit call argument here,
|
|
420
|
+
* unlike {@link FilesOrgLimits}'s tenant-scoped calls, which resolve the
|
|
421
|
+
* caller's own organisation from its signed request instead.
|
|
422
|
+
*
|
|
423
|
+
* Finding (this campaign's report): unlike {@link AiOrganisationPort}'s bearer
|
|
424
|
+
* credential, `createFilesManagementClient`'s whole transport is "a signed
|
|
425
|
+
* human proof or an issued management key" (`files/packages/sdk/src/
|
|
426
|
+
* client.ts:117-119`) — concretely an Ed25519-signed `x-wtfalch-management`
|
|
427
|
+
* request (`files/apps/host/src/lib/authz/service-assertion.ts`'s
|
|
428
|
+
* `signManagementRequest`/`AssertionTrust`). A host can construct this without
|
|
429
|
+
* any human session: it only needs the Ed25519 private key (`SigningIdentity`)
|
|
430
|
+
* an operator's `MANAGEMENT_PUBLIC_KEYS`/`MANAGEMENT_ISSUER` pins — the same
|
|
431
|
+
* signer {@link FilesOrgLimits} needs for `setQuota`/`setBudget`, since the
|
|
432
|
+
* `organisation` field inside the signed claim selects the org per call, so
|
|
433
|
+
* one shared keypair signs for every org rather than needing a secret per
|
|
434
|
+
* org.
|
|
435
|
+
*/
|
|
436
|
+
export interface FilesOrganisationPort {
|
|
437
|
+
provisionOrganisation(input: {
|
|
438
|
+
organisationId: string;
|
|
439
|
+
ceilingBytes: string | null;
|
|
440
|
+
ceilingMicros: string | null;
|
|
441
|
+
shareHost?: string | null;
|
|
442
|
+
}): Promise<{
|
|
443
|
+
organisationId: string;
|
|
444
|
+
ceilingBytes: string | null;
|
|
445
|
+
ceilingMicros: string | null;
|
|
446
|
+
usedBytes: string;
|
|
447
|
+
reservedBytes: string;
|
|
448
|
+
spentMicros: string;
|
|
449
|
+
suspended: boolean;
|
|
450
|
+
shareHost: string | null;
|
|
451
|
+
created: boolean;
|
|
452
|
+
}>;
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Provisions one org's organisation on AI and switches its AI use on (#80),
|
|
456
|
+
* matching `ai/packages/sdk/src/client.ts:98-108`'s `provisionOrganisation`/
|
|
457
|
+
* `setOrganisationOffers` (`ProvisionOrganisation`/`ProvisionOrganisationResult`,
|
|
458
|
+
* `SetOrganisationOffers`/`OrganisationOffersView`,
|
|
459
|
+
* `ai/packages/sdk/src/types.ts:121-143`). Flat, not a factory, for the same
|
|
460
|
+
* reason as {@link FilesOrganisationPort}.
|
|
461
|
+
*
|
|
462
|
+
* Finding (this campaign's report): AI's machine credential is a plain bearer
|
|
463
|
+
* key — archon's own `AI_MANAGEMENT_KEY`, minted by
|
|
464
|
+
* `ai/apps/host/scripts/authz/bootstrap-archon.ts`'s `keys:issue-archon` with
|
|
465
|
+
* `ARCHON_AI_PERMISSIONS` (`ai.organisations:provision`,
|
|
466
|
+
* `ai.organisations:offer`, `ai.budgets:update`, among others) copied onto
|
|
467
|
+
* it — used directly with `createAiClient`, unlike Files' signed-assertion
|
|
468
|
+
* transport above.
|
|
469
|
+
*/
|
|
470
|
+
export interface AiOrganisationPort {
|
|
471
|
+
provisionOrganisation(input: {
|
|
472
|
+
organisationId: string;
|
|
473
|
+
limitMicros: string | null;
|
|
474
|
+
}): Promise<{
|
|
475
|
+
organisationId: string;
|
|
476
|
+
limitMicros: string | null;
|
|
477
|
+
created: boolean;
|
|
478
|
+
}>;
|
|
479
|
+
setOrganisationOffers(organisationId: string, offers: string[]): Promise<{
|
|
480
|
+
organisationId: string;
|
|
481
|
+
offers: string[];
|
|
482
|
+
}>;
|
|
483
|
+
}
|
|
484
|
+
export interface FoundryStepExecutorPorts {
|
|
485
|
+
coolify: CoolifyPort;
|
|
486
|
+
cloudflare: CloudflarePort;
|
|
487
|
+
zitadel: ZitadelPort;
|
|
488
|
+
mail: MailPort;
|
|
489
|
+
/** A factory, not a flat client: Files/AI are org-scoped credentials the host resolves per org. */
|
|
490
|
+
filesLimitsFor: (orgId: OrgId) => FilesOrgLimits;
|
|
491
|
+
aiLimitsFor: (orgId: OrgId) => AiOrgLimits;
|
|
492
|
+
/** Flat, not a factory: `organisationId` is an explicit argument on both ports (see each port's own doc comment). */
|
|
493
|
+
filesOrganisation: FilesOrganisationPort;
|
|
494
|
+
aiOrganisation: AiOrganisationPort;
|
|
495
|
+
/**
|
|
496
|
+
* Hands a just-minted, one-time secret (today: an API app's
|
|
497
|
+
* `clientSecret`) to the host, immediately after the provider returns it
|
|
498
|
+
* and before the step that created it returns. This package "holds no
|
|
499
|
+
* credential and never will" (this file's own top doc comment), so a
|
|
500
|
+
* secret is never a step's return value, never goes into `serviceIds` (the
|
|
501
|
+
* store, hence the DB), and is never logged or interpolated into a thrown
|
|
502
|
+
* error -- this is its one way out of the executor. `ref` names what the
|
|
503
|
+
* secret belongs to (e.g. `zitadel:app:<org.slug>:<key>`); routing it
|
|
504
|
+
* anywhere durable (a keys Worker, a secret store) is the host's job, the
|
|
505
|
+
* same division `executor.ts`'s top comment draws for provider tokens
|
|
506
|
+
* themselves.
|
|
507
|
+
*/
|
|
508
|
+
deliverSecret(ref: string, secret: string): Promise<void>;
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Thrown by name for a step this executor deliberately does not implement:
|
|
512
|
+
* chat (no admin API exists yet), plus moving the issuer onto or off the
|
|
513
|
+
* org's domain (built, but not in v1 — domain verification and passkey
|
|
514
|
+
* invalidation need their own design). Never carries a token, secret or key
|
|
515
|
+
* (task brief: "errors carry the step and the provider's status only").
|
|
516
|
+
*/
|
|
517
|
+
export declare class NotBuiltError extends Error {
|
|
518
|
+
constructor(step: string, detail: string);
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* The health URLs an outside prober should call for this org (#43): one per
|
|
522
|
+
* app foundry runs for it. A hosting org's app answers at its own hostname,
|
|
523
|
+
* and a site org's site app and cms app both serve `/api/health`
|
|
524
|
+
* (https://github.com/wtfalch/cms/tree/main/apps). An org that runs neither
|
|
525
|
+
* has nothing to probe. Exported so archon, which owns uptime, never repeats
|
|
526
|
+
* this file's hostname rules.
|
|
527
|
+
*/
|
|
528
|
+
export declare function healthUrls(org: DeclaredOrg): string[];
|
|
529
|
+
export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer {
|
|
530
|
+
private readonly ports;
|
|
531
|
+
constructor(ports: FoundryStepExecutorPorts);
|
|
532
|
+
execute(action: StepAction, context: StepExecutionContext): Promise<{
|
|
533
|
+
resourceId?: string;
|
|
534
|
+
} | undefined>;
|
|
535
|
+
/**
|
|
536
|
+
* `plan/diff.ts`'s `planAccess` (#84): puts the org's app hostname behind a
|
|
537
|
+
* Cloudflare Access app that lets only `hosting.accessEmails` through.
|
|
538
|
+
* foundry names its Access app `foundry:<orgId>` and reuses only an app with
|
|
539
|
+
* that name, so a re-run after a crash does not duplicate it. It refuses,
|
|
540
|
+
* rather than adopt, when another Access app already holds the hostname:
|
|
541
|
+
* destroy would later delete it. It also refuses when its own app carries
|
|
542
|
+
* any policy but foundry's, since that policy could let others in.
|
|
543
|
+
*/
|
|
544
|
+
private cloudflareAccess;
|
|
545
|
+
/**
|
|
546
|
+
* `plan/diff.ts`'s `planRateLimit` (#43): one Cloudflare rate limiting
|
|
547
|
+
* rule, scoped to `SIGN_IN_PATH_EXPRESSION`, on the org's own Cloudflare
|
|
548
|
+
* zone -- only ever called for an own-domain org (`planRateLimit`'s own
|
|
549
|
+
* gate), so `org.domain` is set and that zone is the org's alone, never
|
|
550
|
+
* shared with another org the way `oikos.host` is.
|
|
551
|
+
*
|
|
552
|
+
* Rate limiting rules attach to a zone's `http_ratelimit` phase entry
|
|
553
|
+
* point ruleset, one `GET`/`PUT` pair per zone (`@wtfalch/providers` 0.5.0
|
|
554
|
+
* has no typed method for either -- `CloudflarePort.request`'s own doc
|
|
555
|
+
* comment) -- so "find by name" here means reading that one ruleset's
|
|
556
|
+
* `rules[]` and matching `RATE_LIMIT_RULE_DESCRIPTION`, the same shape
|
|
557
|
+
* `cloudflareAccess` above finds its own Access app by name. The Free
|
|
558
|
+
* plan allows exactly one rule per zone, so a rule already there under a
|
|
559
|
+
* different description is refused, never adopted or replaced, mirroring
|
|
560
|
+
* `cloudflareAccess`'s own refusal when a foreign Access app already
|
|
561
|
+
* holds the hostname.
|
|
562
|
+
*/
|
|
563
|
+
private cloudflareRateLimit;
|
|
564
|
+
private zitadelIssuer;
|
|
565
|
+
/**
|
|
566
|
+
* `plan/diff.ts`'s `planOwner`: invites the org's owner into its ZITADEL
|
|
567
|
+
* org by email. Never logs, returns or throws the email itself -- only the
|
|
568
|
+
* invited user id, handed back as `resourceId` so `runSteps` records it
|
|
569
|
+
* under `zitadel:owner:<orgId>`, which is exactly what makes the next
|
|
570
|
+
* `plan()` call idempotent (`planOwner` plans nothing once that key is
|
|
571
|
+
* recorded).
|
|
572
|
+
*/
|
|
573
|
+
private zitadelOwner;
|
|
574
|
+
/**
|
|
575
|
+
* `plan/diff.ts`'s `planCustomers` (plan gap A9): the org's customers sign
|
|
576
|
+
* in through a second ZITADEL org, `<slug> customers`, with
|
|
577
|
+
* self-registration on and a grant to the org's app project. Staff stay in
|
|
578
|
+
* the org's own invite-only ZITADEL org. The space keeps the name apart from
|
|
579
|
+
* every issuer org, which is named by a slug and a slug has no spaces. Every call here is idempotent, so
|
|
580
|
+
* a re-run after a crash finishes the job.
|
|
581
|
+
*/
|
|
582
|
+
private zitadelCustomers;
|
|
583
|
+
private zitadelApp;
|
|
584
|
+
private ensureZitadelProject;
|
|
585
|
+
private ensureCoolifyProject;
|
|
586
|
+
private resolveServer;
|
|
587
|
+
private coolifyApp;
|
|
588
|
+
/**
|
|
589
|
+
* #75: mounts a persistent volume at {@link LOG_DIR} on the app, unless one
|
|
590
|
+
* is already there. Found by `mount_path`, not a recorded id — no
|
|
591
|
+
* `serviceIds` entry exists for a storage the way one does for the app or
|
|
592
|
+
* database itself, and Coolify's own listing is cheap enough to check
|
|
593
|
+
* first every time, the same way `coolifyDatabase` finds its database by
|
|
594
|
+
* name before creating one. Never called from the `enabled === false`
|
|
595
|
+
* (park) branch above, so parking an app never touches its volume either.
|
|
596
|
+
*
|
|
597
|
+
* Mounting a volume on an application already running does not attach it
|
|
598
|
+
* to the live container -- Coolify only binds a storage in at the
|
|
599
|
+
* container's next start (Finding, refuted against the first version of
|
|
600
|
+
* this method: it created the storage and let a passing `listStorages`
|
|
601
|
+
* read report `hasLogVolume: true` on the very next plan, with the
|
|
602
|
+
* container itself still writing nowhere). `wasAlreadyRunning` tells this
|
|
603
|
+
* method whether `appId` names an app `coolifyApp` found already there
|
|
604
|
+
* (restart it once the volume is created) or one it just created fresh
|
|
605
|
+
* (already about to get its first deploy/start from
|
|
606
|
+
* `createGithubAppApplication`'s own instant-deploy path, so a restart
|
|
607
|
+
* here would only be a redundant extra one).
|
|
608
|
+
*/
|
|
609
|
+
private ensureLogVolume;
|
|
610
|
+
private coolifyDatabase;
|
|
611
|
+
/**
|
|
612
|
+
* Pushes the database's connection string into the app's environment as
|
|
613
|
+
* `DATABASE_URL` -- never also as `DATABASE_URL_OWNER` (Decision, this
|
|
614
|
+
* campaign's report). Archon's own boot script
|
|
615
|
+
* (`archon/scripts/migrate-and-start.sh`) treats a bare `DATABASE_URL` with
|
|
616
|
+
* no `DATABASE_URL_OWNER` as "before the split": it migrates and serves as
|
|
617
|
+
* the one role it has, which is exactly what a database Coolify just
|
|
618
|
+
* created (one superuser role, no separate runtime role yet) is. Setting
|
|
619
|
+
* both to the *same* superuser URL would instead take the split branch --
|
|
620
|
+
* `db-role-guard.sh` then refuses to boot, since `DATABASE_URL_OWNER` being
|
|
621
|
+
* set means it expects `DATABASE_URL` to connect as a distinct,
|
|
622
|
+
* non-superuser runtime role (`<database>_rt`), which nothing here creates.
|
|
623
|
+
* Minting that role's own password and URL is a provisioning step of its
|
|
624
|
+
* own, not built here (Finding, this campaign's report).
|
|
625
|
+
*
|
|
626
|
+
* The connection string is a live credential
|
|
627
|
+
* (`postgres://user:password@host/db`): it is read from
|
|
628
|
+
* `CoolifyPort.getDatabase` and handed straight to `bulkUpsertEnvs` and
|
|
629
|
+
* nowhere else in this class -- never returned, never recorded in
|
|
630
|
+
* `serviceIds` (hence never in the store or an audit record), never logged,
|
|
631
|
+
* never interpolated into a thrown error.
|
|
632
|
+
*/
|
|
633
|
+
private pushDatabaseUrl;
|
|
634
|
+
/**
|
|
635
|
+
* Pushes what the org's app needs to sign a user in through ZITADEL (gap
|
|
636
|
+
* P2, `.plans/2026-09-26-level-1-org-map.md`) -- a second `bulkUpsertEnvs`
|
|
637
|
+
* call rather than folding into `pushDatabaseUrl`'s, since this one runs
|
|
638
|
+
* unconditionally (`coolifyApp`) where that one runs only once
|
|
639
|
+
* `hosting.database` is declared and provisioned.
|
|
640
|
+
*
|
|
641
|
+
* `APP_URL` and `LOG_DIR` (#75) are pushed unconditionally; every other
|
|
642
|
+
* value is pushed only once its own source step has actually recorded it
|
|
643
|
+
* (`context.recordedIds()`) -- on a first-ever apply,
|
|
644
|
+
* run one step at a time in `diff.ts`'s `computePlanDetail` order, that is
|
|
645
|
+
* `zitadel:issuer` (`AUTH_ORGANIZATION_ID`), `zitadel:owner`
|
|
646
|
+
* (`OWNER_USER_ID`, gap P3) and every `zitadel:app` step
|
|
647
|
+
* (`AUTH_CLIENT_ID`), all ordered before `coolify:app` for exactly this
|
|
648
|
+
* reason. AUTH_APP_KEY (waits on an auth broker registration API) and
|
|
649
|
+
* DATABASE_URL_OWNER (a runtime-role split not built yet, `pushDatabaseUrl`'s
|
|
650
|
+
* own doc comment) are out of scope here (task brief).
|
|
651
|
+
*/
|
|
652
|
+
private pushAppEnv;
|
|
653
|
+
/**
|
|
654
|
+
* `null` once the app already has one -- `AUTH_COOKIE_SECRET` is generated
|
|
655
|
+
* only the first time (task brief), never regenerated or overwritten, so a
|
|
656
|
+
* later apply's `bulkUpsertEnvs` call simply omits the key rather than
|
|
657
|
+
* upsert a fresh value over it.
|
|
658
|
+
*/
|
|
659
|
+
private ensureAuthCookieSecret;
|
|
660
|
+
/**
|
|
661
|
+
* `plan/diff.ts`'s `planSite` (#116, slicing #99's "provision the cms app,
|
|
662
|
+
* database and site app per org"): the 2026-09-25 decision
|
|
663
|
+
* (https://github.com/wtfalch/cms/blob/main/.plans/2026-09-25-org-sites.md)
|
|
664
|
+
* means foundry never calls a cms admin API for the org's site -- it
|
|
665
|
+
* provisions two ordinary Coolify applications from the one
|
|
666
|
+
* https://github.com/wtfalch/cms repo, the same way `coolifyApp` above
|
|
667
|
+
* provisions the org's own hosted app: a site app (`apps/site`, the
|
|
668
|
+
* reference renderer) at `org.domain`, built from
|
|
669
|
+
* `CMS_SITE_APP_DOCKERFILE`, and a cms app (`apps/host`, single-org mode,
|
|
670
|
+
* https://github.com/wtfalch/cms/blob/main/docs/adr/0013-one-cms-per-org.md)
|
|
671
|
+
* at {@link cmsHostnameFor}, built from the repo's own root
|
|
672
|
+
* `CMS_APP_DOCKERFILE` -- one repo, two applications, told apart by
|
|
673
|
+
* `dockerfile_location`.
|
|
674
|
+
*
|
|
675
|
+
* `action.key` is `org.id` (`planSite`'s own singleton key), so `execute`'s
|
|
676
|
+
* own auto-record has only one `resourceId` slot for this step
|
|
677
|
+
* (`cms:site:<orgId>`) -- returned here as the cms app's id, the resource
|
|
678
|
+
* this step is named for. The site app's id has no such slot, so both ids
|
|
679
|
+
* are also recorded as scope ids ({@link CMS_APP_SCOPE_KEY}/
|
|
680
|
+
* {@link SITE_APP_SCOPE_KEY}), read back by the park branch below on a
|
|
681
|
+
* later apply, when nothing gets provisioned to return one -- read
|
|
682
|
+
* independently of each other there, since a crash partway through a
|
|
683
|
+
* first-ever provision can leave only one of the two actually recorded.
|
|
684
|
+
*
|
|
685
|
+
* #117: this step also finds or creates the cms app's own Postgres
|
|
686
|
+
* database ({@link ensureCmsDatabase}) and pushes its owner and runtime
|
|
687
|
+
* connection strings ({@link ensureCmsRuntimeUrl}) onto the cms app, in the
|
|
688
|
+
* same `bulkUpsertEnvs` call as `APP_URL`/`CMS_ORGANISATION_ID`/
|
|
689
|
+
* `CMS_SITE_URL` below. The database's id is recorded as a third flat scope
|
|
690
|
+
* id ({@link CMS_DATABASE_SCOPE_KEY}), for the same reason the two app ids
|
|
691
|
+
* are: no `system:kind:key` slot of its own. Never touched from the park
|
|
692
|
+
* branch above -- parking stops the two apps only, same as #116's own
|
|
693
|
+
* destroy note; this step never parks or destroys the database.
|
|
694
|
+
*/
|
|
695
|
+
private cmsSite;
|
|
696
|
+
/**
|
|
697
|
+
* `cmsSite`'s own find-or-create of a Postgres database for the cms app,
|
|
698
|
+
* modeled on `coolifyDatabase` above: `ensureCoolifyProject` (already run
|
|
699
|
+
* by `cmsSite` before this is called) and `findOrCreateEnvironment` give
|
|
700
|
+
* the project/environment this reuses, then `listDatabases` finds one
|
|
701
|
+
* already created by name before `createPostgresDatabase` makes one.
|
|
702
|
+
* Named `<org.slug>-cms-db`, distinct from `coolifyDatabase`'s own
|
|
703
|
+
* `<org.slug>-db`, so the two never collide in the same project for an org
|
|
704
|
+
* that has both.
|
|
705
|
+
*
|
|
706
|
+
* `context.recordedIds()` is checked first, the same short-circuit
|
|
707
|
+
* `coolifyDatabase` itself does not have (that step's own `resourceId` slot
|
|
708
|
+
* already covers it) -- here it is load-bearing, since this database has
|
|
709
|
+
* no `system:kind:key` slot of its own ({@link CMS_DATABASE_SCOPE_KEY}'s
|
|
710
|
+
* doc comment) and `cms:site` runs on every apply once `org.site.enabled`,
|
|
711
|
+
* not just the first.
|
|
712
|
+
*
|
|
713
|
+
* Destroying this database is out of scope here (#117, same as #116's own
|
|
714
|
+
* destroy note for the cms/site apps).
|
|
715
|
+
*/
|
|
716
|
+
private ensureCmsDatabase;
|
|
717
|
+
/**
|
|
718
|
+
* `null` once the cms app already has a `DATABASE_URL` -- never rotated,
|
|
719
|
+
* the same rule `ensureAuthCookieSecret` follows for `AUTH_COOKIE_SECRET`,
|
|
720
|
+
* and for the same kind of reason: `@wtfalch/cms`'s own
|
|
721
|
+
* `apps/host/scripts/migrate.mjs` (`ensureRuntimeRole`) creates or updates
|
|
722
|
+
* the live `cms_rt` Postgres role from exactly the username/password this
|
|
723
|
+
* method mints, once, at container boot -- overwriting `DATABASE_URL` on a
|
|
724
|
+
* later apply would leave that already-created role's real password out of
|
|
725
|
+
* sync with whatever foundry hands the app next.
|
|
726
|
+
*
|
|
727
|
+
* The password is minted with `randomBytes` (the same import
|
|
728
|
+
* `ensureAuthCookieSecret` already uses) and encoded `base64url` -- `+`/`/`
|
|
729
|
+
* swapped for `-`/`_`, no `=` padding -- so it is already URL-safe in a
|
|
730
|
+
* `postgres://` URL's userinfo segment with no further percent-encoding
|
|
731
|
+
* needed. `dbInternalUrl` (`getDatabase`'s own `internal_db_url`) is parsed
|
|
732
|
+
* with `URL` for its host, path and query string; its own superuser
|
|
733
|
+
* username and password are discarded -- this mints a distinct, restricted
|
|
734
|
+
* role's own credential, never reuses the superuser's. The query string
|
|
735
|
+
* (`search`) is kept as-is and appended unchanged (#117 review round 2):
|
|
736
|
+
* a parameter there (e.g. `?sslmode=require`) is a connection requirement
|
|
737
|
+
* Coolify itself put on this database, not something specific to the
|
|
738
|
+
* superuser role, so the migration (`DATABASE_URL_OWNER`) and runtime
|
|
739
|
+
* (`DATABASE_URL`) connections must carry the same one.
|
|
740
|
+
*
|
|
741
|
+
* The password is a live credential: built here and handed straight to
|
|
742
|
+
* `cmsSite`'s own `bulkUpsertEnvs` call and nowhere else in this class --
|
|
743
|
+
* never returned, recorded, logged, or interpolated into a thrown error
|
|
744
|
+
* (the same rule `pushDatabaseUrl`'s doc comment states). That rule is why
|
|
745
|
+
* `URL`'s own parse failure is caught below rather than left to propagate:
|
|
746
|
+
* nothing here states or controls the superuser password's alphabet
|
|
747
|
+
* (Coolify's own, unlike the `cms_rt` one minted just below it), and
|
|
748
|
+
* Node's `TypeError [ERR_INVALID_URL]` on an unescaped `/`, `#` or `?` in
|
|
749
|
+
* it carries the *whole* input -- credential included -- on its own
|
|
750
|
+
* `.input` property (never in `.message`, but still reachable by a logger
|
|
751
|
+
* that inspects the error object, not just its message). Caught and
|
|
752
|
+
* turned into "skip `DATABASE_URL` for this apply" instead: `cmsSite`
|
|
753
|
+
* still pushes `DATABASE_URL_OWNER` and its other envs, and a later apply
|
|
754
|
+
* retries this once `listEnvs` still shows no `DATABASE_URL`.
|
|
755
|
+
*/
|
|
756
|
+
private ensureCmsRuntimeUrl;
|
|
757
|
+
/**
|
|
758
|
+
* `cmsSite`'s own find-by-name-then-create flow for one of its two
|
|
759
|
+
* applications -- the same shape `coolifyApp` above inlines for its own,
|
|
760
|
+
* single, application, extracted here since `cmsSite` needs it twice in
|
|
761
|
+
* one step. Both of `cmsSite`'s apps share `CMS_REPO`'s own default branch
|
|
762
|
+
* (`main`) -- there is no per-org `gitBranch`/`buildPack` for this repo
|
|
763
|
+
* the way `org.hosting` carries for the org's *own* app, since the cms
|
|
764
|
+
* site is not that app.
|
|
765
|
+
*
|
|
766
|
+
* An app found by name (rather than just created) is started back up if
|
|
767
|
+
* it is not already running or on its way there (#116 review round 3,
|
|
768
|
+
* Finding 1): parking (`cmsSite`'s own `enabled: false` branch) stops both
|
|
769
|
+
* apps without deleting them, so re-enabling finds them here, still
|
|
770
|
+
* stopped, and `ProvidersCmsReader` would otherwise keep reporting
|
|
771
|
+
* `enabled: false` forever -- the plan never converging back to on.
|
|
772
|
+
* `coolifyApp`'s own analogous gap (it never restarted a found-but-stopped
|
|
773
|
+
* app either) has since closed the same way, inline in that method (#130);
|
|
774
|
+
* this method is `cmsSite`'s only caller.
|
|
775
|
+
*/
|
|
776
|
+
private findOrCreateCmsApp;
|
|
777
|
+
private cloudflareTunnel;
|
|
778
|
+
private cloudflareZone;
|
|
779
|
+
/**
|
|
780
|
+
* `plan/diff.ts`'s `planMail`: gives an org mail, moves it between modes,
|
|
781
|
+
* or parks it on the one shared Stalwart instance (`this.ports.mail`,
|
|
782
|
+
* `@wtfalch/email/postmaster`'s `applyOrgMail`/`parkOrgMail` bound), then
|
|
783
|
+
* publishes whatever DNS records the call returns.
|
|
784
|
+
*/
|
|
785
|
+
private coolifyMail;
|
|
786
|
+
/**
|
|
787
|
+
* Upserts each DNS record `applyOrgMail` says foundry must publish, found
|
|
788
|
+
* by an exact name+type match (`CloudflarePort.findDnsRecord`'s own doc
|
|
789
|
+
* comment) so update never mistakes a stale record of a different type for
|
|
790
|
+
* a match. A `DKIM` record can be absent on the first call, since Stalwart
|
|
791
|
+
* mints the key on a background task; the next apply publishes it.
|
|
792
|
+
*/
|
|
793
|
+
private publishMailDnsRecords;
|
|
794
|
+
/**
|
|
795
|
+
* #80: provisions the org's own organisation on Files, before
|
|
796
|
+
* {@link filesStorage} can set its spend limit. `provisionOrganisation` is
|
|
797
|
+
* safely repeatable on its own (`FilesOrganisationPort`'s doc comment), so
|
|
798
|
+
* this is called every time regardless of whether it already ran --
|
|
799
|
+
* idempotent by find-or-create, not by checking `recordedIds` first.
|
|
800
|
+
*/
|
|
801
|
+
private filesOrganisation;
|
|
802
|
+
private filesStorage;
|
|
803
|
+
/**
|
|
804
|
+
* #80: provisions the org's own organisation on AI and switches its AI use
|
|
805
|
+
* on, before {@link aiBudget} can set its own spend limit. Both
|
|
806
|
+
* `provisionOrganisation` and `setOrganisationOffers` are safely repeatable
|
|
807
|
+
* on their own (`AiOrganisationPort`'s doc comment), so both are called
|
|
808
|
+
* every time regardless of whether they already ran.
|
|
809
|
+
*/
|
|
810
|
+
private aiOrganisation;
|
|
811
|
+
private aiBudget;
|
|
812
|
+
/**
|
|
813
|
+
* {@link StepDestroyer.destroy}: deletes the one resource
|
|
814
|
+
* `FoundryPlanApply.destroy` names. A provider 404 (already gone) counts as
|
|
815
|
+
* deleted -- `isNotFoundError` below -- since a destroy retried after a
|
|
816
|
+
* partial failure must find an already-deleted resource a no-op, not a new
|
|
817
|
+
* error.
|
|
818
|
+
*/
|
|
819
|
+
destroy(target: DestroyTarget, context: {
|
|
820
|
+
readonly org: DeclaredOrg;
|
|
821
|
+
readonly recordedIds: Readonly<Record<string, string>>;
|
|
822
|
+
}): Promise<void>;
|
|
823
|
+
}
|