@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/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. Everything else in `live` that none
38
- * of these twelve singletons and however many declared issuer apps claim is
39
- * unmanaged. The full field-by-field provisioning surface (SSO, guests,
40
- * calls, adopted/bespoke apps, the actual
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
- if (!(org.hosting.usesDomain || org.site.enabled) || !org.domain)
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);
@@ -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