@wtfalch/foundry 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +111 -0
  2. package/dist/bootstrap/bootstrap.js +323 -0
  3. package/dist/bootstrap/declare.d.ts +36 -0
  4. package/dist/bootstrap/declare.js +99 -0
  5. package/dist/bootstrap/drive.d.ts +40 -0
  6. package/dist/bootstrap/drive.js +51 -0
  7. package/dist/bootstrap/index.d.ts +3 -0
  8. package/dist/bootstrap/index.js +1 -0
  9. package/dist/calls-sfu-deploy-support.d.ts +17 -6
  10. package/dist/calls-sfu-deploy-support.js +19 -7
  11. package/dist/calls-sfu-keys.d.ts +21 -0
  12. package/dist/calls-sfu-keys.js +27 -0
  13. package/dist/calls-sfu-ssh-adapter.d.ts +55 -0
  14. package/dist/calls-sfu-ssh-adapter.js +78 -0
  15. package/dist/calls-sfu-ssh-client.d.ts +49 -0
  16. package/dist/calls-sfu-ssh-client.js +82 -0
  17. package/dist/calls-sfu.d.ts +36 -12
  18. package/dist/calls-sfu.js +41 -9
  19. package/dist/index.d.ts +38 -7
  20. package/dist/index.js +20 -4
  21. package/dist/migrations/0016_platform.sql +61 -0
  22. package/dist/migrations/0017_requested_changes.sql +42 -0
  23. package/dist/migrations/0018_platform_apply.sql +19 -0
  24. package/dist/migrations/0019_mail_owner_mailbox.sql +15 -0
  25. package/dist/migrations/0020_mail_apps.sql +7 -0
  26. package/dist/migrations/0021_mail_role_mailboxes.sql +6 -0
  27. package/dist/org.d.ts +81 -0
  28. package/dist/org.js +96 -0
  29. package/dist/plan/agora.d.ts +77 -0
  30. package/dist/plan/agora.js +127 -0
  31. package/dist/plan/cloudflared-connector.d.ts +70 -0
  32. package/dist/plan/cloudflared-connector.js +106 -0
  33. package/dist/plan/diff.d.ts +20 -3
  34. package/dist/plan/diff.js +713 -115
  35. package/dist/plan/executor.d.ts +680 -172
  36. package/dist/plan/executor.js +755 -189
  37. package/dist/plan/index.d.ts +28 -5
  38. package/dist/plan/index.js +15 -2
  39. package/dist/plan/jobs-scheduler-http.d.ts +29 -0
  40. package/dist/plan/jobs-scheduler-http.js +50 -0
  41. package/dist/plan/live.d.ts +51 -2
  42. package/dist/plan/live.js +8 -0
  43. package/dist/plan/mail-copy.d.ts +125 -0
  44. package/dist/plan/mail-copy.js +166 -0
  45. package/dist/plan/mail-identity-allowlist.d.ts +50 -0
  46. package/dist/plan/mail-identity-allowlist.js +11 -0
  47. package/dist/plan/mail-identity.d.ts +68 -0
  48. package/dist/plan/mail-identity.js +17 -0
  49. package/dist/plan/plan-apply.d.ts +197 -3
  50. package/dist/plan/plan-apply.js +278 -9
  51. package/dist/plan/platform-diff.d.ts +487 -0
  52. package/dist/plan/platform-diff.js +1220 -0
  53. package/dist/plan/platform-org-ports.d.ts +50 -0
  54. package/dist/plan/platform-org-ports.js +70 -0
  55. package/dist/plan/platform-org.d.ts +121 -0
  56. package/dist/plan/platform-org.js +115 -0
  57. package/dist/plan/platform-providers.d.ts +615 -0
  58. package/dist/plan/platform-providers.js +4410 -0
  59. package/dist/plan/platform-sfu.d.ts +128 -0
  60. package/dist/plan/platform-sfu.js +142 -0
  61. package/dist/plan/providers-readers.js +22 -1
  62. package/dist/plan/zitadel-machine-user.d.ts +83 -0
  63. package/dist/plan/zitadel-machine-user.js +238 -0
  64. package/dist/plan.d.ts +6 -0
  65. package/dist/platform.d.ts +55 -0
  66. package/dist/platform.js +46 -0
  67. package/dist/requested-changes.d.ts +118 -0
  68. package/dist/requested-changes.js +125 -0
  69. package/dist/slug.js +15 -2
  70. package/dist/store/audit.js +4 -2
  71. package/dist/store/postgres.d.ts +29 -1
  72. package/dist/store/postgres.js +203 -4
  73. package/dist/store/schema.d.ts +755 -0
  74. package/dist/store/schema.js +70 -0
  75. package/dist/store.d.ts +53 -0
  76. package/package.json +1 -1
@@ -1,7 +1,8 @@
1
1
  import type { OrgMailOptions, OrgMailOrg, OrgMailResult } from '@wtfalch/email/postmaster';
2
- import type { DeclaredOrg, HostingBuildPack, OrgId } from '../org.js';
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
@@ -60,6 +61,7 @@ export interface CoolifyPort {
60
61
  uuid: string;
61
62
  is_usable: boolean;
62
63
  is_reachable: boolean;
64
+ ip?: string;
63
65
  }>>;
64
66
  requireGithubApp(preferredUuid?: string): Promise<{
65
67
  uuid: string;
@@ -217,6 +219,15 @@ export interface CoolifyPort {
217
219
  name?: string;
218
220
  description?: string;
219
221
  instant_deploy?: boolean;
222
+ /**
223
+ * `host:container`, comma separated -- publishes host ports directly
224
+ * (`@wtfalch/providers`' `CoolifyClient.createDockerImageApplication`
225
+ * doc comment: "a tunnelled web app never needs and a mail server cannot
226
+ * do without"). Used by `platform-providers.ts`'s `provisionPlatformMail`
227
+ * (#169) for Stalwart's SMTP/submission ports, which cannot go through
228
+ * Cloudflare's HTTP-only tunnel the way `platform:auth`'s apps do.
229
+ */
230
+ ports_mappings?: string;
220
231
  }): Promise<{
221
232
  uuid: string;
222
233
  }>;
@@ -292,6 +303,15 @@ export interface CloudflarePort {
292
303
  name: string;
293
304
  }>;
294
305
  putTunnelConfig(tunnelId: string, config: unknown): Promise<unknown>;
306
+ /**
307
+ * The tunnel's connector token (#197) -- a `cloudflared` container reads
308
+ * this at boot to dial into the tunnel. A live credential: matches
309
+ * `@wtfalch/providers`' `CloudflareClient.getTunnelToken` method-for-method.
310
+ * A caller must hand this straight to `bulkUpsertEnvs` and nowhere else --
311
+ * never a log, an error, a return value or a recorded scope id (see
312
+ * `cloudflared-connector.ts`'s `ensureCloudflaredConnector`).
313
+ */
314
+ getTunnelToken(tunnelId: string): Promise<string>;
295
315
  /** `FoundryPlanApply.destroy`'s teardown of the org's own Cloudflare tunnel. */
