@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.
@@ -1,5 +1,7 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { URL } from 'node:url';
3
+ import { CALLS_SFU_PRIVATE_URL, CALLS_SFU_PUBLIC_URL } from '../calls-sfu.js';
4
+ import { MAS_IMAGE, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
3
5
  // ---------------------------------------------------------------------------
4
6
  // Errors
5
7
  // ---------------------------------------------------------------------------
@@ -32,14 +34,30 @@ function hostnameFor(org) {
32
34
  }
33
35
  /**
34
36
  * `cms.<domain>` -- the cms studio's own hostname (`cmsSite` below), apart
35
- * from the public site itself at `org.domain` (used directly there). Only
36
- * ever called from `cmsSite`, which only ever runs once `org.site.enabled`
37
- * is true, so `org.domain` is non-null by the same `validateSite` guarantee
37
+ * from the public site itself at `org.domain` (used directly there). Called
38
+ * from `cmsSite` and from `zitadelApp` for the fixed `cms` client (#139),
39
+ * both of which only run once `org.site.enabled` is true, so `org.domain`
40
+ * is non-null by the same `validateSite` guarantee
38
41
  * {@link tunnelIngressHostnames}'s own doc comment cites.
39
42
  */
40
43
  function cmsHostnameFor(org) {
41
44
  return `cms.${org.domain}`;
42
45
  }
46
+ /**
47
+ * #145's "Decided" (2026-09-27): the calls service's own public hostname.
48
+ * `calls.usesDomain: false` (or no `domain`) is served flat, like
49
+ * `<slug>-stage.oikos.host` and `<slug>-pr-<n>.oikos.host` (`../slug.ts`) --
50
+ * the shared tunnel's wildcard `*.oikos.host` DNS record already reaches it,
51
+ * so nothing changes in `scripts/shared-ingress.mjs`. `calls.usesDomain: true`
52
+ * gets `calls.<domain>` instead, on its own per-org tunnel
53
+ * ({@link tunnelIngressHostnames}), the same subdomain-of-the-org's-own-domain
54
+ * shape {@link cmsHostnameFor} uses for `cms.<domain>`.
55
+ */
56
+ function callsHostnameFor(org) {
57
+ return org.calls.usesDomain && org.domain
58
+ ? `calls.${org.domain}`
59
+ : `${org.slug}-calls.oikos.host`;
60
+ }
43
61
  /**
44
62
  * The hostnames `cloudflareTunnel` routes to `COOLIFY_PROXY_ORIGIN`
45
63
  * (https://github.com/wtfalch/foundry/issues/115). A site-enabled org
@@ -51,11 +69,17 @@ function cmsHostnameFor(org) {
51
69
  * concretely a hosting org, whose one app hostname this served before #115
52
70
  * ever existed -- keeps exactly the prior single-hostname behavior
53
71
  * ({@link hostnameFor}), unchanged.
72
+ *
73
+ * #145: an own-domain calls service adds `calls.<domain>` on top of whichever
74
+ * of those a site/hosting org already gets, since calls can run alongside
75
+ * either (or neither).
54
76
  */
55
77
  function tunnelIngressHostnames(org) {
56
- if (org.site.enabled && org.domain)
57
- return [org.domain, `cms.${org.domain}`];
58
- return [hostnameFor(org)];
78
+ const hostnames = org.site.enabled && org.domain ? [org.domain, `cms.${org.domain}`] : [hostnameFor(org)];
79
+ if (org.calls.enabled && org.calls.usesDomain && org.domain) {
80
+ hostnames.push(`calls.${org.domain}`);
81
+ }
82
+ return hostnames;
59
83
  }
60
84
  /**
61
85
  * The health URLs an outside prober should call for this org (#43): one per
@@ -141,8 +165,15 @@ const RATE_LIMIT_REQUESTS_PER_PERIOD = 10;
141
165
  * org declares none of its own — the repo may not exist yet at decision
142
166
  * time, and Coolify's own GitHub App create call is what will fail loudly
143
167
  * against it, the same as any other repo name that does not exist.
168
+ *
169
+ * Hestia moved into this repo's own `apps/hestia` (estate DECISIONS.md,
170
+ * 2026-09-28; the hestia repo archives once this move lands), so the
171
+ * standard app now builds from foundry's own repo, told apart from
172
+ * `packages/foundry` by `dockerfile_location` — the same one-repo,
173
+ * multiple-apps shape `cmsSite`/`CMS_APP_DOCKERFILE` already use below.
144
174
  */
145
- const DEFAULT_APP_REPO = 'wtfalch/hestia';
175
+ const DEFAULT_APP_REPO = 'wtfalch/foundry';
176
+ const DEFAULT_APP_DOCKERFILE = 'apps/hestia/Dockerfile';
146
177
  /**
147
178
  * The one repo `cmsSite` builds both of its apps from
148
179
  * (https://github.com/wtfalch/cms) -- the 2026-09-25 decision this campaign
@@ -185,6 +216,16 @@ const MAIL_HOST = 'mail.wtfalch.dev';
185
216
  * report).
186
217
  */
187
218
  const APP_CLIENT_ID_SCOPE_KEY = 'zitadel_app_client_id';
219
+ /**
220
+ * #139: the `cms` app's own ZITADEL web client's `clientId`, recorded
221
+ * separately from {@link APP_CLIENT_ID_SCOPE_KEY} -- that key belongs to the
222
+ * org's own hosted app (`diff.ts`'s `planIssuerApps`), a different
223
+ * `zitadel:app` step from the fixed `cms` client `diff.ts`'s `planCmsClient`
224
+ * plans. `cmsSite` reads this key to push `AUTH_CLIENT_ID` onto the cms app,
225
+ * the same way `pushAppEnv` reads `APP_CLIENT_ID_SCOPE_KEY` for the hosted
226
+ * app.
227
+ */
228
+ const CMS_CLIENT_ID_SCOPE_KEY = 'cms_client_id';
188
229
  /**
189
230
  * `cmsSite` provisions two applications from one `cms:site` step, but
190
231
  * `execute`'s own return value auto-records only one `resourceId`, under
@@ -210,6 +251,86 @@ const SITE_APP_SCOPE_KEY = 'coolify_site_app';
210
251
  * never collide with that form.
211
252
  */
212
253
  const CMS_DATABASE_SCOPE_KEY = 'coolify_cms_database';
254
+ /**
255
+ * #138: the Resend domain's own id (`createDomain`/`findDomain`'s result,
256
+ * never a key or token) for this org's mail domain -- recorded so the app
257
+ * step (#140 rework) can scope its `<slug>-app@<domainId>` key to this domain without
258
+ * calling Resend a second time to look it up. No `system:kind:key` slot of
259
+ * its own for the same reason {@link CMS_DATABASE_SCOPE_KEY} has none:
260
+ * `coolify:mail`'s one step already auto-records `result.domain` as its
261
+ * `resourceId`.
262
+ */
263
+ const RESEND_DOMAIN_ID_SCOPE_KEY = 'resend_domain_id';
264
+ /**
265
+ * #138/#140: the mail domain `registerResendDomain` verified with Resend --
266
+ * `org.domain` when the org uses its own domain, else `<slug>.<apex>` for a
267
+ * domainless org (2026-09-28: domainless orgs get their own Resend domain
268
+ * too). Recorded so the app step can derive `MAIL_FROM` without recomputing
269
+ * `mailOrgFor`'s own domain choice, the same way {@link APP_CLIENT_ID_SCOPE_KEY}
270
+ * carries a public value out.
271
+ */
272
+ const RESEND_MAIL_DOMAIN_SCOPE_KEY = 'resend_mail_domain';
273
+ /**
274
+ * `coolifyCalls` provisions four resources from one `coolify:calls` step
275
+ * (#128) — the same one-step, several-resource shape `cmsSite` already solves
276
+ * with flat scope ids (`CMS_APP_SCOPE_KEY`/`SITE_APP_SCOPE_KEY`/
277
+ * `CMS_DATABASE_SCOPE_KEY`, that constant's own doc comment). This step's own
278
+ * auto-recorded `resourceId` (under `coolify:calls:<orgId>`) is the MAS app's
279
+ * id, the one every secret this stack mints lives on
280
+ * ({@link CALLS_SECRET_ENV_KEYS}); the other three get their own flat scope
281
+ * id here for the same reason CMS's site app and database do.
282
+ */
283
+ const CALLS_SYNAPSE_SCOPE_KEY = 'coolify_calls_synapse';
284
+ const CALLS_MAS_SCOPE_KEY = 'coolify_calls_mas';
285
+ const CALLS_SYNAPSE_DB_SCOPE_KEY = 'coolify_calls_synapse_database';
286
+ const CALLS_MAS_DB_SCOPE_KEY = 'coolify_calls_mas_database';
287
+ /**
288
+ * Synapse refuses to start against a database whose `LC_COLLATE`/`LC_CTYPE`
289
+ * is not `C` (https://element-hq.github.io/synapse/latest/postgres.html);
290
+ * Coolify's official postgres image otherwise creates one with `en_US.utf8`.
291
+ * `postgres_initdb_args` is the only point this can be set -- it cannot be
292
+ * migrated onto an existing database. MAS has no such requirement, so this
293
+ * is passed for the Synapse database only. Not using Synapse's own
294
+ * `allow_unsafe_locale` escape hatch: that trades a loud startup refusal for
295
+ * silent index corruption after a future glibc upgrade.
296
+ */
297
+ const CALLS_SYNAPSE_DB_INITDB_ARGS = '--locale=C --encoding=UTF8';
298
+ /**
299
+ * `CallsMatrixSecrets`' six fields, held nowhere else once minted
300
+ * (`calls-matrix.ts`'s own doc comment: no `ctx.outputs` state file exists
301
+ * here) — pushed onto the MAS app's own envs purely as memory for the next
302
+ * apply, never read by MAS itself (MAS reads only its `config.yaml` file
303
+ * storage). `multiline: true` on the two PEM keys, the same flag
304
+ * `CoolifyPort.bulkUpsertEnvs` already carries for exactly this shape of
305
+ * value.
306
+ */
307
+ const CALLS_SECRET_ENV_KEYS = {
308
+ masSynapseSharedSecret: 'CALLS_MATRIX_MAS_SYNAPSE_SHARED_SECRET',
309
+ masClientId: 'CALLS_MATRIX_MAS_CLIENT_ID',
310
+ masClientSecret: 'CALLS_MATRIX_MAS_CLIENT_SECRET',
311
+ masEncryptionKey: 'CALLS_MATRIX_MAS_ENCRYPTION_KEY',
312
+ masRsaKeyPem: 'CALLS_MATRIX_MAS_RSA_KEY_PEM',
313
+ masEcKeyPem: 'CALLS_MATRIX_MAS_EC_KEY_PEM',
314
+ };
315
+ /** `FROM ${MAS_IMAGE}` plus the entry command its published image needs (chat's own `MAS_DOCKERFILE`, same reason: no arguments by default). */
316
+ const CALLS_MAS_DOCKERFILE = [
317
+ `FROM ${MAS_IMAGE}`,
318
+ 'CMD ["server", "--config", "/config/config.yaml"]',
319
+ ].join('\n');
320
+ /**
321
+ * `coolifyCallsService`'s own database (#145) -- no `system:kind:key` slot
322
+ * of its own, the same reason {@link CALLS_SYNAPSE_SCOPE_KEY}'s doc comment
323
+ * gives for the Matrix stack's two: this step's one auto-recorded
324
+ * `resourceId` (under `coolify:calls-service:<orgId>`) is the calls service
325
+ * app itself, not its database.
326
+ */
327
+ const CALLS_SERVICE_DB_SCOPE_KEY = 'coolify_calls_service_database';
328
+ /** `wtfalch/calls`'s own repo -- `coolifyCallsService` deploys its `packages/service` (#145, part 2 of #128). */
329
+ const CALLS_SERVICE_REPO = 'wtfalch/calls';
330
+ /** `packages/service/Dockerfile`, built from the repo root (that Dockerfile's own top comment). */
331
+ const CALLS_SERVICE_DOCKERFILE = 'packages/service/Dockerfile';
332
+ /** The port `calls/packages/service/Dockerfile` documents (`EXPOSE 8080`) -- `config.mjs`'s own `PORT` has no default, so this must be pushed explicitly. */
333
+ const CALLS_SERVICE_PORT = '8080';
213
334
  /**
214
335
  * Whether an application's own Coolify `status` (`` `running:healthy`,
215
336
  * `exited:unhealthy` `` -- `@wtfalch/providers`' own doc comment) means it is
@@ -249,10 +370,14 @@ export class FoundryStepExecutor {
249
370
  return this.coolifyApp(action, org, context);
250
371
  case 'coolify:database':
251
372
  return this.coolifyDatabase(action, org, context);
373
+ case 'coolify:calls':
374
+ return this.coolifyCalls(action, org, context);
375
+ case 'coolify:calls-service':
376
+ return this.coolifyCallsService(action, org, context);
252
377
  case 'cloudflare:tunnel':
253
378
  return this.cloudflareTunnel(action, org, context);
254
379
  case 'cloudflare:zone':
255
- return this.cloudflareZone(action);
380
+ return this.cloudflareZone(action, org);
256
381
  case 'cloudflare:access':
257
382
  return this.cloudflareAccess(org);
258
383
  case 'cloudflare:ratelimit':
@@ -266,7 +391,7 @@ export class FoundryStepExecutor {
266
391
  case 'ai:budget':
267
392
  return this.aiBudget(action, org);
268
393
  case 'coolify:mail':
269
- return this.coolifyMail(action, org);
394
+ return this.coolifyMail(action, org, context);
270
395
  case 'coolify:chat':
271
396
  throw new NotBuiltError(`coolify:chat for ${org.slug}`, "chat's per-org Synapse+MAS has no admin API yet; tracked at https://github.com/wtfalch/chat/issues/40");
272
397
  case 'cms:site':
@@ -427,10 +552,39 @@ export class FoundryStepExecutor {
427
552
  const projectId = await this.ensureZitadelProject(org, zitadelOrgId, context);
428
553
  // A kind change deletes the recorded app then recreates it (task
429
554
  // brief); the first-ever create for this key has nothing recorded, so
430
- // this is a no-op then.
555
+ // this is a no-op then. A 404 here (already gone) is tolerated the same
556
+ // way `destroy` tolerates one -- a retry after the delete half already
557
+ // succeeded but the recreate half failed must find an already-deleted
558
+ // app a no-op, not a new error that wedges the apply forever.
431
559
  const priorAppId = recorded[`zitadel:app:${action.key}`];
432
560
  if (priorAppId) {
433
- await this.ports.zitadel.deleteApp(zitadelOrgId, projectId, priorAppId);
561
+ try {
562
+ await this.ports.zitadel.deleteApp(zitadelOrgId, projectId, priorAppId);
563
+ }
564
+ catch (error) {
565
+ if (!isNotFoundError(error))
566
+ throw error;
567
+ }
568
+ // The recreate half is retried on its own failure (the delete above
569
+ // already tolerates the resulting 404), so a retry can also land here
570
+ // after an earlier attempt's create call actually succeeded against
571
+ // ZITADEL but whose response this executor never saw (a dropped
572
+ // connection, a killed process). That app is deleted, not reused: the
573
+ // lost response also lost its clientSecret and clientId, which
574
+ // `listApps` cannot return, so reusing it would leave the secret
575
+ // undelivered and the clientId unrecorded forever. The create below
576
+ // then runs every side effect. A first-ever create (no `priorAppId`)
577
+ // has nothing to find here and never pays this read.
578
+ const orphan = await this.findRecreatedApp(zitadelOrgId, projectId, action.key, appType);
579
+ if (orphan) {
580
+ try {
581
+ await this.ports.zitadel.deleteApp(zitadelOrgId, projectId, orphan.id);
582
+ }
583
+ catch (error) {
584
+ if (!isNotFoundError(error))
585
+ throw error;
586
+ }
587
+ }
434
588
  }
435
589
  if (appType === 'api') {
436
590
  const created = await this.ports.zitadel.createApiApp(zitadelOrgId, projectId, {
@@ -442,7 +596,13 @@ export class FoundryStepExecutor {
442
596
  await this.ports.deliverSecret(`zitadel:app:${org.slug}:${action.key}`, created.clientSecret);
443
597
  return { resourceId: created.appId };
444
598
  }
445
- const hostname = hostnameFor(org);
599
+ // #139: the fixed `cms` client (`diff.ts`'s `planCmsClient`) signs
600
+ // people into the cms studio at `cms.<domain>`, not the org's own app
601
+ // hostname -- `org.site.enabled` guarantees `org.domain` is set
602
+ // (`org.ts`'s `validateSite`), the same guarantee `cmsHostnameFor`'s own
603
+ // doc comment relies on.
604
+ const isCmsClient = action.key === 'cms' && org.site.enabled;
605
+ const hostname = isCmsClient ? cmsHostnameFor(org) : hostnameFor(org);
446
606
  // Native apps sign in via device code, with no redirect of their own
447
607
  // (auth/scripts/provision.mjs:475-497, prior art cited in the decisions
448
608
  // file) -- empty arrays rather than a web app's callback/logout URLs.
@@ -453,15 +613,30 @@ export class FoundryStepExecutor {
453
613
  postLogoutRedirectUris: appType === 'native' ? [] : [`https://${hostname}/`],
454
614
  });
455
615
  if (appType === 'web') {
456
- // `coolifyApp`'s own `pushAppEnv` reads this to push AUTH_CLIENT_ID
457
- // (level-1 app-env campaign, gap P2) -- public, not a secret, so a
458
- // plain scope id, not `deliverSecret`. A native app signs in via
459
- // device code with no cookie session of its own, so its clientId is
460
- // never something the app's env needs.
461
- context.recordScopeId(APP_CLIENT_ID_SCOPE_KEY, created.clientId);
616
+ // `coolifyApp`'s own `pushAppEnv` reads `APP_CLIENT_ID_SCOPE_KEY` to
617
+ // push AUTH_CLIENT_ID (level-1 app-env campaign, gap P2); `cmsSite`
618
+ // reads `CMS_CLIENT_ID_SCOPE_KEY` for the same env on the cms app
619
+ // (#139) -- public, not a secret, so a plain scope id, not
620
+ // `deliverSecret`. A native app signs in via device code with no
621
+ // cookie session of its own, so its clientId is never something an
622
+ // app's env needs.
623
+ context.recordScopeId(isCmsClient ? CMS_CLIENT_ID_SCOPE_KEY : APP_CLIENT_ID_SCOPE_KEY, created.clientId);
462
624
  }
463
625
  return { resourceId: created.appId };
464
626
  }
627
+ /**
628
+ * The app an earlier attempt's recreate already made under the same
629
+ * name, whose response this executor never saw -- deleted before the
630
+ * create so no duplicate is left behind. Matches on name and the
631
+ * desired kind, and skips a removed app -- ZITADEL's
632
+ * search can return one that has since been deleted (`isNotFoundError`'s
633
+ * own doc comment covers why a delete can be retried into a 404), and
634
+ * that is not the survivor this is looking for.
635
+ */
636
+ async findRecreatedApp(zitadelOrgId, projectId, name, appType) {
637
+ const apps = await this.ports.zitadel.listApps(zitadelOrgId, projectId);
638
+ return apps.find((app) => app.name === name && app.appType === appType && app.state !== 'APP_STATE_REMOVED');
639
+ }
465
640
  async ensureZitadelProject(org, zitadelOrgId, context) {
466
641
  const recorded = context.recordedIds();
467
642
  const existing = recorded.zitadel_project;
@@ -537,6 +712,9 @@ export class FoundryStepExecutor {
537
712
  build_pack: org.hosting.buildPack,
538
713
  name: org.slug,
539
714
  domains: hostnameFor(org),
715
+ // Only the shared standard app builds from this repo; a bespoke
716
+ // app's Dockerfile is at its own repo's root.
717
+ dockerfile_location: org.bespokeAppRepo ? undefined : DEFAULT_APP_DOCKERFILE,
540
718
  });
541
719
  appId = created.uuid;
542
720
  }
