@wtfalch/foundry 0.4.0 → 0.5.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.
Files changed (66) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +96 -0
  2. package/dist/bootstrap/bootstrap.js +285 -0
  3. package/dist/bootstrap/declare.d.ts +36 -0
  4. package/dist/bootstrap/declare.js +94 -0
  5. package/dist/bootstrap/drive.d.ts +40 -0
  6. package/dist/bootstrap/drive.js +51 -0
  7. package/dist/bootstrap/index.d.ts +3 -0
  8. package/dist/bootstrap/index.js +1 -0
  9. package/dist/calls-sfu-deploy-support.d.ts +9 -6
  10. package/dist/calls-sfu-deploy-support.js +9 -6
  11. package/dist/calls-sfu-keys.d.ts +21 -0
  12. package/dist/calls-sfu-keys.js +27 -0
  13. package/dist/calls-sfu-ssh-adapter.d.ts +55 -0
  14. package/dist/calls-sfu-ssh-adapter.js +78 -0
  15. package/dist/calls-sfu-ssh-client.d.ts +49 -0
  16. package/dist/calls-sfu-ssh-client.js +82 -0
  17. package/dist/calls-sfu.d.ts +5 -4
  18. package/dist/calls-sfu.js +5 -4
  19. package/dist/index.d.ts +31 -6
  20. package/dist/index.js +16 -3
  21. package/dist/migrations/0016_platform.sql +61 -0
  22. package/dist/migrations/0017_requested_changes.sql +42 -0
  23. package/dist/migrations/0018_platform_apply.sql +19 -0
  24. package/dist/org.js +8 -0
  25. package/dist/plan/agora.d.ts +77 -0
  26. package/dist/plan/agora.js +127 -0
  27. package/dist/plan/cloudflared-connector.d.ts +70 -0
  28. package/dist/plan/cloudflared-connector.js +106 -0
  29. package/dist/plan/diff.d.ts +20 -3
  30. package/dist/plan/diff.js +631 -116
  31. package/dist/plan/executor.d.ts +475 -168
  32. package/dist/plan/executor.js +543 -175
  33. package/dist/plan/index.d.ts +22 -5
  34. package/dist/plan/index.js +12 -2
  35. package/dist/plan/jobs-scheduler-http.d.ts +29 -0
  36. package/dist/plan/jobs-scheduler-http.js +50 -0
  37. package/dist/plan/live.d.ts +51 -2
  38. package/dist/plan/live.js +8 -0
  39. package/dist/plan/plan-apply.d.ts +192 -3
  40. package/dist/plan/plan-apply.js +278 -9
  41. package/dist/plan/platform-diff.d.ts +312 -0
  42. package/dist/plan/platform-diff.js +867 -0
  43. package/dist/plan/platform-org-ports.d.ts +50 -0
  44. package/dist/plan/platform-org-ports.js +70 -0
  45. package/dist/plan/platform-org.d.ts +120 -0
  46. package/dist/plan/platform-org.js +115 -0
  47. package/dist/plan/platform-providers.d.ts +501 -0
  48. package/dist/plan/platform-providers.js +3029 -0
  49. package/dist/plan/platform-sfu.d.ts +93 -0
  50. package/dist/plan/platform-sfu.js +100 -0
  51. package/dist/plan/providers-readers.js +22 -1
  52. package/dist/plan/zitadel-machine-user.d.ts +34 -0
  53. package/dist/plan/zitadel-machine-user.js +95 -0
  54. package/dist/plan.d.ts +6 -0
  55. package/dist/platform.d.ts +55 -0
  56. package/dist/platform.js +46 -0
  57. package/dist/requested-changes.d.ts +108 -0
  58. package/dist/requested-changes.js +95 -0
  59. package/dist/slug.js +15 -2
  60. package/dist/store/audit.js +4 -2
  61. package/dist/store/postgres.d.ts +29 -1
  62. package/dist/store/postgres.js +172 -2
  63. package/dist/store/schema.d.ts +683 -0
  64. package/dist/store/schema.js +57 -0
  65. package/dist/store.d.ts +46 -0
  66. package/package.json +1 -1
@@ -1,7 +1,9 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { URL } from 'node:url';
3
3
  import { CALLS_SFU_PRIVATE_URL, CALLS_SFU_PUBLIC_URL } from '../calls-sfu.js';
