@wtfalch/foundry 0.5.0 → 0.7.1

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 +16 -1
  2. package/dist/bootstrap/bootstrap.js +40 -2
  3. package/dist/bootstrap/declare.d.ts +3 -1
  4. package/dist/bootstrap/declare.js +12 -3
  5. package/dist/bootstrap/versions.d.ts +17 -0
  6. package/dist/bootstrap/versions.js +12 -0
  7. package/dist/calls-sfu-deploy-support.d.ts +8 -0
  8. package/dist/calls-sfu-deploy-support.js +10 -1
  9. package/dist/calls-sfu.d.ts +36 -12
  10. package/dist/calls-sfu.js +42 -9
  11. package/dist/index.d.ts +10 -4
  12. package/dist/index.js +6 -3
  13. package/dist/migrations/0019_mail_owner_mailbox.sql +15 -0
  14. package/dist/migrations/0020_mail_apps.sql +7 -0
  15. package/dist/migrations/0021_mail_role_mailboxes.sql +6 -0
  16. package/dist/migrations/0022_chat_enabled.sql +6 -0
  17. package/dist/migrations/0023_hosting_app_env.sql +8 -0
  18. package/dist/migrations/0024_audit_events.sql +211 -0
  19. package/dist/migrations/0025_audit_display.sql +147 -0
  20. package/dist/migrations/0026_audit_erase_email_guard.sql +78 -0
  21. package/dist/migrations/0027_audit_chain.sql +170 -0
  22. package/dist/migrations/0028_audit_rls.sql +111 -0
  23. package/dist/org.d.ts +105 -0
  24. package/dist/org.js +101 -0
  25. package/dist/plan/agora.d.ts +21 -0
  26. package/dist/plan/agora.js +23 -0
  27. package/dist/plan/calls-matrix.d.ts +2 -2
  28. package/dist/plan/calls-matrix.js +3 -2
  29. package/dist/plan/cloudflared-connector.d.ts +3 -2
  30. package/dist/plan/cloudflared-connector.js +5 -3
  31. package/dist/plan/diff.d.ts +15 -2
  32. package/dist/plan/diff.js +399 -51
  33. package/dist/plan/executor.d.ts +307 -61
  34. package/dist/plan/executor.js +489 -129
  35. package/dist/plan/index.d.ts +9 -1
  36. package/dist/plan/index.js +5 -1
  37. package/dist/plan/login-host.d.ts +64 -0
  38. package/dist/plan/login-host.js +52 -0
  39. package/dist/plan/mail-copy.d.ts +125 -0
  40. package/dist/plan/mail-copy.js +166 -0
  41. package/dist/plan/mail-identity-allowlist.d.ts +50 -0
  42. package/dist/plan/mail-identity-allowlist.js +11 -0
  43. package/dist/plan/mail-identity.d.ts +68 -0
  44. package/dist/plan/mail-identity.js +17 -0
  45. package/dist/plan/plan-apply.d.ts +26 -8
  46. package/dist/plan/plan-apply.js +10 -9
  47. package/dist/plan/platform-diff.d.ts +207 -10
  48. package/dist/plan/platform-diff.js +403 -15
  49. package/dist/plan/platform-org.d.ts +3 -2
  50. package/dist/plan/platform-providers.d.ts +139 -25
  51. package/dist/plan/platform-providers.js +1531 -95
  52. package/dist/plan/platform-sfu.d.ts +54 -19
  53. package/dist/plan/platform-sfu.js +64 -22
  54. package/dist/plan/providers-readers.js +44 -1
  55. package/dist/plan/zitadel-machine-user.d.ts +60 -11
  56. package/dist/plan/zitadel-machine-user.js +182 -39
  57. package/dist/requested-changes.d.ts +17 -7
  58. package/dist/requested-changes.js +37 -7
  59. package/dist/store/audit.d.ts +40 -4
  60. package/dist/store/audit.js +114 -29
  61. package/dist/store/postgres.js +35 -3
  62. package/dist/store/schema.d.ts +109 -1
  63. package/dist/store/schema.js +20 -0
  64. package/dist/store.d.ts +7 -0
  65. package/package.json +5 -3
  66. package/versions.json +8 -0
@@ -1,9 +1,12 @@
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
+ import { AGORA_CLIENT_ID_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, agoraHostnameFor, desiredAgoraEnv, } from './agora.js';
5
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';
6
+ import { ensureCloudflaredConnector } from './cloudflared-connector.js';
7
+ import { LOGIN_BINDING_SCOPE_KEY, loginBindingState, loginHostClientIds, } from './login-host.js';
8
+ import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
9
+ import { APP_ENV_APPLIED_SCOPE_KEY, DB_ENV_APPLIED_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, resolveAppEnv, } from './platform-diff.js';
7
10
  // ---------------------------------------------------------------------------
8
11
  // Errors
9
12
  // ---------------------------------------------------------------------------
@@ -108,10 +111,11 @@ export function healthUrls(org) {
108
111
  }
109
112
  /**
110
113
  * The `OrgMailOrg` argument for `applyOrgMail`/`parkOrgMail`: an org with
111
- * `mail.usesDomain` off has no domain of its own, so it is level 1 on
112
- * `MAIL_APEX` and its mode is forced to `'hosted'` — `@wtfalch/email`'s own
113
- * `resolveDomain` refuses any other mode for a domain-less org, and a
114
- * level-1 `{slug}.{apex}` domain always has Stalwart's MX (foundry#7 "Mail").
114
+ * `mail.usesDomain` off has no domain of its own, so it is level 1 on the
115
+ * platform's own mail apex (`coolifyMail`'s `options.apex`) and its mode is
116
+ * forced to `'hosted'` — `@wtfalch/email`'s own `resolveDomain` refuses any
117
+ * other mode for a domain-less org, and a level-1 `{slug}.{apex}` domain
118
+ * always has Stalwart's MX (foundry#7 "Mail").
115
119
  */
