@wtfalch/foundry 0.5.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 (42) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +16 -1
  2. package/dist/bootstrap/bootstrap.js +40 -2
  3. package/dist/bootstrap/declare.js +6 -1
  4. package/dist/calls-sfu-deploy-support.d.ts +8 -0
  5. package/dist/calls-sfu-deploy-support.js +10 -1
  6. package/dist/calls-sfu.d.ts +31 -8
  7. package/dist/calls-sfu.js +36 -5
  8. package/dist/index.d.ts +8 -2
  9. package/dist/index.js +5 -2
  10. package/dist/migrations/0019_mail_owner_mailbox.sql +15 -0
  11. package/dist/migrations/0020_mail_apps.sql +7 -0
  12. package/dist/migrations/0021_mail_role_mailboxes.sql +6 -0
  13. package/dist/org.d.ts +81 -0
  14. package/dist/org.js +88 -0
  15. package/dist/plan/diff.js +93 -10
  16. package/dist/plan/executor.d.ts +236 -35
  17. package/dist/plan/executor.js +274 -76
  18. package/dist/plan/index.d.ts +7 -1
  19. package/dist/plan/index.js +4 -1
  20. package/dist/plan/mail-copy.d.ts +125 -0
  21. package/dist/plan/mail-copy.js +166 -0
  22. package/dist/plan/mail-identity-allowlist.d.ts +50 -0
  23. package/dist/plan/mail-identity-allowlist.js +11 -0
  24. package/dist/plan/mail-identity.d.ts +68 -0
  25. package/dist/plan/mail-identity.js +17 -0
  26. package/dist/plan/plan-apply.d.ts +5 -0
  27. package/dist/plan/platform-diff.d.ts +184 -9
  28. package/dist/plan/platform-diff.js +368 -15
  29. package/dist/plan/platform-org.d.ts +3 -2
  30. package/dist/plan/platform-providers.d.ts +138 -24
  31. package/dist/plan/platform-providers.js +1460 -79
  32. package/dist/plan/platform-sfu.d.ts +54 -19
  33. package/dist/plan/platform-sfu.js +64 -22
  34. package/dist/plan/zitadel-machine-user.d.ts +60 -11
  35. package/dist/plan/zitadel-machine-user.js +182 -39
  36. package/dist/requested-changes.d.ts +17 -7
  37. package/dist/requested-changes.js +37 -7
  38. package/dist/store/postgres.js +31 -2
  39. package/dist/store/schema.d.ts +72 -0
  40. package/dist/store/schema.js +13 -0
  41. package/dist/store.d.ts +7 -0
  42. package/package.json +1 -1
package/dist/plan/diff.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { NO_AGORA_HOST_VALUES, agoraEnvDrift, agoraHostMissing, desiredAgoraEnv, } from './agora.js';
2
+ import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, } from './mail-copy.js';
2
3
  import { ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, } from './platform-diff.js';
3
4
  import { DEFAULT_ORG_PLATFORM_READINESS } from './platform-org.js';
4
5
  /** The `Plan` (#19's public contract) a `PlanDetail` carries. */
@@ -73,6 +74,9 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
73
74
  const mail = planMail(org, byKey, claimed, recordedIds, platformReadiness.mailHost);
74
75
  if (mail.step)
75
76
  plannedSteps.push(mail.step);
77
+ const mailCopy = planMailCopy(org, byKey, recordedIds);
78
+ if (mailCopy)
79
+ plannedSteps.push(mailCopy);
76
80
  const chat = planChat(org, byKey, claimed);
77
81
  if (chat)
78
82
  plannedSteps.push(chat);
@@ -197,6 +201,11 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
197
201
  unmanaged.push({ description: issuer.blocked, key: 'zitadel:issuer' });
198
202
  if (mail.blocked)
199
203
  unmanaged.push({ description: mail.blocked, key: 'coolify:mail' });
204
+ // #162: reported separately from `mail.blocked` -- missing an owner
205
+ // mailbox address never blocks the rest of this org's mail.
206
+ if (mail.mailboxBlocked) {
207
+ unmanaged.push({ description: mail.mailboxBlocked, key: 'coolify:mail:owner-mailbox' });
208
+ }
200
209
  if (calls.blocked)
201
210
  unmanaged.push({ description: calls.blocked, key: 'coolify:calls' });