4
+ import { AGORA_CLIENT_ID_SCOPE_KEY, agoraHostnameFor, desiredAgoraEnv, } from './agora.js';
4
5
  import { MAS_IMAGE, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
6
+ import { ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, } from './platform-diff.js';
5
7
  // ---------------------------------------------------------------------------
6
8
  // Errors
7
9
  // ---------------------------------------------------------------------------
@@ -79,6 +81,13 @@ function tunnelIngressHostnames(org) {
79
81
  if (org.calls.enabled && org.calls.usesDomain && org.domain) {
80
82
  hostnames.push(`calls.${org.domain}`);
81
83
  }
84
+ // #164: Agora always moves onto `agora.<domain>` once `org.domain` is set
85
+ // -- no toggle to gate this on. A level-1 org (no domain) adds nothing
86
+ // here: `<slug>-agora.oikos.host` rides the shared `*.oikos.host` wildcard
87
+ // tunnel (`scripts/shared-ingress.mjs`), not a per-org tunnel.
88
+ if (org.domain) {
89
+ hostnames.push(agoraHostnameFor(org));
90
+ }
82
91
  return hostnames;
83
92
  }
84
93
  /**
@@ -121,7 +130,7 @@ function mailOrgFor(org, usesDomain, mode) {
121
130
  */
122
131
  const COOLIFY_PROXY_ORIGIN = 'http://coolify-proxy:80';
123
132
  /** The one Access policy foundry puts on its own Access app (#84). */
124
- const ACCESS_POLICY_NAME = 'foundry: allowed emails';
133
+ export const ACCESS_POLICY_NAME = 'foundry: allowed emails';
125
134
  /**
126
135
  * The sign-in broker's own route mount (`@wtfalch/auth`'s `NextAuth`,
127
136
  * `dist/next.d.ts`'s `handlers` doc comment: "`export const { GET, POST } =
@@ -187,6 +196,25 @@ const DEFAULT_APP_DOCKERFILE = 'apps/hestia/Dockerfile';
187
196
  const CMS_REPO = 'wtfalch/cms';
188
197
  const CMS_APP_DOCKERFILE = 'Dockerfile';
189
198
  const CMS_SITE_APP_DOCKERFILE = 'apps/site/Dockerfile';
199
+ /**
200
+ * #164: the repo `coolifyAgora` builds the org's Agora from
201
+ * (https://github.com/wtfalch/agora) -- the issue's own "How" names this
202
+ * exact Dockerfile, and it is the only build strategy any app this file
203
+ * provisions ever uses ({@link DEFAULT_APP_DOCKERFILE}/{@link CMS_APP_DOCKERFILE}/
204
+ * `CALLS_SERVICE_DOCKERFILE` below are all `dockerfile_location` into a
205
+ * `createGithubAppApplication` build-from-repo call, never a registry
206
+ * image): "Coolify builds each org's Agora from the agora repo" is not a
207
+ * fresh product choice here, it is this file's one established convention,
208
+ * followed rather than reopened (#164 "Decide" 1). The agora repo's own
209
+ * Dockerfile (`apps/agora/Dockerfile`) already `EXPOSE`s 8080 and serves
210
+ * from a build built at the repository root, the same one-repo shape
211
+ * `CMS_REPO`'s own two apps use -- no `ports_exposes`/`PORT` env is needed
212
+ * the way `CALLS_SERVICE_PORT` is for the calls service's own Node process,
213
+ * since Coolify's github-app build already reads the exposed port the same
214
+ * way it does for `cmsSite`'s two apps above.
215
+ */
216
+ const AGORA_REPO = 'wtfalch/agora';
217
+ const AGORA_APP_DOCKERFILE = 'apps/agora/Dockerfile';
190
218
  /**
191
219
  * Every app foundry deploys keeps its own logs across a redeploy, so a
192
220
  * database outage does not also erase the only other copy of the evidence
@@ -199,9 +227,13 @@ const CMS_SITE_APP_DOCKERFILE = 'apps/site/Dockerfile';
199
227
  */
200
228
  const LOG_DIR = '/app/logs';
201
229
  /**
202
- * The one shared Stalwart apex and MX host every org's mail lives on (#7
203
- * "Mail"), the same values `@wtfalch/email`'s own README examples use for
204
- * `applyOrgMail`/`parkOrgMail`'s `OrgMailOptions`.
230
+ * #177: the fallback Stalwart apex and mail host `coolifyMail` uses when its
231
+ * own step carries none (`diff.ts`'s `planMail` always supplies one once a
232
+ * platform is wired -- `platform-org.ts`'s `DEFAULT_ORG_PLATFORM_READINESS`
233
+ * is this exact pair, so a host that has not wired `FoundryPlanApplyOptions.
234
+ * platform` yet keeps running on the address every org's mail always ran on
235
+ * before the platform existed to ask, the same values `@wtfalch/email`'s own
236
+ * README examples use for `applyOrgMail`/`parkOrgMail`'s `OrgMailOptions`).
205
237
  */
206
238
  const MAIL_APEX = 'wtfalch.dev';
207
239
  const MAIL_HOST = 'mail.wtfalch.dev';
@@ -252,24 +284,61 @@ const SITE_APP_SCOPE_KEY = 'coolify_site_app';
252
284
  */
253
285
  const CMS_DATABASE_SCOPE_KEY = 'coolify_cms_database';
254
286
  /**
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`.
287
+ * #138/#140 (2026-09-28 lettermint-org-mail swap): the mail domain
288
+ * `coolifyMail` resolved with Stalwart -- `org.domain` when the org uses its
289
+ * own domain, else `<slug>.<apex>` for a domainless org (2026-09-28:
290
+ * domainless orgs get their own Lettermint domain too). Recorded so the app
291
+ * step can derive `MAIL_FROM` and drive its own Lettermint domain/project
292
+ * work (`ensureAppLettermintMail`) without recomputing `mailOrgFor`'s own
293
+ * domain choice, the same way {@link APP_CLIENT_ID_SCOPE_KEY} carries a
294
+ * public value out. All of that org's Lettermint work -- ensure domain,
295
+ * restrict, publish DNS, verify -- now happens inside the same step that
296
+ * reads this key (`ensureAppLettermintMail`), so unlike the Resend design
297
+ * this rework replaces, no separate domain-id scope key is recorded: the
298
+ * domain is looked up by name fresh each apply. Renamed from
299
+ * `resend_mail_domain`.
300
+ */
301
+ const MAIL_DOMAIN_SCOPE_KEY = 'mail_domain';
302
+ /**
303
+ * #162: the owner's own mailbox, created once inside `coolify:mail` after
304
+ * `applyOrgMail` succeeds, alongside {@link MAIL_DOMAIN_SCOPE_KEY} -- another
305
+ * flat scope id for the same reason, no `:`. Recorded with the address
306
+ * itself (not just a boolean) so a future step could read it back the same
307
+ * way {@link MAIL_DOMAIN_SCOPE_KEY} lets `ensureAppLettermintMail` read the
308
+ * domain back; `ensureOwnerMailbox` only ever checks that this key is set at
309
+ * all before deciding whether to call `MailPort.createMailbox` again.
310
+ * `plan/diff.ts` keeps its own copy of this literal (`MAIL_OWNER_MAILBOX_SCOPE_KEY`
311
+ * there) rather than importing it, the same restated-not-shared rule this
312
+ * file's own `LOG_DIR` doc comment names for `diff.ts`.
313
+ */
314
+ const MAIL_OWNER_MAILBOX_SCOPE_KEY = 'mail_owner_mailbox';
315
+ /**
316
+ * #80: the id of an ai key `aiKey` has minted but not yet delivered, a flat
317
+ * scope id (no `:`). `plan/diff.ts` keeps its own copy of this literal.
318
+ */
319
+ const AI_KEY_UNDELIVERED_SCOPE_KEY = 'ai_key_undelivered';
320
+ /** How many times `aiKey` tries `deliverSecret` before giving up. */
321
+ const AI_KEY_DELIVERY_ATTEMPTS = 3;
322
+ /**
323
+ * #173: the id of a jobs project `jobsRegistration` has registered but not
324
+ * yet delivered the credentials of, a flat scope id (no `:`). `serviceIds` is
325
+ * already per org, so the key needs no org in it. `plan/diff.ts` keeps its own
326
+ * copy of this literal.
262
327
  */
263
- const RESEND_DOMAIN_ID_SCOPE_KEY = 'resend_domain_id';
328
+ const JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY = 'jobs_registration_undelivered';
329
+ /** How many times `jobsRegistration` tries each `deliverSecret` before giving up. */
330
+ const JOBS_DELIVERY_ATTEMPTS = 3;
264
331
  /**
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.
332
+ * The one mailbox this executor mints on its own (#162) -- a plain,
333
+ * discoverable local part rather than anything derived from the owner's own
334
+ * (possibly personal) `email`, so it never collides with a name and never
335
+ * repeats a stranger's inbox back at them. #162's own "Decide" section
336
+ * leaves open whether every *other* person in the org also gets a mailbox
337
+ * from foundry, or only from Boule -- unresolved, on purpose, until a human
338
+ * picks one; this constant and {@link ensureOwnerMailbox} only ever cover
339
+ * the owner, which every candidate answer to that question already includes.
271
340
  */
272
- const RESEND_MAIL_DOMAIN_SCOPE_KEY = 'resend_mail_domain';
341
+ const MAIL_OWNER_LOCAL_PART = 'owner';
273
342
  /**
274
343
  * `coolifyCalls` provisions four resources from one `coolify:calls` step
275
344
  * (#128) — the same one-step, several-resource shape `cmsSite` already solves
@@ -342,7 +411,7 @@ const CALLS_SERVICE_PORT = '8080';
342
411
  * same "nothing says it's off" convention `planHostingToggle` (`diff.ts`)
343
412
  * uses for the org's own app.
344
413
  */
345
- function isRunningOrStarting(status) {
414
+ export function isRunningOrStarting(status) {
346
415
  if (!status)
347
416
  return true;
348
417
  return !status.startsWith('exited') && !status.startsWith('stopped');
@@ -390,12 +459,18 @@ export class FoundryStepExecutor {
390
459
  return this.aiOrganisation(action, org);
391
460
  case 'ai:budget':
392
461
  return this.aiBudget(action, org);
462
+ case 'ai:key':
463
+ return this.aiKey(action, org, context);
393
464
  case 'coolify:mail':
394
465
  return this.coolifyMail(action, org, context);
395
466
  case 'coolify:chat':
396
467
  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");
397
468
  case 'cms:site':
398
469
  return this.cmsSite(action, org, context);
470
+ case 'coolify:agora':
471
+ return this.coolifyAgora(action, org, context);
472
+ case 'jobs:registration':
473
+ return this.jobsRegistration(action, org, context);
399
474
  default:
400
475
  throw new Error(`FoundryStepExecutor has no dispatch for ${action.system}:${action.kind}`);
401
476
  }
@@ -600,27 +675,51 @@ export class FoundryStepExecutor {
600
675
  // people into the cms studio at `cms.<domain>`, not the org's own app
601
676
  // hostname -- `org.site.enabled` guarantees `org.domain` is set
602
677
  // (`org.ts`'s `validateSite`), the same guarantee `cmsHostnameFor`'s own
603
- // doc comment relies on.
678
+ // doc comment relies on. #164: the fixed `agora` client (`diff.ts`'s
679
+ // `planAgoraClient`) is the same shape, always -- there is no
680
+ // `org.site.enabled`-style guard to check, since Agora is always on.
604
681
  const isCmsClient = action.key === 'cms' && org.site.enabled;
605
- const hostname = isCmsClient ? cmsHostnameFor(org) : hostnameFor(org);
682
+ // The Agora client stays `appType: 'web'`: providers' `createOidcApp`
683
+ // makes every non-native app a public client (authorization code + PKCE,
684
+ // `OIDC_AUTH_METHOD_TYPE_NONE`, no secret), which is what Agora's browser
685
+ // SPA needs. Providers cannot create ZITADEL's `USER_AGENT` app kind.
686
+ const isAgoraClient = action.key === 'agora';
687
+ const hostname = isCmsClient
688
+ ? cmsHostnameFor(org)
689
+ : isAgoraClient
690
+ ? agoraHostnameFor(org)
691
+ : hostnameFor(org);
606
692
  // Native apps sign in via device code, with no redirect of their own
607
693
  // (auth/scripts/provision.mjs:475-497, prior art cited in the decisions
608
694
  // file) -- empty arrays rather than a web app's callback/logout URLs.
695
+ // #164: Agora's own callback path is `/callback`, not `/auth/callback`
696
+ // (agora/apps/agora/README.md, "Deploying an Agora": "The issuer's OAuth
697
+ // client needs `https://<agora address>/callback` as a redirect URI") --
698
+ // Agora is not built on `@wtfalch/auth`'s `NextAuth` mount the way every
699
+ // other web app this file provisions is.
700
+ const redirectUri = isAgoraClient
701
+ ? `https://${hostname}/callback`
702
+ : `https://${hostname}/auth/callback`;
609
703
  const created = await this.ports.zitadel.createOidcApp(zitadelOrgId, projectId, {
610
704
  name: action.key,
611
705
  appType,
612
- redirectUris: appType === 'native' ? [] : [`https://${hostname}/auth/callback`],
706
+ redirectUris: appType === 'native' ? [] : [redirectUri],
613
707
  postLogoutRedirectUris: appType === 'native' ? [] : [`https://${hostname}/`],
614
708
  });
615
709
  if (appType === 'web') {
616
710
  // `coolifyApp`'s own `pushAppEnv` reads `APP_CLIENT_ID_SCOPE_KEY` to
617
711
  // push AUTH_CLIENT_ID (level-1 app-env campaign, gap P2); `cmsSite`
618
712
  // 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);
713
+ // (#139), and `coolifyAgora` reads `AGORA_CLIENT_ID_SCOPE_KEY` for
714
+ // `AGORA_CONFIG`'s own `clientId` (#164) -- public, not a secret, so a
715
+ // plain scope id, not `deliverSecret`. A native app signs in via
716
+ // device code with no cookie session of its own, so its clientId is
717
+ // never something an app's env needs.
718
+ context.recordScopeId(isCmsClient
719
+ ? CMS_CLIENT_ID_SCOPE_KEY
720
+ : isAgoraClient
721
+ ? AGORA_CLIENT_ID_SCOPE_KEY
722
+ : APP_CLIENT_ID_SCOPE_KEY, created.clientId);
624
723
  }
625
724
  return { resourceId: created.appId };
626
725
  }
@@ -740,11 +839,12 @@ export class FoundryStepExecutor {
740
839
  // unlike the database push above: every app has a hostname, and an
741
840
  // upsert of the same value on a later apply is a no-op, not a change.
742
841
  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));
842
+ // #140 rework (2026-09-28 lettermint-org-mail swap): the hosted app's own
843
+ // Lettermint project, domain restriction and `MAIL_FROM` (only the app
844
+ // started by this same step, per the design's "only apps that send mail
845
+ // get it" -- cms and calls do not). A no-op until `coolify:mail` has
846
+ // actually resolved a mail domain for this org.
847
+ await this.ensureAppLettermintMail(appId, org, context, Boolean(existing));
748
848
  return { resourceId: appId };
749
849
  }
750
850
  /**
@@ -919,32 +1019,40 @@ export class FoundryStepExecutor {
919
1019
  return randomBytes(32).toString('base64');
920
1020
  }
921
1021
  /**
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
1022
+ * #140 rework (2026-09-28 lettermint-org-mail swap, "Core rule" +
1023
+ * "Credential model"): all of the org's Lettermint work now lives here,
1024
+ * in the app step, since the first project -- and so the first domain
1025
+ * restriction -- can only exist once the token has a home (the app env). A
1026
+ * no-op until `coolify:mail` has recorded {@link MAIL_DOMAIN_SCOPE_KEY} for
1027
+ * this org; the next apply fills it in once `coolify:mail` runs first
927
1028
  * (`diff.ts`'s `computePlanDetail` order).
928
1029
  *
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.
1030
+ * Order, exactly as the swap's "Core rule" states it: ensure the
1031
+ * `<slug>-app` project, ensure the mail domain, restrict that domain to the
1032
+ * org's own projects, publish its DNS records, trigger verification, then
1033
+ * delete every stale `<slug>-app` project. New projects are created and the
1034
+ * domain restricted to them before any old project is deleted, so the
1035
+ * restriction list never becomes empty.
934
1036
  *
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.
1037
+ * Mint rule ("Credential model"): the project is healthy when the app env
1038
+ * holds `LETTERMINT_API_TOKEN` and Lettermint lists exactly one project
1039
+ * named `<slug>-app` -- unlike Resend's key, a Lettermint project's name
1040
+ * carries no domain id, since a domain switch is handled entirely by
1041
+ * {@link LettermintPort.restrictDomainToProjects} below and never needs a
1042
+ * fresh project. Otherwise a fresh project is created and its token
1043
+ * written to the env first, and only then is every other `<slug>-app`
1044
+ * project deleted -- a lost mint response and a duplicate both end the
1045
+ * same way. An app that was already running is restarted before that
1046
+ * delete, since a container reads its env only at start (`running`, the
1047
+ * same reason {@link ensureLogVolume} restarts one).
1048
+ *
1049
+ * The domain's restriction is rebuilt from every project named `<slug>-app`
1050
+ * or `<slug>-relay` (the relay project itself is #149, not built yet) --
1051
+ * never patched, so it can never end up scoped only to a project about to
1052
+ * be deleted.
945
1053
  *
946
1054
  * `MAIL_FROM` is upserted every time: it depends only on the mail domain
947
- * `coolify:mail` resolved (`RESEND_MAIL_DOMAIN_SCOPE_KEY`).
1055
+ * `coolify:mail` resolved.
948
1056
  *
949
1057
  * The token goes straight to `bulkUpsertEnvs` and nowhere else: never
950
1058
  * returned, never recorded in `serviceIds` (`recordScopeId`'s own doc
@@ -952,49 +1060,81 @@ export class FoundryStepExecutor {
952
1060
  * same discipline `pushDatabaseUrl`'s doc comment states for a database's
953
1061
  * own connection string.
954
1062
  */
955
- async ensureAppResendKey(appId, org, context, running) {
956
- const recorded = context.recordedIds();
957
- const domainId = recorded[RESEND_DOMAIN_ID_SCOPE_KEY];
958
- if (!domainId)
1063
+ async ensureAppLettermintMail(appId, org, context, running) {
1064
+ const mailDomain = context.recordedIds()[MAIL_DOMAIN_SCOPE_KEY];
1065
+ if (!mailDomain)
959
1066
  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))),
