@wtfalch/foundry 0.3.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.d.ts +17 -0
- package/dist/calls-sfu.js +17 -0
- package/dist/index.d.ts +1 -1
- package/dist/migrations/0014_calls_enabled.sql +7 -0
- package/dist/migrations/0015_domain_unique.sql +39 -0
- package/dist/org.d.ts +18 -2
- package/dist/org.js +48 -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 +105 -6
- package/dist/plan/executor.d.ts +390 -2
- package/dist/plan/executor.js +799 -28
- package/dist/plan/index.d.ts +1 -1
- package/dist/plan/plan-apply.js +12 -5
- package/dist/slug.js +8 -2
- package/dist/store/postgres.d.ts +11 -6
- package/dist/store/postgres.js +85 -27
- package/dist/store/schema.d.ts +17 -0
- package/dist/store/schema.js +1 -0
- package/dist/store.d.ts +10 -0
- package/package.json +2 -2
package/dist/plan/diff.js
CHANGED
|
@@ -34,10 +34,14 @@ function resourceKey(system, kind, key) {
|
|
|
34
34
|
* rest: the org's owner, invited by ZITADEL email, idempotent on the
|
|
35
35
|
* *recorded* resource id (`recordedIds`, sourced from
|
|
36
36
|
* `AppliedState.serviceIds`) rather than a live resource, since a human user
|
|
37
|
-
* is never something `live` can contain.
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
37
|
+
* is never something `live` can contain. #128 adds one more singleton, off
|
|
38
|
+
* by default unlike chat's own: {@link planCalls}, the org's own calls
|
|
39
|
+
* Matrix stack (`org.ts`'s `CallsConfig.enabled`). #145 adds one more,
|
|
40
|
+
* planned right after it and gated on the same flag: {@link planCallsService},
|
|
41
|
+
* the calls service itself (`wtfalch/calls`'s `packages/service`). Everything
|
|
42
|
+
* else in `live` that none of these fifteen singletons and however many
|
|
43
|
+
* declared issuer apps claim is unmanaged. The full field-by-field provisioning surface (SSO,
|
|
44
|
+
* guests, calls on the org's own domain, adopted/bespoke apps, the actual
|
|
41
45
|
* Zitadel/Coolify/Cloudflare/Files/AI/cms shapes) is #20's "provision
|
|
42
46
|
* Company Factory end to end", which wires real readers and executors
|
|
43
47
|
* against this seam.
|
|
@@ -58,6 +62,16 @@ export function computePlanDetail(org, live, recordedIds = {}) {
|
|
|
58
62
|
const chat = planChat(org, byKey, claimed);
|
|
59
63
|
if (chat)
|
|
60
64
|
plannedSteps.push(chat);
|
|
65
|
+
const calls = planCalls(org, byKey, claimed);
|
|
66
|
+
if (calls)
|
|
67
|
+
plannedSteps.push(calls);
|
|
68
|
+
// Right after the Matrix stack (#145): its own executor
|
|
69
|
+
// (`executor.ts`'s `coolifyCallsService`) reads the Synapse/MAS addresses
|
|
70
|
+
// and MAS client credentials `coolifyCalls` just recorded, the same
|
|
71
|
+
// ordering `planOwner` relies on for `zitadel:issuer`.
|
|
72
|
+
const callsService = planCallsService(org, byKey, claimed);
|
|
73
|
+
if (callsService)
|
|
74
|
+
plannedSteps.push(callsService);
|
|
61
75
|
const filesOrganisation = planFilesOrganisation(org, byKey, claimed);
|
|
62
76
|
if (filesOrganisation)
|
|
63
77
|
plannedSteps.push(filesOrganisation);
|
|
@@ -70,6 +84,12 @@ export function computePlanDetail(org, live, recordedIds = {}) {
|
|
|
70
84
|
const aiBudget = planAiBudget(org, byKey, claimed);
|
|
71
85
|
if (aiBudget)
|
|
72
86
|
plannedSteps.push(aiBudget);
|
|
87
|
+
// Before `planSite`'s own step (#139): `cmsSite`'s executor reads this
|
|
88
|
+
// step's recorded client id, the same way `coolifyApp` runs after the
|
|
89
|
+
// hosted app's own `zitadel:app` steps.
|
|
90
|
+
const cmsClient = planCmsClient(org, byKey, claimed);
|
|
91
|
+
if (cmsClient)
|
|
92
|
+
plannedSteps.push(cmsClient);
|
|
73
93
|
const site = planSite(org, byKey, claimed);
|
|
74
94
|
if (site)
|
|
75
95
|
plannedSteps.push(site);
|
|
@@ -335,6 +355,34 @@ function planChat(org, byKey, claimed) {
|
|
|
335
355
|
},
|
|
336
356
|
};
|
|
337
357
|
}
|
|
358
|
+
/**
|
|
359
|
+
* The org's own calls Matrix stack (#128) -- a dedicated Synapse and
|
|
360
|
+
* matrix-authentication-service, entirely separate from chat's
|
|
361
|
+
* (`plan/calls-matrix.ts`'s own top doc comment: a call guest must never gain
|
|
362
|
+
* chat membership). Unlike chat, off by default: `org.ts`'s
|
|
363
|
+
* `CallsConfig.enabled` doc comment. Same on/off, park-not-delete shape as
|
|
364
|
+
* {@link planApp}/{@link planDatabase}/{@link planTunnel} below
|
|
365
|
+
* (`planHostingToggle`, #7 corrections 1) -- turning it off parks Synapse and
|
|
366
|
+
* MAS, never either database (`executor.ts`'s `coolifyCalls`).
|
|
367
|
+
*/
|
|
368
|
+
function planCalls(org, byKey, claimed) {
|
|
369
|
+
return planHostingToggle('coolify', 'calls', org.calls.enabled, org, byKey, claimed, () => `provision ${org.slug}'s calls Matrix stack (Synapse + matrix-authentication-service)`, () => `park ${org.slug}'s calls Matrix stack (stops serving; data is retained, not deleted -- #7 corrections 1)`);
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* The org's own calls service (#145, part 2 of #128) -- `wtfalch/calls`'s
|
|
373
|
+
* `packages/service`, deployed alongside the calls Matrix stack {@link planCalls}
|
|
374
|
+
* provisions and wired to it and to the shared LiveKit SFU (foundry#122/#127).
|
|
375
|
+
* Gated on the same `org.calls.enabled` flag as the Matrix stack, not a flag
|
|
376
|
+
* of its own: a calls service with no Matrix stack behind it has nothing to
|
|
377
|
+
* authenticate a guest against, and a Matrix stack alone answers no browser,
|
|
378
|
+
* so the two are declared as one on/off switch even though they are two
|
|
379
|
+
* planner steps. Same on/off, park-not-delete shape as {@link planCalls}
|
|
380
|
+
* (`planHostingToggle`, #7 corrections 1) -- turning it off parks the app,
|
|
381
|
+
* never its database (`executor.ts`'s `coolifyCallsService`).
|
|
382
|
+
*/
|
|
383
|
+
function planCallsService(org, byKey, claimed) {
|
|
384
|
+
return planHostingToggle('coolify', 'calls-service', org.calls.enabled, org, byKey, claimed, () => `provision ${org.slug}'s calls service`, () => `park ${org.slug}'s calls service (stops serving; data is retained, not deleted -- #7 corrections 1)`);
|
|
385
|
+
}
|
|
338
386
|
/**
|
|
339
387
|
* Deduped `ai.*` permissions offered to every organisation by default
|
|
340
388
|
* (Decision, this campaign's report): the minimum set that lets an org
|
|
@@ -473,6 +521,46 @@ function planAiBudget(org, byKey, claimed) {
|
|
|
473
521
|
},
|
|
474
522
|
};
|
|
475
523
|
}
|
|
524
|
+
/**
|
|
525
|
+
* https://github.com/wtfalch/foundry/issues/139 (parent decision:
|
|
526
|
+
* https://github.com/wtfalch/foundry/issues/99): every site-enabled org gets
|
|
527
|
+
* one fixed ZITADEL web client named `cms`, so its studio
|
|
528
|
+
* (`@wtfalch/cms`'s `apps/host`) can sign anyone in -- automatically, with no
|
|
529
|
+
* new field on `DeclaredOrg`. Modeled on {@link planIssuerApps}'s single-app
|
|
530
|
+
* shape: provision when missing, recreate when the live `appType` differs,
|
|
531
|
+
* nothing otherwise. There is no park for this client either, for the same
|
|
532
|
+
* reason `planIssuerApps`'s own doc comment gives -- ZITADEL has no notion of
|
|
533
|
+
* a disabled-but-retained OIDC client.
|
|
534
|
+
*/
|
|
535
|
+
function planCmsClient(org, byKey, claimed) {
|
|
536
|
+
if (!org.site.enabled)
|
|
537
|
+
return null;
|
|
538
|
+
const key = resourceKey('zitadel', 'app', 'cms');
|
|
539
|
+
const live = byKey.get(key);
|
|
540
|
+
if (live)
|
|
541
|
+
claimed.add(key);
|
|
542
|
+
const desired = { appType: 'web' };
|
|
543
|
+
if (!live) {
|
|
544
|
+
return {
|
|
545
|
+
action: { system: 'zitadel', kind: 'app', key: 'cms', desired },
|
|
546
|
+
step: {
|
|
547
|
+
description: `provision the web ZITADEL app 'cms' for ${org.slug}'s cms studio sign-in`,
|
|
548
|
+
destroys: false,
|
|
549
|
+
migrates: false,
|
|
550
|
+
},
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
if (live.config.appType === 'web')
|
|
554
|
+
return null;
|
|
555
|
+
return {
|
|
556
|
+
action: { system: 'zitadel', kind: 'app', key: 'cms', desired },
|
|
557
|
+
step: {
|
|
558
|
+
description: `recreate ZITADEL app 'cms' for ${org.slug} as web (was ${String(live.config.appType)})`,
|
|
559
|
+
destroys: true,
|
|
560
|
+
migrates: false,
|
|
561
|
+
},
|
|
562
|
+
};
|
|
563
|
+
}
|
|
476
564
|
/**
|
|
477
565
|
* The org's cms site (`org.ts`'s `SiteConfig`), a per-org singleton
|
|
478
566
|
* resource modeled on {@link planMail}. Disabling a live site parks it,
|
|
@@ -643,10 +731,16 @@ function planDatabase(org, byKey, claimed) {
|
|
|
643
731
|
* (`executor.ts`'s `tunnelIngressHostnames`) -- there is no separate
|
|
644
732
|
* `site.tunnel` toggle to gate on the way `hosting.tunnel` does, since a site
|
|
645
733
|
* has no meaning without one.
|
|
734
|
+
*
|
|
735
|
+
* #145: an own-domain calls service (`org.ts`'s `CallsConfig.usesDomain`)
|
|
736
|
+
* needs a tunnel too, for the same reason a site does -- an org that wants
|
|
737
|
+
* `calls.<domain>` but declares no other own-domain feature must still get
|
|
738
|
+
* one provisioned, not silently have nowhere for that hostname to route.
|
|
646
739
|
*/
|
|
647
740
|
function planTunnel(org, byKey, claimed) {
|
|
648
741
|
const desired = (org.hosting.tunnel && org.hosting.usesDomain && Boolean(org.domain)) ||
|
|
649
|
-
(org.site.enabled && Boolean(org.domain))
|
|
742
|
+
(org.site.enabled && Boolean(org.domain)) ||
|
|
743
|
+
(org.calls.enabled && org.calls.usesDomain && Boolean(org.domain));
|
|
650
744
|
return planHostingToggle('cloudflare', 'tunnel', desired, org, byKey, claimed, () => `provision a Cloudflare tunnel for ${org.slug}'s app`, () => `park ${org.slug}'s Cloudflare tunnel (configuration is retained, not deleted -- #7 corrections 1)`);
|
|
651
745
|
}
|
|
652
746
|
/**
|
|
@@ -664,9 +758,14 @@ function planTunnel(org, byKey, claimed) {
|
|
|
664
758
|
* though it can never have `hosting` on at the same time (`validateSite`) --
|
|
665
759
|
* `org.site.enabled` already guarantees both `domain` and `site.usesDomain`
|
|
666
760
|
* are set, so the `org.domain` check below still covers it.
|
|
761
|
+
*
|
|
762
|
+
* #145: an own-domain calls service (`org.ts`'s `CallsConfig.usesDomain`)
|
|
763
|
+
* needs a zone for the same reason, whether or not the org also has hosting
|
|
764
|
+
* or a site on its domain.
|
|
667
765
|
*/
|
|
668
766
|
function planZone(org, byKey, claimed) {
|
|
669
|
-
|
|
767
|
+
const usesDomain = org.hosting.usesDomain || org.site.enabled || (org.calls.enabled && org.calls.usesDomain);
|
|
768
|
+
if (!usesDomain || !org.domain)
|
|
670
769
|
return null;
|
|
671
770
|
const key = resourceKey('cloudflare', 'zone', org.domain);
|
|
672
771
|
const live = byKey.get(key);
|
package/dist/plan/executor.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { OrgMailOptions, OrgMailOrg, OrgMailResult } from '@wtfalch/email/postmaster';
|
|
2
2
|
import type { DeclaredOrg, HostingBuildPack, OrgId } from '../org.js';
|
|
3
|
+
import type { DeclaredOrgStore } from '../store.js';
|
|
3
4
|
import type { StepAction } from './diff.js';
|
|
4
5
|
import type { DestroyTarget, StepDestroyer, StepExecutionContext, StepExecutor } from './plan-apply.js';
|
|
5
6
|
/**
|
|
@@ -104,6 +105,7 @@ export interface CoolifyPort {
|
|
|
104
105
|
name: string;
|
|
105
106
|
postgres_db: string;
|
|
106
107
|
postgres_user: string;
|
|
108
|
+
postgres_initdb_args?: string;
|
|
107
109
|
}): Promise<{
|
|
108
110
|
uuid: string;
|
|
109
111
|
internal_db_url: string;
|
|
@@ -199,6 +201,74 @@ export interface CoolifyPort {
|
|
|
199
201
|
deleteApplication(uuid: string): Promise<unknown>;
|
|
200
202
|
/** `FoundryPlanApply.destroy`'s teardown of the org's own database. */
|
|
201
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>;
|
|
202
272
|
}
|
|
203
273
|
/**
|
|
204
274
|
* The Cloudflare calls this executor makes, shaped to match
|
|
@@ -300,6 +370,85 @@ export interface CloudflarePort {
|
|
|
300
370
|
*/
|
|
301
371
|
request<T>(method: string, path: string, body?: unknown, missingOkay?: boolean): Promise<T>;
|
|
302
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
|
+
}
|
|
303
452
|
/**
|
|
304
453
|
* The Stalwart calls this executor makes for one org's mail, shaped to match
|
|
305
454
|
* `@wtfalch/email/postmaster`'s `applyOrgMail`/`parkOrgMail` method-for-method
|
|
@@ -310,6 +459,24 @@ export interface MailPort {
|
|
|
310
459
|
applyOrgMail(org: OrgMailOrg, options: OrgMailOptions): Promise<OrgMailResult>;
|
|
311
460
|
parkOrgMail(org: OrgMailOrg, options: OrgMailOptions): Promise<void>;
|
|
312
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>;
|
|
479
|
+
}
|
|
313
480
|
/**
|
|
314
481
|
* The ZITADEL calls this executor makes. `findOrCreateOrg`/`findOrCreateProject`
|
|
315
482
|
* already exist on `@wtfalch/providers/zitadel`'s `ZitadelClient` (returning
|
|
@@ -359,6 +526,23 @@ export interface ZitadelPort {
|
|
|
359
526
|
clientSecret: string;
|
|
360
527
|
}>;
|
|
361
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
|
+
}>>;
|
|
362
546
|
/**
|
|
363
547
|
* The org-owner flow (goal line): find-or-create the human user by email in
|
|
364
548
|
* the org, then have ZITADEL email them an invite to set up a passkey.
|
|
@@ -486,6 +670,9 @@ export interface FoundryStepExecutorPorts {
|
|
|
486
670
|
cloudflare: CloudflarePort;
|
|
487
671
|
zitadel: ZitadelPort;
|
|
488
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;
|
|
489
676
|
/** A factory, not a flat client: Files/AI are org-scoped credentials the host resolves per org. */
|
|
490
677
|
filesLimitsFor: (orgId: OrgId) => FilesOrgLimits;
|
|
491
678
|
aiLimitsFor: (orgId: OrgId) => AiOrgLimits;
|
|
@@ -506,6 +693,14 @@ export interface FoundryStepExecutorPorts {
|
|
|
506
693
|
* themselves.
|
|
507
694
|
*/
|
|
508
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'>;
|
|
509
704
|
}
|
|
510
705
|
/**
|
|
511
706
|
* Thrown by name for a step this executor deliberately does not implement:
|
|
@@ -581,6 +776,16 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
581
776
|
*/
|
|
582
777
|
private zitadelCustomers;
|
|
583
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;
|
|
584
789
|
private ensureZitadelProject;
|
|
585
790
|
private ensureCoolifyProject;
|
|
586
791
|
private resolveServer;
|
|
@@ -657,6 +862,41 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
657
862
|
* upsert a fresh value over it.
|
|
658
863
|
*/
|
|
659
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;
|
|
660
900
|
/**
|
|
661
901
|
* `plan/diff.ts`'s `planSite` (#116, slicing #99's "provision the cms app,
|
|
662
902
|
* database and site app per org"): the 2026-09-25 decision
|
|
@@ -774,13 +1014,118 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
774
1014
|
* this method is `cmsSite`'s only caller.
|
|
775
1015
|
*/
|
|
776
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;
|
|
777
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
|
+
*/
|
|
778
1116
|
private cloudflareZone;
|
|
779
1117
|
/**
|
|
780
1118
|
* `plan/diff.ts`'s `planMail`: gives an org mail, moves it between modes,
|
|
781
1119
|
* or parks it on the one shared Stalwart instance (`this.ports.mail`,
|
|
782
1120
|
* `@wtfalch/email/postmaster`'s `applyOrgMail`/`parkOrgMail` bound), then
|
|
783
|
-
* publishes whatever DNS records the call returns
|
|
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`).
|
|
784
1129
|
*/
|
|
785
1130
|
private coolifyMail;
|
|
786
1131
|
/**
|
|
@@ -788,9 +1133,52 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
788
1133
|
* by an exact name+type match (`CloudflarePort.findDnsRecord`'s own doc
|
|
789
1134
|
* comment) so update never mistakes a stale record of a different type for
|
|
790
1135
|
* 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.
|
|
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.
|
|
792
1139
|
*/
|
|
793
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;
|
|
794
1182
|
/**
|
|
795
1183
|
* #80: provisions the org's own organisation on Files, before
|
|
796
1184
|
* {@link filesStorage} can set its spend limit. `provisionOrganisation` is
|