116
120
  function mailOrgFor(org, usesDomain, mode) {
117
121
  return usesDomain && org.domain
@@ -226,38 +230,6 @@ const AGORA_APP_DOCKERFILE = 'apps/agora/Dockerfile';
226
230
  * restated rather than shared across the two files.
227
231
  */
228
232
  const LOG_DIR = '/app/logs';
229
- /**
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`).
237
- */
238
- const MAIL_APEX = 'wtfalch.dev';
239
- const MAIL_HOST = 'mail.wtfalch.dev';
240
- /**
241
- * The recorded OIDC app's `clientId` (level-1 app-env campaign, gap P2) —
242
- * not a `system:kind:key` resource id (that slot already holds the app's
243
- * `appId`, `zitadelApp`'s own `resourceId`) and not a secret (`deliverSecret`
244
- * is for that), so a flat scope id like `zitadel_org`/`zitadel_project`
245
- * above. A level-1 org declares exactly one web `issuerApps` entry for its
246
- * one standard app, so one flat key is enough; an org with more than one web
247
- * app would need a richer key, not built here (Finding, this campaign's
248
- * report).
249
- */
250
- const APP_CLIENT_ID_SCOPE_KEY = 'zitadel_app_client_id';
251
- /**
252
- * #139: the `cms` app's own ZITADEL web client's `clientId`, recorded
253
- * separately from {@link APP_CLIENT_ID_SCOPE_KEY} -- that key belongs to the
254
- * org's own hosted app (`diff.ts`'s `planIssuerApps`), a different
255
- * `zitadel:app` step from the fixed `cms` client `diff.ts`'s `planCmsClient`
256
- * plans. `cmsSite` reads this key to push `AUTH_CLIENT_ID` onto the cms app,
257
- * the same way `pushAppEnv` reads `APP_CLIENT_ID_SCOPE_KEY` for the hosted
258
- * app.
259
- */
260
- const CMS_CLIENT_ID_SCOPE_KEY = 'cms_client_id';
261
233
  /**
262
234
  * `cmsSite` provisions two applications from one `cms:site` step, but
263
235
  * `execute`'s own return value auto-records only one `resourceId`, under
@@ -299,6 +271,13 @@ const CMS_DATABASE_SCOPE_KEY = 'coolify_cms_database';
299
271
  * `resend_mail_domain`.
300
272
  */
301
273
  const MAIL_DOMAIN_SCOPE_KEY = 'mail_domain';
274
+ /**
275
+ * #235: the Coolify app running `cloudflared` for this org's own tunnel --
276
+ * one connector per org tunnel (Decided, William, 2026-10-01), the same
277
+ * `ensureCloudflaredConnector` every platform tunnel step already calls
278
+ * (`platform-providers.ts`), never a shared tunnel with appended ingress.
279
+ */
280
+ const CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY = 'cloudflare_tunnel_connector_app';
302
281
  /**
303
282
  * #162: the owner's own mailbox, created once inside `coolify:mail` after
304
283
  * `applyOrgMail` succeeds, alongside {@link MAIL_DOMAIN_SCOPE_KEY} -- another
@@ -312,6 +291,29 @@ const MAIL_DOMAIN_SCOPE_KEY = 'mail_domain';
312
291
  * file's own `LOG_DIR` doc comment names for `diff.ts`.
313
292
  */
314
293
  const MAIL_OWNER_MAILBOX_SCOPE_KEY = 'mail_owner_mailbox';
294
+ /**
295
+ * #149 review finding: `MailPort.hasOrgRelay` only confirms Stalwart's route
296
+ * holds *a* secret, not that it is the one {@link FoundryStepExecutor
297
+ * .ensureOrgLettermintRelay} itself wrote for the project Lettermint
298
+ * currently lists under `<slug>-relay` -- a route a stale run wrote for a
299
+ * project that has since been lost and reprovisioned under the same name
300
+ * would otherwise read as healthy. Recorded as `<project id>@<mail domain>`
301
+ * right after a successful `MailPort.applyOrgRelay`, a flat scope id (no
302
+ * `:`) the same way {@link MAIL_OWNER_MAILBOX_SCOPE_KEY} is. The domain half
303
+ * also catches a domain switch: the healthy branch never calls
304
+ * `applyOrgRelay` again, so without this the Stalwart route would stay
305
+ * restricted to the org's previous domain forever.
306
+ */
307
+ const ORG_MAIL_RELAY_SCOPE_KEY = 'mail_relay';
308
+ /**
309
+ * #230: the role mailboxes {@link ensureRoleMailboxes} has created, as one
310
+ * flat scope id holding their full addresses (`local@domain`, lowercased, so a
311
+ * mail domain change owes the new domain its own set) joined by `,`
312
+ * (`validateMail` refuses a `,` in a name, so the join is unambiguous). Updated after each
313
+ * mailbox, so a crash part-way through never recreates the ones already
314
+ * made. `plan/diff.ts` keeps its own copy of this literal.
315
+ */
316
+ const MAIL_ROLE_MAILBOXES_SCOPE_KEY = 'mail_role_mailboxes';
315
317
  /**
316
318
  * #80: the id of an ai key `aiKey` has minted but not yet delivered, a flat
317
319
  * scope id (no `:`). `plan/diff.ts` keeps its own copy of this literal.
@@ -328,17 +330,6 @@ const AI_KEY_DELIVERY_ATTEMPTS = 3;
328
330
  const JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY = 'jobs_registration_undelivered';
329
331
  /** How many times `jobsRegistration` tries each `deliverSecret` before giving up. */
330
332
  const JOBS_DELIVERY_ATTEMPTS = 3;
331
- /**
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.
340
- */
341
- const MAIL_OWNER_LOCAL_PART = 'owner';
342
333
  /**
343
334
  * `coolifyCalls` provisions four resources from one `coolify:calls` step
344
335
  * (#128) — the same one-step, several-resource shape `cmsSite` already solves
@@ -431,6 +422,8 @@ export class FoundryStepExecutor {
431
422
  return this.zitadelIssuer(action, org, context);
432
423
  case 'zitadel:owner':
433
424
  return this.zitadelOwner(action, org, context);
425
+ case 'zitadel:login-host':
426
+ return this.zitadelLoginHost(action, org, context);
434
427
  case 'zitadel:app':
435
428
  return this.zitadelApp(action, org, context);
436
429
  case 'zitadel:customers':
@@ -463,6 +456,8 @@ export class FoundryStepExecutor {
463
456
  return this.aiKey(action, org, context);
464
457
  case 'coolify:mail':
465
458
  return this.coolifyMail(action, org, context);
459
+ case 'coolify:mail-copy':
460
+ return this.mailCopy(org, context);
466
461
  case 'coolify:chat':
467
462
  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");
468
463
  case 'cms:site':
@@ -598,6 +593,54 @@ export class FoundryStepExecutor {
598
593
  });
599
594
  return { resourceId: userId };
600
595
  }
596
+ /**
597
+ * `plan/diff.ts`'s `planLoginHost` (#267): adds the org's own company
598
+ * login host (`action.desired.host`) to the ZITADEL instance's trusted
599
+ * domains, writes zitadel-login's binding, then repoints the apps whose
600
+ * client id that binding holds. Apps created later are not in it yet:
601
+ * the next run replans (`diff.ts`'s `planLoginHost`) and moves them.
602
+ */
603
+ async zitadelLoginHost(action, org, context) {
604
+ const recorded = context.recordedIds();
605
+ const zitadelOrgId = recorded.zitadel_org;
606
+ if (!zitadelOrgId) {
607
+ throw new Error(`zitadel:login-host for ${org.slug}: no ZITADEL org recorded yet -- the issuer must be provisioned first`);
608
+ }
609
+ // `planLoginHost` blocks the step while this port is not wired.
610
+ const bindings = this.ports.loginHostBindings;
611
+ if (!bindings) {
612
+ throw new Error(`zitadel:login-host for ${org.slug}: the loginHostBindings port is not wired`);
613
+ }
614
+ const host = action.desired.host;
615
+ await this.ports.zitadel.ensureTrustedDomain(host);
616
+ // zitadel-login's own routing table, written before any app is repointed
617
+ // so no app ever points at a host with no binding.
618
+ const clientIds = loginHostClientIds(recorded);
619
+ await bindings.upsertBinding({ host, organizationId: zitadelOrgId, clientIds });
620
+ // Only apps whose client id the binding now holds: any other app in the
621
+ // project would lose sign-in. The rest move on a later run, once the
622
+ // recorded client ids differ from the binding and replan this step.
623
+ const boundAppIds = new Set([
624
+ ...(recorded[APP_CLIENT_ID_SCOPE_KEY]
625
+ ? org.hosting.issuerApps.filter((a) => a.kind === 'web').map((a) => a.name)
626
+ : []),
627
+ ...(recorded[CMS_CLIENT_ID_SCOPE_KEY] ? ['cms'] : []),
628
+ ...(recorded[AGORA_CLIENT_ID_SCOPE_KEY] ? ['agora'] : []),
629
+ ].map((name) => recorded[`zitadel:app:${name}`]));
630
+ const projectId = recorded.zitadel_project;
631
+ if (projectId) {
632
+ const apps = await this.ports.zitadel.listApps(zitadelOrgId, projectId);
633
+ for (const app of apps) {
634
+ if (app.state === 'APP_STATE_REMOVED' || !boundAppIds.has(app.id))
635
+ continue;
636
+ await this.ports.zitadel.updateOidcAppLoginBaseUri(zitadelOrgId, projectId, app.id, `https://${host}/`);
637
+ }
638
+ }
639
+ // Last, so a crash above reruns the whole step. A client id recorded
640
+ // after this point differs from it and replans the step next run.
641
+ context.recordScopeId(LOGIN_BINDING_SCOPE_KEY, loginBindingState(host, clientIds));
642
+ return { resourceId: host };
643
+ }
601
644
  /**
602
645
  * `plan/diff.ts`'s `planCustomers` (plan gap A9): the org's customers sign
603
646
  * in through a second ZITADEL org, `<slug> customers`, with
@@ -822,11 +865,17 @@ export class FoundryStepExecutor {
822
865
  // resource id is already recorded by the time this runs -- but this also
823
866
  // covers the database being turned on after the app already exists (the
824
867
  // app step still ran this apply, found the app by name, and lands here).
868
+ let dbPush = { pushed: false, changed: false };
825
869
  if (org.hosting.database) {
826
870
  const dbId = context.recordedIds()[`coolify:database:${action.key}`];
827
- if (dbId)
828
- await this.pushDatabaseUrl(appId, dbId);
871
+ if (dbId) {
872
+ dbPush = await this.pushDatabaseUrl(appId, dbId, org.slug.replace(/-/g, '_'));
873
+ }
829
874
  }
875
+ // Restart when the push changed something, or when no earlier run got as
876
+ // far as restarting after one (marker missing): a crash between the push
877
+ // and the restart leaves the env right and the container stale.
878
+ const dbNeedsRestart = dbPush.pushed && (dbPush.changed || !context.recordedIds()[DB_ENV_APPLIED_SCOPE_KEY]);
830
879
  // Every app keeps its own logs across a redeploy (#75). Unconditional,
831
880
  // like the env push below: found by mount path before create, so a
832
881
  // re-run (or the drift step `diff.ts`'s `planApp` plans for an app that
@@ -838,7 +887,17 @@ export class FoundryStepExecutor {
838
887
  // The app receives whatever it needs to sign in (gap P2). Unconditional,
839
888
  // unlike the database push above: every app has a hostname, and an
840
889
  // upsert of the same value on a later apply is a no-op, not a change.
841
- await this.pushAppEnv(appId, org, context);
890
+ const appEnv = await this.pushAppEnv(appId, org, context);
891
+ // Rule 4 (#239, #243): a container reads its env only at start, so an app
892
+ // that was already there is restarted, once, when its database URLs or its
893
+ // declared appEnv changed.
894
+ if ((appEnv.changed || dbNeedsRestart) && existing)
895
+ await this.ports.coolify.restart(appId);
896
+ // Recorded only after the restart succeeded: a failed restart leaves the
897
+ // old value, so the next plan replans (rule 3). The db marker is not a URL.
898
+ context.recordScopeId(APP_ENV_APPLIED_SCOPE_KEY, appEnv.applied);
899
+ if (dbPush.pushed)
900
+ context.recordScopeId(DB_ENV_APPLIED_SCOPE_KEY, '1');
842
901
  // #140 rework (2026-09-28 lettermint-org-mail swap): the hosted app's own
843
902
  // Lettermint project, domain restriction and `MAIL_FROM` (only the app
844
903
  // started by this same step, per the design's "only apps that send mail
@@ -922,39 +981,80 @@ export class FoundryStepExecutor {
922
981
  // (`coolifyApp`'s own push handles the more common order, database
923
982
  // before app on the same apply -- `diff.ts`'s `computePlanDetail`).
924
983
  const appId = context.recordedIds()[`coolify:app:${action.key}`];
925
- if (appId)
926
- await this.pushDatabaseUrl(appId, dbId);
984
+ if (appId) {
985
+ const push = await this.pushDatabaseUrl(appId, dbId, org.slug.replace(/-/g, '_'));
986
+ if (push.pushed) {
987
+ if (push.changed || !context.recordedIds()[DB_ENV_APPLIED_SCOPE_KEY]) {
988
+ await this.ports.coolify.restart(appId);
989
+ }
990
+ context.recordScopeId(DB_ENV_APPLIED_SCOPE_KEY, '1');
991
+ }
992
+ }
927
993
  return { resourceId: dbId };
928
994
  }
929
995
  /**
930
- * Pushes the database's connection string into the app's environment as
931
- * `DATABASE_URL` -- never also as `DATABASE_URL_OWNER` (Decision, this
932
- * campaign's report). Archon's own boot script
933
- * (`archon/scripts/migrate-and-start.sh`) treats a bare `DATABASE_URL` with
934
- * no `DATABASE_URL_OWNER` as "before the split": it migrates and serves as
935
- * the one role it has, which is exactly what a database Coolify just
936
- * created (one superuser role, no separate runtime role yet) is. Setting
937
- * both to the *same* superuser URL would instead take the split branch --
938
- * `db-role-guard.sh` then refuses to boot, since `DATABASE_URL_OWNER` being
939
- * set means it expects `DATABASE_URL` to connect as a distinct,
940
- * non-superuser runtime role (`<database>_rt`), which nothing here creates.
941
- * Minting that role's own password and URL is a provisioning step of its
942
- * own, not built here (Finding, this campaign's report).
996
+ * Pushes the database's connection string into the app's environment,
997
+ * split the same way `cmsSite`'s own database is (#117, #239): the
998
+ * superuser connection as `DATABASE_URL_OWNER`, and a distinct, restricted
999
+ * `<dbName>_rt` role's connection as `DATABASE_URL`. Archon's and manage's
1000
+ * own boot scripts (`db-role-guard.sh`) refuse to start against a
1001
+ * superuser `DATABASE_URL`, and lokessmie's refuses to start without
1002
+ * `DATABASE_URL_OWNER` at all -- a bare superuser `DATABASE_URL` with no
1003
+ * `DATABASE_URL_OWNER` is "before the split" there too.
943
1004
  *
944
- * The connection string is a live credential
945
- * (`postgres://user:password@host/db`): it is read from
946
- * `CoolifyPort.getDatabase` and handed straight to `bulkUpsertEnvs` and
947
- * nowhere else in this class -- never returned, never recorded in
948
- * `serviceIds` (hence never in the store or an audit record), never logged,
949
- * never interpolated into a thrown error.
1005
+ * Both connection strings are live credentials
1006
+ * (`postgres://user:password@host/db`): read from `CoolifyPort.getDatabase`
1007
+ * (or minted here, for the runtime one) and handed straight to
1008
+ * `bulkUpsertEnvs` and nowhere else in this class -- never returned, never
1009
+ * recorded in `serviceIds` (hence never in the store or an audit record),
1010
+ * never logged, never interpolated into a thrown error.
950
1011
  */
951
- async pushDatabaseUrl(appId, dbId) {
1012
+ async pushDatabaseUrl(appId, dbId, dbName) {
952
1013
  const db = await this.ports.coolify.getDatabase(dbId);
1014
+ // Nothing to push; `planApp` reports this as blocked from the live reader.
953
1015
  if (!db.internal_db_url)
954
- return;
955
- await this.ports.coolify.bulkUpsertEnvs(appId, [
956
- { key: 'DATABASE_URL', value: db.internal_db_url },
957
- ]);
1016
+ return { pushed: false, changed: false };
1017
+ const envs = [
1018
+ { key: 'DATABASE_URL_OWNER', value: db.internal_db_url },
1019
+ ];
1020
+ const runtimeUrl = await this.ensureAppRuntimeUrl(appId, db.internal_db_url, dbName);
1021
+ if (runtimeUrl)
1022
+ envs.push({ key: 'DATABASE_URL', value: runtimeUrl });
1023
+ // True when a value actually changed, so the caller restarts the app only
1024
+ // then (the owner URL is upserted on every apply).
1025
+ const ownerBefore = (await this.ports.coolify.listEnvs(appId)).find((e) => e.key === 'DATABASE_URL_OWNER' && !e.is_preview)?.value;
1026
+ await this.ports.coolify.bulkUpsertEnvs(appId, envs);
1027
+ return { pushed: true, changed: runtimeUrl !== null || ownerBefore !== db.internal_db_url };
1028
+ }
1029
+ /**
1030
+ * `null` once the app already has a `DATABASE_URL` -- never rotated, the
1031
+ * same rule `ensureCmsRuntimeUrl` follows and for the same reason: the
1032
+ * app's own boot script creates or updates the live `<dbName>_rt` Postgres
1033
+ * role from exactly the username/password this method mints, once:
1034
+ * overwriting `DATABASE_URL` on a later apply would leave that
1035
+ * already-created role's real password out of sync with whatever foundry
1036
+ * hands the app next. See `ensureCmsRuntimeUrl`'s own doc comment for the
1037
+ * parsing and encoding choices this mirrors exactly.
1038
+ */
1039
+ async ensureAppRuntimeUrl(appId, dbInternalUrl, dbName) {
1040
+ const envs = await this.ports.coolify.listEnvs(appId);
1041
+ // An existing `DATABASE_URL` that is still the superuser string is what an
1042
+ // app got before the split (#239): treat it as absent so the runtime role
1043
+ // is minted and replaces it, or the app's db-role-guard refuses to boot.
1044
+ const already = envs.some((e) => e.key === 'DATABASE_URL' && !e.is_preview && e.value !== dbInternalUrl);
1045
+ if (already)
1046
+ return null;
1047
+ let host;
1048
+ let pathname;
1049
+ let search;
1050
+ try {
1051
+ ({ host, pathname, search } = new URL(dbInternalUrl));
1052
+ }
1053
+ catch {
1054
+ return null;
1055
+ }
1056
+ const password = randomBytes(24).toString('base64url');
1057
+ return `postgres://${dbName}_rt:${password}@${host}${pathname}${search}`;
958
1058
  }
959
1059
  /**
960
1060
  * Pushes what the org's app needs to sign a user in through ZITADEL (gap
@@ -970,9 +1070,12 @@ export class FoundryStepExecutor {
970
1070
  * `zitadel:issuer` (`AUTH_ORGANIZATION_ID`), `zitadel:owner`
971
1071
  * (`OWNER_USER_ID`, gap P3) and every `zitadel:app` step
972
1072
  * (`AUTH_CLIENT_ID`), all ordered before `coolify:app` for exactly this
973
- * reason. AUTH_APP_KEY (waits on an auth broker registration API) and
974
- * DATABASE_URL_OWNER (a runtime-role split not built yet, `pushDatabaseUrl`'s
975
- * own doc comment) are out of scope here (task brief).
1073
+ * reason. `AUTH_APP_KEY` (#206 -- which broker an org's app registers with
1074
+ * is its own design question, left out of #204's own "How" rather than
1075
+ * guessed) is out of scope here (task brief). `DATABASE_URL_OWNER`/
1076
+ * `DATABASE_URL` are pushed by `pushDatabaseUrl` instead, not here --
1077
+ * that call runs only once `hosting.database` is declared and
1078
+ * provisioned, where this one is unconditional (#239).
976
1079
  */
977
1080
  async pushAppEnv(appId, org, context) {
978
1081
  const recorded = context.recordedIds();
@@ -1003,7 +1106,21 @@ export class FoundryStepExecutor {
1003
1106
  const cookieSecret = await this.ensureAuthCookieSecret(appId);
1004
1107
  if (cookieSecret)
1005
1108
  envs.push({ key: 'AUTH_COOKIE_SECRET', value: cookieSecret });
1109
+ // #243: the app's own declared map from its env var names to values the
1110
+ // plan already mints for this org (Files, AI, mail) -- each bespoke app
1111
+ // names these differently (lokessmie uses STORAGE_*/AI_*). Nothing is
1112
+ // minted here; a source with no value yet (its own step has not run,
1113
+ // or has not run for this apply) is simply omitted, the same way every
1114
+ // other optional entry above is.
1115
+ const appEnv = resolveAppEnv(org, recorded).values;
1116
+ for (const [key, value] of Object.entries(appEnv))
1117
+ envs.push({ key, value });
1006
1118
  await this.ports.coolify.bulkUpsertEnvs(appId, envs);
1119
+ // `coolifyApp` records `applied` once the restart succeeded, so `planApp`
1120
+ // replans this step when the declared map changes (rule 3). Ids and a
1121
+ // domain, never a secret.
1122
+ const applied = JSON.stringify(appEnv);
1123
+ return { changed: applied !== (recorded[APP_ENV_APPLIED_SCOPE_KEY] ?? '{}'), applied };
1007
1124
  }
1008
1125
  /**
1009
1126
  * `null` once the app already has one -- `AUTH_COOKIE_SECRET` is generated
@@ -1052,7 +1169,10 @@ export class FoundryStepExecutor {
1052
1169
  * be deleted.
1053
1170
  *
1054
1171
  * `MAIL_FROM` is upserted every time: it depends only on the mail domain
1055
- * `coolify:mail` resolved.
1172
+ * `coolify:mail` resolved and `org.mail.noReplyLocalPart` (#162, decided
1173
+ * 2026-09-29 -- defaults to `'no-reply'`, restated here rather than
1174
+ * imported from `org.ts`'s `DEFAULT_NO_REPLY_LOCAL_PART`, this file's own
1175
+ * `LOG_DIR`-style convention).
1056
1176
  *
1057
1177
  * The token goes straight to `bulkUpsertEnvs` and nowhere else: never
1058
1178
  * returned, never recorded in `serviceIds` (`recordScopeId`'s own doc
@@ -1065,7 +1185,6 @@ export class FoundryStepExecutor {
1065
1185
  if (!mailDomain)
1066
1186
  return;
1067
1187
  const appName = `${org.slug}-app`;
1068
- const relayName = `${org.slug}-relay`;
1069
1188
  const [projects, envs] = await Promise.all([
1070
1189
  this.ports.lettermint.listProjects(),
1071
1190
  this.ports.coolify.listEnvs(appId),
@@ -1086,18 +1205,133 @@ export class FoundryStepExecutor {
1086
1205
  allProjects = [...projects, created.project];
1087
1206
  envUpdates.push({ key: 'LETTERMINT_API_TOKEN', value: created.token });
1088
1207
  }
1089
- envUpdates.push({ key: 'MAIL_FROM', value: `no-reply@${mailDomain}` });
1208
+ envUpdates.push({
1209
+ key: 'MAIL_FROM',
1210
+ value: `${org.mail.noReplyLocalPart ?? 'no-reply'}@${mailDomain}`,
1211
+ });
1090
1212
  await this.ports.coolify.bulkUpsertEnvs(appId, envUpdates);
1091
1213
  // A running container read its env at start and still holds the old
1092
1214
  // token, so a fresh one only reaches it through a restart -- before the
1093
1215
  // old project is deleted below. A just-created app starts with the new env.
1094
1216
  if (!healthy && running)
1095
1217
  await this.ports.coolify.restart(appId);
1218
+ await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects);
1219
+ for (const project of allProjects) {
1220
+ if (project.name === appName && project.id !== keepProjectId) {
1221
+ await this.ports.lettermint.deleteProject(project.id);
1222
+ }
1223
+ }
1224
+ }
1225
+ /**
1226
+ * #149: mints this org's own Lettermint relay credential and writes it to
1227
+ * Stalwart via `MailPort.applyOrgRelay`, the same mint rule
1228
+ * {@link ensureAppLettermintMail} already uses for the org's app token --
1229
+ * "healthy" means Stalwart's own route exists ({@link MailPort.hasOrgRelay}),
1230
+ * AND Lettermint lists a project named `<slug>-relay` whose id
1231
+ * {@link ORG_MAIL_RELAY_SCOPE_KEY} records for this
1232
+ * exact mail domain (review finding: a route with a secret and a
1233
+ * same-named project both present is not enough -- the project could be a
1234
+ * lost-and-reprovisioned one this step never wrote a route for, and a mail
1235
+ * domain switch must re-point the route rather than read as healthy
1236
+ * forever because the old route still has a secret in it). A route with no
1237
+ * live project behind it, a project the route's own secret never reached,
1238
+ * or a recorded project id/domain that no longer matches, are all exactly
1239
+ * as unhealthy as neither existing.
1240
+ *
1241
+ * A fresh project is created and its token written to Stalwart first, and
1242
+ * only then is every other `<slug>-relay` project deleted -- a lost route
1243
+ * and a duplicate project both end the same way. The new project's domain
1244
+ * restriction runs before the token is written, so a failure there never
1245
+ * touches the working route. Review finding: if either the
1246
+ * domain-restriction step or `applyOrgRelay` throws after a
1247
+ * fresh project was minted, that project is deleted before the error
1248
+ * propagates -- otherwise every retry sees more than one `<slug>-relay`
1249
+ * project and mints yet another one, growing the orphan count without
1250
+ * bound.
1251
+ *
1252
+ * `coolify:mail` runs before `coolify:app` (`diff.ts`'s own step order), so
1253
+ * this is very often the first of the two to reach the org's Lettermint
1254
+ * domain: {@link ensureLettermintDomainRestriction} registers and restricts
1255
+ * it the same way the app step does, rather than assuming it already
1256
+ * exists -- the domain must never end up unrestricted, whichever step gets
1257
+ * there first.
1258
+ */
1259
+ async ensureOrgLettermintRelay(org, mailOrg, mailDomain, context) {
1260
+ const relayName = `${org.slug}-relay`;
1261
+ const [projects, hasRelay] = await Promise.all([
1262
+ this.ports.lettermint.listProjects(),
1263
+ this.ports.mail.hasOrgRelay(mailOrg),
1264
+ ]);
1265
+ // The recorded project, not "the only one": a leftover duplicate whose
1266
+ // delete keeps failing must not force a fresh mint on every apply.
1267
+ const recordedRelay = context.recordedIds()[ORG_MAIL_RELAY_SCOPE_KEY];
1268
+ const held = projects.find((p) => p.name === relayName && `${p.id}@${mailDomain}` === recordedRelay);
1269
+ const healthy = held !== undefined && hasRelay;
1270
+ let allProjects = projects;
1271
+ let keepProjectId;
1272
+ if (healthy && held) {
1273
+ keepProjectId = held.id;
1274
+ await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects);
1275
+ }
1276
+ else {
1277
+ const created = await this.ports.lettermint.createProject(relayName);
1278
+ keepProjectId = created.project.id;
1279
+ allProjects = [...projects, created.project];
1280
+ try {
1281
+ // Everything that can fail for the new project runs before the token
1282
+ // reaches Stalwart: a failure here leaves the working route alone.
1283
+ // The restriction covers old and new projects, so the old one keeps
1284
+ // sending until the route switches.
1285
+ await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects);
1286
+ await this.ports.mail.applyOrgRelay(mailOrg, created.token);
1287
+ }
1288
+ catch (err) {
1289
+ await this.discardLettermintProject(created.project.id);
1290
+ throw err;
1291
+ }
1292
+ context.recordScopeId(ORG_MAIL_RELAY_SCOPE_KEY, `${keepProjectId}@${mailDomain}`);
1293
+ // Written now: a crash before the end-of-run write would leave the old
1294
+ // project recorded, and the next apply would sweep the one the route uses.
1295
+ await context.persistScopeIds();
1296
+ }
1297
+ for (const project of allProjects) {
1298
+ if (project.name === relayName && project.id !== keepProjectId) {
1299
+ await this.ports.lettermint.deleteProject(project.id);
1300
+ }
1301
+ }
1302
+ }
1303
+ /**
1304
+ * Deletes a just-minted project from inside a `catch`. A failed delete is
1305
+ * dropped so it never replaces the error being rethrown; the orphan is
1306
+ * swept by the next apply's duplicate delete.
1307
+ */
1308
+ async discardLettermintProject(id) {
1309
+ try {
1310
+ await this.ports.lettermint.deleteProject(id);
1311
+ }
1312
+ catch {
1313
+ // Dropped: the caller rethrows the original error.
1314
+ }
1315
+ }
1316
+ /**
1317
+ * Ensures this org's Lettermint domain exists, is restricted to exactly
1318
+ * the `<slug>-app`/`<slug>-relay` projects live in `allProjects`, has its
1319
+ * DNS published and a verification triggered. Shared by
1320
+ * {@link ensureAppLettermintMail} and {@link ensureOrgLettermintRelay}
1321
+ * (#149) -- whichever of the two steps runs first on a fresh org creates
1322
+ * the domain, and the other then finds it (`findExactLettermintDomain`).
1323
+ *
1324
+ * A crash between the restriction write and the DNS publish/verify below
1325
+ * is safe even for a freshly created domain: DNS is only published, and
1326
+ * verification only triggered, after the restriction -- so an
1327
+ * unrestricted domain cannot verify and cannot send, whichever project
1328
+ * owns it (post-#140 review finding, restated here rather than only at
1329
+ * the one call site it used to have).
1330
+ */
1331
+ async ensureLettermintDomainRestriction(org, mailDomain, allProjects) {
1332
+ const appName = `${org.slug}-app`;
1333
+ const relayName = `${org.slug}-relay`;
1096
1334
  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
1335
  const domain = existing
1102
1336
  ? await this.ports.lettermint.getDomain(existing.id)
1103
1337
  : await this.ports.lettermint.createDomain(mailDomain);
@@ -1107,15 +1341,10 @@ export class FoundryStepExecutor {
1107
1341
  await this.ports.lettermint.restrictDomainToProjects(domain.id, scopedProjectIds);
1108
1342
  const zone = await this.ports.cloudflare.findZoneByApex(mailDomain);
1109
1343
  if (!zone) {
1110
- throw new Error(`coolify:app for ${org.slug}: no Cloudflare zone found for ${mailDomain}`);
1344
+ throw new Error(`Lettermint for ${org.slug}: no Cloudflare zone found for ${mailDomain}`);
1111
1345
  }
1112
1346
  await this.publishLettermintDnsRecords(zone.id, domain.dnsRecords);
1113
1347
  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
- }
1118
- }
1119
1348
  }