202
211
  if (filesOrganisation.blocked) {
@@ -353,6 +362,10 @@ function planOwner(org, recordedIds, authReady) {
353
362
  * `LOG_DIR` precedent follows for a value shared with `executor.ts`.
354
363
  */
355
364
  const MAIL_OWNER_MAILBOX_SCOPE_KEY = 'mail_owner_mailbox';
365
+ /** #230: restated from `executor.ts`'s `MAIL_ROLE_MAILBOXES_SCOPE_KEY`, same rule: comma-joined local parts already created. */
366
+ const MAIL_ROLE_MAILBOXES_SCOPE_KEY = 'mail_role_mailboxes';
367
+ /** Restated from `executor.ts`, the same rule: the mail domain the last apply resolved. */
368
+ const MAIL_DOMAIN_SCOPE_KEY = 'mail_domain';
356
369
  /** Restated from `executor.ts`, the same rule: the id of an ai key minted but not delivered. */
357
370
  export const AI_KEY_UNDELIVERED_SCOPE_KEY = 'ai_key_undelivered';
358
371
  /** Restated from `executor.ts`, the same rule: the id of a jobs project registered but not delivered. */
@@ -376,13 +389,14 @@ function planMail(org, byKey, claimed, recordedIds, mailHost) {
376
389
  // after having it would silently hide its own leftover mail resource from
377
390
  // the unmanaged list.
378
391
  if (org.mail.mode === 'off')
379
- return { step: null, blocked: null };
392
+ return { step: null, blocked: null, mailboxBlocked: null };
380
393
  if (live)
381
394
  claimed.add(key);
382
395
  if (!mailHost) {
383
396
  return {
384
397
  step: null,
385
398
  blocked: `${org.slug}'s mail is blocked, not planned: the platform's mail server is off or not yet applied`,
399
+ mailboxBlocked: null,
386
400
  };
387
401
  }
388
402
  const desired = {
@@ -391,12 +405,33 @@ function planMail(org, byKey, claimed, recordedIds, mailHost) {
391
405
  apex: mailHost.apex,
392
406
  mailHost: mailHost.mailHost,
393
407
  };
394
- // #162: an owner's mailbox is minted inside the same `coolify:mail` step,
395
- // after `applyOrgMail` -- so as long as one is still owed, this step must
396
- // keep showing up even once mode/usesDomain themselves have nothing left
397
- // to change, or the operator would apply and never see it happen.
398
- const mailboxOwed = Boolean(org.owner) && !recordedIds[MAIL_OWNER_MAILBOX_SCOPE_KEY];
399
- const mailboxNote = mailboxOwed ? ` and give ${org.slug}'s owner a mailbox` : '';
408
+ // #162 (decided 2026-09-29): an owner's mailbox is minted inside the same
409
+ // `coolify:mail` step, after `applyOrgMail` -- so as long as one is still
410
+ // owed, this step must keep showing up even once mode/usesDomain
411
+ // themselves have nothing left to change, or the operator would apply and
412
+ // never see it happen. Nothing is guessed any more: without a declared
413
+ // `mail.ownerMailboxAddress`, the mailbox alone is reported blocked below
414
+ // (`mailboxBlocked`), the same "blocked, not planned" pattern a blocked
415
+ // Agora already follows (PR 180) -- the rest of this step, and every
416
+ // other step in the org's plan, is unaffected.
417
+ const mailboxAlreadyGiven = Boolean(recordedIds[MAIL_OWNER_MAILBOX_SCOPE_KEY]);
418
+ const hasOwner = Boolean(org.owner);
419
+ const ownerMailboxAddress = org.mail.ownerMailboxAddress ?? null;
420
+ const mailboxBlocked = hasOwner && !mailboxAlreadyGiven && !ownerMailboxAddress
421
+ ? `${org.slug}'s owner mailbox is blocked, not planned: mail.ownerMailboxAddress is not declared`
422
+ : null;
423
+ const mailboxOwed = hasOwner && !mailboxAlreadyGiven && ownerMailboxAddress !== null;
424
+ // #230: declared role mailboxes not yet recorded as created. Names only
425
+ // ever count here, never appear in a description, same as the owner's address.
426
+ // Recorded per address, so a changed mail domain owes its own set. A hosted
427
+ // org's domain is only known once recorded; until then every role is owed.
428
+ const createdRoles = new Set((recordedIds[MAIL_ROLE_MAILBOXES_SCOPE_KEY] ?? '').split(','));
429
+ const roleDomain = (org.mail.usesDomain && org.domain) || recordedIds[MAIL_DOMAIN_SCOPE_KEY];
430
+ const rolesOwed = (org.mail.roleMailboxes ?? []).filter((r) => !roleDomain || !createdRoles.has(`${r}@${roleDomain}`.toLowerCase())).length;
431
+ const ownerNote = mailboxOwed ? `give ${org.slug}'s owner a mailbox` : '';
432
+ const roleNote = rolesOwed > 0 ? `create ${rolesOwed} role mailbox(es) for ${org.slug}` : '';
433
+ const mailboxWork = [ownerNote, roleNote].filter(Boolean).join(' and ');
434
+ const mailboxNote = mailboxWork ? ` and ${mailboxWork}` : '';
400
435
  if (!live) {
401
436
  return {
402
437
  step: {
@@ -408,6 +443,7 @@ function planMail(org, byKey, claimed, recordedIds, mailHost) {
408
443
  },
409
444
  },
410
445
  blocked: null,
446
+ mailboxBlocked,
411
447
  };
412
448
  }
413
449
  const liveMode = live.config.mode;
@@ -417,20 +453,21 @@ function planMail(org, byKey, claimed, recordedIds, mailHost) {
417
453
  // them (or it never recorded them). Re-applying is idempotent.
418
454
  const addressMoved = recordedIds[ORG_MAIL_APPLIED_APEX_SCOPE_KEY] !== mailHost.apex ||
419
455
  recordedIds[ORG_MAIL_APPLIED_HOST_SCOPE_KEY] !== mailHost.mailHost;
420
- if (!mailboxOwed && !addressMoved)
421
- return { step: null, blocked: null };
456
+ if (!mailboxWork && !addressMoved)
457
+ return { step: null, blocked: null, mailboxBlocked };
422
458
  return {
423
459
  step: {
424
460
  action: { system: 'coolify', kind: 'mail', key: org.id, desired },
425
461
  step: {
426
462
  description: addressMoved
427
463
  ? `re-apply ${org.slug}'s mail on the platform's mail host ${mailHost.mailHost}${mailboxNote}`
428
- : `give ${org.slug}'s owner a mailbox`,
464
+ : mailboxWork,
429
465
  destroys: false,
430
466
  migrates: false,
431
467
  },
432
468
  },
433
469
  blocked: null,
470
+ mailboxBlocked,
434
471
  };
435
472
  }
436
473
  if (liveUsesDomain && !desired.usesDomain) {
@@ -446,6 +483,21 @@ function planMail(org, byKey, claimed, recordedIds, mailHost) {
446
483
  },
447
484
  },
448
485
  blocked: null,
486
+ mailboxBlocked,
487
+ };
488
+ }
489
+ // #113: moving from `received` to `hosted` is an MX cutover, and foundry
490
+ // never cuts over before an org's old mail has copied into Stalwart --
491
+ // `planMailCopy`'s own step is what runs that copy. Blocked, not planned,
492
+ // the same "withhold the step rather than apply it half-ready" rule
493
+ // `mailboxBlocked` already follows just above.
494
+ if (liveMode === 'received' &&
495
+ desired.mode === 'hosted' &&
496
+ !recordedIds[MAIL_COPY_DONE_SCOPE_KEY]) {
497
+ return {
498
+ step: null,
499
+ blocked: `${org.slug}'s mail cannot move to hosted yet: its old mail is still being copied into Stalwart (coolify:mail-copy, #113)`,
500
+ mailboxBlocked,
449
501
  };
450
502
  }
451
503
  // A mode transition (#7 "Transitions") or turning a domain on is a
@@ -460,6 +512,37 @@ function planMail(org, byKey, claimed, recordedIds, mailHost) {
460
512
  },
461
513
  },
462
514
  blocked: null,
515
+ mailboxBlocked,
516
+ };
517
+ }
518
+ /**
519
+ * #113: the one-time copy of an org's old mail into Stalwart that must
520
+ * finish before `planMail` allows the `received` -> `hosted` MX cutover
521
+ * above. Planned only for that exact transition -- a fresh org declared
522
+ * `hosted` from the start, or one moving between any other pair of modes,
523
+ * has nothing to migrate from and never sees this step. Never matched
524
+ * against a live resource: like `platform-diff.ts`'s `planSfu`, there is no
525
+ * live state to probe here (`runMailCopyStep`'s own progress is recorded
526
+ * state, not something a reader lists), so this plans again on every apply
527
+ * until {@link MAIL_COPY_DONE_SCOPE_KEY} is recorded.
528
+ */
529
+ function planMailCopy(org, byKey, recordedIds) {
530
+ if (org.mail.mode !== 'hosted')
531
+ return null;
532
+ if (recordedIds[MAIL_COPY_DONE_SCOPE_KEY])
533
+ return null;
534
+ const live = byKey.get(resourceKey('coolify', 'mail', org.id));
535
+ if (!live || live.config.mode !== 'received')
536
+ return null;
537
+ const completed = parseMailCopyCompleted(recordedIds[MAIL_COPY_COMPLETED_SCOPE_KEY]);
538
+ const progress = completed.length > 0 ? ` (${completed.length} account(s) already copied)` : '';
539
+ return {
540
+ action: { system: 'coolify', kind: 'mail-copy', key: org.id, desired: {} },
541
+ step: {
542
+ description: `copy ${org.slug}'s old mail into Stalwart before the MX cutover${progress} -- re-apply again to continue, or tomorrow if a provider's daily limit was hit`,
543
+ destroys: false,
544
+ migrates: false,
545
+ },
463
546
  };
464
547
  }