@@ -562,6 +740,11 @@ export class FoundryStepExecutor {
562
740
  // unlike the database push above: every app has a hostname, and an
563
741
  // upsert of the same value on a later apply is a no-op, not a change.
564
742
  await this.pushAppEnv(appId, org, context);
743
+ // #140 rework: the hosted app's own Resend sending key and `MAIL_FROM`
744
+ // (only the app started by this same step, per the design's "only apps
745
+ // that send mail get it" -- cms and calls do not). A no-op until
746
+ // `coolify:mail` has actually registered a Resend domain for this org.
747
+ await this.ensureAppResendKey(appId, org, context, Boolean(existing));
565
748
  return { resourceId: appId };
566
749
  }
567
750
  /**
@@ -735,6 +918,83 @@ export class FoundryStepExecutor {
735
918
  return null;
736
919
  return randomBytes(32).toString('base64');
737
920
  }
921
+ /**
922
+ * #140 rework: the hosted app's own Resend sending key, pushed as
923
+ * `RESEND_API_KEY` straight into the app's env, the one place its token
924
+ * ever lives (Resend shows a key's token once, at creation). A no-op until
925
+ * `coolify:mail` has recorded a {@link RESEND_DOMAIN_ID_SCOPE_KEY} for this
926
+ * org; the next apply fills it in once `coolify:mail` runs first
927
+ * (`diff.ts`'s `computePlanDetail` order).
928
+ *
929
+ * The key is named `<slug>-app@<resendDomainId>`, so a key limited to a
930
+ * mail domain the org no longer uses (a `usesDomain` flip moves it between
931
+ * `org.domain` and `<slug>.<apex>`) never passes for the current one.
932
+ * `RESEND_API_KEY_ID` sits beside the token in the env -- an id, not a
933
+ * secret -- so the env can say which key its token belongs to.
934
+ *
935
+ * The mint rule (`.plans/2026-09-27-org-mail-resend-design.md` "One rule
936
+ * for minting"): healthy when the env holds a token and its
937
+ * `RESEND_API_KEY_ID` is the one key Resend lists under the current name.
938
+ * Otherwise one fresh key is minted and written to the env first, and only
939
+ * then is every other `<slug>-app@` key deleted -- a lost mint response, a
940
+ * duplicate, a hand-made key, and a previous domain's key all end the same
941
+ * way. An app that was already running is restarted before that delete,
942
+ * since a container reads its env only at start (`running`, the same
943
+ * reason {@link ensureLogVolume} restarts one). The restart is not
944
+ * awaited to completion, so the old container can briefly outlive its key.
945
+ *
946
+ * `MAIL_FROM` is upserted every time: it depends only on the mail domain
947
+ * `coolify:mail` resolved (`RESEND_MAIL_DOMAIN_SCOPE_KEY`).
948
+ *
949
+ * The token goes straight to `bulkUpsertEnvs` and nowhere else: never
950
+ * returned, never recorded in `serviceIds` (`recordScopeId`'s own doc
951
+ * comment), never logged, never interpolated into a thrown error -- the
952
+ * same discipline `pushDatabaseUrl`'s doc comment states for a database's
953
+ * own connection string.
954
+ */
955
+ async ensureAppResendKey(appId, org, context, running) {
956
+ const recorded = context.recordedIds();
957
+ const domainId = recorded[RESEND_DOMAIN_ID_SCOPE_KEY];
958
+ if (!domainId)
959
+ return;
960
+ const prefix = `${org.slug}-app@`;
961
+ const keyName = `${prefix}${domainId}`;
962
+ const [ours, envs] = await Promise.all([
963
+ this.ports.resend.listApiKeys().then((keys) => keys.filter((k) => k.name.startsWith(prefix))),
964
+ this.ports.coolify.listEnvs(appId),
965
+ ]);
966
+ const envValue = (key) => envs.find((e) => e.key === key && !e.is_preview)?.value;
967
+ const current = ours.filter((k) => k.name === keyName);
968
+ const held = current.length === 1 ? current[0] : undefined;
969
+ const healthy = held !== undefined &&
970
+ Boolean(envValue('RESEND_API_KEY')) &&
971
+ envValue('RESEND_API_KEY_ID') === held.id;
972
+ const envUpdates = [];
973
+ let keep = healthy ? held?.id : undefined;
974
+ if (!healthy) {
975
+ const created = await this.ports.resend.createApiKey({
976
+ name: keyName,
977
+ permission: 'sending_access',
978
+ domainId,
979
+ });
980
+ keep = created.id;
981
+ envUpdates.push({ key: 'RESEND_API_KEY', value: created.token }, { key: 'RESEND_API_KEY_ID', value: created.id });
982
+ }
983
+ const mailDomain = recorded[RESEND_MAIL_DOMAIN_SCOPE_KEY];
984
+ if (mailDomain)
985
+ envUpdates.push({ key: 'MAIL_FROM', value: `no-reply@${mailDomain}` });
986
+ if (envUpdates.length > 0)
987
+ await this.ports.coolify.bulkUpsertEnvs(appId, envUpdates);
988
+ // A running container read its env at start and still holds the old
989
+ // token, so a fresh key only reaches it through a restart -- before the
990
+ // old key is deleted below. A just-created app starts with the new env.
991
+ if (!healthy && running)
992
+ await this.ports.coolify.restart(appId);
993
+ for (const key of ours) {
994
+ if (key.id !== keep)
995
+ await this.ports.resend.deleteApiKey(key.id);
996
+ }
997
+ }
738
998
  /**
739
999
  * `plan/diff.ts`'s `planSite` (#116, slicing #99's "provision the cms app,
740
1000
  * database and site app per org"): the 2026-09-25 decision
@@ -832,11 +1092,32 @@ export class FoundryStepExecutor {
832
1092
  // (see that section's own comment) instead of caught, so a database
833
1093
  // failure must not also cost the app these two calls already have
834
1094
  // everything they need for.
835
- await this.ports.coolify.bulkUpsertEnvs(cms.appId, [
1095
+ //
1096
+ // #139: `@wtfalch/cms`'s `AUTH_*` contract
1097
+ // (https://github.com/wtfalch/cms/blob/main/apps/host/.env.example),
1098
+ // pushed in this same call. `AUTH_ORGANIZATION_ID` and `AUTH_CLIENT_ID`
1099
+ // are pushed only once their own source step has recorded them --
1100
+ // `zitadel:issuer` and this org's `cms` `zitadel:app` step
1101
+ // (`diff.ts`'s `planCmsClient`), both ordered before `cms:site` for
1102
+ // exactly this reason, the same rule `pushAppEnv` follows for the
1103
+ // hosted app. `AUTH_COOKIE_SECRET` is generated once and never rotated
1104
+ // (`ensureAuthCookieSecret`'s own doc comment).
1105
+ const recorded = context.recordedIds();
1106
+ const cmsEnvs = [
836
1107
  { key: 'APP_URL', value: `https://${cmsHostnameFor(org)}` },
837
1108
  { key: 'CMS_ORGANISATION_ID', value: org.id },
838
1109
  { key: 'CMS_SITE_URL', value: `https://${domain}` },
839
- ]);
1110
+ ];
1111
+ if (recorded.zitadel_org) {
1112
+ cmsEnvs.push({ key: 'AUTH_ORGANIZATION_ID', value: recorded.zitadel_org });
1113
+ }
1114
+ const cmsClientId = recorded[CMS_CLIENT_ID_SCOPE_KEY];
1115
+ if (cmsClientId)
1116
+ cmsEnvs.push({ key: 'AUTH_CLIENT_ID', value: cmsClientId });
1117
+ const cookieSecret = await this.ensureAuthCookieSecret(cms.appId);
1118
+ if (cookieSecret)
1119
+ cmsEnvs.push({ key: 'AUTH_COOKIE_SECRET', value: cookieSecret });
1120
+ await this.ports.coolify.bulkUpsertEnvs(cms.appId, cmsEnvs);
840
1121
  await this.ports.coolify.bulkUpsertEnvs(site.appId, [
841
1122
  { key: 'CMS_URL', value: `https://${cmsHostnameFor(org)}` },
842
1123
  ]);
@@ -1015,6 +1296,371 @@ export class FoundryStepExecutor {
1015
1296
  });
1016
1297
  return { appId: created.uuid, existed: false };
1017
1298
  }
1299
+ // ---- calls (#128) ------------------------------------------------------
1300
+ /**
1301
+ * `plan/diff.ts`'s `planCalls`: the org's own calls Matrix stack, entirely
1302
+ * separate from chat's (`calls-matrix.ts`'s own top doc comment) -- two
1303
+ * Coolify-managed Postgres databases, a Synapse app and an MAS app, no
1304
+ * public hostname of its own (neither app is reachable from outside
1305
+ * Coolify's own Docker network) -- `coolifyCallsService` below (#145, part 2
1306
+ * of #128) is the one that gets a public hostname, since it is the one a
1307
+ * browser actually talks to. Park stops Synapse and MAS only, the same
1308
+ * park-not-delete rule `coolifyApp`/`cmsSite` already follow (#7
1309
+ * corrections 1) -- their databases are never touched from that branch, the
1310
+ * same way `cmsSite`'s own database never is.
1311
+ */
1312
+ async coolifyCalls(action, org, context) {
1313
+ if (action.desired.enabled === false) {
1314
+ const recorded = context.recordedIds();
1315
+ const synapseId = recorded[CALLS_SYNAPSE_SCOPE_KEY];
1316
+ const masId = recorded[CALLS_MAS_SCOPE_KEY];
1317
+ if (!synapseId && !masId) {
1318
+ throw new Error(`coolify:calls for ${org.slug}: no live calls stack recorded, nothing to park`);
1319
+ }
1320
+ if (synapseId)
1321
+ await this.ports.coolify.raw('POST', `/applications/${synapseId}/stop`);
1322
+ if (masId)
1323
+ await this.ports.coolify.raw('POST', `/applications/${masId}/stop`);
1324
+ return { resourceId: masId ?? synapseId };
1325
+ }
1326
+ const projectUuid = await this.ensureCoolifyProject(org, context);
1327
+ const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
1328
+ const server = await this.resolveServer();
1329
+ const names = {
1330
+ synapse: `${org.slug}-calls-synapse`,
1331
+ mas: `${org.slug}-calls-mas`,
1332
+ synapseDb: `${org.slug}-calls-synapse-db`,
1333
+ masDb: `${org.slug}-calls-mas-db`,
1334
+ };
1335
+ const synapseDbId = await this.ensureCallsDatabase(context, projectUuid, environment, names.synapseDb, 'synapse', CALLS_SYNAPSE_DB_SCOPE_KEY, CALLS_SYNAPSE_DB_INITDB_ARGS);
1336
+ context.recordScopeId(CALLS_SYNAPSE_DB_SCOPE_KEY, synapseDbId);
1337
+ const masDbId = await this.ensureCallsDatabase(context, projectUuid, environment, names.masDb, 'mas', CALLS_MAS_DB_SCOPE_KEY);
1338
+ context.recordScopeId(CALLS_MAS_DB_SCOPE_KEY, masDbId);
1339
+ const [synapseDbRow, masDbRow] = await Promise.all([
1340
+ this.ports.coolify.getDatabase(synapseDbId),
1341
+ this.ports.coolify.getDatabase(masDbId),
1342
+ ]);
1343
+ const synapseDb = synapseDbRow.internal_db_url
1344
+ ? parseCallsDbUrl(synapseDbRow.internal_db_url)
1345
+ : null;
1346
+ const masDb = masDbRow.internal_db_url ? parseCallsDbUrl(masDbRow.internal_db_url) : null;
1347
+ if (!synapseDb || !masDb) {
1348
+ // Static config check before the two apps below are ever touched
1349
+ // (issue #128's "static config checks run before the first Coolify
1350
+ // call", carried from PR 344's review) -- Coolify can take a moment to
1351
+ // report a freshly created database's `internal_db_url`; the next
1352
+ // apply retries this from a clean, unstarted state rather than half
1353
+ // deploying Synapse or MAS against a connection string it does not have
1354
+ // yet.
1355
+ throw new Error(`coolify:calls for ${org.slug}: a database has no internal_db_url yet -- retry this apply once Coolify reports one`);
1356
+ }
1357
+ const synapse = await this.findOrCreateCallsApp(names.synapse, environment.id, () => this.ports.coolify.createDockerImageApplication({
1358
+ project_uuid: projectUuid,
1359
+ server_uuid: server.uuid,
1360
+ environment_name: environment.name,
1361
+ environment_uuid: environment.uuid,
1362
+ docker_registry_image_name: SYNAPSE_IMAGE,
1363
+ docker_registry_image_tag: SYNAPSE_TAG,
1364
+ ports_exposes: '8008',
1365
+ name: names.synapse,
1366
+ description: `Synapse for ${org.slug}'s calls stack (${SYNAPSE_IMAGE}:${SYNAPSE_TAG})`,
1367
+ instant_deploy: false,
1368
+ }));
1369
+ if (!synapse.existed) {
1370
+ await this.ports.coolify.updateApplication(synapse.appId, {
1371
+ health_check_enabled: false,
1372
+ custom_internal_name: names.synapse,
1373
+ });
1374
+ }
1375
+ context.recordScopeId(CALLS_SYNAPSE_SCOPE_KEY, synapse.appId);
1376
+ const mas = await this.findOrCreateCallsApp(names.mas, environment.id, () => this.ports.coolify.createDockerfileApplication({
1377
+ project_uuid: projectUuid,
1378
+ server_uuid: server.uuid,
1379
+ environment_name: environment.name,
1380
+ environment_uuid: environment.uuid,
1381
+ dockerfile: CALLS_MAS_DOCKERFILE,
1382
+ name: names.mas,
1383
+ description: `matrix-authentication-service for ${org.slug}'s calls stack`,
1384
+ instant_deploy: false,
1385
+ }));
1386
+ if (!mas.existed) {
1387
+ await this.ports.coolify.updateApplication(mas.appId, {
1388
+ health_check_enabled: false,
1389
+ custom_internal_name: names.mas,
1390
+ });
1391
+ }
1392
+ context.recordScopeId(CALLS_MAS_SCOPE_KEY, mas.appId);
1393
+ // Synapse's own signing key, media store and pid file (#75's own
1394
+ // durable-volume convention, reused here for a different reason: this
1395
+ // volume is Synapse's own state, not a log).
1396
+ const synapseStorages = await this.ports.coolify.listStorages(synapse.appId);
1397
+ if (!(synapseStorages.persistent_storages ?? []).some((s) => s.mount_path === '/data')) {
1398
+ await this.ports.coolify.createPersistentStorage(synapse.appId, {
1399
+ name: `${names.synapse}-data`,
1400
+ mount_path: '/data',
1401
+ });
1402
+ }
1403
+ const secrets = await this.ensureCallsMatrixSecrets(mas.appId);
1404
+ await this.ensureCallsFileStorage(synapse.appId, '/data/homeserver.yaml', callsSynapseHomeserverYaml({
1405
+ orgSlug: org.slug,
1406
+ db: synapseDb,
1407
+ masHost: names.mas,
1408
+ masSharedSecret: secrets.value.masSynapseSharedSecret,
1409
+ }));
1410
+ // Synapse's official image reads `/data/homeserver.yaml` by default, but
1411
+ // it also needs write access to the mounted volume it does not own by
1412
+ // default (`calls/proof/provider/scripts/guest-stack.mjs`'s own working
1413
+ // config carries the same three envs).
1414
+ await this.ports.coolify.bulkUpsertEnvs(synapse.appId, [
1415
+ { key: 'SYNAPSE_CONFIG_PATH', value: '/data/homeserver.yaml' },
1416
+ { key: 'UID', value: '0' },
1417
+ { key: 'GID', value: '0' },
1418
+ ]);
1419
+ await this.ensureCallsFileStorage(mas.appId, '/config/config.yaml', callsMasConfigYaml({
1420
+ orgSlug: org.slug,
1421
+ db: masDb,
1422
+ synapseHost: names.synapse,
1423
+ sharedSecret: secrets.value.masSynapseSharedSecret,
1424
+ clientId: secrets.value.masClientId,
1425
+ clientSecret: secrets.value.masClientSecret,
1426
+ encryptionKey: secrets.value.masEncryptionKey,
1427
+ rsaKeyPem: secrets.value.masRsaKeyPem,
1428
+ ecKeyPem: secrets.value.masEcKeyPem,
1429
+ }));
1430
+ // Issue #128's "a running app is redeployed when its config changed"
1431
+ // (PR 344's review fix): a file-storage write alone never reaches a
1432
+ // running container until its next deploy, but redeploying an unchanged,
1433
+ // already-running app on every apply would mean this step never
1434
+ // converges. A brand new app or a freshly minted secret set are the only
1435
+ // two reasons the file just written could differ from what the container
1436
+ // already has -- reusing existing secrets means byte-identical content.
1437
+ if (!synapse.existed || secrets.changed)
1438
+ await this.ports.coolify.deploy(synapse.appId);
1439
+ if (!mas.existed || secrets.changed)
1440
+ await this.ports.coolify.deploy(mas.appId);
1441
+ return { resourceId: mas.appId };
1442
+ }
1443
+ /**
1444
+ * `coolifyCalls`'s own find-or-create for Synapse/MAS, restarting a
1445
+ * found-but-stopped app (#116 review round 3, Finding 1's same fix). Reused
1446
+ * by `coolifyCallsService` (#145) for the calls service app itself -- the
1447
+ * same find-by-name-then-start-or-create shape applies unchanged.
1448
+ */
1449
+ async findOrCreateCallsApp(name, environmentId, create) {
1450
+ const existing = await this.ports.coolify.findApplicationByName(name, {
1451
+ environmentIds: [environmentId],
1452
+ });
1453
+ if (existing) {
1454
+ const live = await this.ports.coolify.getApplication(existing.uuid);
1455
+ if (!isRunningOrStarting(live.status)) {
1456
+ await this.ports.coolify.raw('POST', `/applications/${existing.uuid}/start`);
1457
+ }
1458
+ return { appId: existing.uuid, existed: true };
1459
+ }
1460
+ const created = await create();
1461
+ return { appId: created.uuid, existed: false };
1462
+ }
1463
+ /**
1464
+ * `coolifyCalls`'s own database find-or-create, modeled on
1465
+ * `ensureCmsDatabase`: `context.recordedIds()` is checked first since
1466
+ * neither database has a `system:kind:key` slot of its own (this step's one
1467
+ * auto-recorded `resourceId` is the MAS app, not either database --
1468
+ * {@link CALLS_SYNAPSE_SCOPE_KEY}'s own doc comment), then `listDatabases`
1469
+ * finds one already created by name before `createPostgresDatabase` makes
1470
+ * one. Reused by `coolifyCallsService` (#145) for the calls service's own
1471
+ * database, whose {@link CALLS_SERVICE_DB_SCOPE_KEY} has no
1472
+ * `system:kind:key` slot of its own for the same reason.
1473
+ */
1474
+ async ensureCallsDatabase(context, projectUuid, environment, name, postgresDb, scopeKey, postgresInitdbArgs) {
1475
+ const existing = context.recordedIds()[scopeKey];
1476
+ if (existing)
1477
+ return existing;
1478
+ const found = (await this.ports.coolify.listDatabases()).find((d) => d.name === name && d.environment_id === environment.id);
1479
+ if (found)
1480
+ return found.uuid;
1481
+ const server = await this.resolveServer();
1482
+ const created = await this.ports.coolify.createPostgresDatabase({
1483
+ project_uuid: projectUuid,
1484
+ server_uuid: server.uuid,
1485
+ environment_name: environment.name,
1486
+ environment_uuid: environment.uuid,
1487
+ name,
1488
+ postgres_db: postgresDb,
1489
+ postgres_user: postgresDb,
1490
+ ...(postgresInitdbArgs ? { postgres_initdb_args: postgresInitdbArgs } : {}),
1491
+ });
1492
+ return created.uuid;
1493
+ }
1494
+ /**
1495
+ * Reads back {@link CALLS_SECRET_ENV_KEYS} from the MAS app's own envs
1496
+ * before minting fresh ones -- this stack's only memory across a re-run
1497
+ * (`calls-matrix.ts`'s `CallsMatrixSecrets` doc comment). `changed: true`
1498
+ * only when nothing usable was found: a partial set (a crash mid-mint) is
1499
+ * still treated as "mint fresh", since MAS's shared secret and Synapse's
1500
+ * copy of it must always agree.
1501
+ */
1502
+ async ensureCallsMatrixSecrets(masAppId) {
1503
+ const envs = await this.ports.coolify.listEnvs(masAppId);
1504
+ const read = (key) => envs.find((e) => e.key === key && !e.is_preview)?.value;
1505
+ const fields = Object.entries(CALLS_SECRET_ENV_KEYS);
1506
+ const existing = {};
1507
+ for (const [field, envKey] of fields) {
1508
+ const value = read(envKey);
1509
+ if (value !== undefined)
1510
+ existing[field] = value;
1511
+ }
1512
+ const hasAll = fields.every(([field]) => existing[field] !== undefined);
1513
+ if (hasAll)
1514
+ return { value: existing, changed: false };
1515
+ const minted = mintCallsMatrixSecrets();
1516
+ await this.ports.coolify.bulkUpsertEnvs(masAppId, fields.map(([field, envKey]) => ({
1517
+ key: envKey,
1518
+ value: minted[field],
1519
+ multiline: field === 'masRsaKeyPem' || field === 'masEcKeyPem',
1520
+ })));
1521
+ return { value: minted, changed: true };
1522
+ }
1523
+ /**
1524
+ * Upserts a file storage's content, creating it if the application has none
1525
+ * at that mount path yet -- there is no way to read a file storage's
1526
+ * current content back from Coolify to diff it, the same limit
1527
+ * `archon/bootstrap/steps/chat-instance.ts`'s own `ensureFileStorage` notes,
1528
+ * so an existing one is always re-written.
1529
+ */
1530
+ async ensureCallsFileStorage(applicationUuid, mountPath, content) {
1531
+ const storages = await this.ports.coolify.listStorages(applicationUuid);
1532
+ const found = storages.file_storages?.find((s) => s.mount_path === mountPath);
1533
+ if (found) {
1534
+ await this.ports.coolify.updateFileStorage(applicationUuid, found.uuid, content);
1535
+ }
1536
+ else {
1537
+ await this.ports.coolify.createFileStorage(applicationUuid, {
1538
+ mount_path: mountPath,
1539
+ content,
1540
+ });
1541
+ }
1542
+ }
1543
+ // ---- calls service (#145, part 2 of #128) -------------------------------
1544
+ /**
1545
+ * `plan/diff.ts`'s `planCallsService`: the org's own calls service --
1546
+ * `wtfalch/calls`'s `packages/service`, wired to the calls Matrix stack
1547
+ * {@link coolifyCalls} provisions and the shared LiveKit SFU (#122/#127).
1548
+ * Static config checks run first, before any Coolify call (#128's own
1549
+ * carried-over PR 344 review fix, restated in #145's own issue body): the
1550
+ * calls Matrix stack must already be recorded, its MAS app must already
1551
+ * carry its client id/secret, and the shared SFU's LiveKit credentials must
1552
+ * already be resolvable -- none of the three is minted or guessed here.
1553
+ * Park stops the app only, the same park-not-delete rule
1554
+ * {@link coolifyCalls}/{@link coolifyApp} already follow (#7 corrections
1555
+ * 1); its own database is never touched from that branch.
1556
+ */
1557
+ async coolifyCallsService(action, org, context) {
1558
+ if (action.desired.enabled === false) {
1559
+ const appId = context.recordedIds()[`coolify:calls-service:${action.key}`];
1560
+ if (!appId) {
1561
+ throw new Error(`coolify:calls-service for ${org.slug}: no live app recorded, nothing to park`);
1562
+ }
1563
+ await this.ports.coolify.raw('POST', `/applications/${appId}/stop`);
1564
+ return { resourceId: appId };
1565
+ }
1566
+ const recorded = context.recordedIds();
1567
+ const synapseId = recorded[CALLS_SYNAPSE_SCOPE_KEY];
1568
+ const masId = recorded[CALLS_MAS_SCOPE_KEY];
1569
+ if (!synapseId || !masId) {
1570
+ throw new Error(`coolify:calls-service for ${org.slug}: the calls Matrix stack has not been provisioned yet -- coolify:calls must run first`);
1571
+ }
1572
+ const masCredentials = await this.readCallsMasClientCredentials(masId);
1573
+ if (!masCredentials) {
1574
+ throw new Error(`coolify:calls-service for ${org.slug}: the calls Matrix stack's MAS client credentials are not on the MAS app yet -- retry this apply once coolify:calls has minted them`);
1575
+ }
1576
+ const sfu = await this.ports.callsSfu.resolveCredentials();
1577
+ if (!sfu) {
1578
+ throw new Error(`coolify:calls-service for ${org.slug}: the shared calls SFU has no LiveKit credentials configured yet -- see https://github.com/wtfalch/foundry/issues/122`);
1579
+ }
1580
+ const projectUuid = await this.ensureCoolifyProject(org, context);
1581
+ const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
1582
+ const name = `${org.slug}-calls`;
1583
+ const synapseHost = `${org.slug}-calls-synapse`;
1584
+ const masHost = `${org.slug}-calls-mas`;
1585
+ const dbName = `${name}-db`;
1586
+ const dbId = await this.ensureCallsDatabase(context, projectUuid, environment, dbName, dbName.replace(/-/g, '_'), CALLS_SERVICE_DB_SCOPE_KEY);
1587
+ context.recordScopeId(CALLS_SERVICE_DB_SCOPE_KEY, dbId);
1588
+ const db = await this.ports.coolify.getDatabase(dbId);
1589
+ if (!db.internal_db_url) {
1590
+ throw new Error(`coolify:calls-service for ${org.slug}: its database has no internal_db_url yet -- retry this apply once Coolify reports one`);
1591
+ }
1592
+ const server = await this.resolveServer();
1593
+ const githubApp = await this.ports.coolify.requireGithubApp();
1594
+ const app = await this.findOrCreateCallsApp(name, environment.id, () => this.ports.coolify.createGithubAppApplication({
1595
+ project_uuid: projectUuid,
1596
+ server_uuid: server.uuid,
1597
+ environment_name: environment.name,
1598
+ environment_uuid: environment.uuid,
1599
+ github_app_uuid: githubApp.uuid,
1600
+ git_repository: CALLS_SERVICE_REPO,
1601
+ git_branch: 'main',
1602
+ build_pack: 'dockerfile',
1603
+ name,
1604
+ domains: callsHostnameFor(org),
1605
+ dockerfile_location: CALLS_SERVICE_DOCKERFILE,
1606
+ }));
1607
+ // Every app foundry deploys keeps its own logs (#75), reused as-is here.
1608
+ await this.ensureLogVolume(app.appId, org, app.existed);
1609
+ const bindingSecret = await this.ensureCallsBindingSecret(app.appId);
1610
+ const envs = [
1611
+ { key: 'DATABASE_URL', value: db.internal_db_url },
1612
+ { key: 'LIVEKIT_URL', value: CALLS_SFU_PRIVATE_URL },
1613
+ { key: 'LIVEKIT_PUBLIC_URL', value: CALLS_SFU_PUBLIC_URL },
1614
+ { key: 'LIVEKIT_API_KEY', value: sfu.apiKey },
1615
+ { key: 'LIVEKIT_API_SECRET', value: sfu.apiSecret },
1616
+ { key: 'MAS_URL', value: `http://${masHost}:8080/` },
1617
+ { key: 'MAS_HOMESERVER', value: callsServerName(org.slug) },
1618
+ { key: 'MAS_CLIENT_ID', value: masCredentials.clientId },
1619
+ { key: 'MAS_CLIENT_SECRET', value: masCredentials.clientSecret },
1620
+ { key: 'SYNAPSE_URL', value: `http://${synapseHost}:8008` },
1621
+ { key: 'CALLS_ORIGIN', value: `https://${callsHostnameFor(org)}` },
1622
+ { key: 'CALLS_HOST_ADAPTER', value: 'production' },
1623
+ { key: 'CALLS_TRUSTED_IP_HEADER', value: 'cf-connecting-ip' },
1624
+ { key: 'PORT', value: CALLS_SERVICE_PORT },
1625
+ ];
1626
+ if (bindingSecret)
1627
+ envs.push({ key: 'CALLS_BINDING_SECRET', value: bindingSecret });
1628
+ await this.ports.coolify.bulkUpsertEnvs(app.appId, envs);
1629
+ return { resourceId: app.appId };
1630
+ }
1631
+ /**
1632
+ * Reads back the calls Matrix stack's MAS client id/secret from the MAS
1633
+ * app's own envs ({@link CALLS_SECRET_ENV_KEYS}) -- never mints: minting
1634
+ * either is {@link ensureCallsMatrixSecrets}'s own job, and a missing value
1635
+ * here means that stack has not finished provisioning yet, not something
1636
+ * this step can fix on its own.
1637
+ */
1638
+ async readCallsMasClientCredentials(masAppId) {
1639
+ const envs = await this.ports.coolify.listEnvs(masAppId);
1640
+ const read = (key) => envs.find((e) => e.key === key && !e.is_preview)?.value;
1641
+ const clientId = read(CALLS_SECRET_ENV_KEYS.masClientId);
1642
+ const clientSecret = read(CALLS_SECRET_ENV_KEYS.masClientSecret);
1643
+ if (!clientId || !clientSecret)
1644
+ return null;
1645
+ return { clientId, clientSecret };
1646
+ }
1647
+ /**
1648
+ * `null` once the calls service app already has a `CALLS_BINDING_SECRET` --
1649
+ * never rotated, the same rule {@link ensureAuthCookieSecret} follows for
1650
+ * `AUTH_COOKIE_SECRET`: it wraps the provider-token ciphertext already
1651
+ * stored under any binding minted so far
1652
+ * (`calls/packages/service/src/config.mjs`'s own doc comment on
1653
+ * `CALLS_BINDING_SECRET`), so overwriting it would strand every binding
1654
+ * minted under the old value. 32 raw bytes, hex-encoded -- the exact shape
1655
+ * `config.mjs`'s own regex requires.
1656
+ */
1657
+ async ensureCallsBindingSecret(appId) {
1658
+ const envs = await this.ports.coolify.listEnvs(appId);
1659
+ const already = envs.some((e) => e.key === 'CALLS_BINDING_SECRET' && !e.is_preview);
1660
+ if (already)
1661
+ return null;
1662
+ return randomBytes(32).toString('hex');
1663
+ }
1018
1664
  // ---- cloudflare --------------------------------------------------------