296
316
  deleteTunnel(tunnelId: string): Promise<void>;
297
317
  /** `cloudflareAccess` finds the org's Access app by domain before creating one (#84). */
@@ -310,6 +330,7 @@ export interface CloudflarePort {
310
330
  listAccessPolicies(appId: string): Promise<Array<{
311
331
  id: string;
312
332
  name: string;
333
+ include?: unknown[];
313
334
  }>>;
314
335
  createAccessPolicy(appId: string, policy: {
315
336
  name: string;
@@ -371,83 +392,103 @@ export interface CloudflarePort {
371
392
  request<T>(method: string, path: string, body?: unknown, missingOkay?: boolean): Promise<T>;
372
393
  }
373
394
  /**
374
- * One DNS record a Resend domain's own response names it must publish --
375
- * `record` is Resend's own label (`SPF`, `DKIM`, `DMARC`, `MX`), which
376
- * {@link ResendPort.createDomain}/{@link ResendPort.getDomain} return
377
- * alongside `type`/`name`/`value` for `coolifyMail` to act on, the same shape
378
- * https://resend.com/docs/api-reference/domains/create-domain documents.
395
+ * One DNS record a Lettermint domain's own response says must be published --
396
+ * `purpose` is Lettermint's own label (`return_path`, `dmarc`, `dkim_legacy`,
397
+ * `dkim_primary`, `dkim_secondary`), replacing Resend's `record` field, from
398
+ * {@link LettermintPort.createDomain}/{@link LettermintPort.getDomain}, the
399
+ * same shape https://lettermint.co/docs/api-reference/team/0.0.1/lettermint-team-openapi.json's
400
+ * `DomainDnsRecordData` documents (`fqdn`/`content` renamed to this port's own
401
+ * `name`/`value`, matching {@link publishLettermintDnsRecords}'s sibling
402
+ * `publishMailDnsRecords` naming).
379
403
  */
380
- export interface ResendDnsRecordSpec {
381
- readonly record: 'SPF' | 'DKIM' | 'DMARC' | 'MX' | string;
382
- readonly name: string;
404
+ export interface LettermintDnsRecordSpec {
405
+ readonly purpose: 'return_path' | 'dmarc' | 'dkim_legacy' | 'dkim_primary' | 'dkim_secondary';
383
406
  readonly type: 'TXT' | 'CNAME' | 'MX';
407
+ readonly name: string;
384
408
  readonly value: string;
385
- readonly priority?: number;
386
409
  }
387
- export interface ResendDomain {
410
+ export interface LettermintDomain {
411
+ readonly id: string;
412
+ readonly domain: string;
413
+ readonly status: 'verified' | 'partially_verified' | 'pending_verification' | 'failed_verification';
414
+ readonly dnsRecords: readonly LettermintDnsRecordSpec[];
415
+ }
416
+ export interface LettermintProject {
388
417
  readonly id: string;
389
418
  readonly name: string;
390
- readonly status: string;
391
- readonly records?: readonly ResendDnsRecordSpec[];
392
419
  }
393
420
  /**
394
- * The Resend calls this executor makes to verify an org's mail domain (#138)
395
- * and mint its app's sending key (#140 rework) -- the shared Stalwart relays
396
- * every outbound message through Resend
421
+ * The Lettermint calls this executor makes to verify an org's mail domain
422
+ * (#138) and mint its app's sending project (#140 rework, 2026-09-28
423
+ * lettermint-org-mail swap) -- the shared Stalwart relays every outbound
424
+ * message through Lettermint
397
425
  * (https://github.com/wtfalch/email/blob/main/packages/email/src/postmaster/objects.ts#L149-L168),
398
- * which refuses mail from a domain Resend has not verified. `@wtfalch/providers`
399
- * has no Resend client yet, unlike Coolify/Cloudflare/ZITADEL above, so this
400
- * port names Resend's own REST shapes directly
401
- * (https://resend.com/docs/api-reference/domains/create-domain,
402
- * https://resend.com/docs/api-reference/api-keys/create-api-key,
403
- * https://resend.com/docs/api-reference/api-keys/list-api-keys,
404
- * https://resend.com/docs/api-reference/api-keys/delete-api-key) -- the same
405
- * prior art `wtfalch/app-template`'s `bootstrap/steps/resend-email.ts` already
406
- * proved against a live account. The relay's own `<slug>-relay` key
407
- * (`.plans/2026-09-27-org-mail-resend-design.md`) is a separate change, filed
408
- * as https://github.com/wtfalch/foundry/issues/149 and out of scope here.
426
+ * which refuses mail from a domain it has not verified. `@wtfalch/providers`
427
+ * has no Lettermint client yet, unlike Coolify/Cloudflare/ZITADEL above, so
428
+ * this port names Lettermint's own REST shapes directly
429
+ * (`.plans/2026-09-28-lettermint-api.md`, its own primary-source citations).
430
+ * Replaces `ResendPort` (#138/#140), trimmed to what `coolifyApp`/`coolifyMail`
431
+ * below actually call.
432
+ *
433
+ * Unlike Resend's per-domain sending key, a Lettermint domain is
434
+ * unrestricted by default -- any project in the team could send from it --
435
+ * so a project's own domain access is a separate call
436
+ * ({@link restrictDomainToProjects}), and the credential unit is a *project*,
437
+ * not a per-domain key: Lettermint's API mints, at most, one live token per
438
+ * project and has no call to list or revoke an individual token within one
439
+ * (`.plans/2026-09-28-lettermint-api.md` §4), so `<slug>-app` is a project
440
+ * name, never suffixed with a domain id the way Resend's key name was.
409
441
  */
410
- export interface ResendPort {
411
- /** An exact name match, never a listing scan `coolifyMail` has to page itself. */
442
+ export interface LettermintPort {
443
+ /**
444
+ * Lettermint's own `filter[domain]` match is partial and case-insensitive
445
+ * (`.plans/2026-09-28-lettermint-api.md` §3: "partial, case-insensitive"),
446
+ * so a result here can be a lookalike (`notacme.com`, `mail.acme.com`) --
447
+ * `domain` is returned precisely so every caller can reject one via
448
+ * `FoundryStepExecutor.findExactLettermintDomain` rather than a listing
449
+ * scan of its own.
450
+ */
412
451
  findDomain(name: string): Promise<{
413
452
  id: string;
414
- name: string;
415
- status: string;
453
+ domain: string;
454
+ status: LettermintDomain['status'];
416
455
  } | undefined>;
417
- createDomain(name: string): Promise<ResendDomain>;
418
- /** Re-reads a domain already found by {@link findDomain}, for its current status and DNS records. */
419
- getDomain(id: string): Promise<ResendDomain>;
456
+ /** `POST /domains`, its response already carrying `dnsRecords`. */
457
+ createDomain(name: string): Promise<LettermintDomain>;
458
+ /** Re-reads a domain already found by {@link findDomain}, for its current DNS records. */
459
+ getDomain(id: string): Promise<LettermintDomain>;
420
460
  /**
421
- * Asks Resend to re-check DNS now. `coolifyMail` never polls or waits on
422
- * this -- the task brief: "do not block the plan on DNS propagation:
423
- * record the status, and let the next apply re-check it (idempotent, like
424
- * `applyOrgMail`)".
461
+ * Asks Lettermint to re-check DNS now. Never polled or awaited to
462
+ * completion -- the same "do not block the plan on DNS propagation" rule
463
+ * `ResendPort.verifyDomain` followed: record the status, and let the next
464
+ * apply re-check it.
425
465
  */
426
466
  verifyDomain(id: string): Promise<{
427
- status: string;
467
+ status: LettermintDomain['status'];
428
468
  }>;
469
+ /** `parkLettermintMail`'s own cleanup once an org's mail is parked (mode `'off'`). */
470
+ deleteDomain(id: string): Promise<void>;
429
471
  /**
430
- * Every key in the account, `id`+`name` only -- Resend never returns a
431
- * key's token except at creation, so this is how the mint rule
432
- * (`.plans/2026-09-27-org-mail-resend-design.md` "One rule for minting")
433
- * tells whether the app's key is healthy before deciding to mint. `ensureAppResendKey`'s own doc comment below
434
- * spells the rule out.
472
+ * `POST /projects`. `token` is the one-time secret -- Lettermint shows a
473
+ * project's token once, at creation, and the API has no call to mint a
474
+ * second one on the same project -- so a caller that loses it creates a
475
+ * fresh project instead (`ensureAppLettermintMail`'s own mint rule).
435
476
  */
436
- listApiKeys(): Promise<Array<{
437
- id: string;
438
- name: string;
439
- }>>;
440
- /** Sending-only, scoped to one domain -- the app step's `<slug>-app@<domainId>` (`ensureAppResendKey`), minted under the mint rule. */
441
- createApiKey(input: {
442
- name: string;
443
- permission: 'sending_access';
444
- domainId: string;
445
- }): Promise<{
446
- id: string;
477
+ createProject(name: string): Promise<{
478
+ project: LettermintProject;
447
479
  token: string;
448
480
  }>;
449
- /** Deletes one key by id -- the mint rule's own cleanup of an orphan, a duplicate, or a key for a mail domain the org no longer uses. */
450
- deleteApiKey(id: string): Promise<void>;
481
+ /** Every project in the team, `id`+`name` only -- the mint rule's own health check and `parkLettermintMail`'s orphan scan both filter this by name themselves. */
482
+ listProjects(): Promise<readonly LettermintProject[]>;
483
+ /** Deletes one project by id -- the mint rule's own cleanup of an orphan or a duplicate, and `parkLettermintMail`'s teardown. */
484
+ deleteProject(id: string): Promise<void>;
485
+ /**
486
+ * `PUT /domains/{domainId}/projects`. Rebuilds the domain's whole
487
+ * restriction from `projectIds` -- never patched -- so a domain never ends
488
+ * up unrestricted (any team project could then send as the org) nor
489
+ * restricted only to a project already deleted.
490
+ */
491
+ restrictDomainToProjects(domainId: string, projectIds: readonly string[]): Promise<void>;
451
492
  }
452
493
  /**
453
494
  * The Stalwart calls this executor makes for one org's mail, shaped to match
@@ -458,21 +499,71 @@ export interface ResendPort {
458
499
  export interface MailPort {
459
500
  applyOrgMail(org: OrgMailOrg, options: OrgMailOptions): Promise<OrgMailResult>;
460
501
  parkOrgMail(org: OrgMailOrg, options: OrgMailOptions): Promise<void>;
502
+ /**
503
+ * Gives one person a mailbox on the org's already-resolved mail domain
504
+ * (#162). The host implements this with `@wtfalch/email/postmaster`'s own
505
+ * `createMailbox`, resolving `org`'s domain to a `domainId` the same way
506
+ * `applyOrgMail` does -- that function already refuses a duplicate `local`
507
+ * on the resolved domain, so this port method never has to. `ensureOwnerMailbox`
508
+ * and `ensureRoleMailboxes` (#230) below are this executor's callers, and each checks its own recorded scope
509
+ * id first, so a second apply never calls this at all.
510
+ */
511
+ createMailbox(org: OrgMailOrg, mailbox: {
512
+ address: string;
513
+ name: string;
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>;
461
544
  }
462
545
  /**
463
- * The shared calls SFU's own LiveKit API credentials (#122, #127), resolved
464
- * by the host from wherever `scripts/calls-sfu-deploy.mjs`'s own
465
- * `CallsSfuConfig` delivered them (#145) -- this package never mints or
466
- * invents a LiveKit key/secret pair itself, the same "the executor is
467
- * constructed with narrow ports ... a host [archon] supplies real clients"
468
- * rule this file's own top doc comment states for every provider credential.
469
- * `resolveCredentials` returns `null` when the SFU has not been configured
470
- * yet, the same "nothing recorded" signal a bare `CoolifyPort.listEnvs` gives
471
- * `ensureCallsMatrixSecrets` -- just for a secret this package never held in
472
- * the first place, so there is nowhere here to read it back from 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.
551
+ *
552
+ * As of #170 the platform's own `sfu:livekit` step
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
557
+ * held-keys entry back -- still resolved by the host (archon), never minted
558
+ * or read directly by this port itself, the same "the executor is
559
+ * constructed with narrow ports ... a host supplies real clients" rule this
560
+ * file's own top doc comment states for every provider credential.
561
+ * `resolveCredentials` returns `null` before the platform's `sfu` step has
562
+ * ever applied for that org, the same "nothing recorded" signal a bare
563
+ * `CoolifyPort.listEnvs` gives `ensureCallsMatrixSecrets`.
473
564
  */
474
565
  export interface CallsSfuPort {
475
- resolveCredentials(): Promise<{
566
+ resolveCredentials(orgSlug: string): Promise<{
476
567
  apiKey: string;
477
568
  apiSecret: string;
478
569
  } | null>;
@@ -573,26 +664,108 @@ export interface ZitadelPort {
573
664
  deleteProject(orgId: string, projectId: string): Promise<void>;
574
665
  /** `FoundryPlanApply.destroy`'s last step: removes the whole ZITADEL org and everything foundry made inside it. */
575
666
  deleteOrg(orgId: string): Promise<void>;
576
- }
577
- /**
578
- * One org's spend limit on Files, matching `files/packages/sdk/src/client.ts:154-166`'s
579
- * `setQuota`/`setBudget` (the caller's own organisation, resolved from its
580
- * signed request — no organisation id parameter, hence a factory per org
581
- * rather than one flat port).
582
- */
583
- export interface FilesOrgLimits {
584
- setQuota(limitBytes: string | null): Promise<unknown>;
585
- setBudget(limitMicros: string | null): Promise<unknown>;
586
- }
587
- /**
588
- * One org's budget on AI, matching `ai/packages/sdk/src/client.ts:94-101`'s
589
- * `updateBudget` (`UpdateBudget` from `ai/packages/sdk/src/types.ts:134-137`).
590
- */
591
- export interface AiOrgLimits {
592
- updateBudget(input: {
593
- keyId: string | null;
594
- limitMicros: string;
595
- }): Promise<unknown>;
667
+ /**
668
+ * #168: mints foundry's own machine user, on the platform's own freshly
669
+ * built ZITADEL instance, with the instance-wide admin role, and a fresh
670
+ * Personal Access Token for it -- "Foundry mints this key; nobody pastes
671
+ * it." `orgId` is the ZITADEL org the machine user lives in (every ZITADEL
672
+ * user belongs to some org, the same shape {@link inviteOrgOwner} already
673
+ * has for a human, even though the IAM_OWNER role this user gets is
674
+ * instance-wide once granted, not scoped to that org). Idempotent on the
675
+ * user itself -- a real client finds or creates it by `userName` -- but
676
+ * never on the token: ZITADEL shows a PAT's value once, at mint, and never
677
+ * again, the same "minted once, delivered once" rule {@link createApiApp}'s
678
+ * own doc comment follows for a client secret. The caller
679
+ * (`platform-providers.ts`'s `FoundryPlatformStepExecutor`) hands the token
680
+ * straight to {@link FoundryStepExecutorPorts.deliverSecret} and never
681
+ * returns, stores or logs it, and only calls this once per platform,
682
+ * guarded by its own recorded scope id -- never by anything this method
683
+ * does internally.
684
+ *
685
+ * `@wtfalch/providers` has no machine-user support at all yet (checked
686
+ * 2026-09-29) -- "being added to providers in parallel", the same
687
+ * precedent {@link createOidcApp}/{@link createApiApp} set above -- so this
688
+ * port states the shape it needs to land with; a real client that gains
689
+ * exactly this method satisfies it with no adapter.
690
+ */
691
+ mintAdminMachineUser(orgId: string, input: {
692
+ userName: string;
693
+ name: string;
694
+ }): Promise<{
695
+ userId: string;
696
+ token: string;
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[]>;
596
769
  }
597
770
  /**
598
771
  * Provisions one org's organisation on Files (#80), matching
@@ -600,9 +773,8 @@ export interface AiOrgLimits {
600
773
  * `createFilesManagementClient().provisionOrganisation`
601
774
  * (`ProvisionOrganisationInput`/`ProvisionOrganisationResult`,
602
775
  * `files/packages/sdk/src/types.ts:146-156`). Flat, not a factory, like
603
- * {@link ZitadelPort}: `organisationId` is an explicit call argument here,
604
- * unlike {@link FilesOrgLimits}'s tenant-scoped calls, which resolve the
605
- * caller's own organisation from its signed request instead.
776
+ * {@link ZitadelPort}: `organisationId` is an explicit call argument on every
777
+ * method here.
606
778
  *
607
779
  * Finding (this campaign's report): unlike {@link AiOrganisationPort}'s bearer
608
780
  * credential, `createFilesManagementClient`'s whole transport is "a signed
@@ -611,11 +783,17 @@ export interface AiOrgLimits {
611
783
  * request (`files/apps/host/src/lib/authz/service-assertion.ts`'s
612
784
  * `signManagementRequest`/`AssertionTrust`). A host can construct this without
613
785
  * any human session: it only needs the Ed25519 private key (`SigningIdentity`)
614
- * an operator's `MANAGEMENT_PUBLIC_KEYS`/`MANAGEMENT_ISSUER` pins — the same
615
- * signer {@link FilesOrgLimits} needs for `setQuota`/`setBudget`, since the
616
- * `organisation` field inside the signed claim selects the org per call, so
617
- * one shared keypair signs for every org rather than needing a secret per
618
- * org.
786
+ * an operator's `MANAGEMENT_PUBLIC_KEYS`/`MANAGEMENT_ISSUER` pins.
787
+ *
788
+ * #177: `setQuota`/`setBudget` (`files/packages/sdk/src/client.ts:154-166`)
789
+ * used to live on a separate `FilesOrgLimits` a host built per org through a
790
+ * `filesLimitsFor(orgId)` factory on `FoundryStepExecutorPorts` — ceremony
791
+ * left over from nothing but currying an id this port's own callers already
792
+ * have in hand. The signed claim's `organisation` field selects the org per
793
+ * call, so one shared keypair signs for every org already, the same signer
794
+ * `provisionOrganisation` uses; there was never a per-org client to build,
795
+ * only an org id to pass. Both methods now take it explicitly, like
796
+ * `provisionOrganisation` always did, and `filesLimitsFor` is gone.
619
797
  */
620
798
  export interface FilesOrganisationPort {
621
799
  provisionOrganisation(input: {
@@ -634,6 +812,9 @@ export interface FilesOrganisationPort {
634
812
  shareHost: string | null;
635
813
  created: boolean;
636
814
  }>;
815
+ /** #27, #177: the org's own spend limit on Files -- see this interface's own doc comment for why `organisationId` is explicit here. */
816
+ setQuota(organisationId: string, limitBytes: string | null): Promise<unknown>;
817
+ setBudget(organisationId: string, limitMicros: string | null): Promise<unknown>;
637
818
  }
638
819
  /**
639
820
  * Provisions one org's organisation on AI and switches its AI use on (#80),
@@ -650,6 +831,13 @@ export interface FilesOrganisationPort {
650
831
  * `ai.organisations:offer`, `ai.budgets:update`, among others) copied onto
651
832
  * it — used directly with `createAiClient`, unlike Files' signed-assertion
652
833
  * transport above.
834
+ *
835
+ * #172: `platform:ai` (`platform-providers.ts`'s `provisionPlatformAi`)
836
+ * deploys the ai host this port talks to, records its internal address
837
+ * (`PLATFORM_AI_INTERNAL_URL_SCOPE_KEY`) and delivers foundry's own
838
+ * management key under `PLATFORM_AI_MANAGEMENT_KEY_REF` -- but only through a
839
+ * host-supplied `AiManagementKeyMinter`, since that same script-only mint is
840
+ * still the one way ai issues its first platform credential.
653
841
  */
654
842
  export interface AiOrganisationPort {
655
843
  provisionOrganisation(input: {
@@ -664,21 +852,113 @@ export interface AiOrganisationPort {
664
852
  organisationId: string;
665
853
  offers: string[];
666
854
  }>;
855
+ /**
856
+ * Mints a key inside `organisationId` (#80 step 3, `wtfalch/ai#108`/`#113`),
857
+ * matching `ai/packages/sdk/src/client.ts`'s `issueOrganisationKey`
858
+ * (`IssueOrganisationKey`/`IssuedOrganisationKey`,
859
+ * `ai/packages/sdk/src/types.ts:150-166`). The secret comes back once, the
860
+ * same as `ZitadelPort.inviteOrgOwner`'s and `zitadelApp`'s own one-time
861
+ * secrets -- `aiKey` hands it straight to `deliverSecret` and returns only
862
+ * the key's id.
863
+ */
864
+ issueOrganisationKey(organisationId: string, input: {
865
+ name: string;
866
+ expiresDays: number;
867
+ permissions: string[];
868
+ }): Promise<{
869
+ organisationId: string;
870
+ id: string;
871
+ secret: string;
872
+ }>;
873
+ /**
874
+ * #27, #177: the org's own budget on AI, matching
875
+ * `ai/packages/sdk/src/client.ts:94-101`'s `updateBudget` (`UpdateBudget`
876
+ * from `ai/packages/sdk/src/types.ts:134-137`). Used to live on a separate
877
+ * `AiOrgLimits` a host built per org through an `aiLimitsFor(orgId)`
878
+ * factory on `FoundryStepExecutorPorts` -- the same dead currying
879
+ * {@link FilesOrganisationPort}'s own doc comment explains for
880
+ * `setQuota`/`setBudget`. `organisationId` is explicit here now, like
881
+ * every other method on this port.
882
+ */
883
+ updateBudget(organisationId: string, input: {
884
+ keyId: string | null;
885
+ limitMicros: string;
886
+ }): Promise<unknown>;
887
+ }
888
+ /**
889
+ * #173: the jobs scheduler's own admin API
890
+ * (https://github.com/wtfalch/jobs, "The admin API", jobs#62), authenticated
891
+ * with `SCHEDULER_ADMIN_TOKEN` as a bearer token by whatever real client
892
+ * satisfies this port -- this package holds no credential of its own, the
893
+ * same rule every other port in this file follows. No `@wtfalch/providers`
894
+ * client for this scheduler exists yet, so this port states the shape a real
895
+ * one should satisfy structurally, the same convention `ZitadelPort`'s
896
+ * not-yet-existing methods already follow.
897
+ *
898
+ * Matches `POST /admin/projects` method-for-method: idempotent by
899
+ * `(name, baseUrl)`. A first call returns the freshly minted `secret` and
900
+ * `syncKey`, shown exactly once; a repeat call for the same `(name, baseUrl)`
901
+ * returns just the `id`, with neither -- `jobsRegistration` below never
902
+ * re-delivers a secret it did not just mint, deciding purely on whether this
903
+ * resolves with one. A repeat call naming the same project under a different
904
+ * `baseUrl` is refused (the real API's `409`); this port surfaces that as a
905
+ * thrown error, like any other provider refusal in this file.
906
+ */
907
+ export interface JobsSchedulerPort {
908
+ registerProject(input: {
909
+ name: string;
910
+ baseUrl: string;
911
+ alertUrl?: string;
912
+ }): Promise<{
913
+ id: string;
914
+ secret?: string;
915
+ syncKey?: string;
916
+ }>;
917
+ }
918
+ /**
919
+ * #177: where the org steps' files, AI and jobs ports come from when the host
920
+ * does not supply them. `platform-org-ports.ts`'s `PlatformOrgPorts` builds
921
+ * them from the platform's recorded addresses and held keys.
922
+ */
923
+ export interface PlatformOrgPortSource {
924
+ files(): Promise<FilesOrganisationPort>;
925
+ ai(): Promise<AiOrganisationPort>;
926
+ jobs(): Promise<JobsSchedulerPort>;
667
927
  }
668
928
  export interface FoundryStepExecutorPorts {
669
929
  coolify: CoolifyPort;
670
930
  cloudflare: CloudflarePort;
671
931
  zitadel: ZitadelPort;
672
932
  mail: MailPort;
673
- resend: ResendPort;
933
+ lettermint: LettermintPort;
934
+ /**
935
+ * #173: registers an org's own app with the platform's jobs scheduler -- see
936
+ * {@link JobsSchedulerPort}'s own doc comment. Optional: a host with no
937
+ * scheduler deployed builds the executor without one, and tells the planner
938
+ * so (`FoundryPlanApplyOptions.jobsScheduler`), which then reports each
939
+ * org's registration as blocked instead of planning a step that would throw.
940
+ */
941
+ jobsScheduler?: JobsSchedulerPort;
674
942
  /** The shared calls SFU's own LiveKit credentials (#145) -- see {@link CallsSfuPort}'s own doc comment. */
675
943
  callsSfu: CallsSfuPort;
676
- /** A factory, not a flat client: Files/AI are org-scoped credentials the host resolves per org. */
677
- filesLimitsFor: (orgId: OrgId) => FilesOrgLimits;
678
- aiLimitsFor: (orgId: OrgId) => AiOrgLimits;
679
- /** Flat, not a factory: `organisationId` is an explicit argument on both ports (see each port's own doc comment). */
680
- filesOrganisation: FilesOrganisationPort;
681
- aiOrganisation: AiOrganisationPort;
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;
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). */
954
+ filesOrganisation?: FilesOrganisationPort;
955
+ aiOrganisation?: AiOrganisationPort;
956
+ /**
957
+ * #177: builds the files, AI and jobs ports from the platform's addresses
958
+ * and held keys. A port set above overrides it (tests); with neither, the
959
+ * step throws. The planner blocks these steps first while a key is not held.
960
+ */
961
+ platformOrg?: PlatformOrgPortSource;
682
962
  /**
683
963
  * Hands a just-minted, one-time secret (today: an API app's
684
964
  * `clientSecret`) to the host, immediately after the provider returns it
@@ -721,6 +1001,20 @@ export declare class NotBuiltError extends Error {
721
1001
  * this file's hostname rules.
722
1002
  */
723
1003
  export declare function healthUrls(org: DeclaredOrg): string[];
1004
+ /** The one Access policy foundry puts on its own Access app (#84). */
1005
+ export declare const ACCESS_POLICY_NAME = "foundry: allowed emails";
1006
+ /**
1007
+ * Whether an application's own Coolify `status` (`` `running:healthy`,
1008
+ * `exited:unhealthy` `` -- `@wtfalch/providers`' own doc comment) means it is
1009
+ * already up, or on its way there -- `findOrCreateCmsApp`'s own check of
1010
+ * whether a found-but-parked app still needs `start`ing (#116 review round
1011
+ * 3, Finding 1). Only `exited`/`stopped` (Coolify's own resting-off states;
1012
+ * `stopped` is not one this codebase has verified against a live instance,
1013
+ * included defensively) read as off; a missing `status` defaults to on, the
1014
+ * same "nothing says it's off" convention `planHostingToggle` (`diff.ts`)
1015
+ * uses for the org's own app.
1016
+ */
1017
+ export declare function isRunningOrStarting(status: string | undefined): boolean;
724
1018
  export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer {
725
1019
  private readonly ports;
726
1020
  constructor(ports: FoundryStepExecutorPorts);
@@ -850,9 +1144,10 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
850
1144
  * `zitadel:issuer` (`AUTH_ORGANIZATION_ID`), `zitadel:owner`
851
1145
  * (`OWNER_USER_ID`, gap P3) and every `zitadel:app` step
852
1146
  * (`AUTH_CLIENT_ID`), all ordered before `coolify:app` for exactly this
853
- * reason. AUTH_APP_KEY (waits on an auth broker registration API) and
854
- * DATABASE_URL_OWNER (a runtime-role split not built yet, `pushDatabaseUrl`'s
855
- * 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).
856
1151
  */
857
1152
  private pushAppEnv;
858
1153
  /**
@@ -863,32 +1158,43 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
863
1158
  */
864
1159
  private ensureAuthCookieSecret;
865
1160
  /**
866
- * #140 rework: the hosted app's own Resend sending key, pushed as
867
- * `RESEND_API_KEY` straight into the app's env, the one place its token
868
- * ever lives (Resend shows a key's token once, at creation). A no-op until
869
- * `coolify:mail` has recorded a {@link RESEND_DOMAIN_ID_SCOPE_KEY} for this
870
- * org; the next apply fills it in once `coolify:mail` runs first
1161
+ * #140 rework (2026-09-28 lettermint-org-mail swap, "Core rule" +
1162
+ * "Credential model"): all of the org's Lettermint work now lives here,
1163
+ * in the app step, since the first project -- and so the first domain
1164
+ * restriction -- can only exist once the token has a home (the app env). A
1165
+ * no-op until `coolify:mail` has recorded {@link MAIL_DOMAIN_SCOPE_KEY} for
1166
+ * this org; the next apply fills it in once `coolify:mail` runs first
871
1167
  * (`diff.ts`'s `computePlanDetail` order).
872
1168
  *
873
- * The key is named `<slug>-app@<resendDomainId>`, so a key limited to a
874
- * mail domain the org no longer uses (a `usesDomain` flip moves it between
875
- * `org.domain` and `<slug>.<apex>`) never passes for the current one.
876
- * `RESEND_API_KEY_ID` sits beside the token in the env -- an id, not a
877
- * secret -- so the env can say which key its token belongs to.
1169
+ * Order, exactly as the swap's "Core rule" states it: ensure the
1170
+ * `<slug>-app` project, ensure the mail domain, restrict that domain to the
1171
+ * org's own projects, publish its DNS records, trigger verification, then
1172
+ * delete every stale `<slug>-app` project. New projects are created and the
1173
+ * domain restricted to them before any old project is deleted, so the
1174
+ * restriction list never becomes empty.
1175
+ *
1176
+ * Mint rule ("Credential model"): the project is healthy when the app env
1177
+ * holds `LETTERMINT_API_TOKEN` and Lettermint lists exactly one project
1178
+ * named `<slug>-app` -- unlike Resend's key, a Lettermint project's name
1179
+ * carries no domain id, since a domain switch is handled entirely by
1180
+ * {@link LettermintPort.restrictDomainToProjects} below and never needs a
1181
+ * fresh project. Otherwise a fresh project is created and its token
1182
+ * written to the env first, and only then is every other `<slug>-app`
1183
+ * project deleted -- a lost mint response and a duplicate both end the
1184
+ * same way. An app that was already running is restarted before that
1185
+ * delete, since a container reads its env only at start (`running`, the
1186
+ * same reason {@link ensureLogVolume} restarts one).
878
1187
  *
879
- * The mint rule (`.plans/2026-09-27-org-mail-resend-design.md` "One rule
880
- * for minting"): healthy when the env holds a token and its
881
- * `RESEND_API_KEY_ID` is the one key Resend lists under the current name.
882
- * Otherwise one fresh key is minted and written to the env first, and only
883
- * then is every other `<slug>-app@` key deleted -- a lost mint response, a
884
- * duplicate, a hand-made key, and a previous domain's key all end the same
885
- * way. An app that was already running is restarted before that delete,
886
- * since a container reads its env only at start (`running`, the same
887
- * reason {@link ensureLogVolume} restarts one). The restart is not
888
- * awaited to completion, so the old container can briefly outlive its key.
1188
+ * The domain's restriction is rebuilt from every project named `<slug>-app`
1189
+ * or `<slug>-relay` (the relay project itself is #149, not built yet) --
1190
+ * never patched, so it can never end up scoped only to a project about to
1191
+ * be deleted.
889
1192
  *
890
1193
  * `MAIL_FROM` is upserted every time: it depends only on the mail domain
891
- * `coolify:mail` resolved (`RESEND_MAIL_DOMAIN_SCOPE_KEY`).
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).
892
1198
  *
893
1199
  * The token goes straight to `bulkUpsertEnvs` and nowhere else: never
894
1200
  * returned, never recorded in `serviceIds` (`recordScopeId`'s own doc
@@ -896,7 +1202,77 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
896
1202
  * same discipline `pushDatabaseUrl`'s doc comment states for a database's
897
1203
  * own connection string.
898
1204
  */
899
- private ensureAppResendKey;
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;
1263
+ /**
1264
+ * Review finding (post-#140 rework): {@link LettermintPort.findDomain}'s
1265
+ * own match is partial and case-insensitive, so a raw result can be a
1266
+ * lookalike (`notacme.com`, `mail.acme.com`) rather than `name` itself --
1267
+ * and every caller here full-replaces the found domain's project
1268
+ * restriction ({@link LettermintPort.restrictDomainToProjects}), so taking
1269
+ * a lookalike as a match would let it be taken over. Every domain lookup
1270
+ * in this class (`ensureAppLettermintMail`, `parkLettermintMail`,
1271
+ * `deleteStaleLettermintDomain`) goes through this instead of the port
1272
+ * directly, and treats anything but an exact, case-insensitive match as
1273
+ * not found.
1274
+ */
1275
+ private findExactLettermintDomain;
900
1276
  /**
901
1277
  * `plan/diff.ts`'s `planSite` (#116, slicing #99's "provision the cms app,
902
1278
  * database and site app per org"): the 2026-09-25 decision
@@ -1096,6 +1472,56 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1096
1472
  * `config.mjs`'s own regex requires.
1097
1473
  */
1098
1474
  private ensureCallsBindingSecret;
1475
+ /**
1476
+ * `plan/diff.ts`'s `planAgora`: provisions the org's own Agora
1477
+ * (https://github.com/wtfalch/agora) as one more Coolify application, from
1478
+ * that repo's `apps/agora/Dockerfile` ({@link AGORA_REPO}'s doc comment),
1479
+ * and pushes its env. `findOrCreateCallsApp` is reused as-is for the
1480
+ * find-by-name-then-start-or-create shape, like {@link coolifyCallsService}.
1481
+ * There is no park branch: Agora is always on.
1482
+ *
1483
+ * The env comes from `plan/agora.ts`'s `desiredAgoraEnv`, the same function
1484
+ * the plan compares the live app against, so a re-plan after this step is
1485
+ * empty. The step never writes a partial config: when any input is missing
1486
+ * (the org's ZITADEL org id or Agora client id, or an issuer or
1487
+ * mail/messaging project id the host does not supply,
1488
+ * `AgoraHostPort`, read by the plan) it throws naming each one, before
1489
+ * it creates anything.
1490
+ *
1491
+ * `AGORA_CANONICAL_HOST` is always pushed. `AGORA_MAIL_UPSTREAM`/
1492
+ * `AGORA_FILES_UPSTREAM` are pushed once the platform's own mail/files
1493
+ * hosts are on and applied (#177's `platform-org.ts`'s
1494
+ * `agoraHostValuesFromPlatform`, read by `plan-apply.ts` when no host
1495
+ * `agora` port overrides it) -- absent otherwise. The chat upstreams are
1496
+ * still never pushed: chat has no admin API yet
1497
+ * (https://github.com/wtfalch/chat/issues/40). Agora answers 502 on those
1498
+ * routes rather than serving anything wrong.
1499
+ */
1500
+ private coolifyAgora;
1501
+ /**
1502
+ * `plan/diff.ts`'s `planJobsRegistration`: registers the org's own app with
1503
+ * the platform's jobs scheduler (`JobsSchedulerPort.registerProject`, the
1504
+ * scheduler's `POST /admin/projects`), then hands the freshly minted
1505
+ * `secret` and `syncKey` to the host through `deliverSecret` -- but only
1506
+ * when this call actually minted them. The admin API is itself idempotent
1507
+ * by `(name, baseUrl)`: a repeat call for an already-registered project
1508
+ * resolves with just the `id`, and this never re-delivers a secret or sync
1509
+ * key it did not just mint, the same "registers an app once" rule
1510
+ * `diff.ts`'s own plan-level gate (recordedIds, mirroring `planOwner`)
1511
+ * already provides on a normal re-plan -- this check is what keeps that
1512
+ * true even if this step somehow runs twice on the same apply.
1513
+ *
1514
+ * Never logs, returns or records either credential (this file's own top
1515
+ * doc comment's "a secret is never a step's return value" rule) -- only the
1516
+ * scheduler's own non-secret project id comes back as `resourceId`.
1517
+ */
1518
+ /** #177: the host's own port when it supplies one, else the platform's, built from its address and held key. */
1519
+ private filesPort;
1520
+ private aiPort;
1521
+ private jobsPort;
1522
+ private jobsRegistration;
1523
+ /** `deliverSecret`, tried up to {@link JOBS_DELIVERY_ATTEMPTS} times; true once one attempt succeeds. */
1524
+ private deliverWithRetry;
1099
1525
  private cloudflareTunnel;
1100
1526
  /**
1101
1527
  * `plan/diff.ts`'s `planZone`: finds or creates the Cloudflare zone for the
@@ -1118,67 +1544,119 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1118
1544
  * `plan/diff.ts`'s `planMail`: gives an org mail, moves it between modes,
1119
1545
  * or parks it on the one shared Stalwart instance (`this.ports.mail`,
1120
1546
  * `@wtfalch/email/postmaster`'s `applyOrgMail`/`parkOrgMail` bound), then
1121
- * publishes whatever DNS records the call returns, then registers the same
1122
- * mail domain with Resend (#138) -- the shared Stalwart relays every
1123
- * outbound message through Resend, and Resend refuses mail from a domain
1124
- * it has not verified. No step here ever deletes the app's Resend key:
1125
- * `diff.ts`'s `planMail` plans nothing for mode `'off'`, so turning mail
1126
- * off leaves the key live, like every other resource foundry stops
1127
- * managing (#10). Switching the mail domain replaces the key instead
1128
- * (`ensureAppResendKey`).
1547
+ * publishes whatever Stalwart DNS records the call returns, and records the
1548
+ * resolved mail domain as a scope id ({@link MAIL_DOMAIN_SCOPE_KEY}).
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.
1558
+ *
1559
+ * Review finding: a mail domain switch (`org.domain` changes, or
1560
+ * `usesDomain` flips) must also delete the *old* Lettermint domain here,
1561
+ * before recording the new one -- otherwise it stays registered and
1562
+ * restricted to the org's still-live `<slug>-app`/`<slug>-relay` projects,
1563
+ * which could then keep sending as it forever. {@link deleteStaleLettermintDomain}
1564
+ * does this by comparing the previously recorded domain (case-insensitive)
1565
+ * against the one this apply just resolved.
1566
+ *
1567
+ * #162: once `applyOrgMail` has given the org a domain, {@link ensureOwnerMailbox}
1568
+ * gives its declared owner a mailbox on it, so Agora's Mail section is not
1569
+ * empty for them after the org's first apply.
1129
1570
  */
1130
1571
  private coolifyMail;
1572
+ /**
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.
1593
+ */
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;
1606
+ /**
1607
+ * Deletes the org's previous Lettermint domain once the mail domain it was
1608
+ * registered under has changed -- otherwise it stays registered and
1609
+ * restricted to the org's still-live `<slug>-app` project after the switch
1610
+ * (review finding, post-#140 rework). A no-op on first apply (no previous
1611
+ * domain recorded) and on a same-domain re-apply (compared
1612
+ * case-insensitively, the same rule {@link findExactLettermintDomain}
1613
+ * applies to the lookup itself).
1614
+ */
1615
+ private deleteStaleLettermintDomain;
1131
1616
  /**
1132
1617
  * Upserts each DNS record `applyOrgMail` says foundry must publish, found
1133
1618
  * by an exact name+type match (`CloudflarePort.findDnsRecord`'s own doc
1134
1619
  * comment) so update never mistakes a stale record of a different type for
1135
1620
  * a match. A `DKIM` record can be absent on the first call, since Stalwart
1136
- * mints the key on a background task; the next apply publishes it. Returns
1137
- * the zone id so `registerResendDomain` publishes Resend's own records on
1138
- * the same zone without a second lookup.
1621
+ * mints the key on a background task; the next apply publishes it.
1139
1622
  */
1140
1623
  private publishMailDnsRecords;
1141
1624
  /**
1142
- * #138: registers `domain` with Resend -- the custom domain, or
1143
- * `<slug>.<apex>` for a level-1 org, exactly what `publishMailDnsRecords`
1144
- * just published the Stalwart records for. Idempotent by name, the same way
1145
- * `applyOrgMail` itself is: found by `findDomain` rather than any id this
1146
- * class recorded, so a crash between here and the next apply just repeats
1147
- * the same find-or-create. Never blocks on DNS propagation (task brief):
1148
- * `verifyDomain` asks Resend to re-check once, and its returned status is
1149
- * neither polled nor awaited -- an unverified domain here is exactly what
1150
- * the next apply corrects.
1151
- *
1152
- * #140 rework: mints no key of its own any more. It records the domain's
1153
- * own id and the resolved mail domain as scope ids so `ensureAppResendKey`
1154
- * (`coolifyApp`) can mint the app's own `<slug>-app@<domainId>` key once the app
1155
- * exists, without this step or the app step calling Resend to look the
1156
- * domain back up.
1157
- */
1158
- private registerResendDomain;
1159
- /**
1160
- * Upserts each DNS record Resend's domain names, the same find-then-upsert
1161
- * `publishMailDnsRecords` above uses for Stalwart's own records -- except
1162
- * SPF is merged into whatever record that call just published rather than
1163
- * created a second time (RFC 7208 treats two `v=spf1` TXT records on one
1164
- * name as a permerror and silently breaks SPF for every sender on the
1165
- * domain), and an existing `_dmarc` record is never overwritten. Prior art:
1166
- * `wtfalch/app-template`'s `bootstrap/steps/resend-email.ts`.
1625
+ * Parking mail (2026-09-28 lettermint-org-mail swap, "Core rule": "Parking
1626
+ * mail deletes the org's projects and then the domain itself, so no
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.
1634
+ * A no-op until a previous apply recorded {@link MAIL_DOMAIN_SCOPE_KEY}:
1635
+ * nothing was ever provisioned for an org whose mail went straight from
1636
+ * never-applied to off.
1167
1637
  */
1168
- private publishResendDnsRecords;
1638
+ private parkLettermintMail;
1169
1639
  /**
1170
- * Inserts Resend's `include:` token into whatever SPF record
1171
- * `publishMailDnsRecords` already published for this name, instead of
1172
- * creating a second `v=spf1` TXT record -- the classic foot-gun that makes
1173
- * every receiver treat SPF as a permerror and skip it entirely.
1640
+ * Upserts each DNS record Lettermint's domain names, the same find-then-
1641
+ * upsert `publishMailDnsRecords` above uses for Stalwart's own records --
1642
+ * except an existing `_dmarc` record is never overwritten (a stricter
1643
+ * policy an org already set, `p=quarantine`/`p=reject`, should not be
1644
+ * relaxed back to Lettermint's own default). Resend's own domain also
1645
+ * carried an apex `SPF` TXT record that had to be merged into whatever SPF
1646
+ * record already existed there (RFC 7208: two `v=spf1` TXT records on one
1647
+ * name is a permerror); Lettermint's own DNS records
1648
+ * (`.plans/2026-09-28-lettermint-api.md` §3) have no such record -- its
1649
+ * `purpose` enum is `return_path`/`dmarc`/`dkim_legacy`/`dkim_primary`/
1650
+ * `dkim_secondary`, none of them SPF -- so that merge has no case left to
1651
+ * handle here and is dropped rather than kept as dead code.
1174
1652
  */
1175
- private mergeResendSpfRecord;
1653
+ private publishLettermintDnsRecords;
1176
1654
  /**
1177
1655
  * Never overwrites an existing `_dmarc` record -- a stricter policy an org
1178
1656
  * already set (`p=quarantine`/`p=reject`) should not be relaxed back to
1179
- * Resend's own `p=none` default.
1657
+ * Lettermint's own `p=none` default.
1180
1658
  */
1181
- private ensureResendDmarcRecord;
1659
+ private ensureLettermintDmarcRecord;
1182
1660
  /**
1183
1661
  * #80: provisions the org's own organisation on Files, before
1184
1662
  * {@link filesStorage} can set its spend limit. `provisionOrganisation` is
@@ -1197,6 +1675,16 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1197
1675
  */
1198
1676
  private aiOrganisation;
1199
1677
  private aiBudget;
1678
+ /**
1679
+ * #80 step 3: mints the org's own ai key, once, and hands its one-time
1680
+ * secret straight to {@link FoundryStepExecutorPorts.deliverSecret} --
1681
+ * never returned, stored in `serviceIds` or logged, the same discipline
1682
+ * `zitadelApp`'s own `created.clientSecret` follows. `diff.ts`'s
1683
+ * `planAiKey` never plans this step again once its resource id
1684
+ * (the key's id, not its secret) is recorded, since ai's own API offers no
1685
+ * way to check for an already-minted key.
1686
+ */
1687
+ private aiKey;
1200
1688
  /**
1201
1689
  * {@link StepDestroyer.destroy}: deletes the one resource
1202
1690
  * `FoundryPlanApply.destroy` names. A provider 404 (already gone) counts as
@@ -1209,3 +1697,23 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1209
1697
  readonly recordedIds: Readonly<Record<string, string>>;
1210
1698
  }): Promise<void>;
1211
1699
  }
1700
+ /**
1701
+ * True for a provider's own "already gone" response, however it surfaces --
1702
+ * `@wtfalch/providers`' `ZitadelError` carries a structured `status: number`
1703
+ * (`zitadel.ts`), while its Coolify and Cloudflare clients throw a plain
1704
+ * `Error` whose message embeds the HTTP status (`` `Coolify ${method} ${path}
1705
+ * → ${status}: ${text}` ``, `` `CF ${method} ${path} → ${status}: ${msg}` ``).
1706
+ * Ports are structural here (this file's own top comment: never import a
1707
+ * concrete provider class), so this cannot `instanceof`-check a real error
1708
+ * type -- it duck-types a `status` field first.
1709
+ *
1710
+ * A prior version fell back to scanning the *whole* message for a standalone
1711
+ * `404` when no `status` field was present. That is unsafe: the Coolify and
1712
+ * Cloudflare clients put the resource path -- which can contain a hex UUID
1713
+ * -- ahead of the status in the message, so a UUID that happens to contain
1714
+ * the digits `404` (`9a404b12-...`) made a genuine 500 read as a 404. The
1715
+ * fallback below instead anchors to the exact position both clients put the
1716
+ * status in: the first `→ `, after the client, method and path, and before
1717
+ * `:`. It never scans the path or the response body.
1718
+ */
1719
+ export declare function isNotFoundError(error: unknown): boolean;