@wtfalch/foundry 0.4.0 → 0.6.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 (76) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +111 -0
  2. package/dist/bootstrap/bootstrap.js +323 -0
  3. package/dist/bootstrap/declare.d.ts +36 -0
  4. package/dist/bootstrap/declare.js +99 -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 +17 -6
  10. package/dist/calls-sfu-deploy-support.js +19 -7
  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 +36 -12
  18. package/dist/calls-sfu.js +41 -9
  19. package/dist/index.d.ts +38 -7
  20. package/dist/index.js +20 -4
  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/migrations/0019_mail_owner_mailbox.sql +15 -0
  25. package/dist/migrations/0020_mail_apps.sql +7 -0
  26. package/dist/migrations/0021_mail_role_mailboxes.sql +6 -0
  27. package/dist/org.d.ts +81 -0
  28. package/dist/org.js +96 -0
  29. package/dist/plan/agora.d.ts +77 -0
  30. package/dist/plan/agora.js +127 -0
  31. package/dist/plan/cloudflared-connector.d.ts +70 -0
  32. package/dist/plan/cloudflared-connector.js +106 -0
  33. package/dist/plan/diff.d.ts +20 -3
  34. package/dist/plan/diff.js +713 -115
  35. package/dist/plan/executor.d.ts +680 -172
  36. package/dist/plan/executor.js +755 -189
  37. package/dist/plan/index.d.ts +28 -5
  38. package/dist/plan/index.js +15 -2
  39. package/dist/plan/jobs-scheduler-http.d.ts +29 -0
  40. package/dist/plan/jobs-scheduler-http.js +50 -0
  41. package/dist/plan/live.d.ts +51 -2
  42. package/dist/plan/live.js +8 -0
  43. package/dist/plan/mail-copy.d.ts +125 -0
  44. package/dist/plan/mail-copy.js +166 -0
  45. package/dist/plan/mail-identity-allowlist.d.ts +50 -0
  46. package/dist/plan/mail-identity-allowlist.js +11 -0
  47. package/dist/plan/mail-identity.d.ts +68 -0
  48. package/dist/plan/mail-identity.js +17 -0
  49. package/dist/plan/plan-apply.d.ts +197 -3
  50. package/dist/plan/plan-apply.js +278 -9
  51. package/dist/plan/platform-diff.d.ts +487 -0
  52. package/dist/plan/platform-diff.js +1220 -0
  53. package/dist/plan/platform-org-ports.d.ts +50 -0
  54. package/dist/plan/platform-org-ports.js +70 -0
  55. package/dist/plan/platform-org.d.ts +121 -0
  56. package/dist/plan/platform-org.js +115 -0
  57. package/dist/plan/platform-providers.d.ts +615 -0
  58. package/dist/plan/platform-providers.js +4410 -0
  59. package/dist/plan/platform-sfu.d.ts +128 -0
  60. package/dist/plan/platform-sfu.js +142 -0
  61. package/dist/plan/providers-readers.js +22 -1
  62. package/dist/plan/zitadel-machine-user.d.ts +83 -0
  63. package/dist/plan/zitadel-machine-user.js +238 -0
  64. package/dist/plan.d.ts +6 -0
  65. package/dist/platform.d.ts +55 -0
  66. package/dist/platform.js +46 -0
  67. package/dist/requested-changes.d.ts +118 -0
  68. package/dist/requested-changes.js +125 -0
  69. package/dist/slug.js +15 -2
  70. package/dist/store/audit.js +4 -2
  71. package/dist/store/postgres.d.ts +29 -1
  72. package/dist/store/postgres.js +203 -4
  73. package/dist/store/schema.d.ts +755 -0
  74. package/dist/store/schema.js +70 -0
  75. package/dist/store.d.ts +53 -0
  76. package/package.json +1 -1