465
548
  function planChat(org, byKey, claimed) {
@@ -2,6 +2,7 @@ import type { OrgMailOptions, OrgMailOrg, OrgMailResult } from '@wtfalch/email/p
2
2
  import type { DeclaredOrg, HostingBuildPack } from '../org.js';
3
3
  import type { DeclaredOrgStore } from '../store.js';
4
4
  import type { StepAction } from './diff.js';
5
+ import { type MailCopyPort } from './mail-copy.js';
5
6
  import type { DestroyTarget, StepDestroyer, StepExecutionContext, StepExecutor } from './plan-apply.js';
6
7
  /**
7
8
  * The real {@link StepExecutor} (#20 "provision Company Factory end to
@@ -504,35 +505,65 @@ export interface MailPort {
504
505
  * `createMailbox`, resolving `org`'s domain to a `domainId` the same way
505
506
  * `applyOrgMail` does -- that function already refuses a duplicate `local`
506
507
  * on the resolved domain, so this port method never has to. `ensureOwnerMailbox`
507
- * below is this executor's one caller, and it checks its own recorded scope
508
+ * and `ensureRoleMailboxes` (#230) below are this executor's callers, and each checks its own recorded scope
508
509
  * id first, so a second apply never calls this at all.
509
510
  */
510
511
  createMailbox(org: OrgMailOrg, mailbox: {
511
512
  address: string;
512
513
  name: string;
513
514
  }): Promise<void>;
515
+ /**
516
+ * Upserts this org's own outbound relay route on Stalwart (#149): an
517
+ * `MtaRoute` through Lettermint, restricted to `org`'s domain, keyed by
518
+ * `org.slug` -- the host implements this with `@wtfalch/email/postmaster`'s
519
+ * own `applyOrgRelay` (https://github.com/wtfalch/email/issues/118), which
520
+ * rebuilds the shared routing expression so two orgs' routes never
521
+ * collide. `secret` is the Lettermint project token this executor just
522
+ * minted (`ensureOrgLettermintRelay`'s own mint rule) -- it goes straight
523
+ * here and nowhere else: never returned, never recorded, never logged, the
524
+ * same discipline `ensureAppLettermintMail`'s own doc comment states for
525
+ * `LETTERMINT_API_TOKEN`.
526
+ */
527
+ applyOrgRelay(org: OrgMailOrg, secret: string): Promise<void>;
528
+ /**
529
+ * Drops this org's relay route (#149), implemented with
530
+ * `@wtfalch/email/postmaster`'s own `removeOrgRelay` -- a missing route is
531
+ * a no-op, so `parkLettermintMail` can call this unconditionally before it
532
+ * deletes the Lettermint project the route pointed at.
533
+ */
534
+ removeOrgRelay(org: OrgMailOrg): Promise<void>;
535
+ /**
536
+ * True once this org's relay route exists on Stalwart with a secret set
537
+ * (#149). `ensureOrgLettermintRelay`'s own mint rule reads this alongside
538
+ * Lettermint's own project listing: a project without a live route (the
539
+ * route recorded a token this executor never minted) is exactly as
540
+ * unhealthy as a route with no project behind it, so a fresh mint fixes
541
+ * either.
542
+ */
543
+ hasOrgRelay(org: OrgMailOrg): Promise<boolean>;
514
544
  }