1067
+ const appName = `${org.slug}-app`;
1068
+ const relayName = `${org.slug}-relay`;
1069
+ const [projects, envs] = await Promise.all([
1070
+ this.ports.lettermint.listProjects(),
964
1071
  this.ports.coolify.listEnvs(appId),
965
1072
  ]);
966
1073
  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;
1074
+ const ours = projects.filter((p) => p.name === appName);
1075
+ const held = ours.length === 1 ? ours[0] : undefined;
1076
+ const healthy = held !== undefined && Boolean(envValue('LETTERMINT_API_TOKEN'));
1077
+ let allProjects = projects;
1078
+ let keepProjectId;
972
1079
  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);
1080
+ if (healthy && held) {
1081
+ keepProjectId = held.id;
1082
+ }
1083
+ else {
1084
+ const created = await this.ports.lettermint.createProject(appName);
1085
+ keepProjectId = created.project.id;
1086
+ allProjects = [...projects, created.project];
1087
+ envUpdates.push({ key: 'LETTERMINT_API_TOKEN', value: created.token });
1088
+ }
1089
+ envUpdates.push({ key: 'MAIL_FROM', value: `no-reply@${mailDomain}` });
1090
+ await this.ports.coolify.bulkUpsertEnvs(appId, envUpdates);
988
1091
  // 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.