@@ -1,7 +1,10 @@
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 { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
7
+ import { ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, } from './platform-diff.js';
5
8
  // ---------------------------------------------------------------------------
6
9
  // Errors
7
10
  // ---------------------------------------------------------------------------
@@ -79,6 +82,13 @@ function tunnelIngressHostnames(org) {
79
82
  if (org.calls.enabled && org.calls.usesDomain && org.domain) {
80
83
  hostnames.push(`calls.${org.domain}`);
81
84
  }
85
+ // #164: Agora always moves onto `agora.<domain>` once `org.domain` is set
86
+ // -- no toggle to gate this on. A level-1 org (no domain) adds nothing
87
+ // here: `<slug>-agora.oikos.host` rides the shared `*.oikos.host` wildcard
88
+ // tunnel (`scripts/shared-ingress.mjs`), not a per-org tunnel.
89
+ if (org.domain) {
90
+ hostnames.push(agoraHostnameFor(org));
91
+ }
82
92
  return hostnames;
83
93
  }
84
94
  /**
@@ -99,10 +109,11 @@ export function healthUrls(org) {
99
109
  }
100
110
  /**
101
111
  * The `OrgMailOrg` argument for `applyOrgMail`/`parkOrgMail`: an org with
102
- * `mail.usesDomain` off has no domain of its own, so it is level 1 on
103
- * `MAIL_APEX` and its mode is forced to `'hosted'` — `@wtfalch/email`'s own
104
- * `resolveDomain` refuses any other mode for a domain-less org, and a
105
- * level-1 `{slug}.{apex}` domain always has Stalwart's MX (foundry#7 "Mail").
112
+ * `mail.usesDomain` off has no domain of its own, so it is level 1 on the
113
+ * platform's own mail apex (`coolifyMail`'s `options.apex`) and its mode is
114
+ * forced to `'hosted'` — `@wtfalch/email`'s own `resolveDomain` refuses any
115
+ * other mode for a domain-less org, and a level-1 `{slug}.{apex}` domain
116
+ * always has Stalwart's MX (foundry#7 "Mail").
106
117
  */
107
118
  function mailOrgFor(org, usesDomain, mode) {
108
119
  return usesDomain && org.domain
@@ -121,7 +132,7 @@ function mailOrgFor(org, usesDomain, mode) {
121
132
  */
122
133
  const COOLIFY_PROXY_ORIGIN = 'http://coolify-proxy:80';
123
134
  /** The one Access policy foundry puts on its own Access app (#84). */
124
- const ACCESS_POLICY_NAME = 'foundry: allowed emails';
135
+ export const ACCESS_POLICY_NAME = 'foundry: allowed emails';
125
136
  /**
126
137
  * The sign-in broker's own route mount (`@wtfalch/auth`'s `NextAuth`,
127
138
  * `dist/next.d.ts`'s `handlers` doc comment: "`export const { GET, POST } =
@@ -187,6 +198,25 @@ const DEFAULT_APP_DOCKERFILE = 'apps/hestia/Dockerfile';
187
198
  const CMS_REPO = 'wtfalch/cms';
188
199
  const CMS_APP_DOCKERFILE = 'Dockerfile';
189
200
  const CMS_SITE_APP_DOCKERFILE = 'apps/site/Dockerfile';
201
+ /**
202
+ * #164: the repo `coolifyAgora` builds the org's Agora from
203
+ * (https://github.com/wtfalch/agora) -- the issue's own "How" names this
204
+ * exact Dockerfile, and it is the only build strategy any app this file
205
+ * provisions ever uses ({@link DEFAULT_APP_DOCKERFILE}/{@link CMS_APP_DOCKERFILE}/
206
+ * `CALLS_SERVICE_DOCKERFILE` below are all `dockerfile_location` into a
207
+ * `createGithubAppApplication` build-from-repo call, never a registry
208
+ * image): "Coolify builds each org's Agora from the agora repo" is not a
209
+ * fresh product choice here, it is this file's one established convention,
210
+ * followed rather than reopened (#164 "Decide" 1). The agora repo's own
211
+ * Dockerfile (`apps/agora/Dockerfile`) already `EXPOSE`s 8080 and serves
212
+ * from a build built at the repository root, the same one-repo shape
213
+ * `CMS_REPO`'s own two apps use -- no `ports_exposes`/`PORT` env is needed
214
+ * the way `CALLS_SERVICE_PORT` is for the calls service's own Node process,
215
+ * since Coolify's github-app build already reads the exposed port the same
216
+ * way it does for `cmsSite`'s two apps above.
217
+ */
218
+ const AGORA_REPO = 'wtfalch/agora';
219
+ const AGORA_APP_DOCKERFILE = 'apps/agora/Dockerfile';
190
220
  /**
191
221
  * Every app foundry deploys keeps its own logs across a redeploy, so a
192
222
  * database outage does not also erase the only other copy of the evidence
@@ -198,13 +228,6 @@ const CMS_SITE_APP_DOCKERFILE = 'apps/site/Dockerfile';
198
228
  * restated rather than shared across the two files.
199
229
  */
200
230
  const LOG_DIR = '/app/logs';
201
- /**
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`.
205
- */
206
- const MAIL_APEX = 'wtfalch.dev';
207
- const MAIL_HOST = 'mail.wtfalch.dev';
208
231
  /**
209
232
  * The recorded OIDC app's `clientId` (level-1 app-env campaign, gap P2) —
210
233
  * not a `system:kind:key` resource id (that slot already holds the app's
@@ -252,24 +275,73 @@ const SITE_APP_SCOPE_KEY = 'coolify_site_app';
252
275
  */
253
276
  const CMS_DATABASE_SCOPE_KEY = 'coolify_cms_database';
254
277
  /**
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`.
278
+ * #138/#140 (2026-09-28 lettermint-org-mail swap): the mail domain
279
+ * `coolifyMail` resolved with Stalwart -- `org.domain` when the org uses its
280
+ * own domain, else `<slug>.<apex>` for a domainless org (2026-09-28:
281
+ * domainless orgs get their own Lettermint domain too). Recorded so the app
282
+ * step can derive `MAIL_FROM` and drive its own Lettermint domain/project
283
+ * work (`ensureAppLettermintMail`) without recomputing `mailOrgFor`'s own
284
+ * domain choice, the same way {@link APP_CLIENT_ID_SCOPE_KEY} carries a
285
+ * public value out. All of that org's Lettermint work -- ensure domain,
286
+ * restrict, publish DNS, verify -- now happens inside the same step that
287
+ * reads this key (`ensureAppLettermintMail`), so unlike the Resend design
288
+ * this rework replaces, no separate domain-id scope key is recorded: the
289
+ * domain is looked up by name fresh each apply. Renamed from
290
+ * `resend_mail_domain`.
291
+ */
292
+ const MAIL_DOMAIN_SCOPE_KEY = 'mail_domain';
293
+ /**
294
+ * #162: the owner's own mailbox, created once inside `coolify:mail` after
295
+ * `applyOrgMail` succeeds, alongside {@link MAIL_DOMAIN_SCOPE_KEY} -- another
296
+ * flat scope id for the same reason, no `:`. Recorded with the address
297
+ * itself (not just a boolean) so a future step could read it back the same
298
+ * way {@link MAIL_DOMAIN_SCOPE_KEY} lets `ensureAppLettermintMail` read the
299
+ * domain back; `ensureOwnerMailbox` only ever checks that this key is set at
300
+ * all before deciding whether to call `MailPort.createMailbox` again.
301
+ * `plan/diff.ts` keeps its own copy of this literal (`MAIL_OWNER_MAILBOX_SCOPE_KEY`
302
+ * there) rather than importing it, the same restated-not-shared rule this
303
+ * file's own `LOG_DIR` doc comment names for `diff.ts`.
304
+ */
305
+ const MAIL_OWNER_MAILBOX_SCOPE_KEY = 'mail_owner_mailbox';
306
+ /**
307
+ * #149 review finding: `MailPort.hasOrgRelay` only confirms Stalwart's route
308
+ * holds *a* secret, not that it is the one {@link FoundryStepExecutor
309
+ * .ensureOrgLettermintRelay} itself wrote for the project Lettermint
310
+ * currently lists under `<slug>-relay` -- a route a stale run wrote for a
311
+ * project that has since been lost and reprovisioned under the same name
312
+ * would otherwise read as healthy. Recorded as `<project id>@<mail domain>`
313
+ * right after a successful `MailPort.applyOrgRelay`, a flat scope id (no
314
+ * `:`) the same way {@link MAIL_OWNER_MAILBOX_SCOPE_KEY} is. The domain half
315
+ * also catches a domain switch: the healthy branch never calls
316
+ * `applyOrgRelay` again, so without this the Stalwart route would stay
317
+ * restricted to the org's previous domain forever.
262
318
  */
263
- const RESEND_DOMAIN_ID_SCOPE_KEY = 'resend_domain_id';
319
+ const ORG_MAIL_RELAY_SCOPE_KEY = 'mail_relay';
264
320
  /**
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.
321
+ * #230: the role mailboxes {@link ensureRoleMailboxes} has created, as one
322
+ * flat scope id holding their full addresses (`local@domain`, lowercased, so a
323
+ * mail domain change owes the new domain its own set) joined by `,`
324
+ * (`validateMail` refuses a `,` in a name, so the join is unambiguous). Updated after each
325
+ * mailbox, so a crash part-way through never recreates the ones already
326
+ * made. `plan/diff.ts` keeps its own copy of this literal.
271
327
  */
272
- const RESEND_MAIL_DOMAIN_SCOPE_KEY = 'resend_mail_domain';
328
+ const MAIL_ROLE_MAILBOXES_SCOPE_KEY = 'mail_role_mailboxes';
329
+ /**
330
+ * #80: the id of an ai key `aiKey` has minted but not yet delivered, a flat
331
+ * scope id (no `:`). `plan/diff.ts` keeps its own copy of this literal.
332
+ */
333
+ const AI_KEY_UNDELIVERED_SCOPE_KEY = 'ai_key_undelivered';
334
+ /** How many times `aiKey` tries `deliverSecret` before giving up. */
335
+ const AI_KEY_DELIVERY_ATTEMPTS = 3;
336
+ /**
337
+ * #173: the id of a jobs project `jobsRegistration` has registered but not
338
+ * yet delivered the credentials of, a flat scope id (no `:`). `serviceIds` is
339
+ * already per org, so the key needs no org in it. `plan/diff.ts` keeps its own
340
+ * copy of this literal.
341
+ */
342
+ const JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY = 'jobs_registration_undelivered';
343
+ /** How many times `jobsRegistration` tries each `deliverSecret` before giving up. */
344
+ const JOBS_DELIVERY_ATTEMPTS = 3;
273
345
  /**
274
346
  * `coolifyCalls` provisions four resources from one `coolify:calls` step
275
347
  * (#128) — the same one-step, several-resource shape `cmsSite` already solves
@@ -342,7 +414,7 @@ const CALLS_SERVICE_PORT = '8080';
342
414
  * same "nothing says it's off" convention `planHostingToggle` (`diff.ts`)
343
415
  * uses for the org's own app.
344
416
  */
345
- function isRunningOrStarting(status) {
417
+ export function isRunningOrStarting(status) {
346
418
  if (!status)
347
419
  return true;
348
420
  return !status.startsWith('exited') && !status.startsWith('stopped');
@@ -390,12 +462,20 @@ export class FoundryStepExecutor {
390
462
  return this.aiOrganisation(action, org);
391
463
  case 'ai:budget':
392
464
  return this.aiBudget(action, org);
465
+ case 'ai:key':
466
+ return this.aiKey(action, org, context);
393
467
  case 'coolify:mail':
394
468
  return this.coolifyMail(action, org, context);
469
+ case 'coolify:mail-copy':
470
+ return this.mailCopy(org, context);
395
471
  case 'coolify:chat':
396
472
  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
473
  case 'cms:site':
398
474
  return this.cmsSite(action, org, context);
475
+ case 'coolify:agora':
476
+ return this.coolifyAgora(action, org, context);
477
+ case 'jobs:registration':
478
+ return this.jobsRegistration(action, org, context);
399
479
  default:
400
480
  throw new Error(`FoundryStepExecutor has no dispatch for ${action.system}:${action.kind}`);
401
481
  }
@@ -600,27 +680,51 @@ export class FoundryStepExecutor {
600
680
  // people into the cms studio at `cms.<domain>`, not the org's own app
601
681
  // hostname -- `org.site.enabled` guarantees `org.domain` is set
602
682
  // (`org.ts`'s `validateSite`), the same guarantee `cmsHostnameFor`'s own
603
- // doc comment relies on.
683
+ // doc comment relies on. #164: the fixed `agora` client (`diff.ts`'s
684
+ // `planAgoraClient`) is the same shape, always -- there is no
685
+ // `org.site.enabled`-style guard to check, since Agora is always on.
604
686
  const isCmsClient = action.key === 'cms' && org.site.enabled;
605
- const hostname = isCmsClient ? cmsHostnameFor(org) : hostnameFor(org);
687
+ // The Agora client stays `appType: 'web'`: providers' `createOidcApp`
688
+ // makes every non-native app a public client (authorization code + PKCE,
689
+ // `OIDC_AUTH_METHOD_TYPE_NONE`, no secret), which is what Agora's browser
690
+ // SPA needs. Providers cannot create ZITADEL's `USER_AGENT` app kind.
691
+ const isAgoraClient = action.key === 'agora';
692
+ const hostname = isCmsClient
693
+ ? cmsHostnameFor(org)
694
+ : isAgoraClient
695
+ ? agoraHostnameFor(org)
696
+ : hostnameFor(org);
606
697
  // Native apps sign in via device code, with no redirect of their own
607
698
  // (auth/scripts/provision.mjs:475-497, prior art cited in the decisions
608
699
  // file) -- empty arrays rather than a web app's callback/logout URLs.
700
+ // #164: Agora's own callback path is `/callback`, not `/auth/callback`
701
+ // (agora/apps/agora/README.md, "Deploying an Agora": "The issuer's OAuth
702
+ // client needs `https://<agora address>/callback` as a redirect URI") --
703
+ // Agora is not built on `@wtfalch/auth`'s `NextAuth` mount the way every
704
+ // other web app this file provisions is.
705
+ const redirectUri = isAgoraClient
706
+ ? `https://${hostname}/callback`
707
+ : `https://${hostname}/auth/callback`;
609
708
  const created = await this.ports.zitadel.createOidcApp(zitadelOrgId, projectId, {
610
709
  name: action.key,
611
710
  appType,
612
- redirectUris: appType === 'native' ? [] : [`https://${hostname}/auth/callback`],
711
+ redirectUris: appType === 'native' ? [] : [redirectUri],
613
712
  postLogoutRedirectUris: appType === 'native' ? [] : [`https://${hostname}/`],
614
713
  });
615
714
  if (appType === 'web') {
616
715
  // `coolifyApp`'s own `pushAppEnv` reads `APP_CLIENT_ID_SCOPE_KEY` to
617
716
  // push AUTH_CLIENT_ID (level-1 app-env campaign, gap P2); `cmsSite`
618
717
  // 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);
718
+ // (#139), and `coolifyAgora` reads `AGORA_CLIENT_ID_SCOPE_KEY` for
719
+ // `AGORA_CONFIG`'s own `clientId` (#164) -- public, not a secret, so a
720
+ // plain scope id, not `deliverSecret`. A native app signs in via
721
+ // device code with no cookie session of its own, so its clientId is
722
+ // never something an app's env needs.
723
+ context.recordScopeId(isCmsClient
724
+ ? CMS_CLIENT_ID_SCOPE_KEY
725
+ : isAgoraClient
726
+ ? AGORA_CLIENT_ID_SCOPE_KEY
727
+ : APP_CLIENT_ID_SCOPE_KEY, created.clientId);
624
728
  }
625
729
  return { resourceId: created.appId };
626
730
  }
@@ -740,11 +844,12 @@ export class FoundryStepExecutor {
740
844
  // unlike the database push above: every app has a hostname, and an
741
845
  // upsert of the same value on a later apply is a no-op, not a change.
742
846
  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));
847
+ // #140 rework (2026-09-28 lettermint-org-mail swap): the hosted app's own
848
+ // Lettermint project, domain restriction and `MAIL_FROM` (only the app
849
+ // started by this same step, per the design's "only apps that send mail
850
+ // get it" -- cms and calls do not). A no-op until `coolify:mail` has
851
+ // actually resolved a mail domain for this org.
852
+ await this.ensureAppLettermintMail(appId, org, context, Boolean(existing));
748
853
  return { resourceId: appId };
749
854
  }
750
855
  /**
@@ -870,9 +975,10 @@ export class FoundryStepExecutor {
870
975
  * `zitadel:issuer` (`AUTH_ORGANIZATION_ID`), `zitadel:owner`
871
976
  * (`OWNER_USER_ID`, gap P3) and every `zitadel:app` step
872
977
  * (`AUTH_CLIENT_ID`), all ordered before `coolify:app` for exactly this
873
- * reason. AUTH_APP_KEY (waits on an auth broker registration API) and
874
- * DATABASE_URL_OWNER (a runtime-role split not built yet, `pushDatabaseUrl`'s
875
- * own doc comment) are out of scope here (task brief).
978
+ * reason. `AUTH_APP_KEY` (#206 -- which broker an org's app registers with
979
+ * is its own design question, left out of #204's own "How" rather than
980
+ * guessed) and DATABASE_URL_OWNER (a runtime-role split not built yet,
981
+ * `pushDatabaseUrl`'s own doc comment) are out of scope here (task brief).
876
982
  */
877
983
  async pushAppEnv(appId, org, context) {
878
984
  const recorded = context.recordedIds();
@@ -919,32 +1025,43 @@ export class FoundryStepExecutor {
919
1025
  return randomBytes(32).toString('base64');
920
1026
  }
921
1027
  /**
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
1028
+ * #140 rework (2026-09-28 lettermint-org-mail swap, "Core rule" +
1029
+ * "Credential model"): all of the org's Lettermint work now lives here,
1030
+ * in the app step, since the first project -- and so the first domain
1031
+ * restriction -- can only exist once the token has a home (the app env). A
1032
+ * no-op until `coolify:mail` has recorded {@link MAIL_DOMAIN_SCOPE_KEY} for
1033
+ * this org; the next apply fills it in once `coolify:mail` runs first
927
1034
  * (`diff.ts`'s `computePlanDetail` order).
928
1035
  *
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.
1036
+ * Order, exactly as the swap's "Core rule" states it: ensure the
1037
+ * `<slug>-app` project, ensure the mail domain, restrict that domain to the
1038
+ * org's own projects, publish its DNS records, trigger verification, then
1039
+ * delete every stale `<slug>-app` project. New projects are created and the
1040
+ * domain restricted to them before any old project is deleted, so the
1041
+ * restriction list never becomes empty.
1042
+ *
1043
+ * Mint rule ("Credential model"): the project is healthy when the app env
1044
+ * holds `LETTERMINT_API_TOKEN` and Lettermint lists exactly one project
1045
+ * named `<slug>-app` -- unlike Resend's key, a Lettermint project's name
1046
+ * carries no domain id, since a domain switch is handled entirely by
1047
+ * {@link LettermintPort.restrictDomainToProjects} below and never needs a
1048
+ * fresh project. Otherwise a fresh project is created and its token
1049
+ * written to the env first, and only then is every other `<slug>-app`
1050
+ * project deleted -- a lost mint response and a duplicate both end the
1051
+ * same way. An app that was already running is restarted before that
1052
+ * delete, since a container reads its env only at start (`running`, the
1053
+ * same reason {@link ensureLogVolume} restarts one).
934
1054
  *
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.
1055
+ * The domain's restriction is rebuilt from every project named `<slug>-app`
1056
+ * or `<slug>-relay` (the relay project itself is #149, not built yet) --
1057
+ * never patched, so it can never end up scoped only to a project about to
1058
+ * be deleted.
945
1059
  *
946
1060
  * `MAIL_FROM` is upserted every time: it depends only on the mail domain
947
- * `coolify:mail` resolved (`RESEND_MAIL_DOMAIN_SCOPE_KEY`).
1061
+ * `coolify:mail` resolved and `org.mail.noReplyLocalPart` (#162, decided
1062
+ * 2026-09-29 -- defaults to `'no-reply'`, restated here rather than
1063
+ * imported from `org.ts`'s `DEFAULT_NO_REPLY_LOCAL_PART`, this file's own
1064
+ * `LOG_DIR`-style convention).
948
1065
  *
949
1066
  * The token goes straight to `bulkUpsertEnvs` and nowhere else: never
950
1067
  * returned, never recorded in `serviceIds` (`recordScopeId`'s own doc
@@ -952,48 +1069,189 @@ export class FoundryStepExecutor {
952
1069
  * same discipline `pushDatabaseUrl`'s doc comment states for a database's
953
1070
  * own connection string.
954
1071
  */
955
- async ensureAppResendKey(appId, org, context, running) {
956
- const recorded = context.recordedIds();
957
- const domainId = recorded[RESEND_DOMAIN_ID_SCOPE_KEY];
958
- if (!domainId)
1072
+ async ensureAppLettermintMail(appId, org, context, running) {
1073
+ const mailDomain = context.recordedIds()[MAIL_DOMAIN_SCOPE_KEY];
1074
+ if (!mailDomain)
959
1075
  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))),
1076
+ const appName = `${org.slug}-app`;
1077
+ const [projects, envs] = await Promise.all([
1078
+ this.ports.lettermint.listProjects(),
964
1079
  this.ports.coolify.listEnvs(appId),
965
1080
  ]);
966
1081
  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;
1082
+ const ours = projects.filter((p) => p.name === appName);
1083
+ const held = ours.length === 1 ? ours[0] : undefined;
1084
+ const healthy = held !== undefined && Boolean(envValue('LETTERMINT_API_TOKEN'));
1085
+ let allProjects = projects;
1086
+ let keepProjectId;
972
1087
  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);
1088
+ if (healthy && held) {
1089
+ keepProjectId = held.id;
1090
+ }
1091
+ else {
1092
+ const created = await this.ports.lettermint.createProject(appName);
1093
+ keepProjectId = created.project.id;
1094
+ allProjects = [...projects, created.project];
1095
+ envUpdates.push({ key: 'LETTERMINT_API_TOKEN', value: created.token });
1096
+ }
1097
+ envUpdates.push({
1098
+ key: 'MAIL_FROM',
1099
+ value: `${org.mail.noReplyLocalPart ?? 'no-reply'}@${mailDomain}`,
1100
+ });
1101
+ await this.ports.coolify.bulkUpsertEnvs(appId, envUpdates);
988
1102
  // 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.
1103
+ // token, so a fresh one only reaches it through a restart -- before the
1104
+ // old project is deleted below. A just-created app starts with the new env.
991
1105
  if (!healthy && running)
992
1106
  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);
1107
+ await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects);
1108
+ for (const project of allProjects) {
1109
+ if (project.name === appName && project.id !== keepProjectId) {
1110
+ await this.ports.lettermint.deleteProject(project.id);
1111
+ }
1112
+ }
1113
+ }
1114
+ /**
1115
+ * #149: mints this org's own Lettermint relay credential and writes it to
1116
+ * Stalwart via `MailPort.applyOrgRelay`, the same mint rule
1117
+ * {@link ensureAppLettermintMail} already uses for the org's app token --
1118
+ * "healthy" means Stalwart's own route exists ({@link MailPort.hasOrgRelay}),
1119
+ * AND Lettermint lists a project named `<slug>-relay` whose id
1120
+ * {@link ORG_MAIL_RELAY_SCOPE_KEY} records for this
1121
+ * exact mail domain (review finding: a route with a secret and a
1122
+ * same-named project both present is not enough -- the project could be a
1123
+ * lost-and-reprovisioned one this step never wrote a route for, and a mail
1124
+ * domain switch must re-point the route rather than read as healthy
1125
+ * forever because the old route still has a secret in it). A route with no
1126
+ * live project behind it, a project the route's own secret never reached,
1127
+ * or a recorded project id/domain that no longer matches, are all exactly
1128
+ * as unhealthy as neither existing.
1129
+ *
1130
+ * A fresh project is created and its token written to Stalwart first, and
1131
+ * only then is every other `<slug>-relay` project deleted -- a lost route
1132
+ * and a duplicate project both end the same way. The new project's domain
1133
+ * restriction runs before the token is written, so a failure there never
1134
+ * touches the working route. Review finding: if either the
1135
+ * domain-restriction step or `applyOrgRelay` throws after a
1136
+ * fresh project was minted, that project is deleted before the error
1137
+ * propagates -- otherwise every retry sees more than one `<slug>-relay`
1138
+ * project and mints yet another one, growing the orphan count without
1139
+ * bound.
1140
+ *
1141
+ * `coolify:mail` runs before `coolify:app` (`diff.ts`'s own step order), so
1142
+ * this is very often the first of the two to reach the org's Lettermint
1143
+ * domain: {@link ensureLettermintDomainRestriction} registers and restricts
1144
+ * it the same way the app step does, rather than assuming it already
1145
+ * exists -- the domain must never end up unrestricted, whichever step gets
1146
+ * there first.
1147
+ */
1148
+ async ensureOrgLettermintRelay(org, mailOrg, mailDomain, context) {
1149
+ const relayName = `${org.slug}-relay`;
1150
+ const [projects, hasRelay] = await Promise.all([
1151
+ this.ports.lettermint.listProjects(),
1152
+ this.ports.mail.hasOrgRelay(mailOrg),
1153
+ ]);
1154
+ // The recorded project, not "the only one": a leftover duplicate whose
1155
+ // delete keeps failing must not force a fresh mint on every apply.
1156
+ const recordedRelay = context.recordedIds()[ORG_MAIL_RELAY_SCOPE_KEY];
1157
+ const held = projects.find((p) => p.name === relayName && `${p.id}@${mailDomain}` === recordedRelay);
1158
+ const healthy = held !== undefined && hasRelay;
1159
+ let allProjects = projects;
1160
+ let keepProjectId;
1161
+ if (healthy && held) {
1162
+ keepProjectId = held.id;
1163
+ await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects);
1164
+ }
1165
+ else {
1166
+ const created = await this.ports.lettermint.createProject(relayName);
1167
+ keepProjectId = created.project.id;
1168
+ allProjects = [...projects, created.project];
1169
+ try {
1170
+ // Everything that can fail for the new project runs before the token
1171
+ // reaches Stalwart: a failure here leaves the working route alone.
1172
+ // The restriction covers old and new projects, so the old one keeps
1173
+ // sending until the route switches.
1174
+ await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects);
1175
+ await this.ports.mail.applyOrgRelay(mailOrg, created.token);
1176
+ }
1177
+ catch (err) {
1178
+ await this.discardLettermintProject(created.project.id);
1179
+ throw err;
1180
+ }
1181
+ context.recordScopeId(ORG_MAIL_RELAY_SCOPE_KEY, `${keepProjectId}@${mailDomain}`);
1182
+ // Written now: a crash before the end-of-run write would leave the old
1183
+ // project recorded, and the next apply would sweep the one the route uses.
1184
+ await context.persistScopeIds();
1185
+ }
1186
+ for (const project of allProjects) {
1187
+ if (project.name === relayName && project.id !== keepProjectId) {
1188
+ await this.ports.lettermint.deleteProject(project.id);
1189
+ }
1190
+ }
1191
+ }
1192
+ /**
1193
+ * Deletes a just-minted project from inside a `catch`. A failed delete is
1194
+ * dropped so it never replaces the error being rethrown; the orphan is
1195
+ * swept by the next apply's duplicate delete.
1196
+ */
1197
+ async discardLettermintProject(id) {
1198
+ try {
1199
+ await this.ports.lettermint.deleteProject(id);
1200
+ }
1201
+ catch {
1202
+ // Dropped: the caller rethrows the original error.
1203
+ }
1204
+ }
1205
+ /**
1206
+ * Ensures this org's Lettermint domain exists, is restricted to exactly
1207
+ * the `<slug>-app`/`<slug>-relay` projects live in `allProjects`, has its
1208
+ * DNS published and a verification triggered. Shared by
1209
+ * {@link ensureAppLettermintMail} and {@link ensureOrgLettermintRelay}
1210
+ * (#149) -- whichever of the two steps runs first on a fresh org creates
1211
+ * the domain, and the other then finds it (`findExactLettermintDomain`).
1212
+ *
1213
+ * A crash between the restriction write and the DNS publish/verify below
1214
+ * is safe even for a freshly created domain: DNS is only published, and
1215
+ * verification only triggered, after the restriction -- so an
1216
+ * unrestricted domain cannot verify and cannot send, whichever project
1217
+ * owns it (post-#140 review finding, restated here rather than only at
1218
+ * the one call site it used to have).
1219
+ */
1220
+ async ensureLettermintDomainRestriction(org, mailDomain, allProjects) {
1221
+ const appName = `${org.slug}-app`;
1222
+ const relayName = `${org.slug}-relay`;
1223
+ const existing = await this.findExactLettermintDomain(mailDomain);
1224
+ const domain = existing
1225
+ ? await this.ports.lettermint.getDomain(existing.id)
1226
+ : await this.ports.lettermint.createDomain(mailDomain);
1227
+ const scopedProjectIds = allProjects
1228
+ .filter((p) => p.name === appName || p.name === relayName)
1229
+ .map((p) => p.id);
1230
+ await this.ports.lettermint.restrictDomainToProjects(domain.id, scopedProjectIds);
1231
+ const zone = await this.ports.cloudflare.findZoneByApex(mailDomain);
1232
+ if (!zone) {
1233
+ throw new Error(`Lettermint for ${org.slug}: no Cloudflare zone found for ${mailDomain}`);
996
1234
  }
1235
+ await this.publishLettermintDnsRecords(zone.id, domain.dnsRecords);
1236
+ await this.ports.lettermint.verifyDomain(domain.id);
1237
+ }
1238
+ /**
1239
+ * Review finding (post-#140 rework): {@link LettermintPort.findDomain}'s
1240
+ * own match is partial and case-insensitive, so a raw result can be a
1241
+ * lookalike (`notacme.com`, `mail.acme.com`) rather than `name` itself --
1242
+ * and every caller here full-replaces the found domain's project
1243
+ * restriction ({@link LettermintPort.restrictDomainToProjects}), so taking
1244
+ * a lookalike as a match would let it be taken over. Every domain lookup
1245
+ * in this class (`ensureAppLettermintMail`, `parkLettermintMail`,
1246
+ * `deleteStaleLettermintDomain`) goes through this instead of the port
1247
+ * directly, and treats anything but an exact, case-insensitive match as
1248
+ * not found.
1249
+ */
1250
+ async findExactLettermintDomain(name) {
1251
+ const found = await this.ports.lettermint.findDomain(name);
1252
+ if (!found || found.domain.toLowerCase() !== name.toLowerCase())
1253
+ return undefined;
1254
+ return found;
997
1255
  }
