@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
@@ -2,6 +2,8 @@ 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 LoginHostBindingsPort } from './login-host.js';
6
+ import { type MailCopyPort } from './mail-copy.js';
5
7
  import type { DestroyTarget, StepDestroyer, StepExecutionContext, StepExecutor } from './plan-apply.js';
6
8
  /**
7
9
  * The real {@link StepExecutor} (#20 "provision Company Factory end to
@@ -139,13 +141,14 @@ export interface CoolifyPort {
139
141
  }>>;
140
142
  /**
141
143
  * Upsert environment variables on an application in one call — used here to
142
- * push the database's `internal_db_url` in as the app's `DATABASE_URL`
143
- * (hosting-wiring campaign), and, for the cms app, its `DATABASE_URL_OWNER`
144
- * plus a freshly minted `DATABASE_URL` runtime-role credential (#117). Each
145
- * value is a live credential: it must reach this call and nothing else —
146
- * never a step's return value, `serviceIds` (hence the store), an audit
147
- * record, a log or a thrown error. See `coolifyApp`'s `pushDatabaseUrl` and
148
- * `cmsSite`'s `ensureCmsRuntimeUrl`.
144
+ * push the org's own app's `DATABASE_URL_OWNER` plus a freshly minted
145
+ * `DATABASE_URL` runtime-role credential (hosting-wiring campaign, split
146
+ * per #239 the same way #117 already splits the cms app's), and likewise
147
+ * for the cms app itself. Each value is a live credential: it must reach
148
+ * this call and nothing else — never a step's return value, `serviceIds`
149
+ * (hence the store), an audit record, a log or a thrown error. See
150
+ * `coolifyApp`'s `pushDatabaseUrl`/`ensureAppRuntimeUrl` and `cmsSite`'s
151
+ * `ensureCmsRuntimeUrl`.
149
152
  */