515
545
  /**
516
- * The shared calls SFU's own LiveKit API credentials (#122, #127, #145).
517
- * Before #170, a human minted this pair by hand and `resolveCredentials`
518
- * read wherever `scripts/calls-sfu-deploy.mjs`'s own `CallsSfuConfig`
519
- * delivered it from -- this package never minted or invented a LiveKit
520
- * key/secret pair itself.
546
+ * The shared calls SFU's own per-org LiveKit API credentials (#122, #127,
547
+ * #145, #152). Before #170, a human minted one pair by hand, shared by every
548
+ * org, and `resolveCredentials` read wherever `scripts/calls-sfu-deploy.mjs`'s
549
+ * own `CallsSfuConfig` delivered it from -- this package never minted or
550
+ * invented a LiveKit key/secret pair itself.
521
551
  *
522
552
  * As of #170 the platform's own `sfu:livekit` step
523
- * (`plan/platform-sfu.ts`'s `applyCallsSfuPlatformStep`) mints the pair, the
524
- * first time it applies, and delivers it to held keys through
525
- * `CallsSfuKeysPort.deliverLivekitKeys`. `resolveCredentials` reads that same
553
+ * (`plan/platform-sfu.ts`'s `applyCallsSfuPlatformStep`) mints the pair, and
554
+ * as of #152 it mints one *per org*, the first time that org applies, and
555
+ * delivers it to held keys through `CallsSfuKeysPort.deliverLivekitKeys`,
556
+ * keyed by the org's slug. `resolveCredentials` reads a specific org's own
526
557
  * held-keys entry back -- still resolved by the host (archon), never minted
527
558
  * or read directly by this port itself, the same "the executor is
528
559
  * constructed with narrow ports ... a host supplies real clients" rule this
529
560
  * file's own top doc comment states for every provider credential.
530
561
  * `resolveCredentials` returns `null` before the platform's `sfu` step has
531
- * ever applied, the same "nothing recorded" signal a bare
562
+ * ever applied for that org, the same "nothing recorded" signal a bare
532
563
  * `CoolifyPort.listEnvs` gives `ensureCallsMatrixSecrets`.
533
564
  */