998
1256
  /**
999
1257
  * `plan/diff.ts`'s `planSite` (#116, slicing #99's "provision the cms app,
@@ -1573,9 +1831,9 @@ export class FoundryStepExecutor {
1573
1831
  if (!masCredentials) {
1574
1832
  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
1833
  }
1576
- const sfu = await this.ports.callsSfu.resolveCredentials();
1834
+ const sfu = await this.ports.callsSfu.resolveCredentials(org.slug);
1577
1835
  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`);
1836
+ throw new Error(`coolify:calls-service for ${org.slug}: the shared calls SFU has no LiveKit credentials for this org yet -- see https://github.com/wtfalch/foundry/issues/152`);
1579
1837
  }
1580
1838
  const projectUuid = await this.ensureCoolifyProject(org, context);
1581
1839
  const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
@@ -1661,6 +1919,156 @@ export class FoundryStepExecutor {
1661
1919
  return null;
1662
1920
  return randomBytes(32).toString('hex');
1663
1921
  }
1922
+ // ---- agora (#164) --------------------------------------------------------
1923
+ /**
1924
+ * `plan/diff.ts`'s `planAgora`: provisions the org's own Agora
1925
+ * (https://github.com/wtfalch/agora) as one more Coolify application, from
1926
+ * that repo's `apps/agora/Dockerfile` ({@link AGORA_REPO}'s doc comment),
1927
+ * and pushes its env. `findOrCreateCallsApp` is reused as-is for the
1928
+ * find-by-name-then-start-or-create shape, like {@link coolifyCallsService}.
1929
+ * There is no park branch: Agora is always on.
1930
+ *
1931
+ * The env comes from `plan/agora.ts`'s `desiredAgoraEnv`, the same function
1932
+ * the plan compares the live app against, so a re-plan after this step is
1933
+ * empty. The step never writes a partial config: when any input is missing
1934
+ * (the org's ZITADEL org id or Agora client id, or an issuer or
1935
+ * mail/messaging project id the host does not supply,
1936
+ * `AgoraHostPort`, read by the plan) it throws naming each one, before
1937
+ * it creates anything.
1938
+ *
1939
+ * `AGORA_CANONICAL_HOST` is always pushed. `AGORA_MAIL_UPSTREAM`/
1940
+ * `AGORA_FILES_UPSTREAM` are pushed once the platform's own mail/files
1941
+ * hosts are on and applied (#177's `platform-org.ts`'s
1942
+ * `agoraHostValuesFromPlatform`, read by `plan-apply.ts` when no host
1943
+ * `agora` port overrides it) -- absent otherwise. The chat upstreams are
1944
+ * still never pushed: chat has no admin API yet
1945
+ * (https://github.com/wtfalch/chat/issues/40). Agora answers 502 on those
1946
+ * routes rather than serving anything wrong.
1947
+ */
1948
+ async coolifyAgora(action, org, context) {
1949
+ // The host values the plan read, carried in the step: never read again
1950
+ // here, so the env written is the env the plan showed.
1951
+ const host = action.desired.host;
1952
+ if (!host) {
1953
+ throw new Error(`Agora for ${org.slug} cannot be configured: the step carries no host values`);
1954
+ }
1955
+ const { env, missing } = desiredAgoraEnv(org, context.recordedIds(), host);
1956
+ if (missing.length > 0) {
1957
+ throw new Error(`Agora for ${org.slug} cannot be configured: missing ${missing.join(', ')}. Nothing was written.`);
1958
+ }
1959
+ const projectUuid = await this.ensureCoolifyProject(org, context);
1960
+ const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
1961
+ const name = `${org.slug}-agora`;
1962
+ const server = await this.resolveServer();
1963
+ const githubApp = await this.ports.coolify.requireGithubApp();
1964
+ const app = await this.findOrCreateCallsApp(name, environment.id, () => this.ports.coolify.createGithubAppApplication({
1965
+ project_uuid: projectUuid,
1966
+ server_uuid: server.uuid,
1967
+ environment_name: environment.name,
1968
+ environment_uuid: environment.uuid,
1969
+ github_app_uuid: githubApp.uuid,
1970
+ git_repository: AGORA_REPO,
1971
+ git_branch: 'main',
1972
+ build_pack: 'dockerfile',
1973
+ name,
1974
+ domains: agoraHostnameFor(org),
1975
+ dockerfile_location: AGORA_APP_DOCKERFILE,
1976
+ }));
1977
+ // Every app foundry deploys keeps its own logs (#75), reused as-is here.
1978
+ await this.ensureLogVolume(app.appId, org, app.existed);
1979
+ await this.ports.coolify.bulkUpsertEnvs(app.appId, Object.entries(env).map(([key, value]) => ({ key, value })));
1980
+ return { resourceId: app.appId };
1981
+ }
1982
+ // ---- jobs (#173) ---------------------------------------------------------
1983
+ /**
1984
+ * `plan/diff.ts`'s `planJobsRegistration`: registers the org's own app with
1985
+ * the platform's jobs scheduler (`JobsSchedulerPort.registerProject`, the
1986
+ * scheduler's `POST /admin/projects`), then hands the freshly minted
1987
+ * `secret` and `syncKey` to the host through `deliverSecret` -- but only
1988
+ * when this call actually minted them. The admin API is itself idempotent
1989
+ * by `(name, baseUrl)`: a repeat call for an already-registered project
1990
+ * resolves with just the `id`, and this never re-delivers a secret or sync
1991
+ * key it did not just mint, the same "registers an app once" rule
1992
+ * `diff.ts`'s own plan-level gate (recordedIds, mirroring `planOwner`)
1993
+ * already provides on a normal re-plan -- this check is what keeps that
1994
+ * true even if this step somehow runs twice on the same apply.
1995
+ *
1996
+ * Never logs, returns or records either credential (this file's own top
1997
+ * doc comment's "a secret is never a step's return value" rule) -- only the
1998
+ * scheduler's own non-secret project id comes back as `resourceId`.
1999
+ */
2000
+ /** #177: the host's own port when it supplies one, else the platform's, built from its address and held key. */
2001
+ async filesPort() {
2002
+ const port = this.ports.filesOrganisation ?? (await this.ports.platformOrg?.files());
2003
+ if (!port)
2004
+ throw new Error('files: this executor was built without a files port (#177)');
2005
+ return port;
2006
+ }
2007
+ async aiPort() {
2008
+ const port = this.ports.aiOrganisation ?? (await this.ports.platformOrg?.ai());
2009
+ if (!port)
2010
+ throw new Error('ai: this executor was built without an ai port (#177)');
2011
+ return port;
2012
+ }
2013
+ async jobsPort(org) {
2014
+ const port = this.ports.jobsScheduler ?? (await this.ports.platformOrg?.jobs());
2015
+ if (!port) {
2016
+ throw new Error(`jobs:registration for ${org.slug}: this executor was built without a jobsScheduler port (#173)`);
2017
+ }
2018
+ return port;
2019
+ }
2020
+ async jobsRegistration(action, org, context) {
2021
+ const scheduler = await this.jobsPort(org);
2022
+ const name = action.desired.name;
2023
+ const baseUrl = action.desired.baseUrl;
2024
+ const registered = await scheduler.registerProject({ name, baseUrl });
2025
+ // A repeat call for an already-registered project carries neither
2026
+ // credential: nothing to deliver.
2027
+ if (!registered.secret && !registered.syncKey)
2028
+ return { resourceId: registered.id };
2029
+ // The project now exists and its credentials are shown exactly once, so
2030
+ // the id is recorded before anything else can fail: a step's return value
2031
+ // is only recorded on success, and without this a failed delivery would
2032
+ // leave the next run's `registerProject` answering with just `{ id }`,
2033
+ // the step recording success, and the sync key lost for good.
2034
+ // `planJobsRegistration` reports this id as blocked instead of planning
2035
+ // the step again.
2036
+ context.recordScopeId(JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY, registered.id);
2037
+ // Written now, so a crash during delivery cannot lose the id either.
2038
+ await context.persistScopeIds();
2039
+ if (!registered.secret || !registered.syncKey) {
2040
+ // Only one half came back; the other can never be fetched again.
2041
+ 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`);
2042
+ }
2043
+ const failed = [];
2044
+ for (const [part, value] of [
2045
+ ['secret', registered.secret],
2046
+ ['syncKey', registered.syncKey],
2047
+ ]) {
2048
+ if (!(await this.deliverWithRetry(`jobs:registration:${org.slug}:${part}`, value))) {
2049
+ failed.push(part);
2050
+ }
2051
+ }
2052
+ if (failed.length > 0) {
2053
+ // Names the project id only; the underlying errors are dropped in case
2054
+ // one echoes a credential.
2055
+ 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`);
2056
+ }
2057
+ return { resourceId: registered.id };
2058
+ }
2059
+ /** `deliverSecret`, tried up to {@link JOBS_DELIVERY_ATTEMPTS} times; true once one attempt succeeds. */
2060
+ async deliverWithRetry(ref, value) {
2061
+ for (let attempt = 1; attempt <= JOBS_DELIVERY_ATTEMPTS; attempt++) {
2062
+ try {
2063
+ await this.ports.deliverSecret(ref, value);
2064
+ return true;
2065
+ }
2066
+ catch {
2067
+ // Retried; the error is dropped in case it echoes the secret.
2068
+ }
2069
+ }
2070
+ return false;
2071
+ }
1664
2072
  // ---- cloudflare --------------------------------------------------------