1092
+ // token, so a fresh one only reaches it through a restart -- before the
1093
+ // old project is deleted below. A just-created app starts with the new env.
991
1094
  if (!healthy && running)
992
1095
  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);
1096
+ const existing = await this.findExactLettermintDomain(mailDomain);
1097
+ // A crash between here and `restrictDomainToProjects` below is safe even
1098
+ // for a freshly created domain: DNS is only published, and verification
1099
+ // only triggered, after the restriction -- so an unrestricted domain
1100
+ // cannot verify and cannot send, whichever project owns it.
1101
+ const domain = existing
1102
+ ? await this.ports.lettermint.getDomain(existing.id)
1103
+ : await this.ports.lettermint.createDomain(mailDomain);
1104
+ const scopedProjectIds = allProjects
1105
+ .filter((p) => p.name === appName || p.name === relayName)
1106
+ .map((p) => p.id);
1107
+ await this.ports.lettermint.restrictDomainToProjects(domain.id, scopedProjectIds);
1108
+ const zone = await this.ports.cloudflare.findZoneByApex(mailDomain);
1109
+ if (!zone) {
1110
+ throw new Error(`coolify:app for ${org.slug}: no Cloudflare zone found for ${mailDomain}`);
1111
+ }
1112
+ await this.publishLettermintDnsRecords(zone.id, domain.dnsRecords);
1113
+ await this.ports.lettermint.verifyDomain(domain.id);
1114
+ for (const project of allProjects) {
1115
+ if (project.name === appName && project.id !== keepProjectId) {
1116
+ await this.ports.lettermint.deleteProject(project.id);
1117
+ }
996
1118
  }
997
1119
  }