534
565
  export interface CallsSfuPort {
535
- resolveCredentials(): Promise<{
566
+ resolveCredentials(orgSlug: string): Promise<{
536
567
  apiKey: string;
537
568
  apiSecret: string;
538
569
  } | null>;
@@ -664,6 +695,77 @@ export interface ZitadelPort {
664
695
  userId: string;
665
696
  token: string;
666
697
  }>;
698
+ /**
699
+ * #216 (building on #209/#215): mints a second, narrower machine user on
700
+ * the same instance, with only the `IAM_LOGIN_CLIENT` role -- never
701
+ * `IAM_OWNER`, which {@link mintAdminMachineUser} already covers and which
702
+ * this method must not reuse or grant in addition. The login app and the
703
+ * broker both refuse to start without this token
704
+ * (`platform-providers.ts`'s `provisionPlatformAuth`, "Known gaps").
705
+ *
706
+ * Idempotent on the user by `userName`, like {@link mintAdminMachineUser},
707
+ * but never on the token the same way: ZITADEL shows a PAT's value once,
708
+ * and a crash or a failed delivery between a prior mint and the caller
709
+ * recording it would otherwise leave that lost token live forever. Unlike
710
+ * {@link mintAdminMachineUser}, revoking this user's PAT destroys nothing
711
+ * -- nothing else was ever encrypted with it, the way ZITADEL's own
712
+ * masterkey protects the admin path's data -- so a real implementation
713
+ * lists and deletes every PAT this user already holds before minting a
714
+ * fresh one, leaving exactly one live token no matter how many times this
715
+ * is called for the same user. This is what lets the caller
716
+ * (`provisionPlatformAuth`) simply re-mint on every rerun until delivery
717
+ * succeeds, rather than blocking forever the way an undelivered admin key
718
+ * does.
719
+ *
720
+ * Refuses (a named error, never a silent reuse) when a `foundry-login-client`
721
+ * user already exists with any IAM role other than exactly
722
+ * `IAM_LOGIN_CLIENT` -- that user was not created by this method, and
723
+ * granting it an additional role, or reusing its PAT, would widen access
724
+ * this caller never asked for.
725
+ *
726
+ * #216 round 2: also returns `patId`, the minted PAT's own id, so the
727
+ * caller can hand it to {@link listLoginClientPats} after delivering the
728
+ * token and confirm nothing else revoked or replaced it in the meantime.
729
+ *
730
+ * #216 (third PR review): also returns `revokedPriorPat`, true exactly
731
+ * when a live PAT existed on this user and was revoked before this mint --
732
+ * never true on a first-ever mint, where nothing was live to revoke. The
733
+ * caller uses this to word a delivery-failure error correctly: on a
734
+ * re-mint the login app and broker are left with no live token at all
735
+ * (the old one is already gone); on a first mint they simply never had one
736
+ * yet.
737
+ */
738
+ mintLoginClientMachineUser(orgId: string, input: {
739
+ userName: string;
740
+ name: string;
741
+ }): Promise<{
742
+ userId: string;
743
+ token: string;
744
+ patId: string;
745
+ revokedPriorPat: boolean;
746
+ }>;
747
+ /**
748
+ * #216 round 2: every live PAT id on the `foundry-login-client` machine
749
+ * user, read straight from ZITADEL. `provisionPlatformAuth`
750
+ * (`platform-providers.ts`) calls this right after it delivers a freshly
751
+ * minted token to the login app and the broker, to close a race two
752
+ * concurrent applies can otherwise hit: run A mints token a and is about to
753
+ * deliver it; run B's own mint (`mintLoginClientMachineUser`, idempotent on
754
+ * the same `userName`) revokes a and mints token b first; run A then
755
+ * delivers the now-revoked a and would, without this check, still record
756
+ * success. Expecting the result to be exactly `[patId]` (the id
757
+ * {@link mintLoginClientMachineUser} returned) catches that: anything else
758
+ * means some other mint ran in between, so this run must fail rather than
759
+ * record delivery, leaving the next apply to re-mint and re-deliver.
760
+ *
761
+ * #216 round 3: a single call made once, after every write, cannot catch a
762
+ * write that lands after it -- `provisionPlatformAuth` now calls this
763
+ * again immediately before each individual write (each retry attempt of
764
+ * each of the login app's env, the broker's env, and each app's deploy),
765
+ * not only once at the end, so a write about to deliver a token this call
766
+ * reports no longer live is never attempted at all.
767
+ */
768
+ listLoginClientPats(orgId: string, userId: string): Promise<string[]>;
667
769
  }
668
770
  /**
669
771
  * Provisions one org's organisation on Files (#80), matching
@@ -839,6 +941,15 @@ export interface FoundryStepExecutorPorts {
839
941
  jobsScheduler?: JobsSchedulerPort;
840
942
  /** The shared calls SFU's own LiveKit credentials (#145) -- see {@link CallsSfuPort}'s own doc comment. */
841
943
  callsSfu: CallsSfuPort;
944
+ /**
945
+ * #113: the org's old-mail copy into Stalwart, ahead of a `received` ->
946
+ * `hosted` MX cutover -- see {@link MailCopyPort}'s own doc comment.
947
+ * Optional the same way `jobsScheduler` is: a host that never runs this
948
+ * transition builds the executor without it, and `coolify:mail-copy`
949
+ * throws a clear "not built" error rather than every other step losing
950
+ * compilation over one dependency nothing else needs.
951
+ */
952
+ mailCopy?: MailCopyPort;
842
953
  /** Flat, not a factory: `organisationId` is an explicit argument on every method of both ports (see each port's own doc comment; #177 dropped the `filesLimitsFor`/`aiLimitsFor` factories this used to need). */
843
954
  filesOrganisation?: FilesOrganisationPort;
844
955
  aiOrganisation?: AiOrganisationPort;
@@ -1033,9 +1144,10 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1033
1144
  * `zitadel:issuer` (`AUTH_ORGANIZATION_ID`), `zitadel:owner`
1034
1145
  * (`OWNER_USER_ID`, gap P3) and every `zitadel:app` step
1035
1146
  * (`AUTH_CLIENT_ID`), all ordered before `coolify:app` for exactly this
1036
- * reason. AUTH_APP_KEY (waits on an auth broker registration API) and
1037
- * DATABASE_URL_OWNER (a runtime-role split not built yet, `pushDatabaseUrl`'s
1038
- * own doc comment) are out of scope here (task brief).
1147
+ * reason. `AUTH_APP_KEY` (#206 -- which broker an org's app registers with
1148
+ * is its own design question, left out of #204's own "How" rather than
1149
+ * guessed) and DATABASE_URL_OWNER (a runtime-role split not built yet,
1150
+ * `pushDatabaseUrl`'s own doc comment) are out of scope here (task brief).
1039
1151
  */
1040
1152
  private pushAppEnv;
1041
1153
  /**
@@ -1079,7 +1191,10 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1079
1191
  * be deleted.
1080
1192
  *
1081
1193
  * `MAIL_FROM` is upserted every time: it depends only on the mail domain
1082
- * `coolify:mail` resolved.
1194
+ * `coolify:mail` resolved and `org.mail.noReplyLocalPart` (#162, decided
1195
+ * 2026-09-29 -- defaults to `'no-reply'`, restated here rather than
1196
+ * imported from `org.ts`'s `DEFAULT_NO_REPLY_LOCAL_PART`, this file's own
1197
+ * `LOG_DIR`-style convention).
1083
1198
  *
1084
1199
  * The token goes straight to `bulkUpsertEnvs` and nowhere else: never
1085
1200
  * returned, never recorded in `serviceIds` (`recordScopeId`'s own doc
@@ -1088,6 +1203,63 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1088
1203
  * own connection string.
1089
1204
  */
1090
1205
  private ensureAppLettermintMail;
1206
+ /**
1207
+ * #149: mints this org's own Lettermint relay credential and writes it to
1208
+ * Stalwart via `MailPort.applyOrgRelay`, the same mint rule
1209
+ * {@link ensureAppLettermintMail} already uses for the org's app token --
1210
+ * "healthy" means Stalwart's own route exists ({@link MailPort.hasOrgRelay}),
1211
+ * AND Lettermint lists a project named `<slug>-relay` whose id
1212
+ * {@link ORG_MAIL_RELAY_SCOPE_KEY} records for this
1213
+ * exact mail domain (review finding: a route with a secret and a
1214
+ * same-named project both present is not enough -- the project could be a
1215
+ * lost-and-reprovisioned one this step never wrote a route for, and a mail
1216
+ * domain switch must re-point the route rather than read as healthy
1217
+ * forever because the old route still has a secret in it). A route with no
1218
+ * live project behind it, a project the route's own secret never reached,
1219
+ * or a recorded project id/domain that no longer matches, are all exactly
1220
+ * as unhealthy as neither existing.
1221
+ *
1222
+ * A fresh project is created and its token written to Stalwart first, and
1223
+ * only then is every other `<slug>-relay` project deleted -- a lost route
1224
+ * and a duplicate project both end the same way. The new project's domain
1225
+ * restriction runs before the token is written, so a failure there never
1226
+ * touches the working route. Review finding: if either the
1227
+ * domain-restriction step or `applyOrgRelay` throws after a
1228
+ * fresh project was minted, that project is deleted before the error
1229
+ * propagates -- otherwise every retry sees more than one `<slug>-relay`
1230
+ * project and mints yet another one, growing the orphan count without
1231
+ * bound.
1232
+ *
1233
+ * `coolify:mail` runs before `coolify:app` (`diff.ts`'s own step order), so
1234
+ * this is very often the first of the two to reach the org's Lettermint
1235
+ * domain: {@link ensureLettermintDomainRestriction} registers and restricts
1236
+ * it the same way the app step does, rather than assuming it already
1237
+ * exists -- the domain must never end up unrestricted, whichever step gets
1238
+ * there first.
1239
+ */
1240
+ private ensureOrgLettermintRelay;
1241
+ /**
1242
+ * Deletes a just-minted project from inside a `catch`. A failed delete is
1243
+ * dropped so it never replaces the error being rethrown; the orphan is
1244
+ * swept by the next apply's duplicate delete.
1245
+ */
1246
+ private discardLettermintProject;
1247
+ /**
1248
+ * Ensures this org's Lettermint domain exists, is restricted to exactly
1249
+ * the `<slug>-app`/`<slug>-relay` projects live in `allProjects`, has its
1250
+ * DNS published and a verification triggered. Shared by
1251
+ * {@link ensureAppLettermintMail} and {@link ensureOrgLettermintRelay}
1252
+ * (#149) -- whichever of the two steps runs first on a fresh org creates
1253
+ * the domain, and the other then finds it (`findExactLettermintDomain`).
1254
+ *
1255
+ * A crash between the restriction write and the DNS publish/verify below
1256
+ * is safe even for a freshly created domain: DNS is only published, and
1257
+ * verification only triggered, after the restriction -- so an
1258
+ * unrestricted domain cannot verify and cannot send, whichever project
1259
+ * owns it (post-#140 review finding, restated here rather than only at
1260
+ * the one call site it used to have).
1261
+ */
1262
+ private ensureLettermintDomainRestriction;
1091
1263
  /**
1092
1264
  * Review finding (post-#140 rework): {@link LettermintPort.findDomain}'s
1093
1265
  * own match is partial and case-insensitive, so a raw result can be a
@@ -1374,20 +1546,21 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1374
1546
  * `@wtfalch/email/postmaster`'s `applyOrgMail`/`parkOrgMail` bound), then
1375
1547
  * publishes whatever Stalwart DNS records the call returns, and records the
1376
1548
  * resolved mail domain as a scope id ({@link MAIL_DOMAIN_SCOPE_KEY}).
1377
- * 2026-09-28 lettermint-org-mail swap: this step no longer calls the mail
1378
- * relay provider at all -- registering the domain, restricting it and
1379
- * publishing its own DNS records all moved to the app step
1380
- * (`ensureAppLettermintMail`, "Core rule"), since the first project can
1381
- * only exist once the token has a home. Parking mail (mode `'off'`) does
1382
- * still reach Lettermint, the one exception: {@link parkLettermintMail}
1383
- * deletes the org's own projects and then its domain, so a domain never
1384
- * ends up restricted only to dead projects.
1549
+ *
1550
+ * #149: this step also mints the org's own Lettermint *relay* credential
1551
+ * ({@link ensureOrgLettermintRelay}) -- the app's own *sending* credential
1552
+ * stays in the app step (`ensureAppLettermintMail`, "Core rule"), since the
1553
+ * first app project can only exist once the token has a home. Parking mail
1554
+ * (mode `'off'`) reaches Lettermint too: {@link parkLettermintMail} drops
1555
+ * the relay route, then deletes the org's own projects and its domain, so
1556
+ * a domain never ends up restricted only to dead projects and no route
1557
+ * ever points at a project this apply is about to remove.
1385
1558
  *
1386
1559
  * Review finding: a mail domain switch (`org.domain` changes, or
1387
1560
  * `usesDomain` flips) must also delete the *old* Lettermint domain here,
1388
1561
  * before recording the new one -- otherwise it stays registered and
1389
- * restricted to the org's still-live `<slug>-app` project, which could
1390
- * then keep sending as it forever. {@link deleteStaleLettermintDomain}
1562
+ * restricted to the org's still-live `<slug>-app`/`<slug>-relay` projects,
1563
+ * which could then keep sending as it forever. {@link deleteStaleLettermintDomain}
1391
1564
  * does this by comparing the previously recorded domain (case-insensitive)
1392
1565
  * against the one this apply just resolved.
1393
1566
  *
@@ -1397,15 +1570,39 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1397
1570
  */
1398
1571
  private coolifyMail;
1399
1572
  /**
1400
- * #162: gives the org's declared owner a mailbox on its just-resolved mail
1401
- * domain, the one address this executor mints on its own
1402
- * ({@link MAIL_OWNER_LOCAL_PART}'s own doc comment). A no-op when the org
1403
- * has no owner yet, or once {@link MAIL_OWNER_MAILBOX_SCOPE_KEY} is already
1404
- * recorded -- checked first so a second apply never calls
1405
- * `MailPort.createMailbox` again, rather than relying on that port method's
1406
- * own duplicate refusal.
1573
+ * `plan/diff.ts`'s `planMailCopy` (#113): this apply's share of copying
1574
+ * the org's old mail into Stalwart, via `mail-copy.ts`'s `runMailCopyStep`
1575
+ * -- the only thing this method itself does is read back what the last
1576
+ * apply recorded, hand it to that function, and record what comes back.
1577
+ * An account that failed to copy records the progress made, never
1578
+ * `mail_copy_done`, then throws with the failing account, phase and
1579
+ * redacted stderr tail, so the apply reports it as not done. A missing
1580
+ * `mailCopy` port throws too: a configuration gap a retry cannot fix.
1581
+ */
1582
+ private mailCopy;
1583
+ /**
1584
+ * #162 (decided 2026-09-29): gives the org's declared owner a mailbox at
1585
+ * its declared `mail.ownerMailboxAddress` -- nothing is guessed any more
1586
+ * (the hardcoded `owner@<domain>` PR 163 shipped first is gone). A no-op
1587
+ * when the org has no owner yet, when no address is declared yet
1588
+ * (`plan/diff.ts`'s `planMail` reports that case as blocked, not planned,
1589
+ * so this step's own mode/domain work still runs), or once
1590
+ * {@link MAIL_OWNER_MAILBOX_SCOPE_KEY} is already recorded -- checked last
1591
+ * so a second apply never calls `MailPort.createMailbox` again, rather
1592
+ * than relying on that port method's own duplicate refusal.
1407
1593
  */
1408
1594
  private ensureOwnerMailbox;
1595
+ /**
1596
+ * #230: creates each of the org's declared `mail.roleMailboxes` on its
1597
+ * just-resolved mail domain, once. A role mailbox has no person behind it,
1598
+ * so its display name is just its local part, and like the owner's it is
1599
+ * created with no password: `MailPort.createMailbox` takes none, so there
1600
+ * 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
1601
+ * {@link MAIL_ROLE_MAILBOXES_SCOPE_KEY} after every mailbox, so a second
1602
+ * apply (or a retry after a crash) skips what exists rather than relying
1603
+ * on the mail server's own duplicate refusal.
1604
+ */
1605
+ private ensureRoleMailboxes;
1409
1606
  /**
1410
1607
  * Deletes the org's previous Lettermint domain once the mail domain it was
1411
1608
  * registered under has changed -- otherwise it stays registered and
@@ -1427,9 +1624,13 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1427
1624
  /**
1428
1625
  * Parking mail (2026-09-28 lettermint-org-mail swap, "Core rule": "Parking
1429
1626
  * mail deletes the org's projects and then the domain itself, so no
1430
- * restriction points only at dead projects"). Deletes every Lettermint
1431
- * project named `<slug>-app` or `<slug>-relay`, then the org's own
1432
- * Lettermint domain, if one was found -- in that order, never the reverse.
1627
+ * restriction points only at dead projects"). #149: first drops the
1628
+ * Stalwart relay route with `MailPort.removeOrgRelay` -- a missing route is
1629
+ * a no-op, per that port method's own contract, so this is safe to call
1630
+ * even for an org whose relay was never minted -- then deletes every
1631
+ * Lettermint project named `<slug>-app` or `<slug>-relay`, then the org's
1632
+ * own Lettermint domain, if one was found -- in that order, never the
1633
+ * reverse, so the route is always gone before the project it pointed at.
1433
1634
  * A no-op until a previous apply recorded {@link MAIL_DOMAIN_SCOPE_KEY}:
1434
1635
  * nothing was ever provisioned for an org whose mail went straight from
1435
1636
  * never-applied to off.