1665
2073
  async cloudflareTunnel(action, org, context) {
1666
2074
  if (action.desired.enabled === false) {
@@ -1739,36 +2147,176 @@ export class FoundryStepExecutor {
1739
2147
  * `plan/diff.ts`'s `planMail`: gives an org mail, moves it between modes,
1740
2148
  * or parks it on the one shared Stalwart instance (`this.ports.mail`,
1741
2149
  * `@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`).
2150
+ * publishes whatever Stalwart DNS records the call returns, and records the
2151
+ * resolved mail domain as a scope id ({@link MAIL_DOMAIN_SCOPE_KEY}).
2152
+ *
2153
+ * #149: this step also mints the org's own Lettermint *relay* credential
2154
+ * ({@link ensureOrgLettermintRelay}) -- the app's own *sending* credential
2155
+ * stays in the app step (`ensureAppLettermintMail`, "Core rule"), since the
2156
+ * first app project can only exist once the token has a home. Parking mail
2157
+ * (mode `'off'`) reaches Lettermint too: {@link parkLettermintMail} drops
2158
+ * the relay route, then deletes the org's own projects and its domain, so
2159
+ * a domain never ends up restricted only to dead projects and no route
2160
+ * ever points at a project this apply is about to remove.
2161
+ *
2162
+ * Review finding: a mail domain switch (`org.domain` changes, or
2163
+ * `usesDomain` flips) must also delete the *old* Lettermint domain here,
2164
+ * before recording the new one -- otherwise it stays registered and
2165
+ * restricted to the org's still-live `<slug>-app`/`<slug>-relay` projects,
2166
+ * which could then keep sending as it forever. {@link deleteStaleLettermintDomain}
2167
+ * does this by comparing the previously recorded domain (case-insensitive)
2168
+ * against the one this apply just resolved.
2169
+ *
2170
+ * #162: once `applyOrgMail` has given the org a domain, {@link ensureOwnerMailbox}
2171
+ * gives its declared owner a mailbox on it, so Agora's Mail section is not
2172
+ * empty for them after the org's first apply.
1750
2173
  */
1751
2174
  async coolifyMail(action, org, context) {
1752
2175
  const mode = action.desired.mode;
1753
2176
  const usesDomain = action.desired.usesDomain;
1754
- const options = { apex: MAIL_APEX, mailHost: MAIL_HOST };
2177
+ // #177: `diff.ts`'s `planMail` carries the platform's own apex/mail host
2178
+ // in `desired` once it is wired -- "the executor writes exactly these
2179
+ // host values, so what the plan showed is what gets applied" (`planAgora`'s
2180
+ // own rule) -- and blocks the step (never plans it) while the platform's
2181
+ // mail is off or not yet applied. So a `desired` missing either value
2182
+ // here is not a live gap to fall back on (#205): it is a step planned
2183
+ // before that rule existed, now stale. Fail rather than silently point an
2184
+ // org's mail at a fixed address nothing may still be listening on; the
2185
+ // fix is to plan this org's mail again.
2186
+ const apex = action.desired.apex;
2187
+ const mailHost = action.desired.mailHost;
2188
+ if (!apex || !mailHost) {
2189
+ throw new Error(`coolify:mail for ${org.slug}: this step's plan carries no platform mail ${!apex && !mailHost ? 'apex or host' : !apex ? 'apex' : 'host'}; plan this org's mail again`);
2190
+ }
2191
+ const options = { apex, mailHost };
1755
2192
  if (mode === 'off') {
1756
- await this.ports.mail.parkOrgMail(mailOrgFor(org, usesDomain, 'hosted'), options);
2193
+ const parkedMailOrg = mailOrgFor(org, usesDomain, 'hosted');
2194
+ await this.ports.mail.parkOrgMail(parkedMailOrg, options);
2195
+ await this.parkLettermintMail(org, parkedMailOrg, context);
1757
2196
  return undefined;
1758
2197
  }
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);
2198
+ const mailOrg = mailOrgFor(org, usesDomain, mode);
2199
+ const result = await this.ports.mail.applyOrgMail(mailOrg, options);
2200
+ await this.publishMailDnsRecords(org, result);
2201
+ await this.deleteStaleLettermintDomain(context, result.domain);
2202
+ context.recordScopeId(MAIL_DOMAIN_SCOPE_KEY, result.domain);
2203
+ // #177: what this step wrote from the platform, so `planMail` can see a later change.
2204
+ context.recordScopeId(ORG_MAIL_APPLIED_APEX_SCOPE_KEY, options.apex);
2205
+ context.recordScopeId(ORG_MAIL_APPLIED_HOST_SCOPE_KEY, options.mailHost);
2206
+ await this.ensureOwnerMailbox(org, mailOrg, context);
2207
+ await this.ensureOrgLettermintRelay(org, mailOrg, result.domain, context);
2208
+ await this.ensureRoleMailboxes(org, mailOrg, result.domain, context);
1762
2209
  return { resourceId: result.domain };
1763
2210
  }
2211
+ /**
2212
+ * `plan/diff.ts`'s `planMailCopy` (#113): this apply's share of copying
2213
+ * the org's old mail into Stalwart, via `mail-copy.ts`'s `runMailCopyStep`
2214
+ * -- the only thing this method itself does is read back what the last
2215
+ * apply recorded, hand it to that function, and record what comes back.
2216
+ * An account that failed to copy records the progress made, never
2217
+ * `mail_copy_done`, then throws with the failing account, phase and
2218
+ * redacted stderr tail, so the apply reports it as not done. A missing
2219
+ * `mailCopy` port throws too: a configuration gap a retry cannot fix.
2220
+ */
2221
+ async mailCopy(org, context) {
2222
+ if (!this.ports.mailCopy) {
2223
+ throw new NotBuiltError(`coolify:mail-copy for ${org.slug}`, 'construct FoundryStepExecutor with `mailCopy` (#113)');
2224
+ }
2225
+ const alreadyCompleted = new Set(parseMailCopyCompleted(context.recordedIds()[MAIL_COPY_COMPLETED_SCOPE_KEY]));
2226
+ const result = await runMailCopyStep(org, this.ports.mailCopy, alreadyCompleted);
2227
+ context.recordScopeId(MAIL_COPY_COMPLETED_SCOPE_KEY, JSON.stringify(result.completed));
2228
+ if (result.done)
2229
+ context.recordScopeId(MAIL_COPY_DONE_SCOPE_KEY, '1');
2230
+ if (result.failure) {
2231
+ const { account, phase, exitCode, detail } = result.failure;
2232
+ // Written now: the throw below ends the step before the end-of-run write.
2233
+ await context.persistScopeIds();
2234
+ throw new Error(`coolify:mail-copy for ${org.slug}: ${phase} of ${account} ${exitCode === undefined ? 'threw' : `exited ${exitCode}`}${detail ? `: ${detail}` : ''} -- retry this apply to continue (a provider's daily limit looks the same)`);
2235
+ }
2236
+ return undefined;
2237
+ }
2238
+ /**
2239
+ * #162 (decided 2026-09-29): gives the org's declared owner a mailbox at
2240
+ * its declared `mail.ownerMailboxAddress` -- nothing is guessed any more
2241
+ * (the hardcoded `owner@<domain>` PR 163 shipped first is gone). A no-op
2242
+ * when the org has no owner yet, when no address is declared yet
2243
+ * (`plan/diff.ts`'s `planMail` reports that case as blocked, not planned,
2244
+ * so this step's own mode/domain work still runs), or once
2245
+ * {@link MAIL_OWNER_MAILBOX_SCOPE_KEY} is already recorded -- checked last
2246
+ * so a second apply never calls `MailPort.createMailbox` again, rather
2247
+ * than relying on that port method's own duplicate refusal.
2248
+ */
2249
+ async ensureOwnerMailbox(org, mailOrg, context) {
2250
+ if (!org.owner)
2251
+ return;
2252
+ const address = org.mail.ownerMailboxAddress ?? null;
2253
+ if (!address)
2254
+ return;
2255
+ if (context.recordedIds()[MAIL_OWNER_MAILBOX_SCOPE_KEY])
2256
+ return;
2257
+ await this.ports.mail.createMailbox(mailOrg, {
2258
+ address,
2259
+ name: `${org.owner.givenName} ${org.owner.familyName}`,
2260
+ });
2261
+ context.recordScopeId(MAIL_OWNER_MAILBOX_SCOPE_KEY, address);
2262
+ }
2263
+ /**
2264
+ * #230: creates each of the org's declared `mail.roleMailboxes` on its
2265
+ * just-resolved mail domain, once. A role mailbox has no person behind it,
2266
+ * so its display name is just its local part, and like the owner's it is
2267
+ * created with no password: `MailPort.createMailbox` takes none, so there
2268
+ * is no secret to store or print. A "already has a mailbox" refusal from the port counts as created. The created set is recorded under
2269
+ * {@link MAIL_ROLE_MAILBOXES_SCOPE_KEY} after every mailbox, so a second
2270
+ * apply (or a retry after a crash) skips what exists rather than relying
2271
+ * on the mail server's own duplicate refusal.
2272
+ */
2273
+ async ensureRoleMailboxes(org, mailOrg, domain, context) {
2274
+ const declared = org.mail.roleMailboxes ?? [];
2275
+ if (declared.length === 0)
2276
+ return;
2277
+ const recorded = context.recordedIds()[MAIL_ROLE_MAILBOXES_SCOPE_KEY];
2278
+ const created = new Set(recorded ? recorded.split(',') : []);
2279
+ for (const local of declared) {
2280
+ const address = `${local}@${domain}`;
2281
+ const entry = address.toLowerCase();
2282
+ if (created.has(entry))
2283
+ continue;
2284
+ try {
2285
+ await this.ports.mail.createMailbox(mailOrg, { address, name: local });
2286
+ }
2287
+ catch (error) {
2288
+ // A crash between create and record leaves the mailbox made but
2289
+ // unrecorded; the port exposes no existence check, only the refusal.
2290
+ if (!/already has a mailbox/i.test(error instanceof Error ? error.message : String(error)))
2291
+ throw error;
2292
+ }
2293
+ created.add(entry);
2294
+ context.recordScopeId(MAIL_ROLE_MAILBOXES_SCOPE_KEY, [...created].join(','));
2295
+ }
2296
+ }
2297
+ /**
2298
+ * Deletes the org's previous Lettermint domain once the mail domain it was
2299
+ * registered under has changed -- otherwise it stays registered and
2300
+ * restricted to the org's still-live `<slug>-app` project after the switch
2301
+ * (review finding, post-#140 rework). A no-op on first apply (no previous
2302
+ * domain recorded) and on a same-domain re-apply (compared
2303
+ * case-insensitively, the same rule {@link findExactLettermintDomain}
2304
+ * applies to the lookup itself).
2305
+ */
2306
+ async deleteStaleLettermintDomain(context, newDomain) {
2307
+ const previous = context.recordedIds()[MAIL_DOMAIN_SCOPE_KEY];
2308
+ if (!previous || previous.toLowerCase() === newDomain.toLowerCase())
2309
+ return;
2310
+ const stale = await this.findExactLettermintDomain(previous);
2311
+ if (stale)
2312
+ await this.ports.lettermint.deleteDomain(stale.id);
2313
+ }
1764
2314
  /**
1765
2315
  * Upserts each DNS record `applyOrgMail` says foundry must publish, found
1766
2316
  * by an exact name+type match (`CloudflarePort.findDnsRecord`'s own doc
1767
2317
  * comment) so update never mistakes a stale record of a different type for
1768
2318
  * 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.
2319
+ * mints the key on a background task; the next apply publishes it.
1772
2320
  */
1773
2321
  async publishMailDnsRecords(org, result) {
1774
2322
  const cf = this.ports.cloudflare;
@@ -1786,53 +2334,55 @@ export class FoundryStepExecutor {
1786
2334
  await cf.createDnsRecord(zone.id, rec);
1787
2335
  }
1788
2336
  }
1789
- return zone.id;
1790
2337
  }
1791
2338
  /**
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.
2339
+ * Parking mail (2026-09-28 lettermint-org-mail swap, "Core rule": "Parking
2340
+ * mail deletes the org's projects and then the domain itself, so no
2341
+ * restriction points only at dead projects"). #149: first drops the
2342
+ * Stalwart relay route with `MailPort.removeOrgRelay` -- a missing route is
2343
+ * a no-op, per that port method's own contract, so this is safe to call
2344
+ * even for an org whose relay was never minted -- then deletes every
2345
+ * Lettermint project named `<slug>-app` or `<slug>-relay`, then the org's
2346
+ * own Lettermint domain, if one was found -- in that order, never the
2347
+ * reverse, so the route is always gone before the project it pointed at.
2348
+ * A no-op until a previous apply recorded {@link MAIL_DOMAIN_SCOPE_KEY}:
2349
+ * nothing was ever provisioned for an org whose mail went straight from
2350
+ * never-applied to off.
1807
2351
  */
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);
2352
+ async parkLettermintMail(org, mailOrg, context) {
2353
+ const mailDomain = context.recordedIds()[MAIL_DOMAIN_SCOPE_KEY];
2354
+ if (!mailDomain)
2355
+ return;
2356
+ await this.ports.mail.removeOrgRelay(mailOrg);
2357
+ const names = new Set([`${org.slug}-app`, `${org.slug}-relay`]);
2358
+ const projects = await this.ports.lettermint.listProjects();
2359
+ for (const project of projects) {
2360
+ if (names.has(project.name))
2361
+ await this.ports.lettermint.deleteProject(project.id);
2362
+ }
2363
+ const domain = await this.findExactLettermintDomain(mailDomain);
2364
+ if (domain)
2365
+ await this.ports.lettermint.deleteDomain(domain.id);
1817
2366
  }