1120
1349
  /**
1121
1350
  * Review finding (post-#140 rework): {@link LettermintPort.findDomain}'s
@@ -1713,9 +1942,9 @@ export class FoundryStepExecutor {
1713
1942
  if (!masCredentials) {
1714
1943
  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`);
1715
1944
  }
1716
- const sfu = await this.ports.callsSfu.resolveCredentials();
1945
+ const sfu = await this.ports.callsSfu.resolveCredentials(org.slug);
1717
1946
  if (!sfu) {
1718
- 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`);
1947
+ 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`);
1719
1948
  }
1720
1949
  const projectUuid = await this.ensureCoolifyProject(org, context);
1721
1950
  const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
@@ -1963,6 +2192,19 @@ export class FoundryStepExecutor {
1963
2192
  await this.ports.cloudflare.putTunnelConfig(tunnelId, {
1964
2193
  ingress: [{ service: 'http_status:404' }],
1965
2194
  });
2195
+ // #235: park stops the connector too -- an ingress with nothing
2196
+ // routed is still a running container dialed into the tunnel.
2197
+ const connectorAppId = context.recordedIds()[CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY];
2198
+ if (connectorAppId) {
2199
+ try {
2200
+ await this.ports.coolify.raw('POST', `/applications/${connectorAppId}/stop`);
2201
+ }
2202
+ catch (error) {
2203
+ // Already gone (destroyed out of band): nothing left to stop.
2204
+ if (!isNotFoundError(error))
2205
+ throw error;
2206
+ }
2207
+ }
1966
2208
  return { resourceId: tunnelId };
1967
2209
  }
1968
2210
  const tunnelName = `${org.slug}-tunnel`;
@@ -1992,6 +2234,29 @@ export class FoundryStepExecutor {
1992
2234
  await this.ports.cloudflare.putTunnelConfig(tunnel.id, {
1993
2235
  ingress: [...ingress, { service: 'http_status:404' }],
1994
2236
  });
2237
+ // #235: a connector for this org's own tunnel, the same call every
2238
+ // platform tunnel step already makes (Decided, William, 2026-10-01:
2239
+ // one connector per org tunnel, never a shared tunnel with appended
2240
+ // ingress). Without this, the tunnel exists and is configured but
2241
+ // nothing is dialed into it, so `hostname` answers with a Cloudflare
2242
+ // error -- #197's own gap, for an org's tunnel rather than a platform
2243
+ // one.
2244
+ const projectUuid = await this.ensureCoolifyProject(org, context);
2245
+ const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
2246
+ await ensureCloudflaredConnector({
2247
+ coolify: this.ports.coolify,
2248
+ cloudflare: this.ports.cloudflare,
2249
+ tunnelId: tunnel.id,
2250
+ appName: `${org.slug}-cloudflared`,
2251
+ projectUuid,
2252
+ environment,
2253
+ resolveServer: () => this.resolveServer(),
2254
+ recordedConnectorAppId: context.recordedIds()[CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY] ?? null,
2255
+ recordAppId: async (appId) => {
2256
+ context.recordScopeId(CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY, appId);
2257
+ await context.persistScopeIds();
2258
+ },
2259
+ });
1995
2260
  return { resourceId: tunnel.id };
1996
2261
  }
1997
2262
  /**
@@ -2031,20 +2296,21 @@ export class FoundryStepExecutor {
2031
2296
  * `@wtfalch/email/postmaster`'s `applyOrgMail`/`parkOrgMail` bound), then
2032
2297
  * publishes whatever Stalwart DNS records the call returns, and records the
2033
2298
  * 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.
2299
+ *
2300
+ * #149: this step also mints the org's own Lettermint *relay* credential
2301
+ * ({@link ensureOrgLettermintRelay}) -- the app's own *sending* credential
2302
+ * stays in the app step (`ensureAppLettermintMail`, "Core rule"), since the
2303
+ * first app project can only exist once the token has a home. Parking mail
2304
+ * (mode `'off'`) reaches Lettermint too: {@link parkLettermintMail} drops
2305
+ * the relay route, then deletes the org's own projects and its domain, so
2306
+ * a domain never ends up restricted only to dead projects and no route
2307
+ * ever points at a project this apply is about to remove.
2042
2308
  *
2043
2309
  * Review finding: a mail domain switch (`org.domain` changes, or
2044
2310
  * `usesDomain` flips) must also delete the *old* Lettermint domain here,
2045
2311
  * 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}
2312
+ * restricted to the org's still-live `<slug>-app`/`<slug>-relay` projects,
2313
+ * which could then keep sending as it forever. {@link deleteStaleLettermintDomain}
2048
2314
  * does this by comparing the previously recorded domain (case-insensitive)
2049
2315
  * against the one this apply just resolved.
2050
2316
  *
@@ -2058,15 +2324,22 @@ export class FoundryStepExecutor {
2058
2324
  // #177: `diff.ts`'s `planMail` carries the platform's own apex/mail host
2059
2325
  // in `desired` once it is wired -- "the executor writes exactly these
2060
2326
  // 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
- };
2327
+ // own rule) -- and blocks the step (never plans it) while the platform's
2328
+ // mail is off or not yet applied. So a `desired` missing either value
2329
+ // here is not a live gap to fall back on (#205): it is a step planned
2330
+ // before that rule existed, now stale. Fail rather than silently point an
2331
+ // org's mail at a fixed address nothing may still be listening on; the
2332
+ // fix is to plan this org's mail again.
2333
+ const apex = action.desired.apex;
2334
+ const mailHost = action.desired.mailHost;
2335
+ if (!apex || !mailHost) {
2336
+ 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`);
2337
+ }
2338
+ const options = { apex, mailHost };
2067
2339
  if (mode === 'off') {
2068
- await this.ports.mail.parkOrgMail(mailOrgFor(org, usesDomain, 'hosted'), options);
2069
- await this.parkLettermintMail(org, context);
2340
+ const parkedMailOrg = mailOrgFor(org, usesDomain, 'hosted');
2341
+ await this.ports.mail.parkOrgMail(parkedMailOrg, options);
2342
+ await this.parkLettermintMail(org, parkedMailOrg, context);
2070
2343
  return undefined;
2071
2344
  }
2072
2345
  const mailOrg = mailOrgFor(org, usesDomain, mode);
@@ -2077,30 +2350,97 @@ export class FoundryStepExecutor {
2077
2350
  // #177: what this step wrote from the platform, so `planMail` can see a later change.
2078
2351
  context.recordScopeId(ORG_MAIL_APPLIED_APEX_SCOPE_KEY, options.apex);
2079
2352
  context.recordScopeId(ORG_MAIL_APPLIED_HOST_SCOPE_KEY, options.mailHost);
2080
- await this.ensureOwnerMailbox(org, mailOrg, result.domain, context);
2353
+ await this.ensureOwnerMailbox(org, mailOrg, context);
2354
+ await this.ensureOrgLettermintRelay(org, mailOrg, result.domain, context);
2355
+ await this.ensureRoleMailboxes(org, mailOrg, result.domain, context);
2081
2356
  return { resourceId: result.domain };
2082
2357
  }
2083
2358
  /**
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.
2359
+ * `plan/diff.ts`'s `planMailCopy` (#113): this apply's share of copying
2360
+ * the org's old mail into Stalwart, via `mail-copy.ts`'s `runMailCopyStep`
2361
+ * -- the only thing this method itself does is read back what the last
2362
+ * apply recorded, hand it to that function, and record what comes back.
2363
+ * An account that failed to copy records the progress made, never
2364
+ * `mail_copy_done`, then throws with the failing account, phase and
2365
+ * redacted stderr tail, so the apply reports it as not done. A missing
2366
+ * `mailCopy` port throws too: a configuration gap a retry cannot fix.
2091
2367
  */