1120
+ /**
1121
+ * Review finding (post-#140 rework): {@link LettermintPort.findDomain}'s
1122
+ * own match is partial and case-insensitive, so a raw result can be a
1123
+ * lookalike (`notacme.com`, `mail.acme.com`) rather than `name` itself --
1124
+ * and every caller here full-replaces the found domain's project
1125
+ * restriction ({@link LettermintPort.restrictDomainToProjects}), so taking
1126
+ * a lookalike as a match would let it be taken over. Every domain lookup
1127
+ * in this class (`ensureAppLettermintMail`, `parkLettermintMail`,
1128
+ * `deleteStaleLettermintDomain`) goes through this instead of the port
1129
+ * directly, and treats anything but an exact, case-insensitive match as
1130
+ * not found.
1131
+ */
1132
+ async findExactLettermintDomain(name) {
1133
+ const found = await this.ports.lettermint.findDomain(name);
1134
+ if (!found || found.domain.toLowerCase() !== name.toLowerCase())
1135
+ return undefined;
1136
+ return found;
1137
+ }
998
1138
  /**
999
1139
  * `plan/diff.ts`'s `planSite` (#116, slicing #99's "provision the cms app,
1000
1140
  * database and site app per org"): the 2026-09-25 decision
@@ -1661,6 +1801,156 @@ export class FoundryStepExecutor {
1661
1801
  return null;
1662
1802
  return randomBytes(32).toString('hex');
1663
1803
  }
1804
+ // ---- agora (#164) --------------------------------------------------------
1805
+ /**
1806
+ * `plan/diff.ts`'s `planAgora`: provisions the org's own Agora
1807
+ * (https://github.com/wtfalch/agora) as one more Coolify application, from
1808
+ * that repo's `apps/agora/Dockerfile` ({@link AGORA_REPO}'s doc comment),
1809
+ * and pushes its env. `findOrCreateCallsApp` is reused as-is for the
1810
+ * find-by-name-then-start-or-create shape, like {@link coolifyCallsService}.
1811
+ * There is no park branch: Agora is always on.
1812
+ *
1813
+ * The env comes from `plan/agora.ts`'s `desiredAgoraEnv`, the same function
1814
+ * the plan compares the live app against, so a re-plan after this step is
1815
+ * empty. The step never writes a partial config: when any input is missing
1816
+ * (the org's ZITADEL org id or Agora client id, or an issuer or
1817
+ * mail/messaging project id the host does not supply,
1818
+ * `AgoraHostPort`, read by the plan) it throws naming each one, before
1819
+ * it creates anything.
1820
+ *
1821
+ * `AGORA_CANONICAL_HOST` is always pushed. `AGORA_MAIL_UPSTREAM`/
1822
+ * `AGORA_FILES_UPSTREAM` are pushed once the platform's own mail/files
1823
+ * hosts are on and applied (#177's `platform-org.ts`'s
1824
+ * `agoraHostValuesFromPlatform`, read by `plan-apply.ts` when no host
1825
+ * `agora` port overrides it) -- absent otherwise. The chat upstreams are
1826
+ * still never pushed: chat has no admin API yet
1827
+ * (https://github.com/wtfalch/chat/issues/40). Agora answers 502 on those
1828
+ * routes rather than serving anything wrong.
1829
+ */
1830
+ async coolifyAgora(action, org, context) {
1831
+ // The host values the plan read, carried in the step: never read again
1832
+ // here, so the env written is the env the plan showed.
1833
+ const host = action.desired.host;
1834
+ if (!host) {
1835
+ throw new Error(`Agora for ${org.slug} cannot be configured: the step carries no host values`);
1836
+ }
1837
+ const { env, missing } = desiredAgoraEnv(org, context.recordedIds(), host);
1838
+ if (missing.length > 0) {
1839
+ throw new Error(`Agora for ${org.slug} cannot be configured: missing ${missing.join(', ')}. Nothing was written.`);
1840
+ }
1841
+ const projectUuid = await this.ensureCoolifyProject(org, context);
1842
+ const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
1843
+ const name = `${org.slug}-agora`;
1844
+ const server = await this.resolveServer();
1845
+ const githubApp = await this.ports.coolify.requireGithubApp();
1846
+ const app = await this.findOrCreateCallsApp(name, environment.id, () => this.ports.coolify.createGithubAppApplication({
1847
+ project_uuid: projectUuid,
1848
+ server_uuid: server.uuid,
1849
+ environment_name: environment.name,
1850
+ environment_uuid: environment.uuid,
1851
+ github_app_uuid: githubApp.uuid,
1852
+ git_repository: AGORA_REPO,
1853
+ git_branch: 'main',
1854
+ build_pack: 'dockerfile',
1855
+ name,
1856
+ domains: agoraHostnameFor(org),
1857
+ dockerfile_location: AGORA_APP_DOCKERFILE,
1858
+ }));
1859
+ // Every app foundry deploys keeps its own logs (#75), reused as-is here.
1860
+ await this.ensureLogVolume(app.appId, org, app.existed);
1861
+ await this.ports.coolify.bulkUpsertEnvs(app.appId, Object.entries(env).map(([key, value]) => ({ key, value })));
1862
+ return { resourceId: app.appId };
1863
+ }
1864
+ // ---- jobs (#173) ---------------------------------------------------------
1865
+ /**
1866
+ * `plan/diff.ts`'s `planJobsRegistration`: registers the org's own app with
1867
+ * the platform's jobs scheduler (`JobsSchedulerPort.registerProject`, the
1868
+ * scheduler's `POST /admin/projects`), then hands the freshly minted
1869
+ * `secret` and `syncKey` to the host through `deliverSecret` -- but only
1870
+ * when this call actually minted them. The admin API is itself idempotent
1871
+ * by `(name, baseUrl)`: a repeat call for an already-registered project
1872
+ * resolves with just the `id`, and this never re-delivers a secret or sync
1873
+ * key it did not just mint, the same "registers an app once" rule
1874
+ * `diff.ts`'s own plan-level gate (recordedIds, mirroring `planOwner`)
1875
+ * already provides on a normal re-plan -- this check is what keeps that
1876
+ * true even if this step somehow runs twice on the same apply.
1877
+ *
1878
+ * Never logs, returns or records either credential (this file's own top
1879
+ * doc comment's "a secret is never a step's return value" rule) -- only the
1880
+ * scheduler's own non-secret project id comes back as `resourceId`.
1881
+ */
1882
+ /** #177: the host's own port when it supplies one, else the platform's, built from its address and held key. */
1883
+ async filesPort() {
1884
+ const port = this.ports.filesOrganisation ?? (await this.ports.platformOrg?.files());
1885
+ if (!port)
1886
+ throw new Error('files: this executor was built without a files port (#177)');
1887
+ return port;
1888
+ }
1889
+ async aiPort() {
1890
+ const port = this.ports.aiOrganisation ?? (await this.ports.platformOrg?.ai());
1891
+ if (!port)
1892
+ throw new Error('ai: this executor was built without an ai port (#177)');
1893
+ return port;
1894
+ }
1895
+ async jobsPort(org) {
1896
+ const port = this.ports.jobsScheduler ?? (await this.ports.platformOrg?.jobs());
1897
+ if (!port) {
1898
+ throw new Error(`jobs:registration for ${org.slug}: this executor was built without a jobsScheduler port (#173)`);
1899
+ }
1900
+ return port;
1901
+ }
1902
+ async jobsRegistration(action, org, context) {
1903
+ const scheduler = await this.jobsPort(org);
1904
+ const name = action.desired.name;
1905
+ const baseUrl = action.desired.baseUrl;
1906
+ const registered = await scheduler.registerProject({ name, baseUrl });
1907
+ // A repeat call for an already-registered project carries neither
1908
+ // credential: nothing to deliver.
1909
+ if (!registered.secret && !registered.syncKey)
1910
+ return { resourceId: registered.id };
1911
+ // The project now exists and its credentials are shown exactly once, so
1912
+ // the id is recorded before anything else can fail: a step's return value
1913
+ // is only recorded on success, and without this a failed delivery would
1914
+ // leave the next run's `registerProject` answering with just `{ id }`,
1915
+ // the step recording success, and the sync key lost for good.
1916
+ // `planJobsRegistration` reports this id as blocked instead of planning
1917
+ // the step again.
1918
+ context.recordScopeId(JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY, registered.id);
1919
+ // Written now, so a crash during delivery cannot lose the id either.
1920
+ await context.persistScopeIds();
1921
+ if (!registered.secret || !registered.syncKey) {
1922
+ // Only one half came back; the other can never be fetched again.
1923
+ throw new Error(`jobs:registration for ${org.slug}: project ${registered.id} was registered but the scheduler returned only one of its secret and sync key; deregister it in the scheduler before registering again`);
1924
+ }
1925
+ const failed = [];
1926
+ for (const [part, value] of [
1927
+ ['secret', registered.secret],
1928
+ ['syncKey', registered.syncKey],
1929
+ ]) {
1930
+ if (!(await this.deliverWithRetry(`jobs:registration:${org.slug}:${part}`, value))) {
1931
+ failed.push(part);
1932
+ }
1933
+ }
1934
+ if (failed.length > 0) {
1935
+ // Names the project id only; the underlying errors are dropped in case
1936
+ // one echoes a credential.
1937
+ throw new Error(`jobs:registration for ${org.slug}: project ${registered.id} was registered but its ${failed.join(' and ')} could not be delivered after ${JOBS_DELIVERY_ATTEMPTS} attempts; deregister it in the scheduler before registering again`);
1938
+ }
1939
+ return { resourceId: registered.id };
1940
+ }
1941
+ /** `deliverSecret`, tried up to {@link JOBS_DELIVERY_ATTEMPTS} times; true once one attempt succeeds. */
1942
+ async deliverWithRetry(ref, value) {
1943
+ for (let attempt = 1; attempt <= JOBS_DELIVERY_ATTEMPTS; attempt++) {
1944
+ try {
1945
+ await this.ports.deliverSecret(ref, value);
1946
+ return true;
1947
+ }
1948
+ catch {
1949
+ // Retried; the error is dropped in case it echoes the secret.
1950
+ }
1951
+ }
1952
+ return false;
1953
+ }
1664
1954
  // ---- cloudflare --------------------------------------------------------