1818
2367
  /**
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`.
2368
+ * Upserts each DNS record Lettermint's domain names, the same find-then-
2369
+ * upsert `publishMailDnsRecords` above uses for Stalwart's own records --
2370
+ * except an existing `_dmarc` record is never overwritten (a stricter
2371
+ * policy an org already set, `p=quarantine`/`p=reject`, should not be
2372
+ * relaxed back to Lettermint's own default). Resend's own domain also
2373
+ * carried an apex `SPF` TXT record that had to be merged into whatever SPF
2374
+ * record already existed there (RFC 7208: two `v=spf1` TXT records on one
2375
+ * name is a permerror); Lettermint's own DNS records
2376
+ * (`.plans/2026-09-28-lettermint-api.md` §3) have no such record -- its
2377
+ * `purpose` enum is `return_path`/`dmarc`/`dkim_legacy`/`dkim_primary`/
2378
+ * `dkim_secondary`, none of them SPF -- so that merge has no case left to
2379
+ * handle here and is dropped rather than kept as dead code.
1826
2380
  */
1827
- async publishResendDnsRecords(zoneId, records) {
2381
+ async publishLettermintDnsRecords(zoneId, records) {
1828
2382
  const cf = this.ports.cloudflare;
1829
2383
  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
2384
  if (record.type === 'TXT' && record.value.toLowerCase().startsWith('v=dmarc1')) {
1835
- await this.ensureResendDmarcRecord(cf, zoneId, record);
2385
+ await this.ensureLettermintDmarcRecord(cf, zoneId, record);
1836
2386
  continue;
1837
2387
  }
1838
2388
  const [existing] = await cf.findDnsRecord(zoneId, record.name, record.type);
@@ -1845,36 +2395,12 @@ export class FoundryStepExecutor {
1845
2395
  }
1846
2396
  }
1847
2397
  }
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
2398
  /**
1873
2399
  * Never overwrites an existing `_dmarc` record -- a stricter policy an org
1874
2400
  * already set (`p=quarantine`/`p=reject`) should not be relaxed back to
1875
- * Resend's own `p=none` default.
2401
+ * Lettermint's own `p=none` default.
1876
2402
  */