2092
- async ensureOwnerMailbox(org, mailOrg, domain, context) {
2368
+ async mailCopy(org, context) {
2369
+ if (!this.ports.mailCopy) {
2370
+ throw new NotBuiltError(`coolify:mail-copy for ${org.slug}`, 'construct FoundryStepExecutor with `mailCopy` (#113)');
2371
+ }
2372
+ const alreadyCompleted = new Set(parseMailCopyCompleted(context.recordedIds()[MAIL_COPY_COMPLETED_SCOPE_KEY]));
2373
+ const result = await runMailCopyStep(org, this.ports.mailCopy, alreadyCompleted);
2374
+ context.recordScopeId(MAIL_COPY_COMPLETED_SCOPE_KEY, JSON.stringify(result.completed));
2375
+ if (result.done)
2376
+ context.recordScopeId(MAIL_COPY_DONE_SCOPE_KEY, '1');
2377
+ if (result.failure) {
2378
+ const { account, phase, exitCode, detail } = result.failure;
2379
+ // Written now: the throw below ends the step before the end-of-run write.
2380
+ await context.persistScopeIds();
2381
+ 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)`);
2382
+ }
2383
+ return undefined;
2384
+ }
2385
+ /**
2386
+ * #162 (decided 2026-09-29): gives the org's declared owner a mailbox at
2387
+ * its declared `mail.ownerMailboxAddress` -- nothing is guessed any more
2388
+ * (the hardcoded `owner@<domain>` PR 163 shipped first is gone). A no-op
2389
+ * when the org has no owner yet, when no address is declared yet
2390
+ * (`plan/diff.ts`'s `planMail` reports that case as blocked, not planned,
2391
+ * so this step's own mode/domain work still runs), or once
2392
+ * {@link MAIL_OWNER_MAILBOX_SCOPE_KEY} is already recorded -- checked last
2393
+ * so a second apply never calls `MailPort.createMailbox` again, rather
2394
+ * than relying on that port method's own duplicate refusal.
2395
+ */
2396
+ async ensureOwnerMailbox(org, mailOrg, context) {
2093
2397
  if (!org.owner)
2094
2398
  return;
2399
+ const address = org.mail.ownerMailboxAddress ?? null;
2400
+ if (!address)
2401
+ return;
2095
2402
  if (context.recordedIds()[MAIL_OWNER_MAILBOX_SCOPE_KEY])
2096
2403
  return;
2097
- const address = `${MAIL_OWNER_LOCAL_PART}@${domain}`;
2098
2404
  await this.ports.mail.createMailbox(mailOrg, {
2099
2405
  address,
2100
2406
  name: `${org.owner.givenName} ${org.owner.familyName}`,
2101
2407
  });
2102
2408
  context.recordScopeId(MAIL_OWNER_MAILBOX_SCOPE_KEY, address);
2103
2409
  }
2410
+ /**
2411
+ * #230: creates each of the org's declared `mail.roleMailboxes` on its
2412
+ * just-resolved mail domain, once. A role mailbox has no person behind it,
2413
+ * so its display name is just its local part, and like the owner's it is
2414
+ * created with no password: `MailPort.createMailbox` takes none, so there
2415
+ * 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
2416
+ * {@link MAIL_ROLE_MAILBOXES_SCOPE_KEY} after every mailbox, so a second
2417
+ * apply (or a retry after a crash) skips what exists rather than relying
2418
+ * on the mail server's own duplicate refusal.
2419
+ */
2420
+ async ensureRoleMailboxes(org, mailOrg, domain, context) {
2421
+ const declared = org.mail.roleMailboxes ?? [];
2422
+ if (declared.length === 0)
2423
+ return;
2424
+ const recorded = context.recordedIds()[MAIL_ROLE_MAILBOXES_SCOPE_KEY];
2425
+ const created = new Set(recorded ? recorded.split(',') : []);
2426
+ for (const local of declared) {
2427
+ const address = `${local}@${domain}`;
2428
+ const entry = address.toLowerCase();
2429
+ if (created.has(entry))
2430
+ continue;
2431
+ try {
2432
+ await this.ports.mail.createMailbox(mailOrg, { address, name: local });
2433
+ }
2434
+ catch (error) {
2435
+ // A crash between create and record leaves the mailbox made but
2436
+ // unrecorded; the port exposes no existence check, only the refusal.
2437
+ if (!/already has a mailbox/i.test(error instanceof Error ? error.message : String(error)))
2438
+ throw error;
2439
+ }
2440
+ created.add(entry);
2441
+ context.recordScopeId(MAIL_ROLE_MAILBOXES_SCOPE_KEY, [...created].join(','));
2442
+ }
2443
+ }
2104
2444
  /**
2105
2445
  * Deletes the org's previous Lettermint domain once the mail domain it was
2106
2446
  * registered under has changed -- otherwise it stays registered and
@@ -2145,17 +2485,22 @@ export class FoundryStepExecutor {
2145
2485
  /**
2146
2486
  * Parking mail (2026-09-28 lettermint-org-mail swap, "Core rule": "Parking
2147
2487
  * 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.
2488
+ * restriction points only at dead projects"). #149: first drops the
2489
+ * Stalwart relay route with `MailPort.removeOrgRelay` -- a missing route is
2490
+ * a no-op, per that port method's own contract, so this is safe to call
2491
+ * even for an org whose relay was never minted -- then deletes every
2492
+ * Lettermint project named `<slug>-app` or `<slug>-relay`, then the org's
2493
+ * own Lettermint domain, if one was found -- in that order, never the
2494
+ * reverse, so the route is always gone before the project it pointed at.
2151
2495
  * A no-op until a previous apply recorded {@link MAIL_DOMAIN_SCOPE_KEY}:
2152
2496
  * nothing was ever provisioned for an org whose mail went straight from
2153
2497
  * never-applied to off.
2154
2498
  */
2155
- async parkLettermintMail(org, context) {
2499
+ async parkLettermintMail(org, mailOrg, context) {
2156
2500
  const mailDomain = context.recordedIds()[MAIL_DOMAIN_SCOPE_KEY];
2157
2501
  if (!mailDomain)
2158
2502
  return;
2503
+ await this.ports.mail.removeOrgRelay(mailOrg);
2159
2504
  const names = new Set([`${org.slug}-app`, `${org.slug}-relay`]);
2160
2505
  const projects = await this.ports.lettermint.listProjects();
2161
2506
  for (const project of projects) {
@@ -2309,9 +2654,24 @@ export class FoundryStepExecutor {
2309
2654
  const { org, recordedIds } = context;
2310
2655
  try {
2311
2656
  switch (`${target.system}:${target.kind}`) {
2312
- case 'cloudflare:tunnel':
2657
+ case 'cloudflare:tunnel': {
2658
+ // #235: the connector app first, its own 404 handled locally --
2659
+ // a retry where this already succeeded must still reach
2660
+ // `deleteTunnel` below, which the outer catch's own 404 handling
2661
+ // would otherwise short-circuit before it ran.
2662
+ const connectorAppId = recordedIds[CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY];
2663
+ if (connectorAppId) {
2664
+ try {
2665
+ await this.ports.coolify.deleteApplication(connectorAppId);
2666
+ }
2667
+ catch (error) {
2668
+ if (!isNotFoundError(error))
2669
+ throw error;
2670
+ }
2671
+ }
2313
2672
  await this.ports.cloudflare.deleteTunnel(target.id);
2314
2673
  return;
2674
+ }
2315
2675
  case 'cloudflare:access':
2316
2676
  await this.ports.cloudflare.deleteAccessApp(target.id);
2317
2677
  return;