1665
1955
  async cloudflareTunnel(action, org, context) {
1666
1956
  if (action.desired.enabled === false) {
@@ -1739,36 +2029,101 @@ export class FoundryStepExecutor {
1739
2029
  * `plan/diff.ts`'s `planMail`: gives an org mail, moves it between modes,
1740
2030
  * or parks it on the one shared Stalwart instance (`this.ports.mail`,
1741
2031
  * `@wtfalch/email/postmaster`'s `applyOrgMail`/`parkOrgMail` bound), then
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`).
2032
+ * publishes whatever Stalwart DNS records the call returns, and records the
2033
+ * resolved mail domain as a scope id ({@link MAIL_DOMAIN_SCOPE_KEY}).
2034
+ * 2026-09-28 lettermint-org-mail swap: this step no longer calls the mail
2035
+ * relay provider at all -- registering the domain, restricting it and
2036
+ * publishing its own DNS records all moved to the app step
2037
+ * (`ensureAppLettermintMail`, "Core rule"), since the first project can
2038
+ * only exist once the token has a home. Parking mail (mode `'off'`) does
2039
+ * still reach Lettermint, the one exception: {@link parkLettermintMail}
2040
+ * deletes the org's own projects and then its domain, so a domain never
2041
+ * ends up restricted only to dead projects.
2042
+ *
2043
+ * Review finding: a mail domain switch (`org.domain` changes, or
2044
+ * `usesDomain` flips) must also delete the *old* Lettermint domain here,
2045
+ * before recording the new one -- otherwise it stays registered and
2046
+ * restricted to the org's still-live `<slug>-app` project, which could
2047
+ * then keep sending as it forever. {@link deleteStaleLettermintDomain}
2048
+ * does this by comparing the previously recorded domain (case-insensitive)
2049
+ * against the one this apply just resolved.
2050
+ *
2051
+ * #162: once `applyOrgMail` has given the org a domain, {@link ensureOwnerMailbox}
2052
+ * gives its declared owner a mailbox on it, so Agora's Mail section is not
2053
+ * empty for them after the org's first apply.
1750
2054
  */
1751
2055
  async coolifyMail(action, org, context) {
1752
2056
  const mode = action.desired.mode;
1753
2057
  const usesDomain = action.desired.usesDomain;
1754
- const options = { apex: MAIL_APEX, mailHost: MAIL_HOST };
2058
+ // #177: `diff.ts`'s `planMail` carries the platform's own apex/mail host
2059
+ // in `desired` once it is wired -- "the executor writes exactly these
2060
+ // host values, so what the plan showed is what gets applied" (`planAgora`'s
2061
+ // own rule). `MAIL_APEX`/`MAIL_HOST` remain the fallback for a plan built
2062
+ // without a platform (this file's own doc comment on those constants).
2063
+ const options = {
2064
+ apex: action.desired.apex ?? MAIL_APEX,
2065
+ mailHost: action.desired.mailHost ?? MAIL_HOST,
2066
+ };
1755
2067
  if (mode === 'off') {
1756
2068
  await this.ports.mail.parkOrgMail(mailOrgFor(org, usesDomain, 'hosted'), options);
2069
+ await this.parkLettermintMail(org, context);
1757
2070
  return undefined;
1758
2071
  }
1759
- const result = await this.ports.mail.applyOrgMail(mailOrgFor(org, usesDomain, mode), options);
1760
- const zoneId = await this.publishMailDnsRecords(org, result);
1761
- await this.registerResendDomain(result.domain, zoneId, context);
2072
+ const mailOrg = mailOrgFor(org, usesDomain, mode);
2073
+ const result = await this.ports.mail.applyOrgMail(mailOrg, options);
2074
+ await this.publishMailDnsRecords(org, result);
2075
+ await this.deleteStaleLettermintDomain(context, result.domain);
2076
+ context.recordScopeId(MAIL_DOMAIN_SCOPE_KEY, result.domain);
2077
+ // #177: what this step wrote from the platform, so `planMail` can see a later change.
2078
+ context.recordScopeId(ORG_MAIL_APPLIED_APEX_SCOPE_KEY, options.apex);
2079
+ context.recordScopeId(ORG_MAIL_APPLIED_HOST_SCOPE_KEY, options.mailHost);
2080
+ await this.ensureOwnerMailbox(org, mailOrg, result.domain, context);
1762
2081
  return { resourceId: result.domain };
1763
2082
  }
2083
+ /**
2084
+ * #162: gives the org's declared owner a mailbox on its just-resolved mail
2085
+ * domain, the one address this executor mints on its own
2086
+ * ({@link MAIL_OWNER_LOCAL_PART}'s own doc comment). A no-op when the org
2087
+ * has no owner yet, or once {@link MAIL_OWNER_MAILBOX_SCOPE_KEY} is already
2088
+ * recorded -- checked first so a second apply never calls
2089
+ * `MailPort.createMailbox` again, rather than relying on that port method's
2090
+ * own duplicate refusal.
2091
+ */
2092
+ async ensureOwnerMailbox(org, mailOrg, domain, context) {
2093
+ if (!org.owner)
2094
+ return;
2095
+ if (context.recordedIds()[MAIL_OWNER_MAILBOX_SCOPE_KEY])
2096
+ return;
2097
+ const address = `${MAIL_OWNER_LOCAL_PART}@${domain}`;
2098
+ await this.ports.mail.createMailbox(mailOrg, {
2099
+ address,
2100
+ name: `${org.owner.givenName} ${org.owner.familyName}`,
2101
+ });
2102
+ context.recordScopeId(MAIL_OWNER_MAILBOX_SCOPE_KEY, address);
2103
+ }
2104
+ /**
2105
+ * Deletes the org's previous Lettermint domain once the mail domain it was
2106
+ * registered under has changed -- otherwise it stays registered and
2107
+ * restricted to the org's still-live `<slug>-app` project after the switch
2108
+ * (review finding, post-#140 rework). A no-op on first apply (no previous
2109
+ * domain recorded) and on a same-domain re-apply (compared
2110
+ * case-insensitively, the same rule {@link findExactLettermintDomain}
2111
+ * applies to the lookup itself).
2112
+ */
2113
+ async deleteStaleLettermintDomain(context, newDomain) {
2114
+ const previous = context.recordedIds()[MAIL_DOMAIN_SCOPE_KEY];
2115
+ if (!previous || previous.toLowerCase() === newDomain.toLowerCase())
2116
+ return;
2117
+ const stale = await this.findExactLettermintDomain(previous);
2118
+ if (stale)
2119
+ await this.ports.lettermint.deleteDomain(stale.id);
2120
+ }
1764
2121
  /**
1765
2122
  * Upserts each DNS record `applyOrgMail` says foundry must publish, found
1766
2123
  * by an exact name+type match (`CloudflarePort.findDnsRecord`'s own doc
1767
2124
  * comment) so update never mistakes a stale record of a different type for
1768
2125
  * a match. A `DKIM` record can be absent on the first call, since Stalwart
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.
2126
+ * mints the key on a background task; the next apply publishes it.
1772
2127
  */
1773
2128
  async publishMailDnsRecords(org, result) {
1774
2129
  const cf = this.ports.cloudflare;
@@ -1786,53 +2141,50 @@ export class FoundryStepExecutor {
1786
2141
  await cf.createDnsRecord(zone.id, rec);
1787
2142
  }
1788
2143
  }
1789
- return zone.id;
1790
2144
  }
1791
2145
  /**
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.
2146
+ * Parking mail (2026-09-28 lettermint-org-mail swap, "Core rule": "Parking
2147
+ * mail deletes the org's projects and then the domain itself, so no
2148
+ * restriction points only at dead projects"). Deletes every Lettermint
2149
+ * project named `<slug>-app` or `<slug>-relay`, then the org's own
2150
+ * Lettermint domain, if one was found -- in that order, never the reverse.
2151
+ * A no-op until a previous apply recorded {@link MAIL_DOMAIN_SCOPE_KEY}:
2152
+ * nothing was ever provisioned for an org whose mail went straight from
2153
+ * never-applied to off.
1807
2154
  */
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);
2155
+ async parkLettermintMail(org, context) {
2156
+ const mailDomain = context.recordedIds()[MAIL_DOMAIN_SCOPE_KEY];
2157
+ if (!mailDomain)
2158
+ return;
2159
+ const names = new Set([`${org.slug}-app`, `${org.slug}-relay`]);
2160
+ const projects = await this.ports.lettermint.listProjects();
2161
+ for (const project of projects) {
2162
+ if (names.has(project.name))
2163
+ await this.ports.lettermint.deleteProject(project.id);
2164
+ }
2165
+ const domain = await this.findExactLettermintDomain(mailDomain);
2166
+ if (domain)
2167
+ await this.ports.lettermint.deleteDomain(domain.id);
1817
2168
  }
1818
2169
  /**
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`.
2170
+ * Upserts each DNS record Lettermint's domain names, the same find-then-
2171
+ * upsert `publishMailDnsRecords` above uses for Stalwart's own records --
2172
+ * except an existing `_dmarc` record is never overwritten (a stricter
2173
+ * policy an org already set, `p=quarantine`/`p=reject`, should not be
2174
+ * relaxed back to Lettermint's own default). Resend's own domain also
2175
+ * carried an apex `SPF` TXT record that had to be merged into whatever SPF
2176
+ * record already existed there (RFC 7208: two `v=spf1` TXT records on one
2177
+ * name is a permerror); Lettermint's own DNS records
2178
+ * (`.plans/2026-09-28-lettermint-api.md` §3) have no such record -- its
2179
+ * `purpose` enum is `return_path`/`dmarc`/`dkim_legacy`/`dkim_primary`/
2180
+ * `dkim_secondary`, none of them SPF -- so that merge has no case left to
2181
+ * handle here and is dropped rather than kept as dead code.
1826
2182
  */
1827
- async publishResendDnsRecords(zoneId, records) {
2183
+ async publishLettermintDnsRecords(zoneId, records) {
1828
2184
  const cf = this.ports.cloudflare;
1829
2185
  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
2186
  if (record.type === 'TXT' && record.value.toLowerCase().startsWith('v=dmarc1')) {
1835
- await this.ensureResendDmarcRecord(cf, zoneId, record);
2187
+ await this.ensureLettermintDmarcRecord(cf, zoneId, record);
1836
2188
  continue;
1837
2189
  }
1838
2190
  const [existing] = await cf.findDnsRecord(zoneId, record.name, record.type);
@@ -1845,36 +2197,12 @@ export class FoundryStepExecutor {
1845
2197
  }
1846
2198
  }
1847
2199
  }
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
2200
  /**
1873
2201
  * Never overwrites an existing `_dmarc` record -- a stricter policy an org
1874
2202
  * already set (`p=quarantine`/`p=reject`) should not be relaxed back to
1875
- * Resend's own `p=none` default.
2203
+ * Lettermint's own `p=none` default.
1876
2204
  */
1877
- async ensureResendDmarcRecord(cf, zoneId, record) {
2205
+ async ensureLettermintDmarcRecord(cf, zoneId, record) {
1878
2206
  const hasDmarc = (await cf.findDnsRecord(zoneId, record.name, 'TXT')).some((r) => r.content.toLowerCase().startsWith('v=dmarc1'));
1879
2207
  if (hasDmarc)
1880
2208
  return;
@@ -1889,7 +2217,8 @@ export class FoundryStepExecutor {
1889
2217
  * idempotent by find-or-create, not by checking `recordedIds` first.
1890
2218
  */
1891
2219
  async filesOrganisation(action, org) {
1892
- const provisioned = await this.ports.filesOrganisation.provisionOrganisation({
2220
+ const files = await this.filesPort();
2221
+ const provisioned = await files.provisionOrganisation({
1893
2222
  organisationId: org.id,
1894
2223
  ceilingBytes: action.desired.ceilingBytes ?? null,
1895
2224
  ceilingMicros: action.desired.ceilingMicros ?? null,
@@ -1897,9 +2226,9 @@ export class FoundryStepExecutor {
1897
2226
  return { resourceId: provisioned.organisationId };
1898
2227
  }
1899
2228
  async filesStorage(action, org) {
1900
- const limits = this.ports.filesLimitsFor(org.id);
1901
- await limits.setQuota(action.desired.limitBytes ?? null);
1902
- await limits.setBudget(action.desired.limitMicros ?? null);
2229
+ const files = await this.filesPort();
2230
+ await files.setQuota(org.id, action.desired.limitBytes ?? null);
2231
+ await files.setBudget(org.id, action.desired.limitMicros ?? null);
1903
2232
  return { resourceId: org.id };
1904
2233
  }
1905
2234
  /**
@@ -1910,11 +2239,12 @@ export class FoundryStepExecutor {
1910
2239
  * every time regardless of whether they already ran.
1911
2240
  */
1912
2241
  async aiOrganisation(action, org) {
1913
- const provisioned = await this.ports.aiOrganisation.provisionOrganisation({
2242
+ const ai = await this.aiPort();
2243
+ const provisioned = await ai.provisionOrganisation({
1914
2244
  organisationId: org.id,
1915
2245
  limitMicros: action.desired.limitMicros ?? null,
1916
2246
  });
1917
- await this.ports.aiOrganisation.setOrganisationOffers(org.id, action.desired.offers ?? []);
2247
+ await ai.setOrganisationOffers(org.id, action.desired.offers ?? []);
1918
2248
  return { resourceId: provisioned.organisationId };
1919
2249
  }
1920
2250
  async aiBudget(action, org) {
@@ -1925,10 +2255,48 @@ export class FoundryStepExecutor {
1925
2255
  // (Finding, this campaign's report).
1926
2256
  throw new Error(`ai:budget for ${org.slug}: AI's updateBudget has no uncapped representation (UpdateBudget.limitMicros is a required string); declare a numeric limit until AI's API adds one`);
1927
2257
  }
1928
- const limits = this.ports.aiLimitsFor(org.id);
1929
- await limits.updateBudget({ keyId: null, limitMicros });
2258
+ const ai = await this.aiPort();
2259
+ await ai.updateBudget(org.id, { keyId: null, limitMicros });
1930
2260
  return { resourceId: org.id };
1931
2261
  }
2262
+ /**
2263
+ * #80 step 3: mints the org's own ai key, once, and hands its one-time
2264
+ * secret straight to {@link FoundryStepExecutorPorts.deliverSecret} --
2265
+ * never returned, stored in `serviceIds` or logged, the same discipline
2266
+ * `zitadelApp`'s own `created.clientSecret` follows. `diff.ts`'s
2267
+ * `planAiKey` never plans this step again once its resource id
2268
+ * (the key's id, not its secret) is recorded, since ai's own API offers no
2269
+ * way to check for an already-minted key.
2270
+ */
2271
+ async aiKey(action, org, context) {
2272
+ const ai = await this.aiPort();
2273
+ const issued = await ai.issueOrganisationKey(org.id, {
2274
+ name: action.desired.name,
2275
+ expiresDays: action.desired.expiresDays,
2276
+ permissions: action.desired.permissions ?? [],
2277
+ });
2278
+ // Recorded before the secret is delivered: a step's return value is only
2279
+ // recorded on success, so a delivery that throws would otherwise lose
2280
+ // the id and the retry would mint a second, orphaned key. `planAiKey`
2281
+ // treats this id as undelivered until the returned resource id (which
2282
+ // only a delivered key gets) is recorded too.
2283
+ context.recordScopeId(AI_KEY_UNDELIVERED_SCOPE_KEY, issued.id);
2284
+ // Written now, so a crash during delivery cannot lose the id either.
2285
+ await context.persistScopeIds();
2286
+ let lastError;
2287
+ for (let attempt = 1; attempt <= AI_KEY_DELIVERY_ATTEMPTS; attempt++) {
2288
+ try {
2289
+ await this.ports.deliverSecret(`ai:key:${org.slug}`, issued.secret);
2290
+ return { resourceId: issued.id };
2291
+ }
2292
+ catch (e) {
2293
+ lastError = e;
2294
+ }
2295
+ }
2296
+ // Names the key id only; the underlying error is dropped in case it
2297
+ // echoes the secret.
2298
+ throw new Error(`ai:key for ${org.slug}: key ${issued.id} was minted but its secret could not be delivered after ${AI_KEY_DELIVERY_ATTEMPTS} attempts (${lastError instanceof Error ? lastError.name : 'error'}); revoke it in ai before a new one is minted`);
2299
+ }
1932
2300
  // ---- destroy -------------------------------------------------------------
1933
2301
  /**
1934
2302
  * {@link StepDestroyer.destroy}: deletes the one resource
@@ -2004,7 +2372,7 @@ export class FoundryStepExecutor {
2004
2372
  * status in: the first `→ `, after the client, method and path, and before
2005
2373
  * `:`. It never scans the path or the response body.
2006
2374
  */
2007
- function isNotFoundError(error) {
2375
+ export function isNotFoundError(error) {
2008
2376
  const status = error?.status;
2009
2377
  if (typeof status === 'number')
2010
2378
  return status === 404;