1877
- async ensureResendDmarcRecord(cf, zoneId, record) {
2403
+ async ensureLettermintDmarcRecord(cf, zoneId, record) {
1878
2404
  const hasDmarc = (await cf.findDnsRecord(zoneId, record.name, 'TXT')).some((r) => r.content.toLowerCase().startsWith('v=dmarc1'));
1879
2405
  if (hasDmarc)
1880
2406
  return;
@@ -1889,7 +2415,8 @@ export class FoundryStepExecutor {
1889
2415
  * idempotent by find-or-create, not by checking `recordedIds` first.
1890
2416
  */
1891
2417
  async filesOrganisation(action, org) {
1892
- const provisioned = await this.ports.filesOrganisation.provisionOrganisation({
2418
+ const files = await this.filesPort();
2419
+ const provisioned = await files.provisionOrganisation({
1893
2420
  organisationId: org.id,
1894
2421
  ceilingBytes: action.desired.ceilingBytes ?? null,
1895
2422
  ceilingMicros: action.desired.ceilingMicros ?? null,
@@ -1897,9 +2424,9 @@ export class FoundryStepExecutor {
1897
2424
  return { resourceId: provisioned.organisationId };
1898
2425
  }
1899
2426
  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);
2427
+ const files = await this.filesPort();
2428
+ await files.setQuota(org.id, action.desired.limitBytes ?? null);
2429
+ await files.setBudget(org.id, action.desired.limitMicros ?? null);
1903
2430
  return { resourceId: org.id };
1904
2431
  }
1905
2432
  /**
@@ -1910,11 +2437,12 @@ export class FoundryStepExecutor {
1910
2437
  * every time regardless of whether they already ran.
1911
2438
  */
1912
2439
  async aiOrganisation(action, org) {
1913
- const provisioned = await this.ports.aiOrganisation.provisionOrganisation({
2440
+ const ai = await this.aiPort();
2441
+ const provisioned = await ai.provisionOrganisation({
1914
2442
  organisationId: org.id,
1915
2443
  limitMicros: action.desired.limitMicros ?? null,
1916
2444
  });
1917
- await this.ports.aiOrganisation.setOrganisationOffers(org.id, action.desired.offers ?? []);
2445
+ await ai.setOrganisationOffers(org.id, action.desired.offers ?? []);
1918
2446
  return { resourceId: provisioned.organisationId };
1919
2447
  }
1920
2448
  async aiBudget(action, org) {
@@ -1925,10 +2453,48 @@ export class FoundryStepExecutor {
1925
2453
  // (Finding, this campaign's report).
1926
2454
  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
2455
  }
1928
- const limits = this.ports.aiLimitsFor(org.id);
1929
- await limits.updateBudget({ keyId: null, limitMicros });
2456
+ const ai = await this.aiPort();
2457
+ await ai.updateBudget(org.id, { keyId: null, limitMicros });
1930
2458
  return { resourceId: org.id };
1931
2459
  }
2460
+ /**
2461
+ * #80 step 3: mints the org's own ai key, once, and hands its one-time
2462
+ * secret straight to {@link FoundryStepExecutorPorts.deliverSecret} --
2463
+ * never returned, stored in `serviceIds` or logged, the same discipline
2464
+ * `zitadelApp`'s own `created.clientSecret` follows. `diff.ts`'s
2465
+ * `planAiKey` never plans this step again once its resource id
2466
+ * (the key's id, not its secret) is recorded, since ai's own API offers no
2467
+ * way to check for an already-minted key.
2468
+ */
2469
+ async aiKey(action, org, context) {
2470
+ const ai = await this.aiPort();
2471
+ const issued = await ai.issueOrganisationKey(org.id, {
2472
+ name: action.desired.name,
2473
+ expiresDays: action.desired.expiresDays,
2474
+ permissions: action.desired.permissions ?? [],
2475
+ });
2476
+ // Recorded before the secret is delivered: a step's return value is only
2477
+ // recorded on success, so a delivery that throws would otherwise lose
2478
+ // the id and the retry would mint a second, orphaned key. `planAiKey`
2479
+ // treats this id as undelivered until the returned resource id (which
2480
+ // only a delivered key gets) is recorded too.
2481
+ context.recordScopeId(AI_KEY_UNDELIVERED_SCOPE_KEY, issued.id);
2482
+ // Written now, so a crash during delivery cannot lose the id either.
2483
+ await context.persistScopeIds();
2484
+ let lastError;
2485
+ for (let attempt = 1; attempt <= AI_KEY_DELIVERY_ATTEMPTS; attempt++) {
2486
+ try {
2487
+ await this.ports.deliverSecret(`ai:key:${org.slug}`, issued.secret);
2488
+ return { resourceId: issued.id };
2489
+ }
2490
+ catch (e) {
2491
+ lastError = e;
2492
+ }
2493
+ }
2494
+ // Names the key id only; the underlying error is dropped in case it
2495
+ // echoes the secret.
2496
+ 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`);
2497
+ }
1932
2498
  // ---- destroy -------------------------------------------------------------
1933
2499
  /**
1934
2500
  * {@link StepDestroyer.destroy}: deletes the one resource
@@ -2004,7 +2570,7 @@ export class FoundryStepExecutor {
2004
2570
  * status in: the first `→ `, after the client, method and path, and before
2005
2571
  * `:`. It never scans the path or the response body.
2006
2572
  */
2007
- function isNotFoundError(error) {
2573
+ export function isNotFoundError(error) {
2008
2574
  const status = error?.status;
2009
2575
  if (typeof status === 'number')
2010
2576
  return status === 404;