@wtfalch/foundry 0.2.0 → 0.4.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/calls-sfu-deploy-support.d.ts +57 -0
- package/dist/calls-sfu-deploy-support.js +82 -0
- package/dist/calls-sfu.d.ts +192 -0
- package/dist/calls-sfu.js +256 -0
- package/dist/index.d.ts +8 -5
- package/dist/index.js +6 -4
- package/dist/migrate.d.ts +38 -0
- package/dist/migrate.js +47 -0
- package/dist/migrations/0012_audit.sql +21 -0
- package/dist/migrations/0013_access_emails.sql +6 -0
- package/dist/migrations/0014_calls_enabled.sql +7 -0
- package/dist/migrations/0015_domain_unique.sql +39 -0
- package/dist/org.d.ts +41 -2
- package/dist/org.js +85 -0
- package/dist/plan/calls-matrix.d.ts +135 -0
- package/dist/plan/calls-matrix.js +275 -0
- package/dist/plan/diff.d.ts +8 -4
- package/dist/plan/diff.js +239 -9
- package/dist/plan/executor.d.ts +751 -14
- package/dist/plan/executor.js +1538 -32
- package/dist/plan/index.d.ts +3 -3
- package/dist/plan/index.js +2 -2
- package/dist/plan/plan-apply.js +29 -7
- package/dist/plan/providers-readers.d.ts +54 -1
- package/dist/plan/providers-readers.js +153 -1
- package/dist/provision.d.ts +56 -0
- package/dist/provision.js +69 -0
- package/dist/slug.d.ts +12 -18
- package/dist/slug.js +74 -2
- package/dist/store/audit.d.ts +8 -0
- package/dist/store/audit.js +43 -0
- package/dist/store/postgres.d.ts +11 -6
- package/dist/store/postgres.js +104 -27
- package/dist/store/schema.d.ts +205 -0
- package/dist/store/schema.js +15 -1
- package/dist/store.d.ts +12 -0
- package/package.json +9 -3
package/dist/plan/executor.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import type { OrgMailOptions, OrgMailOrg, OrgMailResult } from '@wtfalch/email/postmaster';
|
|
1
2
|
import type { DeclaredOrg, HostingBuildPack, OrgId } from '../org.js';
|
|
3
|
+
import type { DeclaredOrgStore } from '../store.js';
|
|
2
4
|
import type { StepAction } from './diff.js';
|
|
3
5
|
import type { DestroyTarget, StepDestroyer, StepExecutionContext, StepExecutor } from './plan-apply.js';
|
|
4
6
|
/**
|
|
@@ -68,6 +70,17 @@ export interface CoolifyPort {
|
|
|
68
70
|
}): Promise<{
|
|
69
71
|
uuid: string;
|
|
70
72
|
} | null>;
|
|
73
|
+
/**
|
|
74
|
+
* One application's own live status (`` `running:healthy`, `exited:unhealthy`
|
|
75
|
+
* `` -- `@wtfalch/providers`' `CoolifyApplication.status` doc comment) --
|
|
76
|
+
* `findOrCreateCmsApp`'s own check of whether an app it found by name
|
|
77
|
+
* (rather than just created) needs starting again (#116 review round 3,
|
|
78
|
+
* Finding 1).
|
|
79
|
+
*/
|
|
80
|
+
getApplication(uuid: string): Promise<{
|
|
81
|
+
uuid: string;
|
|
82
|
+
status?: string;
|
|
83
|
+
}>;
|
|
71
84
|
createGithubAppApplication(input: {
|
|
72
85
|
project_uuid: string;
|
|
73
86
|
server_uuid: string;
|
|
@@ -79,6 +92,8 @@ export interface CoolifyPort {
|
|
|
79
92
|
build_pack: HostingBuildPack;
|
|
80
93
|
name?: string;
|
|
81
94
|
domains?: string;
|
|
95
|
+
/** Selects which of two Dockerfiles in one repo to build (`cmsSite`'s own site/cms apps). */
|
|
96
|
+
dockerfile_location?: string;
|
|
82
97
|
}): Promise<{
|
|
83
98
|
uuid: string;
|
|
84
99
|
}>;
|
|
@@ -90,6 +105,7 @@ export interface CoolifyPort {
|
|
|
90
105
|
name: string;
|
|
91
106
|
postgres_db: string;
|
|
92
107
|
postgres_user: string;
|
|
108
|
+
postgres_initdb_args?: string;
|
|
93
109
|
}): Promise<{
|
|
94
110
|
uuid: string;
|
|
95
111
|
internal_db_url: string;
|
|
@@ -99,7 +115,8 @@ export interface CoolifyPort {
|
|
|
99
115
|
* from `createPostgresDatabase`'s return value) so the app's env is wired
|
|
100
116
|
* from the database's live state whether this run just created it or found
|
|
101
117
|
* it already there (`coolifyApp`/`coolifyDatabase` below, hosting-wiring
|
|
102
|
-
* campaign). `internal_db_url` is
|
|
118
|
+
* campaign; `cmsSite`'s own cms database, #117). `internal_db_url` is
|
|
119
|
+
* `postgres://user:password@host/db` — a
|
|
103
120
|
* live credential, present in the raw Coolify API response and reachable
|
|
104
121
|
* only from inside the Coolify Docker network (`@wtfalch/providers`'
|
|
105
122
|
* `CoolifyClient.createPostgresDatabase` doc comment).
|
|
@@ -122,10 +139,12 @@ export interface CoolifyPort {
|
|
|
122
139
|
/**
|
|
123
140
|
* Upsert environment variables on an application in one call — used here to
|
|
124
141
|
* push the database's `internal_db_url` in as the app's `DATABASE_URL`
|
|
125
|
-
* (hosting-wiring campaign)
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
142
|
+
* (hosting-wiring campaign), and, for the cms app, its `DATABASE_URL_OWNER`
|
|
143
|
+
* plus a freshly minted `DATABASE_URL` runtime-role credential (#117). Each
|
|
144
|
+
* value is a live credential: it must reach this call and nothing else —
|
|
145
|
+
* never a step's return value, `serviceIds` (hence the store), an audit
|
|
146
|
+
* record, a log or a thrown error. See `coolifyApp`'s `pushDatabaseUrl` and
|
|
147
|
+
* `cmsSite`'s `ensureCmsRuntimeUrl`.
|
|
129
148
|
*/
|
|
130
149
|
bulkUpsertEnvs(applicationUuid: string, envs: Array<{
|
|
131
150
|
key: string;
|
|
@@ -134,12 +153,122 @@ export interface CoolifyPort {
|
|
|
134
153
|
literal?: boolean;
|
|
135
154
|
multiline?: boolean;
|
|
136
155
|
}>): Promise<unknown>;
|
|
156
|
+
/**
|
|
157
|
+
* Every environment variable currently on the application — read before
|
|
158
|
+
* generating `AUTH_COOKIE_SECRET` (`ensureAuthCookieSecret`, level-1
|
|
159
|
+
* app-env campaign) so that value is only ever generated once, never
|
|
160
|
+
* overwritten. Every key comes back twice (a production row and an
|
|
161
|
+
* `is_preview` duplicate, `@wtfalch/providers`' `CoolifyClient.listEnvs`
|
|
162
|
+
* doc comment), so a caller checking "does this key already exist" filters
|
|
163
|
+
* `is_preview === false` the same way a deployed-config reader must.
|
|
164
|
+
*/
|
|
165
|
+
listEnvs(applicationUuid: string): Promise<Array<{
|
|
166
|
+
key: string;
|
|
167
|
+
value: string;
|
|
168
|
+
is_preview: boolean;
|
|
169
|
+
}>>;
|
|
137
170
|
/** Escape hatch (`CoolifyClient.raw`) — used for stop/start, which have no typed method yet. */
|
|
138
171
|
raw(method: string, path: string, body?: unknown): Promise<unknown>;
|
|
172
|
+
/**
|
|
173
|
+
* Persistent (named volume) and file storages already attached to an
|
|
174
|
+
* application (#75) — `ensureLogVolume` reads this before creating one, so
|
|
175
|
+
* a re-run never mounts a second volume at the same path.
|
|
176
|
+
*/
|
|
177
|
+
listStorages(applicationUuid: string): Promise<{
|
|
178
|
+
persistent_storages?: Array<{
|
|
179
|
+
uuid: string;
|
|
180
|
+
name?: string;
|
|
181
|
+
mount_path: string;
|
|
182
|
+
}>;
|
|
183
|
+
file_storages?: Array<{
|
|
184
|
+
uuid: string;
|
|
185
|
+
mount_path: string;
|
|
186
|
+
}>;
|
|
187
|
+
}>;
|
|
188
|
+
/** Mounts a named volume on an application (#75) — `ensureLogVolume`'s create path. */
|
|
189
|
+
createPersistentStorage(applicationUuid: string, input: {
|
|
190
|
+
name: string;
|
|
191
|
+
mount_path: string;
|
|
192
|
+
}): Promise<unknown>;
|
|
193
|
+
/**
|
|
194
|
+
* Restarts the running container without a rebuild (#75) — `ensureLogVolume`
|
|
195
|
+
* calls this after mounting a volume on an app that was already running,
|
|
196
|
+
* since Coolify only binds a newly created storage into the container at
|
|
197
|
+
* its next start.
|
|
198
|
+
*/
|
|
199
|
+
restart(applicationUuid: string): Promise<unknown>;
|
|
139
200
|
/** `FoundryPlanApply.destroy`'s teardown of the org's own app. */
|
|
140
201
|
deleteApplication(uuid: string): Promise<unknown>;
|
|
141
202
|
/** `FoundryPlanApply.destroy`'s teardown of the org's own database. */
|
|
142
203
|
deleteDatabase(uuid: string): Promise<unknown>;
|
|
204
|
+
/**
|
|
205
|
+
* An application that runs a published image as-is (#128, `coolifyCalls`'s
|
|
206
|
+
* own Postgres) — matches `@wtfalch/providers`' `CoolifyClient.
|
|
207
|
+
* createDockerImageApplication` method-for-method.
|
|
208
|
+
*/
|
|
209
|
+
createDockerImageApplication(input: {
|
|
210
|
+
project_uuid: string;
|
|
211
|
+
server_uuid: string;
|
|
212
|
+
environment_name: string;
|
|
213
|
+
environment_uuid: string;
|
|
214
|
+
docker_registry_image_name: string;
|
|
215
|
+
docker_registry_image_tag: string;
|
|
216
|
+
ports_exposes: string;
|
|
217
|
+
name?: string;
|
|
218
|
+
description?: string;
|
|
219
|
+
instant_deploy?: boolean;
|
|
220
|
+
}): Promise<{
|
|
221
|
+
uuid: string;
|
|
222
|
+
}>;
|
|
223
|
+
/**
|
|
224
|
+
* An application built from an inline Dockerfile (#128, `coolifyCalls`'s own
|
|
225
|
+
* Synapse and MAS — neither's published image takes the module/command this
|
|
226
|
+
* stack needs from a plain image application) — matches `CoolifyClient.
|
|
227
|
+
* createDockerfileApplication` method-for-method.
|
|
228
|
+
*/
|
|
229
|
+
createDockerfileApplication(input: {
|
|
230
|
+
project_uuid: string;
|
|
231
|
+
server_uuid: string;
|
|
232
|
+
environment_name: string;
|
|
233
|
+
environment_uuid: string;
|
|
234
|
+
dockerfile: string;
|
|
235
|
+
name?: string;
|
|
236
|
+
description?: string;
|
|
237
|
+
instant_deploy?: boolean;
|
|
238
|
+
}): Promise<{
|
|
239
|
+
uuid: string;
|
|
240
|
+
}>;
|
|
241
|
+
/**
|
|
242
|
+
* A single file mounted into the container (#128) — how `homeserver.yaml`
|
|
243
|
+
* and MAS's `config.yaml` reach a Docker-image application with no git
|
|
244
|
+
* repository of its own to carry them. Matches `CoolifyClient.
|
|
245
|
+
* createFileStorage` method-for-method.
|
|
246
|
+
*/
|
|
247
|
+
createFileStorage(applicationUuid: string, input: {
|
|
248
|
+
mount_path: string;
|
|
249
|
+
content: string;
|
|
250
|
+
}): Promise<unknown>;
|
|
251
|
+
/** Updates an existing file storage's content in place. Matches `CoolifyClient.updateFileStorage`. */
|
|
252
|
+
updateFileStorage(applicationUuid: string, storageUuid: string, content: string): Promise<unknown>;
|
|
253
|
+
/**
|
|
254
|
+
* A generic application PATCH (#128) — `coolifyCalls` uses this to give
|
|
255
|
+
* Postgres/Synapse/MAS a fixed `custom_internal_name` so the other two can
|
|
256
|
+
* address each other by a name that never changes across a re-run, instead
|
|
257
|
+
* of guessing at Coolify's own container-naming scheme. Matches
|
|
258
|
+
* `CoolifyClient.updateApplication` method-for-method.
|
|
259
|
+
*/
|
|
260
|
+
updateApplication(uuid: string, patch: Record<string, unknown>): Promise<unknown>;
|
|
261
|
+
/**
|
|
262
|
+
* Queues a deployment (#128) — `coolifyCalls` calls this after writing a
|
|
263
|
+
* fresh Synapse/MAS config file, since a file-storage write alone does not
|
|
264
|
+
* reach the running container until its next deploy (issue #128's "a
|
|
265
|
+
* running app is redeployed when its config changed"). Fire-and-forget,
|
|
266
|
+
* like `restart` above: this executor does not poll a deployment to
|
|
267
|
+
* completion anywhere else, and `coolifyCalls` only calls this when the
|
|
268
|
+
* config it just wrote was actually freshly minted. Matches `CoolifyClient.
|
|
269
|
+
* deploy` method-for-method.
|
|
270
|
+
*/
|
|
271
|
+
deploy(uuid: string, force?: boolean): Promise<unknown>;
|
|
143
272
|
}
|
|
144
273
|
/**
|
|
145
274
|
* The Cloudflare calls this executor makes, shaped to match
|
|
@@ -165,6 +294,31 @@ export interface CloudflarePort {
|
|
|
165
294
|
putTunnelConfig(tunnelId: string, config: unknown): Promise<unknown>;
|
|
166
295
|
/** `FoundryPlanApply.destroy`'s teardown of the org's own Cloudflare tunnel. */
|
|
167
296
|
deleteTunnel(tunnelId: string): Promise<void>;
|
|
297
|
+
/** `cloudflareAccess` finds the org's Access app by domain before creating one (#84). */
|
|
298
|
+
listAccessApps(): Promise<Array<{
|
|
299
|
+
id: string;
|
|
300
|
+
name: string;
|
|
301
|
+
domain: string;
|
|
302
|
+
}>>;
|
|
303
|
+
createAccessApp(app: {
|
|
304
|
+
name: string;
|
|
305
|
+
domain: string;
|
|
306
|
+
type: string;
|
|
307
|
+
}): Promise<{
|
|
308
|
+
id: string;
|
|
309
|
+
}>;
|
|
310
|
+
listAccessPolicies(appId: string): Promise<Array<{
|
|
311
|
+
id: string;
|
|
312
|
+
name: string;
|
|
313
|
+
}>>;
|
|
314
|
+
createAccessPolicy(appId: string, policy: {
|
|
315
|
+
name: string;
|
|
316
|
+
decision: string;
|
|
317
|
+
include: unknown[];
|
|
318
|
+
}): Promise<{
|
|
319
|
+
id: string;
|
|
320
|
+
}>;
|
|
321
|
+
deleteAccessApp(appId: string): Promise<void>;
|
|
168
322
|
/**
|
|
169
323
|
* `FoundryPlanApply.destroy`'s teardown of any DNS record foundry created
|
|
170
324
|
* for the org. Nothing in this executor creates or records one yet (Finding,
|
|
@@ -174,6 +328,154 @@ export interface CloudflarePort {
|
|
|
174
328
|
* creating one.
|
|
175
329
|
*/
|
|
176
330
|
deleteDnsRecord(zoneId: string, recordId: string): Promise<void>;
|
|
331
|
+
/** `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. */
|
|
332
|
+
findDnsRecord(zoneId: string, name: string, type?: string): Promise<Array<{
|
|
333
|
+
id: string;
|
|
334
|
+
type: string;
|
|
335
|
+
content: string;
|
|
336
|
+
name: string;
|
|
337
|
+
}>>;
|
|
338
|
+
createDnsRecord(zoneId: string, rec: {
|
|
339
|
+
type: string;
|
|
340
|
+
name: string;
|
|
341
|
+
content: string;
|
|
342
|
+
proxied?: boolean;
|
|
343
|
+
ttl?: number;
|
|
344
|
+
}): Promise<{
|
|
345
|
+
id: string;
|
|
346
|
+
name: string;
|
|
347
|
+
content: string;
|
|
348
|
+
}>;
|
|
349
|
+
updateDnsRecord(zoneId: string, recordId: string, rec: {
|
|
350
|
+
type: string;
|
|
351
|
+
name: string;
|
|
352
|
+
content: string;
|
|
353
|
+
proxied?: boolean;
|
|
354
|
+
ttl?: number;
|
|
355
|
+
}): Promise<{
|
|
356
|
+
id: string;
|
|
357
|
+
name: string;
|
|
358
|
+
content: string;
|
|
359
|
+
}>;
|
|
360
|
+
/**
|
|
361
|
+
* Escape hatch (mirrors `CoolifyPort.raw`'s own precedent above) --
|
|
362
|
+
* `@wtfalch/providers` 0.5.0's `CloudflareClient` has no typed method for
|
|
363
|
+
* Health Checks or zone rulesets/rate-limiting rules
|
|
364
|
+
* (https://github.com/wtfalch/foundry/issues/43), only this public,
|
|
365
|
+
* generic `request<T>(method, path, body?, missingOkay?)`, which
|
|
366
|
+
* this port names directly rather than inventing a narrower method no real
|
|
367
|
+
* client could structurally satisfy. `cloudflareRateLimit` below is its one
|
|
368
|
+
* caller today. `missingOkay` makes a 404 resolve to `undefined` instead of
|
|
369
|
+
* throwing, matching `CloudflareClient`'s own real behavior exactly.
|
|
370
|
+
*/
|
|
371
|
+
request<T>(method: string, path: string, body?: unknown, missingOkay?: boolean): Promise<T>;
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* One DNS record a Resend domain's own response names it must publish --
|
|
375
|
+
* `record` is Resend's own label (`SPF`, `DKIM`, `DMARC`, `MX`), which
|
|
376
|
+
* {@link ResendPort.createDomain}/{@link ResendPort.getDomain} return
|
|
377
|
+
* alongside `type`/`name`/`value` for `coolifyMail` to act on, the same shape
|
|
378
|
+
* https://resend.com/docs/api-reference/domains/create-domain documents.
|
|
379
|
+
*/
|
|
380
|
+
export interface ResendDnsRecordSpec {
|
|
381
|
+
readonly record: 'SPF' | 'DKIM' | 'DMARC' | 'MX' | string;
|
|
382
|
+
readonly name: string;
|
|
383
|
+
readonly type: 'TXT' | 'CNAME' | 'MX';
|
|
384
|
+
readonly value: string;
|
|
385
|
+
readonly priority?: number;
|
|
386
|
+
}
|
|
387
|
+
export interface ResendDomain {
|
|
388
|
+
readonly id: string;
|
|
389
|
+
readonly name: string;
|
|
390
|
+
readonly status: string;
|
|
391
|
+
readonly records?: readonly ResendDnsRecordSpec[];
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* The Resend calls this executor makes to verify an org's mail domain (#138)
|
|
395
|
+
* and mint its app's sending key (#140 rework) -- the shared Stalwart relays
|
|
396
|
+
* every outbound message through Resend
|
|
397
|
+
* (https://github.com/wtfalch/email/blob/main/packages/email/src/postmaster/objects.ts#L149-L168),
|
|
398
|
+
* which refuses mail from a domain Resend has not verified. `@wtfalch/providers`
|
|
399
|
+
* has no Resend client yet, unlike Coolify/Cloudflare/ZITADEL above, so this
|
|
400
|
+
* port names Resend's own REST shapes directly
|
|
401
|
+
* (https://resend.com/docs/api-reference/domains/create-domain,
|
|
402
|
+
* https://resend.com/docs/api-reference/api-keys/create-api-key,
|
|
403
|
+
* https://resend.com/docs/api-reference/api-keys/list-api-keys,
|
|
404
|
+
* https://resend.com/docs/api-reference/api-keys/delete-api-key) -- the same
|
|
405
|
+
* prior art `wtfalch/app-template`'s `bootstrap/steps/resend-email.ts` already
|
|
406
|
+
* proved against a live account. The relay's own `<slug>-relay` key
|
|
407
|
+
* (`.plans/2026-09-27-org-mail-resend-design.md`) is a separate change, filed
|
|
408
|
+
* as https://github.com/wtfalch/foundry/issues/149 and out of scope here.
|
|
409
|
+
*/
|
|
410
|
+
export interface ResendPort {
|
|
411
|
+
/** An exact name match, never a listing scan `coolifyMail` has to page itself. */
|
|
412
|
+
findDomain(name: string): Promise<{
|
|
413
|
+
id: string;
|
|
414
|
+
name: string;
|
|
415
|
+
status: string;
|
|
416
|
+
} | undefined>;
|
|
417
|
+
createDomain(name: string): Promise<ResendDomain>;
|
|
418
|
+
/** Re-reads a domain already found by {@link findDomain}, for its current status and DNS records. */
|
|
419
|
+
getDomain(id: string): Promise<ResendDomain>;
|
|
420
|
+
/**
|
|
421
|
+
* Asks Resend to re-check DNS now. `coolifyMail` never polls or waits on
|
|
422
|
+
* this -- the task brief: "do not block the plan on DNS propagation:
|
|
423
|
+
* record the status, and let the next apply re-check it (idempotent, like
|
|
424
|
+
* `applyOrgMail`)".
|
|
425
|
+
*/
|
|
426
|
+
verifyDomain(id: string): Promise<{
|
|
427
|
+
status: string;
|
|
428
|
+
}>;
|
|
429
|
+
/**
|
|
430
|
+
* Every key in the account, `id`+`name` only -- Resend never returns a
|
|
431
|
+
* key's token except at creation, so this is how the mint rule
|
|
432
|
+
* (`.plans/2026-09-27-org-mail-resend-design.md` "One rule for minting")
|
|
433
|
+
* tells whether the app's key is healthy before deciding to mint. `ensureAppResendKey`'s own doc comment below
|
|
434
|
+
* spells the rule out.
|
|
435
|
+
*/
|
|
436
|
+
listApiKeys(): Promise<Array<{
|
|
437
|
+
id: string;
|
|
438
|
+
name: string;
|
|
439
|
+
}>>;
|
|
440
|
+
/** Sending-only, scoped to one domain -- the app step's `<slug>-app@<domainId>` (`ensureAppResendKey`), minted under the mint rule. */
|
|
441
|
+
createApiKey(input: {
|
|
442
|
+
name: string;
|
|
443
|
+
permission: 'sending_access';
|
|
444
|
+
domainId: string;
|
|
445
|
+
}): Promise<{
|
|
446
|
+
id: string;
|
|
447
|
+
token: string;
|
|
448
|
+
}>;
|
|
449
|
+
/** Deletes one key by id -- the mint rule's own cleanup of an orphan, a duplicate, or a key for a mail domain the org no longer uses. */
|
|
450
|
+
deleteApiKey(id: string): Promise<void>;
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* The Stalwart calls this executor makes for one org's mail, shaped to match
|
|
454
|
+
* `@wtfalch/email/postmaster`'s `applyOrgMail`/`parkOrgMail` method-for-method
|
|
455
|
+
* with the `StalwartClient` bound, the same way {@link CloudflarePort} mirrors
|
|
456
|
+
* `CloudflareClient`.
|
|
457
|
+
*/
|
|
458
|
+
export interface MailPort {
|
|
459
|
+
applyOrgMail(org: OrgMailOrg, options: OrgMailOptions): Promise<OrgMailResult>;
|
|
460
|
+
parkOrgMail(org: OrgMailOrg, options: OrgMailOptions): Promise<void>;
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* The shared calls SFU's own LiveKit API credentials (#122, #127), resolved
|
|
464
|
+
* by the host from wherever `scripts/calls-sfu-deploy.mjs`'s own
|
|
465
|
+
* `CallsSfuConfig` delivered them (#145) -- this package never mints or
|
|
466
|
+
* invents a LiveKit key/secret pair itself, the same "the executor is
|
|
467
|
+
* constructed with narrow ports ... a host [archon] supplies real clients"
|
|
468
|
+
* rule this file's own top doc comment states for every provider credential.
|
|
469
|
+
* `resolveCredentials` returns `null` when the SFU has not been configured
|
|
470
|
+
* yet, the same "nothing recorded" signal a bare `CoolifyPort.listEnvs` gives
|
|
471
|
+
* `ensureCallsMatrixSecrets` -- just for a secret this package never held in
|
|
472
|
+
* the first place, so there is nowhere here to read it back from itself.
|
|
473
|
+
*/
|
|
474
|
+
export interface CallsSfuPort {
|
|
475
|
+
resolveCredentials(): Promise<{
|
|
476
|
+
apiKey: string;
|
|
477
|
+
apiSecret: string;
|
|
478
|
+
} | null>;
|
|
177
479
|
}
|
|
178
480
|
/**
|
|
179
481
|
* The ZITADEL calls this executor makes. `findOrCreateOrg`/`findOrCreateProject`
|
|
@@ -224,6 +526,23 @@ export interface ZitadelPort {
|
|
|
224
526
|
clientSecret: string;
|
|
225
527
|
}>;
|
|
226
528
|
deleteApp(orgId: string, projectId: string, appId: string): Promise<void>;
|
|
529
|
+
/**
|
|
530
|
+
* `zitadelApp`'s kind-change path reads this to find an app a prior,
|
|
531
|
+
* lost-response create already made under the new name, before calling
|
|
532
|
+
* `createOidcApp`/`createApiApp` again and orphaning that one under the
|
|
533
|
+
* same name. Matches `@wtfalch/providers/zitadel`'s `ZitadelClient.
|
|
534
|
+
* listApps` return shape exactly, so that client satisfies this port with
|
|
535
|
+
* no adapter -- it does not carry `clientId`/`clientSecret` (that provider
|
|
536
|
+
* method never parses either, and a secret is minted once, on creation,
|
|
537
|
+
* never handed back again), which is why the reuse path below cannot
|
|
538
|
+
* re-deliver either one.
|
|
539
|
+
*/
|
|
540
|
+
listApps(orgId: string, projectId: string): Promise<Array<{
|
|
541
|
+
id: string;
|
|
542
|
+
name: string;
|
|
543
|
+
appType: 'web' | 'native' | 'api';
|
|
544
|
+
state: string;
|
|
545
|
+
}>>;
|
|
227
546
|
/**
|
|
228
547
|
* The org-owner flow (goal line): find-or-create the human user by email in
|
|
229
548
|
* the org, then have ZITADEL email them an invite to set up a passkey.
|
|
@@ -350,6 +669,10 @@ export interface FoundryStepExecutorPorts {
|
|
|
350
669
|
coolify: CoolifyPort;
|
|
351
670
|
cloudflare: CloudflarePort;
|
|
352
671
|
zitadel: ZitadelPort;
|
|
672
|
+
mail: MailPort;
|
|
673
|
+
resend: ResendPort;
|
|
674
|
+
/** The shared calls SFU's own LiveKit credentials (#145) -- see {@link CallsSfuPort}'s own doc comment. */
|
|
675
|
+
callsSfu: CallsSfuPort;
|
|
353
676
|
/** A factory, not a flat client: Files/AI are org-scoped credentials the host resolves per org. */
|
|
354
677
|
filesLimitsFor: (orgId: OrgId) => FilesOrgLimits;
|
|
355
678
|
aiLimitsFor: (orgId: OrgId) => AiOrgLimits;
|
|
@@ -370,28 +693,69 @@ export interface FoundryStepExecutorPorts {
|
|
|
370
693
|
* themselves.
|
|
371
694
|
*/
|
|
372
695
|
deliverSecret(ref: string, secret: string): Promise<void>;
|
|
696
|
+
/**
|
|
697
|
+
* Read access to the declared-org store, narrowed to the one query
|
|
698
|
+
* `cloudflareZone` needs for its cross-org refusal check (#131) -- see that
|
|
699
|
+
* method's own doc comment for why a zone needs this where
|
|
700
|
+
* `cloudflareAccess` reads Cloudflare's own live state instead. A real host
|
|
701
|
+
* passes the same store instance it constructs `FoundryPlanApply` with.
|
|
702
|
+
*/
|
|
703
|
+
declaredOrgStore: Pick<DeclaredOrgStore, 'findOrgIdForZone'>;
|
|
373
704
|
}
|
|
374
705
|
/**
|
|
375
706
|
* Thrown by name for a step this executor deliberately does not implement:
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
*
|
|
380
|
-
* carry the step and the provider's status only").
|
|
707
|
+
* chat (no admin API exists yet), plus moving the issuer onto or off the
|
|
708
|
+
* org's domain (built, but not in v1 — domain verification and passkey
|
|
709
|
+
* invalidation need their own design). Never carries a token, secret or key
|
|
710
|
+
* (task brief: "errors carry the step and the provider's status only").
|
|
381
711
|
*/
|
|
382
712
|
export declare class NotBuiltError extends Error {
|
|
383
713
|
constructor(step: string, detail: string);
|
|
384
714
|
}
|
|
385
|
-
/**
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
715
|
+
/**
|
|
716
|
+
* The health URLs an outside prober should call for this org (#43): one per
|
|
717
|
+
* app foundry runs for it. A hosting org's app answers at its own hostname,
|
|
718
|
+
* and a site org's site app and cms app both serve `/api/health`
|
|
719
|
+
* (https://github.com/wtfalch/cms/tree/main/apps). An org that runs neither
|
|
720
|
+
* has nothing to probe. Exported so archon, which owns uptime, never repeats
|
|
721
|
+
* this file's hostname rules.
|
|
722
|
+
*/
|
|
723
|
+
export declare function healthUrls(org: DeclaredOrg): string[];
|
|
389
724
|
export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer {
|
|
390
725
|
private readonly ports;
|
|
391
726
|
constructor(ports: FoundryStepExecutorPorts);
|
|
392
727
|
execute(action: StepAction, context: StepExecutionContext): Promise<{
|
|
393
728
|
resourceId?: string;
|
|
394
729
|
} | undefined>;
|
|
730
|
+
/**
|
|
731
|
+
* `plan/diff.ts`'s `planAccess` (#84): puts the org's app hostname behind a
|
|
732
|
+
* Cloudflare Access app that lets only `hosting.accessEmails` through.
|
|
733
|
+
* foundry names its Access app `foundry:<orgId>` and reuses only an app with
|
|
734
|
+
* that name, so a re-run after a crash does not duplicate it. It refuses,
|
|
735
|
+
* rather than adopt, when another Access app already holds the hostname:
|
|
736
|
+
* destroy would later delete it. It also refuses when its own app carries
|
|
737
|
+
* any policy but foundry's, since that policy could let others in.
|
|
738
|
+
*/
|
|
739
|
+
private cloudflareAccess;
|
|
740
|
+
/**
|
|
741
|
+
* `plan/diff.ts`'s `planRateLimit` (#43): one Cloudflare rate limiting
|
|
742
|
+
* rule, scoped to `SIGN_IN_PATH_EXPRESSION`, on the org's own Cloudflare
|
|
743
|
+
* zone -- only ever called for an own-domain org (`planRateLimit`'s own
|
|
744
|
+
* gate), so `org.domain` is set and that zone is the org's alone, never
|
|
745
|
+
* shared with another org the way `oikos.host` is.
|
|
746
|
+
*
|
|
747
|
+
* Rate limiting rules attach to a zone's `http_ratelimit` phase entry
|
|
748
|
+
* point ruleset, one `GET`/`PUT` pair per zone (`@wtfalch/providers` 0.5.0
|
|
749
|
+
* has no typed method for either -- `CloudflarePort.request`'s own doc
|
|
750
|
+
* comment) -- so "find by name" here means reading that one ruleset's
|
|
751
|
+
* `rules[]` and matching `RATE_LIMIT_RULE_DESCRIPTION`, the same shape
|
|
752
|
+
* `cloudflareAccess` above finds its own Access app by name. The Free
|
|
753
|
+
* plan allows exactly one rule per zone, so a rule already there under a
|
|
754
|
+
* different description is refused, never adopted or replaced, mirroring
|
|
755
|
+
* `cloudflareAccess`'s own refusal when a foreign Access app already
|
|
756
|
+
* holds the hostname.
|
|
757
|
+
*/
|
|
758
|
+
private cloudflareRateLimit;
|
|
395
759
|
private zitadelIssuer;
|
|
396
760
|
/**
|
|
397
761
|
* `plan/diff.ts`'s `planOwner`: invites the org's owner into its ZITADEL
|
|
@@ -412,10 +776,42 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
412
776
|
*/
|
|
413
777
|
private zitadelCustomers;
|
|
414
778
|
private zitadelApp;
|
|
779
|
+
/**
|
|
780
|
+
* The app an earlier attempt's recreate already made under the same
|
|
781
|
+
* name, whose response this executor never saw -- deleted before the
|
|
782
|
+
* create so no duplicate is left behind. Matches on name and the
|
|
783
|
+
* desired kind, and skips a removed app -- ZITADEL's
|
|
784
|
+
* search can return one that has since been deleted (`isNotFoundError`'s
|
|
785
|
+
* own doc comment covers why a delete can be retried into a 404), and
|
|
786
|
+
* that is not the survivor this is looking for.
|
|
787
|
+
*/
|
|
788
|
+
private findRecreatedApp;
|
|
415
789
|
private ensureZitadelProject;
|
|
416
790
|
private ensureCoolifyProject;
|
|
417
791
|
private resolveServer;
|
|
418
792
|
private coolifyApp;
|
|
793
|
+
/**
|
|
794
|
+
* #75: mounts a persistent volume at {@link LOG_DIR} on the app, unless one
|
|
795
|
+
* is already there. Found by `mount_path`, not a recorded id — no
|
|
796
|
+
* `serviceIds` entry exists for a storage the way one does for the app or
|
|
797
|
+
* database itself, and Coolify's own listing is cheap enough to check
|
|
798
|
+
* first every time, the same way `coolifyDatabase` finds its database by
|
|
799
|
+
* name before creating one. Never called from the `enabled === false`
|
|
800
|
+
* (park) branch above, so parking an app never touches its volume either.
|
|
801
|
+
*
|
|
802
|
+
* Mounting a volume on an application already running does not attach it
|
|
803
|
+
* to the live container -- Coolify only binds a storage in at the
|
|
804
|
+
* container's next start (Finding, refuted against the first version of
|
|
805
|
+
* this method: it created the storage and let a passing `listStorages`
|
|
806
|
+
* read report `hasLogVolume: true` on the very next plan, with the
|
|
807
|
+
* container itself still writing nowhere). `wasAlreadyRunning` tells this
|
|
808
|
+
* method whether `appId` names an app `coolifyApp` found already there
|
|
809
|
+
* (restart it once the volume is created) or one it just created fresh
|
|
810
|
+
* (already about to get its first deploy/start from
|
|
811
|
+
* `createGithubAppApplication`'s own instant-deploy path, so a restart
|
|
812
|
+
* here would only be a redundant extra one).
|
|
813
|
+
*/
|
|
814
|
+
private ensureLogVolume;
|
|
419
815
|
private coolifyDatabase;
|
|
420
816
|
/**
|
|
421
817
|
* Pushes the database's connection string into the app's environment as
|
|
@@ -440,8 +836,349 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
440
836
|
* never interpolated into a thrown error.
|
|
441
837
|
*/
|
|
442
838
|
private pushDatabaseUrl;
|
|
839
|
+
/**
|
|
840
|
+
* Pushes what the org's app needs to sign a user in through ZITADEL (gap
|
|
841
|
+
* P2, `.plans/2026-09-26-level-1-org-map.md`) -- a second `bulkUpsertEnvs`
|
|
842
|
+
* call rather than folding into `pushDatabaseUrl`'s, since this one runs
|
|
843
|
+
* unconditionally (`coolifyApp`) where that one runs only once
|
|
844
|
+
* `hosting.database` is declared and provisioned.
|
|
845
|
+
*
|
|
846
|
+
* `APP_URL` and `LOG_DIR` (#75) are pushed unconditionally; every other
|
|
847
|
+
* value is pushed only once its own source step has actually recorded it
|
|
848
|
+
* (`context.recordedIds()`) -- on a first-ever apply,
|
|
849
|
+
* run one step at a time in `diff.ts`'s `computePlanDetail` order, that is
|
|
850
|
+
* `zitadel:issuer` (`AUTH_ORGANIZATION_ID`), `zitadel:owner`
|
|
851
|
+
* (`OWNER_USER_ID`, gap P3) and every `zitadel:app` step
|
|
852
|
+
* (`AUTH_CLIENT_ID`), all ordered before `coolify:app` for exactly this
|
|
853
|
+
* reason. AUTH_APP_KEY (waits on an auth broker registration API) and
|
|
854
|
+
* DATABASE_URL_OWNER (a runtime-role split not built yet, `pushDatabaseUrl`'s
|
|
855
|
+
* own doc comment) are out of scope here (task brief).
|
|
856
|
+
*/
|
|
857
|
+
private pushAppEnv;
|
|
858
|
+
/**
|
|
859
|
+
* `null` once the app already has one -- `AUTH_COOKIE_SECRET` is generated
|
|
860
|
+
* only the first time (task brief), never regenerated or overwritten, so a
|
|
861
|
+
* later apply's `bulkUpsertEnvs` call simply omits the key rather than
|
|
862
|
+
* upsert a fresh value over it.
|
|
863
|
+
*/
|
|
864
|
+
private ensureAuthCookieSecret;
|
|
865
|
+
/**
|
|
866
|
+
* #140 rework: the hosted app's own Resend sending key, pushed as
|
|
867
|
+
* `RESEND_API_KEY` straight into the app's env, the one place its token
|
|
868
|
+
* ever lives (Resend shows a key's token once, at creation). A no-op until
|
|
869
|
+
* `coolify:mail` has recorded a {@link RESEND_DOMAIN_ID_SCOPE_KEY} for this
|
|
870
|
+
* org; the next apply fills it in once `coolify:mail` runs first
|
|
871
|
+
* (`diff.ts`'s `computePlanDetail` order).
|
|
872
|
+
*
|
|
873
|
+
* The key is named `<slug>-app@<resendDomainId>`, so a key limited to a
|
|
874
|
+
* mail domain the org no longer uses (a `usesDomain` flip moves it between
|
|
875
|
+
* `org.domain` and `<slug>.<apex>`) never passes for the current one.
|
|
876
|
+
* `RESEND_API_KEY_ID` sits beside the token in the env -- an id, not a
|
|
877
|
+
* secret -- so the env can say which key its token belongs to.
|
|
878
|
+
*
|
|
879
|
+
* The mint rule (`.plans/2026-09-27-org-mail-resend-design.md` "One rule
|
|
880
|
+
* for minting"): healthy when the env holds a token and its
|
|
881
|
+
* `RESEND_API_KEY_ID` is the one key Resend lists under the current name.
|
|
882
|
+
* Otherwise one fresh key is minted and written to the env first, and only
|
|
883
|
+
* then is every other `<slug>-app@` key deleted -- a lost mint response, a
|
|
884
|
+
* duplicate, a hand-made key, and a previous domain's key all end the same
|
|
885
|
+
* way. An app that was already running is restarted before that delete,
|
|
886
|
+
* since a container reads its env only at start (`running`, the same
|
|
887
|
+
* reason {@link ensureLogVolume} restarts one). The restart is not
|
|
888
|
+
* awaited to completion, so the old container can briefly outlive its key.
|
|
889
|
+
*
|
|
890
|
+
* `MAIL_FROM` is upserted every time: it depends only on the mail domain
|
|
891
|
+
* `coolify:mail` resolved (`RESEND_MAIL_DOMAIN_SCOPE_KEY`).
|
|
892
|
+
*
|
|
893
|
+
* The token goes straight to `bulkUpsertEnvs` and nowhere else: never
|
|
894
|
+
* returned, never recorded in `serviceIds` (`recordScopeId`'s own doc
|
|
895
|
+
* comment), never logged, never interpolated into a thrown error -- the
|
|
896
|
+
* same discipline `pushDatabaseUrl`'s doc comment states for a database's
|
|
897
|
+
* own connection string.
|
|
898
|
+
*/
|
|
899
|
+
private ensureAppResendKey;
|
|
900
|
+
/**
|
|
901
|
+
* `plan/diff.ts`'s `planSite` (#116, slicing #99's "provision the cms app,
|
|
902
|
+
* database and site app per org"): the 2026-09-25 decision
|
|
903
|
+
* (https://github.com/wtfalch/cms/blob/main/.plans/2026-09-25-org-sites.md)
|
|
904
|
+
* means foundry never calls a cms admin API for the org's site -- it
|
|
905
|
+
* provisions two ordinary Coolify applications from the one
|
|
906
|
+
* https://github.com/wtfalch/cms repo, the same way `coolifyApp` above
|
|
907
|
+
* provisions the org's own hosted app: a site app (`apps/site`, the
|
|
908
|
+
* reference renderer) at `org.domain`, built from
|
|
909
|
+
* `CMS_SITE_APP_DOCKERFILE`, and a cms app (`apps/host`, single-org mode,
|
|
910
|
+
* https://github.com/wtfalch/cms/blob/main/docs/adr/0013-one-cms-per-org.md)
|
|
911
|
+
* at {@link cmsHostnameFor}, built from the repo's own root
|
|
912
|
+
* `CMS_APP_DOCKERFILE` -- one repo, two applications, told apart by
|
|
913
|
+
* `dockerfile_location`.
|
|
914
|
+
*
|
|
915
|
+
* `action.key` is `org.id` (`planSite`'s own singleton key), so `execute`'s
|
|
916
|
+
* own auto-record has only one `resourceId` slot for this step
|
|
917
|
+
* (`cms:site:<orgId>`) -- returned here as the cms app's id, the resource
|
|
918
|
+
* this step is named for. The site app's id has no such slot, so both ids
|
|
919
|
+
* are also recorded as scope ids ({@link CMS_APP_SCOPE_KEY}/
|
|
920
|
+
* {@link SITE_APP_SCOPE_KEY}), read back by the park branch below on a
|
|
921
|
+
* later apply, when nothing gets provisioned to return one -- read
|
|
922
|
+
* independently of each other there, since a crash partway through a
|
|
923
|
+
* first-ever provision can leave only one of the two actually recorded.
|
|
924
|
+
*
|
|
925
|
+
* #117: this step also finds or creates the cms app's own Postgres
|
|
926
|
+
* database ({@link ensureCmsDatabase}) and pushes its owner and runtime
|
|
927
|
+
* connection strings ({@link ensureCmsRuntimeUrl}) onto the cms app, in the
|
|
928
|
+
* same `bulkUpsertEnvs` call as `APP_URL`/`CMS_ORGANISATION_ID`/
|
|
929
|
+
* `CMS_SITE_URL` below. The database's id is recorded as a third flat scope
|
|
930
|
+
* id ({@link CMS_DATABASE_SCOPE_KEY}), for the same reason the two app ids
|
|
931
|
+
* are: no `system:kind:key` slot of its own. Never touched from the park
|
|
932
|
+
* branch above -- parking stops the two apps only, same as #116's own
|
|
933
|
+
* destroy note; this step never parks or destroys the database.
|
|
934
|
+
*/
|
|
935
|
+
private cmsSite;
|
|
936
|
+
/**
|
|
937
|
+
* `cmsSite`'s own find-or-create of a Postgres database for the cms app,
|
|
938
|
+
* modeled on `coolifyDatabase` above: `ensureCoolifyProject` (already run
|
|
939
|
+
* by `cmsSite` before this is called) and `findOrCreateEnvironment` give
|
|
940
|
+
* the project/environment this reuses, then `listDatabases` finds one
|
|
941
|
+
* already created by name before `createPostgresDatabase` makes one.
|
|
942
|
+
* Named `<org.slug>-cms-db`, distinct from `coolifyDatabase`'s own
|
|
943
|
+
* `<org.slug>-db`, so the two never collide in the same project for an org
|
|
944
|
+
* that has both.
|
|
945
|
+
*
|
|
946
|
+
* `context.recordedIds()` is checked first, the same short-circuit
|
|
947
|
+
* `coolifyDatabase` itself does not have (that step's own `resourceId` slot
|
|
948
|
+
* already covers it) -- here it is load-bearing, since this database has
|
|
949
|
+
* no `system:kind:key` slot of its own ({@link CMS_DATABASE_SCOPE_KEY}'s
|
|
950
|
+
* doc comment) and `cms:site` runs on every apply once `org.site.enabled`,
|
|
951
|
+
* not just the first.
|
|
952
|
+
*
|
|
953
|
+
* Destroying this database is out of scope here (#117, same as #116's own
|
|
954
|
+
* destroy note for the cms/site apps).
|
|
955
|
+
*/
|
|
956
|
+
private ensureCmsDatabase;
|
|
957
|
+
/**
|
|
958
|
+
* `null` once the cms app already has a `DATABASE_URL` -- never rotated,
|
|
959
|
+
* the same rule `ensureAuthCookieSecret` follows for `AUTH_COOKIE_SECRET`,
|
|
960
|
+
* and for the same kind of reason: `@wtfalch/cms`'s own
|
|
961
|
+
* `apps/host/scripts/migrate.mjs` (`ensureRuntimeRole`) creates or updates
|
|
962
|
+
* the live `cms_rt` Postgres role from exactly the username/password this
|
|
963
|
+
* method mints, once, at container boot -- overwriting `DATABASE_URL` on a
|
|
964
|
+
* later apply would leave that already-created role's real password out of
|
|
965
|
+
* sync with whatever foundry hands the app next.
|
|
966
|
+
*
|
|
967
|
+
* The password is minted with `randomBytes` (the same import
|
|
968
|
+
* `ensureAuthCookieSecret` already uses) and encoded `base64url` -- `+`/`/`
|
|
969
|
+
* swapped for `-`/`_`, no `=` padding -- so it is already URL-safe in a
|
|
970
|
+
* `postgres://` URL's userinfo segment with no further percent-encoding
|
|
971
|
+
* needed. `dbInternalUrl` (`getDatabase`'s own `internal_db_url`) is parsed
|
|
972
|
+
* with `URL` for its host, path and query string; its own superuser
|
|
973
|
+
* username and password are discarded -- this mints a distinct, restricted
|
|
974
|
+
* role's own credential, never reuses the superuser's. The query string
|
|
975
|
+
* (`search`) is kept as-is and appended unchanged (#117 review round 2):
|
|
976
|
+
* a parameter there (e.g. `?sslmode=require`) is a connection requirement
|
|
977
|
+
* Coolify itself put on this database, not something specific to the
|
|
978
|
+
* superuser role, so the migration (`DATABASE_URL_OWNER`) and runtime
|
|
979
|
+
* (`DATABASE_URL`) connections must carry the same one.
|
|
980
|
+
*
|
|
981
|
+
* The password is a live credential: built here and handed straight to
|
|
982
|
+
* `cmsSite`'s own `bulkUpsertEnvs` call and nowhere else in this class --
|
|
983
|
+
* never returned, recorded, logged, or interpolated into a thrown error
|
|
984
|
+
* (the same rule `pushDatabaseUrl`'s doc comment states). That rule is why
|
|
985
|
+
* `URL`'s own parse failure is caught below rather than left to propagate:
|
|
986
|
+
* nothing here states or controls the superuser password's alphabet
|
|
987
|
+
* (Coolify's own, unlike the `cms_rt` one minted just below it), and
|
|
988
|
+
* Node's `TypeError [ERR_INVALID_URL]` on an unescaped `/`, `#` or `?` in
|
|
989
|
+
* it carries the *whole* input -- credential included -- on its own
|
|
990
|
+
* `.input` property (never in `.message`, but still reachable by a logger
|
|
991
|
+
* that inspects the error object, not just its message). Caught and
|
|
992
|
+
* turned into "skip `DATABASE_URL` for this apply" instead: `cmsSite`
|
|
993
|
+
* still pushes `DATABASE_URL_OWNER` and its other envs, and a later apply
|
|
994
|
+
* retries this once `listEnvs` still shows no `DATABASE_URL`.
|
|
995
|
+
*/
|
|
996
|
+
private ensureCmsRuntimeUrl;
|
|
997
|
+
/**
|
|
998
|
+
* `cmsSite`'s own find-by-name-then-create flow for one of its two
|
|
999
|
+
* applications -- the same shape `coolifyApp` above inlines for its own,
|
|
1000
|
+
* single, application, extracted here since `cmsSite` needs it twice in
|
|
1001
|
+
* one step. Both of `cmsSite`'s apps share `CMS_REPO`'s own default branch
|
|
1002
|
+
* (`main`) -- there is no per-org `gitBranch`/`buildPack` for this repo
|
|
1003
|
+
* the way `org.hosting` carries for the org's *own* app, since the cms
|
|
1004
|
+
* site is not that app.
|
|
1005
|
+
*
|
|
1006
|
+
* An app found by name (rather than just created) is started back up if
|
|
1007
|
+
* it is not already running or on its way there (#116 review round 3,
|
|
1008
|
+
* Finding 1): parking (`cmsSite`'s own `enabled: false` branch) stops both
|
|
1009
|
+
* apps without deleting them, so re-enabling finds them here, still
|
|
1010
|
+
* stopped, and `ProvidersCmsReader` would otherwise keep reporting
|
|
1011
|
+
* `enabled: false` forever -- the plan never converging back to on.
|
|
1012
|
+
* `coolifyApp`'s own analogous gap (it never restarted a found-but-stopped
|
|
1013
|
+
* app either) has since closed the same way, inline in that method (#130);
|
|
1014
|
+
* this method is `cmsSite`'s only caller.
|
|
1015
|
+
*/
|
|
1016
|
+
private findOrCreateCmsApp;
|
|
1017
|
+
/**
|
|
1018
|
+
* `plan/diff.ts`'s `planCalls`: the org's own calls Matrix stack, entirely
|
|
1019
|
+
* separate from chat's (`calls-matrix.ts`'s own top doc comment) -- two
|
|
1020
|
+
* Coolify-managed Postgres databases, a Synapse app and an MAS app, no
|
|
1021
|
+
* public hostname of its own (neither app is reachable from outside
|
|
1022
|
+
* Coolify's own Docker network) -- `coolifyCallsService` below (#145, part 2
|
|
1023
|
+
* of #128) is the one that gets a public hostname, since it is the one a
|
|
1024
|
+
* browser actually talks to. Park stops Synapse and MAS only, the same
|
|
1025
|
+
* park-not-delete rule `coolifyApp`/`cmsSite` already follow (#7
|
|
1026
|
+
* corrections 1) -- their databases are never touched from that branch, the
|
|
1027
|
+
* same way `cmsSite`'s own database never is.
|
|
1028
|
+
*/
|
|
1029
|
+
private coolifyCalls;
|
|
1030
|
+
/**
|
|
1031
|
+
* `coolifyCalls`'s own find-or-create for Synapse/MAS, restarting a
|
|
1032
|
+
* found-but-stopped app (#116 review round 3, Finding 1's same fix). Reused
|
|
1033
|
+
* by `coolifyCallsService` (#145) for the calls service app itself -- the
|
|
1034
|
+
* same find-by-name-then-start-or-create shape applies unchanged.
|
|
1035
|
+
*/
|
|
1036
|
+
private findOrCreateCallsApp;
|
|
1037
|
+
/**
|
|
1038
|
+
* `coolifyCalls`'s own database find-or-create, modeled on
|
|
1039
|
+
* `ensureCmsDatabase`: `context.recordedIds()` is checked first since
|
|
1040
|
+
* neither database has a `system:kind:key` slot of its own (this step's one
|
|
1041
|
+
* auto-recorded `resourceId` is the MAS app, not either database --
|
|
1042
|
+
* {@link CALLS_SYNAPSE_SCOPE_KEY}'s own doc comment), then `listDatabases`
|
|
1043
|
+
* finds one already created by name before `createPostgresDatabase` makes
|
|
1044
|
+
* one. Reused by `coolifyCallsService` (#145) for the calls service's own
|
|
1045
|
+
* database, whose {@link CALLS_SERVICE_DB_SCOPE_KEY} has no
|
|
1046
|
+
* `system:kind:key` slot of its own for the same reason.
|
|
1047
|
+
*/
|
|
1048
|
+
private ensureCallsDatabase;
|
|
1049
|
+
/**
|
|
1050
|
+
* Reads back {@link CALLS_SECRET_ENV_KEYS} from the MAS app's own envs
|
|
1051
|
+
* before minting fresh ones -- this stack's only memory across a re-run
|
|
1052
|
+
* (`calls-matrix.ts`'s `CallsMatrixSecrets` doc comment). `changed: true`
|
|
1053
|
+
* only when nothing usable was found: a partial set (a crash mid-mint) is
|
|
1054
|
+
* still treated as "mint fresh", since MAS's shared secret and Synapse's
|
|
1055
|
+
* copy of it must always agree.
|
|
1056
|
+
*/
|
|
1057
|
+
private ensureCallsMatrixSecrets;
|
|
1058
|
+
/**
|
|
1059
|
+
* Upserts a file storage's content, creating it if the application has none
|
|
1060
|
+
* at that mount path yet -- there is no way to read a file storage's
|
|
1061
|
+
* current content back from Coolify to diff it, the same limit
|
|
1062
|
+
* `archon/bootstrap/steps/chat-instance.ts`'s own `ensureFileStorage` notes,
|
|
1063
|
+
* so an existing one is always re-written.
|
|
1064
|
+
*/
|
|
1065
|
+
private ensureCallsFileStorage;
|
|
1066
|
+
/**
|
|
1067
|
+
* `plan/diff.ts`'s `planCallsService`: the org's own calls service --
|
|
1068
|
+
* `wtfalch/calls`'s `packages/service`, wired to the calls Matrix stack
|
|
1069
|
+
* {@link coolifyCalls} provisions and the shared LiveKit SFU (#122/#127).
|
|
1070
|
+
* Static config checks run first, before any Coolify call (#128's own
|
|
1071
|
+
* carried-over PR 344 review fix, restated in #145's own issue body): the
|
|
1072
|
+
* calls Matrix stack must already be recorded, its MAS app must already
|
|
1073
|
+
* carry its client id/secret, and the shared SFU's LiveKit credentials must
|
|
1074
|
+
* already be resolvable -- none of the three is minted or guessed here.
|
|
1075
|
+
* Park stops the app only, the same park-not-delete rule
|
|
1076
|
+
* {@link coolifyCalls}/{@link coolifyApp} already follow (#7 corrections
|
|
1077
|
+
* 1); its own database is never touched from that branch.
|
|
1078
|
+
*/
|
|
1079
|
+
private coolifyCallsService;
|
|
1080
|
+
/**
|
|
1081
|
+
* Reads back the calls Matrix stack's MAS client id/secret from the MAS
|
|
1082
|
+
* app's own envs ({@link CALLS_SECRET_ENV_KEYS}) -- never mints: minting
|
|
1083
|
+
* either is {@link ensureCallsMatrixSecrets}'s own job, and a missing value
|
|
1084
|
+
* here means that stack has not finished provisioning yet, not something
|
|
1085
|
+
* this step can fix on its own.
|
|
1086
|
+
*/
|
|
1087
|
+
private readCallsMasClientCredentials;
|
|
1088
|
+
/**
|
|
1089
|
+
* `null` once the calls service app already has a `CALLS_BINDING_SECRET` --
|
|
1090
|
+
* never rotated, the same rule {@link ensureAuthCookieSecret} follows for
|
|
1091
|
+
* `AUTH_COOKIE_SECRET`: it wraps the provider-token ciphertext already
|
|
1092
|
+
* stored under any binding minted so far
|
|
1093
|
+
* (`calls/packages/service/src/config.mjs`'s own doc comment on
|
|
1094
|
+
* `CALLS_BINDING_SECRET`), so overwriting it would strand every binding
|
|
1095
|
+
* minted under the old value. 32 raw bytes, hex-encoded -- the exact shape
|
|
1096
|
+
* `config.mjs`'s own regex requires.
|
|
1097
|
+
*/
|
|
1098
|
+
private ensureCallsBindingSecret;
|
|
443
1099
|
private cloudflareTunnel;
|
|
1100
|
+
/**
|
|
1101
|
+
* `plan/diff.ts`'s `planZone`: finds or creates the Cloudflare zone for the
|
|
1102
|
+
* org's own domain. Refuses to adopt a zone the store already records
|
|
1103
|
+
* under a *different* org's applied state (#131), the same
|
|
1104
|
+
* do-not-adopt-a-foreign-resource rule `cloudflareAccess` above enforces --
|
|
1105
|
+
* but where that method tells "foundry's own" from "foreign" by reading
|
|
1106
|
+
* Cloudflare's own Access-app name live, a Cloudflare zone carries no
|
|
1107
|
+
* org-identifying field of its own, so this asks the store instead. That
|
|
1108
|
+
* matters because two different, DB-unique `domain` values (`org.ts`'s
|
|
1109
|
+
* `validateDomain` plus its unique index) can still resolve to the same
|
|
1110
|
+
* Cloudflare zone -- a zone is keyed by apex, and one org's domain can be a
|
|
1111
|
+
* subdomain of another org's -- which is exactly the shared-zone,
|
|
1112
|
+
* shared-hostname collision this ticket exists to refuse. An org re-running
|
|
1113
|
+
* its own already-recorded zone is unaffected: the store then names this
|
|
1114
|
+
* same org as the owner.
|
|
1115
|
+
*/
|
|
444
1116
|
private cloudflareZone;
|
|
1117
|
+
/**
|
|
1118
|
+
* `plan/diff.ts`'s `planMail`: gives an org mail, moves it between modes,
|
|
1119
|
+
* or parks it on the one shared Stalwart instance (`this.ports.mail`,
|
|
1120
|
+
* `@wtfalch/email/postmaster`'s `applyOrgMail`/`parkOrgMail` bound), then
|
|
1121
|
+
* publishes whatever DNS records the call returns, then registers the same
|
|
1122
|
+
* mail domain with Resend (#138) -- the shared Stalwart relays every
|
|
1123
|
+
* outbound message through Resend, and Resend refuses mail from a domain
|
|
1124
|
+
* it has not verified. No step here ever deletes the app's Resend key:
|
|
1125
|
+
* `diff.ts`'s `planMail` plans nothing for mode `'off'`, so turning mail
|
|
1126
|
+
* off leaves the key live, like every other resource foundry stops
|
|
1127
|
+
* managing (#10). Switching the mail domain replaces the key instead
|
|
1128
|
+
* (`ensureAppResendKey`).
|
|
1129
|
+
*/
|
|
1130
|
+
private coolifyMail;
|
|
1131
|
+
/**
|
|
1132
|
+
* Upserts each DNS record `applyOrgMail` says foundry must publish, found
|
|
1133
|
+
* by an exact name+type match (`CloudflarePort.findDnsRecord`'s own doc
|
|
1134
|
+
* comment) so update never mistakes a stale record of a different type for
|
|
1135
|
+
* a match. A `DKIM` record can be absent on the first call, since Stalwart
|
|
1136
|
+
* mints the key on a background task; the next apply publishes it. Returns
|
|
1137
|
+
* the zone id so `registerResendDomain` publishes Resend's own records on
|
|
1138
|
+
* the same zone without a second lookup.
|
|
1139
|
+
*/
|
|
1140
|
+
private publishMailDnsRecords;
|
|
1141
|
+
/**
|
|
1142
|
+
* #138: registers `domain` with Resend -- the custom domain, or
|
|
1143
|
+
* `<slug>.<apex>` for a level-1 org, exactly what `publishMailDnsRecords`
|
|
1144
|
+
* just published the Stalwart records for. Idempotent by name, the same way
|
|
1145
|
+
* `applyOrgMail` itself is: found by `findDomain` rather than any id this
|
|
1146
|
+
* class recorded, so a crash between here and the next apply just repeats
|
|
1147
|
+
* the same find-or-create. Never blocks on DNS propagation (task brief):
|
|
1148
|
+
* `verifyDomain` asks Resend to re-check once, and its returned status is
|
|
1149
|
+
* neither polled nor awaited -- an unverified domain here is exactly what
|
|
1150
|
+
* the next apply corrects.
|
|
1151
|
+
*
|
|
1152
|
+
* #140 rework: mints no key of its own any more. It records the domain's
|
|
1153
|
+
* own id and the resolved mail domain as scope ids so `ensureAppResendKey`
|
|
1154
|
+
* (`coolifyApp`) can mint the app's own `<slug>-app@<domainId>` key once the app
|
|
1155
|
+
* exists, without this step or the app step calling Resend to look the
|
|
1156
|
+
* domain back up.
|
|
1157
|
+
*/
|
|
1158
|
+
private registerResendDomain;
|
|
1159
|
+
/**
|
|
1160
|
+
* Upserts each DNS record Resend's domain names, the same find-then-upsert
|
|
1161
|
+
* `publishMailDnsRecords` above uses for Stalwart's own records -- except
|
|
1162
|
+
* SPF is merged into whatever record that call just published rather than
|
|
1163
|
+
* created a second time (RFC 7208 treats two `v=spf1` TXT records on one
|
|
1164
|
+
* name as a permerror and silently breaks SPF for every sender on the
|
|
1165
|
+
* domain), and an existing `_dmarc` record is never overwritten. Prior art:
|
|
1166
|
+
* `wtfalch/app-template`'s `bootstrap/steps/resend-email.ts`.
|
|
1167
|
+
*/
|
|
1168
|
+
private publishResendDnsRecords;
|
|
1169
|
+
/**
|
|
1170
|
+
* Inserts Resend's `include:` token into whatever SPF record
|
|
1171
|
+
* `publishMailDnsRecords` already published for this name, instead of
|
|
1172
|
+
* creating a second `v=spf1` TXT record -- the classic foot-gun that makes
|
|
1173
|
+
* every receiver treat SPF as a permerror and skip it entirely.
|
|
1174
|
+
*/
|
|
1175
|
+
private mergeResendSpfRecord;
|
|
1176
|
+
/**
|
|
1177
|
+
* Never overwrites an existing `_dmarc` record -- a stricter policy an org
|
|
1178
|
+
* already set (`p=quarantine`/`p=reject`) should not be relaxed back to
|
|
1179
|
+
* Resend's own `p=none` default.
|
|
1180
|
+
*/
|
|
1181
|
+
private ensureResendDmarcRecord;
|
|
445
1182
|
/**
|
|
446
1183
|
* #80: provisions the org's own organisation on Files, before
|
|
447
1184
|
* {@link filesStorage} can set its spend limit. `provisionOrganisation` is
|