150
153
  bulkUpsertEnvs(applicationUuid: string, envs: Array<{
151
154
  key: string;
@@ -504,35 +507,65 @@ export interface MailPort {
504
507
  * `createMailbox`, resolving `org`'s domain to a `domainId` the same way
505
508
  * `applyOrgMail` does -- that function already refuses a duplicate `local`
506
509
  * 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
510
+ * and `ensureRoleMailboxes` (#230) below are this executor's callers, and each checks its own recorded scope
508
511
  * id first, so a second apply never calls this at all.
509
512
  */
510
513
  createMailbox(org: OrgMailOrg, mailbox: {
511
514
  address: string;
512
515
  name: string;
513
516
  }): Promise<void>;
517
+ /**
518
+ * Upserts this org's own outbound relay route on Stalwart (#149): an
519
+ * `MtaRoute` through Lettermint, restricted to `org`'s domain, keyed by
520
+ * `org.slug` -- the host implements this with `@wtfalch/email/postmaster`'s
521
+ * own `applyOrgRelay` (https://github.com/wtfalch/email/issues/118), which
522
+ * rebuilds the shared routing expression so two orgs' routes never
523
+ * collide. `secret` is the Lettermint project token this executor just
524
+ * minted (`ensureOrgLettermintRelay`'s own mint rule) -- it goes straight
525
+ * here and nowhere else: never returned, never recorded, never logged, the
526
+ * same discipline `ensureAppLettermintMail`'s own doc comment states for
527
+ * `LETTERMINT_API_TOKEN`.
528
+ */
529
+ applyOrgRelay(org: OrgMailOrg, secret: string): Promise<void>;
530
+ /**
531
+ * Drops this org's relay route (#149), implemented with
532
+ * `@wtfalch/email/postmaster`'s own `removeOrgRelay` -- a missing route is
533
+ * a no-op, so `parkLettermintMail` can call this unconditionally before it
534
+ * deletes the Lettermint project the route pointed at.
535
+ */
536
+ removeOrgRelay(org: OrgMailOrg): Promise<void>;
537
+ /**
538
+ * True once this org's relay route exists on Stalwart with a secret set
539
+ * (#149). `ensureOrgLettermintRelay`'s own mint rule reads this alongside
540
+ * Lettermint's own project listing: a project without a live route (the
541
+ * route recorded a token this executor never minted) is exactly as
542
+ * unhealthy as a route with no project behind it, so a fresh mint fixes
543
+ * either.
544
+ */
545
+ hasOrgRelay(org: OrgMailOrg): Promise<boolean>;
514
546
  }
515
547
  /**
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.
548
+ * The shared calls SFU's own per-org LiveKit API credentials (#122, #127,
549
+ * #145, #152). Before #170, a human minted one pair by hand, shared by every
550
+ * org, and `resolveCredentials` read wherever `scripts/calls-sfu-deploy.mjs`'s
551
+ * own `CallsSfuConfig` delivered it from -- this package never minted or
552
+ * invented a LiveKit key/secret pair itself.
521
553
  *
522
554
  * 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
555
+ * (`plan/platform-sfu.ts`'s `applyCallsSfuPlatformStep`) mints the pair, and
556
+ * as of #152 it mints one *per org*, the first time that org applies, and
557
+ * delivers it to held keys through `CallsSfuKeysPort.deliverLivekitKeys`,
558
+ * keyed by the org's slug. `resolveCredentials` reads a specific org's own
526
559
  * held-keys entry back -- still resolved by the host (archon), never minted
527
560
  * or read directly by this port itself, the same "the executor is
528
561
  * constructed with narrow ports ... a host supplies real clients" rule this
529
562
  * file's own top doc comment states for every provider credential.
530
563
  * `resolveCredentials` returns `null` before the platform's `sfu` step has
531
- * ever applied, the same "nothing recorded" signal a bare
564
+ * ever applied for that org, the same "nothing recorded" signal a bare
532
565
  * `CoolifyPort.listEnvs` gives `ensureCallsMatrixSecrets`.
533
566
  */
534
567
  export interface CallsSfuPort {
535
- resolveCredentials(): Promise<{
568
+ resolveCredentials(orgSlug: string): Promise<{
536
569
  apiKey: string;
537
570
  apiSecret: string;
538
571
  } | null>;
@@ -664,6 +697,95 @@ export interface ZitadelPort {
664
697
  userId: string;
665
698
  token: string;
666
699
  }>;
700
+ /**
701
+ * #216 (building on #209/#215): mints a second, narrower machine user on
702
+ * the same instance, with only the `IAM_LOGIN_CLIENT` role -- never
703
+ * `IAM_OWNER`, which {@link mintAdminMachineUser} already covers and which
704
+ * this method must not reuse or grant in addition. The login app and the
705
+ * broker both refuse to start without this token
706
+ * (`platform-providers.ts`'s `provisionPlatformAuth`, "Known gaps").
707
+ *
708
+ * Idempotent on the user by `userName`, like {@link mintAdminMachineUser},
709
+ * but never on the token the same way: ZITADEL shows a PAT's value once,
710
+ * and a crash or a failed delivery between a prior mint and the caller
711
+ * recording it would otherwise leave that lost token live forever. Unlike
712
+ * {@link mintAdminMachineUser}, revoking this user's PAT destroys nothing
713
+ * -- nothing else was ever encrypted with it, the way ZITADEL's own
714
+ * masterkey protects the admin path's data -- so a real implementation
715
+ * lists and deletes every PAT this user already holds before minting a
716
+ * fresh one, leaving exactly one live token no matter how many times this
717
+ * is called for the same user. This is what lets the caller
718
+ * (`provisionPlatformAuth`) simply re-mint on every rerun until delivery
719
+ * succeeds, rather than blocking forever the way an undelivered admin key
720
+ * does.
721
+ *
722
+ * Refuses (a named error, never a silent reuse) when a `foundry-login-client`
723
+ * user already exists with any IAM role other than exactly
724
+ * `IAM_LOGIN_CLIENT` -- that user was not created by this method, and
725
+ * granting it an additional role, or reusing its PAT, would widen access
726
+ * this caller never asked for.
727
+ *
728
+ * #216 round 2: also returns `patId`, the minted PAT's own id, so the
729
+ * caller can hand it to {@link listLoginClientPats} after delivering the
730
+ * token and confirm nothing else revoked or replaced it in the meantime.
731
+ *
732
+ * #216 (third PR review): also returns `revokedPriorPat`, true exactly
733
+ * when a live PAT existed on this user and was revoked before this mint --
734
+ * never true on a first-ever mint, where nothing was live to revoke. The
735
+ * caller uses this to word a delivery-failure error correctly: on a
736
+ * re-mint the login app and broker are left with no live token at all
737
+ * (the old one is already gone); on a first mint they simply never had one
738
+ * yet.
739
+ */
740
+ mintLoginClientMachineUser(orgId: string, input: {
741
+ userName: string;
742
+ name: string;
743
+ }): Promise<{
744
+ userId: string;
745
+ token: string;
746
+ patId: string;
747
+ revokedPriorPat: boolean;
748
+ }>;
749
+ /**
750
+ * #216 round 2: every live PAT id on the `foundry-login-client` machine
751
+ * user, read straight from ZITADEL. `provisionPlatformAuth`
752
+ * (`platform-providers.ts`) calls this right after it delivers a freshly
753
+ * minted token to the login app and the broker, to close a race two
754
+ * concurrent applies can otherwise hit: run A mints token a and is about to
755
+ * deliver it; run B's own mint (`mintLoginClientMachineUser`, idempotent on
756
+ * the same `userName`) revokes a and mints token b first; run A then
757
+ * delivers the now-revoked a and would, without this check, still record
758
+ * success. Expecting the result to be exactly `[patId]` (the id
759
+ * {@link mintLoginClientMachineUser} returned) catches that: anything else
760
+ * means some other mint ran in between, so this run must fail rather than
761
+ * record delivery, leaving the next apply to re-mint and re-deliver.
762
+ *
763
+ * #216 round 3: a single call made once, after every write, cannot catch a
764
+ * write that lands after it -- `provisionPlatformAuth` now calls this
765
+ * again immediately before each individual write (each retry attempt of
766
+ * each of the login app's env, the broker's env, and each app's deploy),
767
+ * not only once at the end, so a write about to deliver a token this call
768
+ * reports no longer live is never attempted at all.
769
+ */
770
+ listLoginClientPats(orgId: string, userId: string): Promise<string[]>;
771
+ /**
772
+ * #267: adds the org's own company login host to the ZITADEL instance's
773
+ * trusted domains (`@wtfalch/providers`' `ZitadelClient.ensureTrustedDomain`)
774
+ * -- the functionally critical half of `zitadelLoginHost` below, per
775
+ * `auth/docs/company-login-hosts.md`'s enforcement section: "the issuer
776
+ * refuses a public host that is not one of the instance's trusted
777
+ * domains." Idempotent: lists first, adds only if not already trusted.
778
+ */
779
+ ensureTrustedDomain(domain: string): Promise<{
780
+ added: boolean;
781
+ }>;
782
+ /**
783
+ * #267: moves an already-created app's Login V2 base URI, read-modify-
784
+ * write (`@wtfalch/providers`' `ZitadelClient.updateOidcAppLoginBaseUri`).
785
+ * `zitadelLoginHost` below calls this for each live app whose client id
786
+ * the binding holds; `zitadelApp` never sets it at creation.
787
+ */
788
+ updateOidcAppLoginBaseUri(orgId: string, projectId: string, appId: string, loginBaseUri: string): Promise<void>;
667
789
  }
668
790
  /**
669
791
  * Provisions one org's organisation on Files (#80), matching
@@ -839,6 +961,15 @@ export interface FoundryStepExecutorPorts {
839
961
  jobsScheduler?: JobsSchedulerPort;
840
962
  /** The shared calls SFU's own LiveKit credentials (#145) -- see {@link CallsSfuPort}'s own doc comment. */
841
963
  callsSfu: CallsSfuPort;
964
+ /**
965
+ * #113: the org's old-mail copy into Stalwart, ahead of a `received` ->
966
+ * `hosted` MX cutover -- see {@link MailCopyPort}'s own doc comment.
967
+ * Optional the same way `jobsScheduler` is: a host that never runs this
968
+ * transition builds the executor without it, and `coolify:mail-copy`
969
+ * throws a clear "not built" error rather than every other step losing
970
+ * compilation over one dependency nothing else needs.
971
+ */
972
+ mailCopy?: MailCopyPort;
842
973
  /** 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
974
  filesOrganisation?: FilesOrganisationPort;
844
975
  aiOrganisation?: AiOrganisationPort;
@@ -848,6 +979,15 @@ export interface FoundryStepExecutorPorts {
848
979
  * step throws. The planner blocks these steps first while a key is not held.
849
980
  */
850
981
  platformOrg?: PlatformOrgPortSource;
982
+ /**
983
+ * #267: zitadel-login's own `LOGIN_HOST_BINDINGS` entry for the org -- see
984
+ * {@link LoginHostBindingsPort}'s own doc comment. Optional the same way
985
+ * `jobsScheduler`/`mailCopy` are: a host that has not wired this yet gets no
986
+ * login-host step at all -- the planner reports it blocked (the host also
987
+ * sets `FoundryPlanApplyOptions.loginHostBindings`), so nothing is trusted
988
+ * or repointed without the binding.
989
+ */
990
+ loginHostBindings?: LoginHostBindingsPort;
851
991
  /**
852
992
  * Hands a just-minted, one-time secret (today: an API app's
853
993
  * `clientSecret`) to the host, immediately after the provider returns it
@@ -949,6 +1089,14 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
949
1089
  * recorded).
950
1090
  */
951
1091
  private zitadelOwner;
1092
+ /**
1093
+ * `plan/diff.ts`'s `planLoginHost` (#267): adds the org's own company
1094
+ * login host (`action.desired.host`) to the ZITADEL instance's trusted
1095
+ * domains, writes zitadel-login's binding, then repoints the apps whose
1096
+ * client id that binding holds. Apps created later are not in it yet:
1097
+ * the next run replans (`diff.ts`'s `planLoginHost`) and moves them.
1098
+ */
1099
+ private zitadelLoginHost;
952
1100
  /**
953
1101
  * `plan/diff.ts`'s `planCustomers` (plan gap A9): the org's customers sign
954
1102
  * in through a second ZITADEL org, `<slug> customers`, with
@@ -997,28 +1145,34 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
997
1145
  private ensureLogVolume;
998
1146
  private coolifyDatabase;
999
1147
  /**
1000
- * Pushes the database's connection string into the app's environment as
1001
- * `DATABASE_URL` -- never also as `DATABASE_URL_OWNER` (Decision, this
1002
- * campaign's report). Archon's own boot script
1003
- * (`archon/scripts/migrate-and-start.sh`) treats a bare `DATABASE_URL` with
1004
- * no `DATABASE_URL_OWNER` as "before the split": it migrates and serves as
1005
- * the one role it has, which is exactly what a database Coolify just
1006
- * created (one superuser role, no separate runtime role yet) is. Setting
1007
- * both to the *same* superuser URL would instead take the split branch --
1008
- * `db-role-guard.sh` then refuses to boot, since `DATABASE_URL_OWNER` being
1009
- * set means it expects `DATABASE_URL` to connect as a distinct,
1010
- * non-superuser runtime role (`<database>_rt`), which nothing here creates.
1011
- * Minting that role's own password and URL is a provisioning step of its
1012
- * own, not built here (Finding, this campaign's report).
1148
+ * Pushes the database's connection string into the app's environment,
1149
+ * split the same way `cmsSite`'s own database is (#117, #239): the
1150
+ * superuser connection as `DATABASE_URL_OWNER`, and a distinct, restricted
1151
+ * `<dbName>_rt` role's connection as `DATABASE_URL`. Archon's and manage's
1152
+ * own boot scripts (`db-role-guard.sh`) refuse to start against a
1153
+ * superuser `DATABASE_URL`, and lokessmie's refuses to start without
1154
+ * `DATABASE_URL_OWNER` at all -- a bare superuser `DATABASE_URL` with no
1155
+ * `DATABASE_URL_OWNER` is "before the split" there too.
1013
1156
  *
1014
- * The connection string is a live credential
1015
- * (`postgres://user:password@host/db`): it is read from
1016
- * `CoolifyPort.getDatabase` and handed straight to `bulkUpsertEnvs` and
1017
- * nowhere else in this class -- never returned, never recorded in
1018
- * `serviceIds` (hence never in the store or an audit record), never logged,
1019
- * never interpolated into a thrown error.
1157
+ * Both connection strings are live credentials
1158
+ * (`postgres://user:password@host/db`): read from `CoolifyPort.getDatabase`
1159
+ * (or minted here, for the runtime one) and handed straight to
1160
+ * `bulkUpsertEnvs` and nowhere else in this class -- never returned, never
1161
+ * recorded in `serviceIds` (hence never in the store or an audit record),
1162
+ * never logged, never interpolated into a thrown error.
1020
1163
  */
1021
1164
  private pushDatabaseUrl;
1165
+ /**
1166
+ * `null` once the app already has a `DATABASE_URL` -- never rotated, the
1167
+ * same rule `ensureCmsRuntimeUrl` follows and for the same reason: the
1168
+ * app's own boot script creates or updates the live `<dbName>_rt` Postgres
1169
+ * role from exactly the username/password this method mints, once:
1170
+ * overwriting `DATABASE_URL` on a later apply would leave that
1171
+ * already-created role's real password out of sync with whatever foundry
1172
+ * hands the app next. See `ensureCmsRuntimeUrl`'s own doc comment for the
1173
+ * parsing and encoding choices this mirrors exactly.
1174
+ */
1175
+ private ensureAppRuntimeUrl;
1022
1176
  /**
1023
1177
  * Pushes what the org's app needs to sign a user in through ZITADEL (gap
1024
1178
  * P2, `.plans/2026-09-26-level-1-org-map.md`) -- a second `bulkUpsertEnvs`
@@ -1033,9 +1187,12 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1033
1187
  * `zitadel:issuer` (`AUTH_ORGANIZATION_ID`), `zitadel:owner`
1034
1188
  * (`OWNER_USER_ID`, gap P3) and every `zitadel:app` step
1035
1189
  * (`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).
1190
+ * reason. `AUTH_APP_KEY` (#206 -- which broker an org's app registers with
1191
+ * is its own design question, left out of #204's own "How" rather than
1192
+ * guessed) is out of scope here (task brief). `DATABASE_URL_OWNER`/
1193
+ * `DATABASE_URL` are pushed by `pushDatabaseUrl` instead, not here --
1194
+ * that call runs only once `hosting.database` is declared and
1195
+ * provisioned, where this one is unconditional (#239).
1039
1196
  */
1040
1197
  private pushAppEnv;
1041
1198
  /**
@@ -1079,7 +1236,10 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1079
1236
  * be deleted.
1080
1237
  *
1081
1238
  * `MAIL_FROM` is upserted every time: it depends only on the mail domain
1082
- * `coolify:mail` resolved.
1239
+ * `coolify:mail` resolved and `org.mail.noReplyLocalPart` (#162, decided
1240
+ * 2026-09-29 -- defaults to `'no-reply'`, restated here rather than
1241
+ * imported from `org.ts`'s `DEFAULT_NO_REPLY_LOCAL_PART`, this file's own
1242
+ * `LOG_DIR`-style convention).
1083
1243
  *
1084
1244
  * The token goes straight to `bulkUpsertEnvs` and nowhere else: never
1085
1245
  * returned, never recorded in `serviceIds` (`recordScopeId`'s own doc
@@ -1088,6 +1248,63 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1088
1248
  * own connection string.
1089
1249
  */
1090
1250
  private ensureAppLettermintMail;
1251
+ /**
1252
+ * #149: mints this org's own Lettermint relay credential and writes it to
1253
+ * Stalwart via `MailPort.applyOrgRelay`, the same mint rule
1254
+ * {@link ensureAppLettermintMail} already uses for the org's app token --
1255
+ * "healthy" means Stalwart's own route exists ({@link MailPort.hasOrgRelay}),
1256
+ * AND Lettermint lists a project named `<slug>-relay` whose id
1257
+ * {@link ORG_MAIL_RELAY_SCOPE_KEY} records for this
1258
+ * exact mail domain (review finding: a route with a secret and a
1259
+ * same-named project both present is not enough -- the project could be a
1260
+ * lost-and-reprovisioned one this step never wrote a route for, and a mail
1261
+ * domain switch must re-point the route rather than read as healthy
1262
+ * forever because the old route still has a secret in it). A route with no
1263
+ * live project behind it, a project the route's own secret never reached,
1264
+ * or a recorded project id/domain that no longer matches, are all exactly
1265
+ * as unhealthy as neither existing.
1266
+ *
1267
+ * A fresh project is created and its token written to Stalwart first, and
1268
+ * only then is every other `<slug>-relay` project deleted -- a lost route
1269
+ * and a duplicate project both end the same way. The new project's domain
1270
+ * restriction runs before the token is written, so a failure there never
1271
+ * touches the working route. Review finding: if either the
1272
+ * domain-restriction step or `applyOrgRelay` throws after a
1273
+ * fresh project was minted, that project is deleted before the error
1274
+ * propagates -- otherwise every retry sees more than one `<slug>-relay`
1275
+ * project and mints yet another one, growing the orphan count without
1276
+ * bound.
1277
+ *
1278
+ * `coolify:mail` runs before `coolify:app` (`diff.ts`'s own step order), so
1279
+ * this is very often the first of the two to reach the org's Lettermint
1280
+ * domain: {@link ensureLettermintDomainRestriction} registers and restricts
1281
+ * it the same way the app step does, rather than assuming it already
1282
+ * exists -- the domain must never end up unrestricted, whichever step gets
1283
+ * there first.
1284
+ */
1285
+ private ensureOrgLettermintRelay;
1286
+ /**
1287
+ * Deletes a just-minted project from inside a `catch`. A failed delete is
1288
+ * dropped so it never replaces the error being rethrown; the orphan is
1289
+ * swept by the next apply's duplicate delete.
1290
+ */
1291
+ private discardLettermintProject;
1292
+ /**
1293
+ * Ensures this org's Lettermint domain exists, is restricted to exactly
1294
+ * the `<slug>-app`/`<slug>-relay` projects live in `allProjects`, has its
1295
+ * DNS published and a verification triggered. Shared by
1296
+ * {@link ensureAppLettermintMail} and {@link ensureOrgLettermintRelay}
1297
+ * (#149) -- whichever of the two steps runs first on a fresh org creates
1298
+ * the domain, and the other then finds it (`findExactLettermintDomain`).
1299
+ *
1300
+ * A crash between the restriction write and the DNS publish/verify below
1301
+ * is safe even for a freshly created domain: DNS is only published, and
1302
+ * verification only triggered, after the restriction -- so an
1303
+ * unrestricted domain cannot verify and cannot send, whichever project
1304
+ * owns it (post-#140 review finding, restated here rather than only at
1305
+ * the one call site it used to have).
1306
+ */
1307
+ private ensureLettermintDomainRestriction;
1091
1308
  /**
1092
1309
  * Review finding (post-#140 rework): {@link LettermintPort.findDomain}'s
1093
1310
  * own match is partial and case-insensitive, so a raw result can be a
@@ -1374,20 +1591,21 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1374
1591
  * `@wtfalch/email/postmaster`'s `applyOrgMail`/`parkOrgMail` bound), then
1375
1592
  * publishes whatever Stalwart DNS records the call returns, and records the
1376
1593
  * 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.
1594
+ *
1595
+ * #149: this step also mints the org's own Lettermint *relay* credential
1596
+ * ({@link ensureOrgLettermintRelay}) -- the app's own *sending* credential
1597
+ * stays in the app step (`ensureAppLettermintMail`, "Core rule"), since the
1598
+ * first app project can only exist once the token has a home. Parking mail
1599
+ * (mode `'off'`) reaches Lettermint too: {@link parkLettermintMail} drops
1600
+ * the relay route, then deletes the org's own projects and its domain, so
1601
+ * a domain never ends up restricted only to dead projects and no route
1602
+ * ever points at a project this apply is about to remove.
1385
1603
  *
1386
1604
  * Review finding: a mail domain switch (`org.domain` changes, or
1387
1605
  * `usesDomain` flips) must also delete the *old* Lettermint domain here,
1388
1606
  * 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}
1607
+ * restricted to the org's still-live `<slug>-app`/`<slug>-relay` projects,
1608
+ * which could then keep sending as it forever. {@link deleteStaleLettermintDomain}
1391
1609
  * does this by comparing the previously recorded domain (case-insensitive)
1392
1610
  * against the one this apply just resolved.
1393
1611
  *
@@ -1397,15 +1615,39 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1397
1615
  */
1398
1616
  private coolifyMail;
1399
1617
  /**
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.
1618
+ * `plan/diff.ts`'s `planMailCopy` (#113): this apply's share of copying
1619
+ * the org's old mail into Stalwart, via `mail-copy.ts`'s `runMailCopyStep`
1620
+ * -- the only thing this method itself does is read back what the last
1621
+ * apply recorded, hand it to that function, and record what comes back.
1622
+ * An account that failed to copy records the progress made, never
1623
+ * `mail_copy_done`, then throws with the failing account, phase and
1624
+ * redacted stderr tail, so the apply reports it as not done. A missing
1625
+ * `mailCopy` port throws too: a configuration gap a retry cannot fix.
1626
+ */
1627
+ private mailCopy;
1628
+ /**
1629
+ * #162 (decided 2026-09-29): gives the org's declared owner a mailbox at
1630
+ * its declared `mail.ownerMailboxAddress` -- nothing is guessed any more
1631
+ * (the hardcoded `owner@<domain>` PR 163 shipped first is gone). A no-op
1632
+ * when the org has no owner yet, when no address is declared yet
1633
+ * (`plan/diff.ts`'s `planMail` reports that case as blocked, not planned,
1634
+ * so this step's own mode/domain work still runs), or once
1635
+ * {@link MAIL_OWNER_MAILBOX_SCOPE_KEY} is already recorded -- checked last
1636
+ * so a second apply never calls `MailPort.createMailbox` again, rather
1637
+ * than relying on that port method's own duplicate refusal.
1407
1638
  */
1408
1639
  private ensureOwnerMailbox;
1640
+ /**
1641
+ * #230: creates each of the org's declared `mail.roleMailboxes` on its
1642
+ * just-resolved mail domain, once. A role mailbox has no person behind it,
1643
+ * so its display name is just its local part, and like the owner's it is
1644
+ * created with no password: `MailPort.createMailbox` takes none, so there
1645
+ * 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
1646
+ * {@link MAIL_ROLE_MAILBOXES_SCOPE_KEY} after every mailbox, so a second
1647
+ * apply (or a retry after a crash) skips what exists rather than relying
1648
+ * on the mail server's own duplicate refusal.
1649
+ */
1650
+ private ensureRoleMailboxes;
1409
1651
  /**
1410
1652
  * Deletes the org's previous Lettermint domain once the mail domain it was
1411
1653
  * registered under has changed -- otherwise it stays registered and
@@ -1427,9 +1669,13 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1427
1669
  /**
1428
1670
  * Parking mail (2026-09-28 lettermint-org-mail swap, "Core rule": "Parking
1429
1671
  * 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.
1672
+ * restriction points only at dead projects"). #149: first drops the
1673
+ * Stalwart relay route with `MailPort.removeOrgRelay` -- a missing route is
1674
+ * a no-op, per that port method's own contract, so this is safe to call
1675
+ * even for an org whose relay was never minted -- then deletes every
1676
+ * Lettermint project named `<slug>-app` or `<slug>-relay`, then the org's
1677
+ * own Lettermint domain, if one was found -- in that order, never the
1678
+ * reverse, so the route is always gone before the project it pointed at.
1433
1679
  * A no-op until a previous apply recorded {@link MAIL_DOMAIN_SCOPE_KEY}:
1434
1680
  * nothing was ever provisioned for an org whose mail went straight from
1435
1681
  * never-applied to off.