1019
1665
  async cloudflareTunnel(action, org, context) {
1020
1666
  if (action.desired.enabled === false) {
@@ -1058,12 +1704,34 @@ export class FoundryStepExecutor {
1058
1704
  });
1059
1705
  return { resourceId: tunnel.id };
1060
1706
  }
1061
- async cloudflareZone(action) {
1707
+ /**
1708
+ * `plan/diff.ts`'s `planZone`: finds or creates the Cloudflare zone for the
1709
+ * org's own domain. Refuses to adopt a zone the store already records
1710
+ * under a *different* org's applied state (#131), the same
1711
+ * do-not-adopt-a-foreign-resource rule `cloudflareAccess` above enforces --
1712
+ * but where that method tells "foundry's own" from "foreign" by reading
1713
+ * Cloudflare's own Access-app name live, a Cloudflare zone carries no
1714
+ * org-identifying field of its own, so this asks the store instead. That
1715
+ * matters because two different, DB-unique `domain` values (`org.ts`'s
1716
+ * `validateDomain` plus its unique index) can still resolve to the same
1717
+ * Cloudflare zone -- a zone is keyed by apex, and one org's domain can be a
1718
+ * subdomain of another org's -- which is exactly the shared-zone,
1719
+ * shared-hostname collision this ticket exists to refuse. An org re-running
1720
+ * its own already-recorded zone is unaffected: the store then names this
1721
+ * same org as the owner.
1722
+ */
1723
+ async cloudflareZone(action, org) {
1062
1724
  const domain = action.key;
1063
- const existing = await this.ports.cloudflare.findZoneByApex(domain);
1064
- if (existing)
1725
+ const cf = this.ports.cloudflare;
1726
+ const existing = await cf.findZoneByApex(domain);
1727
+ if (existing) {
1728
+ const owner = await this.ports.declaredOrgStore.findOrgIdForZone(existing.id);
1729
+ if (owner !== null && owner !== org.id) {
1730
+ throw new Error(`cloudflare:zone for ${org.slug}: zone ${existing.id} (${domain}) is already recorded under org ${owner}; foundry does not adopt it`);
1731
+ }
1065
1732
  return { resourceId: existing.id };
1066
- const created = await this.ports.cloudflare.createZone(domain);
1733
+ }
1734
+ const created = await cf.createZone(domain);
1067
1735
  return { resourceId: created.id };
1068
1736
  }
1069
1737
  // ---- mail -----------------------------------------------------------
@@ -1071,9 +1739,16 @@ export class FoundryStepExecutor {
1071
1739
  * `plan/diff.ts`'s `planMail`: gives an org mail, moves it between modes,
1072
1740
  * or parks it on the one shared Stalwart instance (`this.ports.mail`,
1073
1741
  * `@wtfalch/email/postmaster`'s `applyOrgMail`/`parkOrgMail` bound), then
1074
- * publishes whatever DNS records the call returns.
1742
+ * publishes whatever DNS records the call returns, then registers the same
1743
+ * mail domain with Resend (#138) -- the shared Stalwart relays every
1744
+ * outbound message through Resend, and Resend refuses mail from a domain
1745
+ * it has not verified. No step here ever deletes the app's Resend key:
1746
+ * `diff.ts`'s `planMail` plans nothing for mode `'off'`, so turning mail
1747
+ * off leaves the key live, like every other resource foundry stops
1748
+ * managing (#10). Switching the mail domain replaces the key instead
1749
+ * (`ensureAppResendKey`).
1075
1750
  */
1076
- async coolifyMail(action, org) {
1751
+ async coolifyMail(action, org, context) {
1077
1752
  const mode = action.desired.mode;
1078
1753
  const usesDomain = action.desired.usesDomain;
1079
1754
  const options = { apex: MAIL_APEX, mailHost: MAIL_HOST };
@@ -1082,7 +1757,8 @@ export class FoundryStepExecutor {
1082
1757
  return undefined;
1083
1758
  }
1084
1759
  const result = await this.ports.mail.applyOrgMail(mailOrgFor(org, usesDomain, mode), options);
1085
- await this.publishMailDnsRecords(org, result);
1760
+ const zoneId = await this.publishMailDnsRecords(org, result);
1761
+ await this.registerResendDomain(result.domain, zoneId, context);
1086
1762
  return { resourceId: result.domain };
1087
1763
  }
1088
1764
  /**
@@ -1090,7 +1766,9 @@ export class FoundryStepExecutor {
1090
1766
  * by an exact name+type match (`CloudflarePort.findDnsRecord`'s own doc
1091
1767
  * comment) so update never mistakes a stale record of a different type for
1092
1768
  * a match. A `DKIM` record can be absent on the first call, since Stalwart
1093
- * mints the key on a background task; the next apply publishes it.
1769
+ * mints the key on a background task; the next apply publishes it. Returns
1770
+ * the zone id so `registerResendDomain` publishes Resend's own records on
1771
+ * the same zone without a second lookup.
1094
1772
  */
1095
1773
  async publishMailDnsRecords(org, result) {
1096
1774
  const cf = this.ports.cloudflare;
@@ -1108,6 +1786,99 @@ export class FoundryStepExecutor {
1108
1786
  await cf.createDnsRecord(zone.id, rec);
1109
1787
  }
1110
1788
  }
1789
+ return zone.id;
1790
+ }
1791
+ /**
1792
+ * #138: registers `domain` with Resend -- the custom domain, or
1793
+ * `<slug>.<apex>` for a level-1 org, exactly what `publishMailDnsRecords`
1794
+ * just published the Stalwart records for. Idempotent by name, the same way
1795
+ * `applyOrgMail` itself is: found by `findDomain` rather than any id this
1796
+ * class recorded, so a crash between here and the next apply just repeats
1797
+ * the same find-or-create. Never blocks on DNS propagation (task brief):
1798
+ * `verifyDomain` asks Resend to re-check once, and its returned status is
1799
+ * neither polled nor awaited -- an unverified domain here is exactly what
1800
+ * the next apply corrects.
1801
+ *
1802
+ * #140 rework: mints no key of its own any more. It records the domain's
1803
+ * own id and the resolved mail domain as scope ids so `ensureAppResendKey`
1804
+ * (`coolifyApp`) can mint the app's own `<slug>-app@<domainId>` key once the app
1805
+ * exists, without this step or the app step calling Resend to look the
1806
+ * domain back up.
1807
+ */
1808
+ async registerResendDomain(domain, zoneId, context) {
1809
+ const existing = await this.ports.resend.findDomain(domain);
1810
+ const resendDomain = existing
1811
+ ? await this.ports.resend.getDomain(existing.id)
1812
+ : await this.ports.resend.createDomain(domain);
1813
+ await this.publishResendDnsRecords(zoneId, resendDomain.records ?? []);
1814
+ await this.ports.resend.verifyDomain(resendDomain.id);
1815
+ context.recordScopeId(RESEND_DOMAIN_ID_SCOPE_KEY, resendDomain.id);
1816
+ context.recordScopeId(RESEND_MAIL_DOMAIN_SCOPE_KEY, domain);
1817
+ }
1818
+ /**
1819
+ * Upserts each DNS record Resend's domain names, the same find-then-upsert
1820
+ * `publishMailDnsRecords` above uses for Stalwart's own records -- except
1821
+ * SPF is merged into whatever record that call just published rather than
1822
+ * created a second time (RFC 7208 treats two `v=spf1` TXT records on one
1823
+ * name as a permerror and silently breaks SPF for every sender on the
1824
+ * domain), and an existing `_dmarc` record is never overwritten. Prior art:
1825
+ * `wtfalch/app-template`'s `bootstrap/steps/resend-email.ts`.
1826
+ */
1827
+ async publishResendDnsRecords(zoneId, records) {
1828
+ const cf = this.ports.cloudflare;
1829
+ for (const record of records) {
1830
+ if (record.type === 'TXT' && record.value.toLowerCase().startsWith('v=spf1')) {
1831
+ await this.mergeResendSpfRecord(cf, zoneId, record);
1832
+ continue;
1833
+ }
1834
+ if (record.type === 'TXT' && record.value.toLowerCase().startsWith('v=dmarc1')) {
1835
+ await this.ensureResendDmarcRecord(cf, zoneId, record);
1836
+ continue;
1837
+ }
1838
+ const [existing] = await cf.findDnsRecord(zoneId, record.name, record.type);
1839
+ const rec = { type: record.type, name: record.name, content: record.value };
1840
+ if (existing) {
1841
+ await cf.updateDnsRecord(zoneId, existing.id, rec);
1842
+ }
1843
+ else {
1844
+ await cf.createDnsRecord(zoneId, rec);
1845
+ }
1846
+ }
1847
+ }
1848
+ /**
1849
+ * Inserts Resend's `include:` token into whatever SPF record
1850
+ * `publishMailDnsRecords` already published for this name, instead of
1851
+ * creating a second `v=spf1` TXT record -- the classic foot-gun that makes
1852
+ * every receiver treat SPF as a permerror and skip it entirely.
1853
+ */
1854
+ async mergeResendSpfRecord(cf, zoneId, record) {
1855
+ const includeToken = record.value.match(/include:\S+/i)?.[0] ?? 'include:_spf.resend.com';
1856
+ const existingSpf = (await cf.findDnsRecord(zoneId, record.name, 'TXT')).find((r) => r.content.toLowerCase().startsWith('v=spf1'));
1857
+ if (!existingSpf) {
1858
+ await cf.createDnsRecord(zoneId, { type: 'TXT', name: record.name, content: record.value });
1859
+ return;
1860
+ }
1861
+ if (existingSpf.content.toLowerCase().includes(includeToken.toLowerCase()))
1862
+ return;
1863
+ const merged = /\s+[~\-?+]all\s*$/.test(existingSpf.content)
1864
+ ? existingSpf.content.replace(/\s+([~\-?+])all\s*$/, ` ${includeToken} $1all`)
1865
+ : `${existingSpf.content.trim()} ${includeToken}`;
1866
+ await cf.updateDnsRecord(zoneId, existingSpf.id, {
1867
+ type: 'TXT',
1868
+ name: record.name,
1869
+ content: merged,
1870
+ });
1871
+ }
1872
+ /**
1873
+ * Never overwrites an existing `_dmarc` record -- a stricter policy an org
1874
+ * already set (`p=quarantine`/`p=reject`) should not be relaxed back to
1875
+ * Resend's own `p=none` default.
1876
+ */
1877
+ async ensureResendDmarcRecord(cf, zoneId, record) {
1878
+ const hasDmarc = (await cf.findDnsRecord(zoneId, record.name, 'TXT')).some((r) => r.content.toLowerCase().startsWith('v=dmarc1'));
1879
+ if (hasDmarc)
1880
+ return;
1881
+ await cf.createDnsRecord(zoneId, { type: 'TXT', name: record.name, content: record.value });
1111
1882
  }
1112
1883
  // ---- files / ai --------------------------------------------------------
1113
1884
  /**