@wtfalch/foundry 0.4.0 → 0.5.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 (66) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +96 -0
  2. package/dist/bootstrap/bootstrap.js +285 -0
  3. package/dist/bootstrap/declare.d.ts +36 -0
  4. package/dist/bootstrap/declare.js +94 -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 +9 -6
  10. package/dist/calls-sfu-deploy-support.js +9 -6
  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 +5 -4
  18. package/dist/calls-sfu.js +5 -4
  19. package/dist/index.d.ts +31 -6
  20. package/dist/index.js +16 -3
  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/org.js +8 -0
  25. package/dist/plan/agora.d.ts +77 -0
  26. package/dist/plan/agora.js +127 -0
  27. package/dist/plan/cloudflared-connector.d.ts +70 -0
  28. package/dist/plan/cloudflared-connector.js +106 -0
  29. package/dist/plan/diff.d.ts +20 -3
  30. package/dist/plan/diff.js +631 -116
  31. package/dist/plan/executor.d.ts +475 -168
  32. package/dist/plan/executor.js +543 -175
  33. package/dist/plan/index.d.ts +22 -5
  34. package/dist/plan/index.js +12 -2
  35. package/dist/plan/jobs-scheduler-http.d.ts +29 -0
  36. package/dist/plan/jobs-scheduler-http.js +50 -0
  37. package/dist/plan/live.d.ts +51 -2
  38. package/dist/plan/live.js +8 -0
  39. package/dist/plan/plan-apply.d.ts +192 -3
  40. package/dist/plan/plan-apply.js +278 -9
  41. package/dist/plan/platform-diff.d.ts +312 -0
  42. package/dist/plan/platform-diff.js +867 -0
  43. package/dist/plan/platform-org-ports.d.ts +50 -0
  44. package/dist/plan/platform-org-ports.js +70 -0
  45. package/dist/plan/platform-org.d.ts +120 -0
  46. package/dist/plan/platform-org.js +115 -0
  47. package/dist/plan/platform-providers.d.ts +501 -0
  48. package/dist/plan/platform-providers.js +3029 -0
  49. package/dist/plan/platform-sfu.d.ts +93 -0
  50. package/dist/plan/platform-sfu.js +100 -0
  51. package/dist/plan/providers-readers.js +22 -1
  52. package/dist/plan/zitadel-machine-user.d.ts +34 -0
  53. package/dist/plan/zitadel-machine-user.js +95 -0
  54. package/dist/plan.d.ts +6 -0
  55. package/dist/platform.d.ts +55 -0
  56. package/dist/platform.js +46 -0
  57. package/dist/requested-changes.d.ts +108 -0
  58. package/dist/requested-changes.js +95 -0
  59. package/dist/slug.js +15 -2
  60. package/dist/store/audit.js +4 -2
  61. package/dist/store/postgres.d.ts +29 -1
  62. package/dist/store/postgres.js +172 -2
  63. package/dist/store/schema.d.ts +683 -0
  64. package/dist/store/schema.js +57 -0
  65. package/dist/store.d.ts +46 -0
  66. package/package.json +1 -1
@@ -1,5 +1,5 @@
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
5
  import type { DestroyTarget, StepDestroyer, StepExecutionContext, StepExecutor } from './plan-apply.js';
@@ -60,6 +60,7 @@ export interface CoolifyPort {
60
60
  uuid: string;
61
61
  is_usable: boolean;
62
62
  is_reachable: boolean;
63
+ ip?: string;
63
64
  }>>;
64
65
  requireGithubApp(preferredUuid?: string): Promise<{
65
66
  uuid: string;
@@ -217,6 +218,15 @@ export interface CoolifyPort {
217
218
  name?: string;
218
219
  description?: string;
219
220
  instant_deploy?: boolean;
221
+ /**
222
+ * `host:container`, comma separated -- publishes host ports directly
223
+ * (`@wtfalch/providers`' `CoolifyClient.createDockerImageApplication`
224
+ * doc comment: "a tunnelled web app never needs and a mail server cannot
225
+ * do without"). Used by `platform-providers.ts`'s `provisionPlatformMail`
226
+ * (#169) for Stalwart's SMTP/submission ports, which cannot go through
227
+ * Cloudflare's HTTP-only tunnel the way `platform:auth`'s apps do.
228
+ */
229
+ ports_mappings?: string;
220
230
  }): Promise<{
221
231
  uuid: string;
222
232
  }>;
@@ -292,6 +302,15 @@ export interface CloudflarePort {
292
302
  name: string;
293
303
  }>;
294
304
  putTunnelConfig(tunnelId: string, config: unknown): Promise<unknown>;
305
+ /**
306
+ * The tunnel's connector token (#197) -- a `cloudflared` container reads
307
+ * this at boot to dial into the tunnel. A live credential: matches
308
+ * `@wtfalch/providers`' `CloudflareClient.getTunnelToken` method-for-method.
309
+ * A caller must hand this straight to `bulkUpsertEnvs` and nowhere else --
310
+ * never a log, an error, a return value or a recorded scope id (see
311
+ * `cloudflared-connector.ts`'s `ensureCloudflaredConnector`).
312
+ */
313
+ getTunnelToken(tunnelId: string): Promise<string>;
295
314
  /** `FoundryPlanApply.destroy`'s teardown of the org's own Cloudflare tunnel. */
296
315
  deleteTunnel(tunnelId: string): Promise<void>;
297
316
  /** `cloudflareAccess` finds the org's Access app by domain before creating one (#84). */
@@ -310,6 +329,7 @@ export interface CloudflarePort {
310
329
  listAccessPolicies(appId: string): Promise<Array<{
311
330
  id: string;
312
331
  name: string;
332
+ include?: unknown[];
313
333
  }>>;
314
334
  createAccessPolicy(appId: string, policy: {
315
335
  name: string;
@@ -371,83 +391,103 @@ export interface CloudflarePort {
371
391
  request<T>(method: string, path: string, body?: unknown, missingOkay?: boolean): Promise<T>;
372
392
  }
373
393
  /**
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.
394
+ * One DNS record a Lettermint domain's own response says must be published --
395
+ * `purpose` is Lettermint's own label (`return_path`, `dmarc`, `dkim_legacy`,
396
+ * `dkim_primary`, `dkim_secondary`), replacing Resend's `record` field, from
397
+ * {@link LettermintPort.createDomain}/{@link LettermintPort.getDomain}, the
398
+ * same shape https://lettermint.co/docs/api-reference/team/0.0.1/lettermint-team-openapi.json's
399
+ * `DomainDnsRecordData` documents (`fqdn`/`content` renamed to this port's own
400
+ * `name`/`value`, matching {@link publishLettermintDnsRecords}'s sibling
401
+ * `publishMailDnsRecords` naming).
379
402
  */
380
- export interface ResendDnsRecordSpec {
381
- readonly record: 'SPF' | 'DKIM' | 'DMARC' | 'MX' | string;
382
- readonly name: string;
403
+ export interface LettermintDnsRecordSpec {
404
+ readonly purpose: 'return_path' | 'dmarc' | 'dkim_legacy' | 'dkim_primary' | 'dkim_secondary';
383
405
  readonly type: 'TXT' | 'CNAME' | 'MX';
406
+ readonly name: string;
384
407
  readonly value: string;
385
- readonly priority?: number;
386
408
  }
387
- export interface ResendDomain {
409
+ export interface LettermintDomain {
410
+ readonly id: string;
411
+ readonly domain: string;
412
+ readonly status: 'verified' | 'partially_verified' | 'pending_verification' | 'failed_verification';
413
+ readonly dnsRecords: readonly LettermintDnsRecordSpec[];
414
+ }
415
+ export interface LettermintProject {
388
416
  readonly id: string;
389
417
  readonly name: string;
390
- readonly status: string;
391
- readonly records?: readonly ResendDnsRecordSpec[];
392
418
  }
393
419
  /**
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
420
+ * The Lettermint calls this executor makes to verify an org's mail domain
421
+ * (#138) and mint its app's sending project (#140 rework, 2026-09-28
422
+ * lettermint-org-mail swap) -- the shared Stalwart relays every outbound
423
+ * message through Lettermint
397
424
  * (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.
425
+ * which refuses mail from a domain it has not verified. `@wtfalch/providers`
426
+ * has no Lettermint client yet, unlike Coolify/Cloudflare/ZITADEL above, so
427
+ * this port names Lettermint's own REST shapes directly
428
+ * (`.plans/2026-09-28-lettermint-api.md`, its own primary-source citations).
429
+ * Replaces `ResendPort` (#138/#140), trimmed to what `coolifyApp`/`coolifyMail`
430
+ * below actually call.
431
+ *
432
+ * Unlike Resend's per-domain sending key, a Lettermint domain is
433
+ * unrestricted by default -- any project in the team could send from it --
434
+ * so a project's own domain access is a separate call
435
+ * ({@link restrictDomainToProjects}), and the credential unit is a *project*,
436
+ * not a per-domain key: Lettermint's API mints, at most, one live token per
437
+ * project and has no call to list or revoke an individual token within one
438
+ * (`.plans/2026-09-28-lettermint-api.md` §4), so `<slug>-app` is a project
439
+ * name, never suffixed with a domain id the way Resend's key name was.
409
440
  */
410
- export interface ResendPort {
411
- /** An exact name match, never a listing scan `coolifyMail` has to page itself. */
441
+ export interface LettermintPort {
442
+ /**
443
+ * Lettermint's own `filter[domain]` match is partial and case-insensitive
444
+ * (`.plans/2026-09-28-lettermint-api.md` §3: "partial, case-insensitive"),
445
+ * so a result here can be a lookalike (`notacme.com`, `mail.acme.com`) --
446
+ * `domain` is returned precisely so every caller can reject one via
447
+ * `FoundryStepExecutor.findExactLettermintDomain` rather than a listing
448
+ * scan of its own.
449
+ */
412
450
  findDomain(name: string): Promise<{
413
451
  id: string;
414
- name: string;
415
- status: string;
452
+ domain: string;
453
+ status: LettermintDomain['status'];
416
454
  } | 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>;
455
+ /** `POST /domains`, its response already carrying `dnsRecords`. */
456
+ createDomain(name: string): Promise<LettermintDomain>;
457
+ /** Re-reads a domain already found by {@link findDomain}, for its current DNS records. */
458
+ getDomain(id: string): Promise<LettermintDomain>;
420
459
  /**
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`)".
460
+ * Asks Lettermint to re-check DNS now. Never polled or awaited to
461
+ * completion -- the same "do not block the plan on DNS propagation" rule
462
+ * `ResendPort.verifyDomain` followed: record the status, and let the next
463
+ * apply re-check it.
425
464
  */
426
465
  verifyDomain(id: string): Promise<{
427
- status: string;
466
+ status: LettermintDomain['status'];
428
467
  }>;
468
+ /** `parkLettermintMail`'s own cleanup once an org's mail is parked (mode `'off'`). */
469
+ deleteDomain(id: string): Promise<void>;
429
470
  /**
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.
471
+ * `POST /projects`. `token` is the one-time secret -- Lettermint shows a
472
+ * project's token once, at creation, and the API has no call to mint a
473
+ * second one on the same project -- so a caller that loses it creates a
474
+ * fresh project instead (`ensureAppLettermintMail`'s own mint rule).
435
475
  */
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;
476
+ createProject(name: string): Promise<{
477
+ project: LettermintProject;
447
478
  token: string;
448
479
  }>;
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>;
480
+ /** 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. */
481
+ listProjects(): Promise<readonly LettermintProject[]>;
482
+ /** Deletes one project by id -- the mint rule's own cleanup of an orphan or a duplicate, and `parkLettermintMail`'s teardown. */
483
+ deleteProject(id: string): Promise<void>;
484
+ /**
485
+ * `PUT /domains/{domainId}/projects`. Rebuilds the domain's whole
486
+ * restriction from `projectIds` -- never patched -- so a domain never ends
487
+ * up unrestricted (any team project could then send as the org) nor
488
+ * restricted only to a project already deleted.
489
+ */
490
+ restrictDomainToProjects(domainId: string, projectIds: readonly string[]): Promise<void>;
451
491
  }
452
492
  /**
453
493
  * The Stalwart calls this executor makes for one org's mail, shaped to match
@@ -458,18 +498,38 @@ export interface ResendPort {
458
498
  export interface MailPort {
459
499
  applyOrgMail(org: OrgMailOrg, options: OrgMailOptions): Promise<OrgMailResult>;
460
500
  parkOrgMail(org: OrgMailOrg, options: OrgMailOptions): Promise<void>;
501
+ /**
502
+ * Gives one person a mailbox on the org's already-resolved mail domain
503
+ * (#162). The host implements this with `@wtfalch/email/postmaster`'s own
504
+ * `createMailbox`, resolving `org`'s domain to a `domainId` the same way
505
+ * `applyOrgMail` does -- that function already refuses a duplicate `local`
506
+ * on the resolved domain, so this port method never has to. `ensureOwnerMailbox`
507
+ * below is this executor's one caller, and it checks its own recorded scope
508
+ * id first, so a second apply never calls this at all.
509
+ */
510
+ createMailbox(org: OrgMailOrg, mailbox: {
511
+ address: string;
512
+ name: string;
513
+ }): Promise<void>;
461
514
  }
462
515
  /**
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.
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.
521
+ *
522
+ * 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
526
+ * held-keys entry back -- still resolved by the host (archon), never minted
527
+ * or read directly by this port itself, the same "the executor is
528
+ * constructed with narrow ports ... a host supplies real clients" rule this
529
+ * file's own top doc comment states for every provider credential.
530
+ * `resolveCredentials` returns `null` before the platform's `sfu` step has
531
+ * ever applied, the same "nothing recorded" signal a bare
532
+ * `CoolifyPort.listEnvs` gives `ensureCallsMatrixSecrets`.
473
533
  */
474
534
  export interface CallsSfuPort {
475
535
  resolveCredentials(): Promise<{
@@ -573,26 +633,37 @@ export interface ZitadelPort {
573
633
  deleteProject(orgId: string, projectId: string): Promise<void>;
574
634
  /** `FoundryPlanApply.destroy`'s last step: removes the whole ZITADEL org and everything foundry made inside it. */
575
635
  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>;
636
+ /**
637
+ * #168: mints foundry's own machine user, on the platform's own freshly
638
+ * built ZITADEL instance, with the instance-wide admin role, and a fresh
639
+ * Personal Access Token for it -- "Foundry mints this key; nobody pastes
640
+ * it." `orgId` is the ZITADEL org the machine user lives in (every ZITADEL
641
+ * user belongs to some org, the same shape {@link inviteOrgOwner} already
642
+ * has for a human, even though the IAM_OWNER role this user gets is
643
+ * instance-wide once granted, not scoped to that org). Idempotent on the
644
+ * user itself -- a real client finds or creates it by `userName` -- but
645
+ * never on the token: ZITADEL shows a PAT's value once, at mint, and never
646
+ * again, the same "minted once, delivered once" rule {@link createApiApp}'s
647
+ * own doc comment follows for a client secret. The caller
648
+ * (`platform-providers.ts`'s `FoundryPlatformStepExecutor`) hands the token
649
+ * straight to {@link FoundryStepExecutorPorts.deliverSecret} and never
650
+ * returns, stores or logs it, and only calls this once per platform,
651
+ * guarded by its own recorded scope id -- never by anything this method
652
+ * does internally.
653
+ *
654
+ * `@wtfalch/providers` has no machine-user support at all yet (checked
655
+ * 2026-09-29) -- "being added to providers in parallel", the same
656
+ * precedent {@link createOidcApp}/{@link createApiApp} set above -- so this
657
+ * port states the shape it needs to land with; a real client that gains
658
+ * exactly this method satisfies it with no adapter.
659
+ */
660
+ mintAdminMachineUser(orgId: string, input: {
661
+ userName: string;
662
+ name: string;
663
+ }): Promise<{
664
+ userId: string;
665
+ token: string;
666
+ }>;
596
667
  }
597
668
  /**
598
669
  * Provisions one org's organisation on Files (#80), matching
@@ -600,9 +671,8 @@ export interface AiOrgLimits {
600
671
  * `createFilesManagementClient().provisionOrganisation`
601
672
  * (`ProvisionOrganisationInput`/`ProvisionOrganisationResult`,
602
673
  * `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.
674
+ * {@link ZitadelPort}: `organisationId` is an explicit call argument on every
675
+ * method here.
606
676
  *
607
677
  * Finding (this campaign's report): unlike {@link AiOrganisationPort}'s bearer
608
678
  * credential, `createFilesManagementClient`'s whole transport is "a signed
@@ -611,11 +681,17 @@ export interface AiOrgLimits {
611
681
  * request (`files/apps/host/src/lib/authz/service-assertion.ts`'s
612
682
  * `signManagementRequest`/`AssertionTrust`). A host can construct this without
613
683
  * 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.
684
+ * an operator's `MANAGEMENT_PUBLIC_KEYS`/`MANAGEMENT_ISSUER` pins.
685
+ *
686
+ * #177: `setQuota`/`setBudget` (`files/packages/sdk/src/client.ts:154-166`)
687
+ * used to live on a separate `FilesOrgLimits` a host built per org through a
688
+ * `filesLimitsFor(orgId)` factory on `FoundryStepExecutorPorts` — ceremony
689
+ * left over from nothing but currying an id this port's own callers already
690
+ * have in hand. The signed claim's `organisation` field selects the org per
691
+ * call, so one shared keypair signs for every org already, the same signer
692
+ * `provisionOrganisation` uses; there was never a per-org client to build,
693
+ * only an org id to pass. Both methods now take it explicitly, like
694
+ * `provisionOrganisation` always did, and `filesLimitsFor` is gone.
619
695
  */
620
696
  export interface FilesOrganisationPort {
621
697
  provisionOrganisation(input: {
@@ -634,6 +710,9 @@ export interface FilesOrganisationPort {
634
710
  shareHost: string | null;
635
711
  created: boolean;
636
712
  }>;
713
+ /** #27, #177: the org's own spend limit on Files -- see this interface's own doc comment for why `organisationId` is explicit here. */
714
+ setQuota(organisationId: string, limitBytes: string | null): Promise<unknown>;
715
+ setBudget(organisationId: string, limitMicros: string | null): Promise<unknown>;
637
716
  }
638
717
  /**
639
718
  * Provisions one org's organisation on AI and switches its AI use on (#80),
@@ -650,6 +729,13 @@ export interface FilesOrganisationPort {
650
729
  * `ai.organisations:offer`, `ai.budgets:update`, among others) copied onto
651
730
  * it — used directly with `createAiClient`, unlike Files' signed-assertion
652
731
  * transport above.
732
+ *
733
+ * #172: `platform:ai` (`platform-providers.ts`'s `provisionPlatformAi`)
734
+ * deploys the ai host this port talks to, records its internal address
735
+ * (`PLATFORM_AI_INTERNAL_URL_SCOPE_KEY`) and delivers foundry's own
736
+ * management key under `PLATFORM_AI_MANAGEMENT_KEY_REF` -- but only through a
737
+ * host-supplied `AiManagementKeyMinter`, since that same script-only mint is
738
+ * still the one way ai issues its first platform credential.
653
739
  */
654
740
  export interface AiOrganisationPort {
655
741
  provisionOrganisation(input: {
@@ -664,21 +750,104 @@ export interface AiOrganisationPort {
664
750
  organisationId: string;
665
751
  offers: string[];
666
752
  }>;
753
+ /**
754
+ * Mints a key inside `organisationId` (#80 step 3, `wtfalch/ai#108`/`#113`),
755
+ * matching `ai/packages/sdk/src/client.ts`'s `issueOrganisationKey`
756
+ * (`IssueOrganisationKey`/`IssuedOrganisationKey`,
757
+ * `ai/packages/sdk/src/types.ts:150-166`). The secret comes back once, the
758
+ * same as `ZitadelPort.inviteOrgOwner`'s and `zitadelApp`'s own one-time
759
+ * secrets -- `aiKey` hands it straight to `deliverSecret` and returns only
760
+ * the key's id.
761
+ */
762
+ issueOrganisationKey(organisationId: string, input: {
763
+ name: string;
764
+ expiresDays: number;
765
+ permissions: string[];
766
+ }): Promise<{
767
+ organisationId: string;
768
+ id: string;
769
+ secret: string;
770
+ }>;
771
+ /**
772
+ * #27, #177: the org's own budget on AI, matching
773
+ * `ai/packages/sdk/src/client.ts:94-101`'s `updateBudget` (`UpdateBudget`
774
+ * from `ai/packages/sdk/src/types.ts:134-137`). Used to live on a separate
775
+ * `AiOrgLimits` a host built per org through an `aiLimitsFor(orgId)`
776
+ * factory on `FoundryStepExecutorPorts` -- the same dead currying
777
+ * {@link FilesOrganisationPort}'s own doc comment explains for
778
+ * `setQuota`/`setBudget`. `organisationId` is explicit here now, like
779
+ * every other method on this port.
780
+ */
781
+ updateBudget(organisationId: string, input: {
782
+ keyId: string | null;
783
+ limitMicros: string;
784
+ }): Promise<unknown>;
785
+ }
786
+ /**
787
+ * #173: the jobs scheduler's own admin API
788
+ * (https://github.com/wtfalch/jobs, "The admin API", jobs#62), authenticated
789
+ * with `SCHEDULER_ADMIN_TOKEN` as a bearer token by whatever real client
790
+ * satisfies this port -- this package holds no credential of its own, the
791
+ * same rule every other port in this file follows. No `@wtfalch/providers`
792
+ * client for this scheduler exists yet, so this port states the shape a real
793
+ * one should satisfy structurally, the same convention `ZitadelPort`'s
794
+ * not-yet-existing methods already follow.
795
+ *
796
+ * Matches `POST /admin/projects` method-for-method: idempotent by
797
+ * `(name, baseUrl)`. A first call returns the freshly minted `secret` and
798
+ * `syncKey`, shown exactly once; a repeat call for the same `(name, baseUrl)`
799
+ * returns just the `id`, with neither -- `jobsRegistration` below never
800
+ * re-delivers a secret it did not just mint, deciding purely on whether this
801
+ * resolves with one. A repeat call naming the same project under a different
802
+ * `baseUrl` is refused (the real API's `409`); this port surfaces that as a
803
+ * thrown error, like any other provider refusal in this file.
804
+ */
805
+ export interface JobsSchedulerPort {
806
+ registerProject(input: {
807
+ name: string;
808
+ baseUrl: string;
809
+ alertUrl?: string;
810
+ }): Promise<{
811
+ id: string;
812
+ secret?: string;
813
+ syncKey?: string;
814
+ }>;
815
+ }
816
+ /**
817
+ * #177: where the org steps' files, AI and jobs ports come from when the host
818
+ * does not supply them. `platform-org-ports.ts`'s `PlatformOrgPorts` builds
819
+ * them from the platform's recorded addresses and held keys.
820
+ */
821
+ export interface PlatformOrgPortSource {
822
+ files(): Promise<FilesOrganisationPort>;
823
+ ai(): Promise<AiOrganisationPort>;
824
+ jobs(): Promise<JobsSchedulerPort>;
667
825
  }
668
826
  export interface FoundryStepExecutorPorts {
669
827
  coolify: CoolifyPort;
670
828
  cloudflare: CloudflarePort;
671
829
  zitadel: ZitadelPort;
672
830
  mail: MailPort;
673
- resend: ResendPort;
831
+ lettermint: LettermintPort;
832
+ /**
833
+ * #173: registers an org's own app with the platform's jobs scheduler -- see
834
+ * {@link JobsSchedulerPort}'s own doc comment. Optional: a host with no
835
+ * scheduler deployed builds the executor without one, and tells the planner
836
+ * so (`FoundryPlanApplyOptions.jobsScheduler`), which then reports each
837
+ * org's registration as blocked instead of planning a step that would throw.
838
+ */
839
+ jobsScheduler?: JobsSchedulerPort;
674
840
  /** The shared calls SFU's own LiveKit credentials (#145) -- see {@link CallsSfuPort}'s own doc comment. */
675
841
  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;
842
+ /** 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
+ filesOrganisation?: FilesOrganisationPort;
844
+ aiOrganisation?: AiOrganisationPort;
845
+ /**
846
+ * #177: builds the files, AI and jobs ports from the platform's addresses
847
+ * and held keys. A port set above overrides it (tests); with neither, the
848
+ * step throws. The planner blocks these steps first while a key is not held.
849
+ */
850
+ platformOrg?: PlatformOrgPortSource;
682
851
  /**
683
852
  * Hands a just-minted, one-time secret (today: an API app's
684
853
  * `clientSecret`) to the host, immediately after the provider returns it
@@ -721,6 +890,20 @@ export declare class NotBuiltError extends Error {
721
890
  * this file's hostname rules.
722
891
  */
723
892
  export declare function healthUrls(org: DeclaredOrg): string[];
893
+ /** The one Access policy foundry puts on its own Access app (#84). */
894
+ export declare const ACCESS_POLICY_NAME = "foundry: allowed emails";
895
+ /**
896
+ * Whether an application's own Coolify `status` (`` `running:healthy`,
897
+ * `exited:unhealthy` `` -- `@wtfalch/providers`' own doc comment) means it is
898
+ * already up, or on its way there -- `findOrCreateCmsApp`'s own check of
899
+ * whether a found-but-parked app still needs `start`ing (#116 review round
900
+ * 3, Finding 1). Only `exited`/`stopped` (Coolify's own resting-off states;
901
+ * `stopped` is not one this codebase has verified against a live instance,
902
+ * included defensively) read as off; a missing `status` defaults to on, the
903
+ * same "nothing says it's off" convention `planHostingToggle` (`diff.ts`)
904
+ * uses for the org's own app.
905
+ */
906
+ export declare function isRunningOrStarting(status: string | undefined): boolean;
724
907
  export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer {
725
908
  private readonly ports;
726
909
  constructor(ports: FoundryStepExecutorPorts);
@@ -863,32 +1046,40 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
863
1046
  */
864
1047
  private ensureAuthCookieSecret;
865
1048
  /**
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
1049
+ * #140 rework (2026-09-28 lettermint-org-mail swap, "Core rule" +
1050
+ * "Credential model"): all of the org's Lettermint work now lives here,
1051
+ * in the app step, since the first project -- and so the first domain
1052
+ * restriction -- can only exist once the token has a home (the app env). A
1053
+ * no-op until `coolify:mail` has recorded {@link MAIL_DOMAIN_SCOPE_KEY} for
1054
+ * this org; the next apply fills it in once `coolify:mail` runs first
871
1055
  * (`diff.ts`'s `computePlanDetail` order).
872
1056
  *
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.
1057
+ * Order, exactly as the swap's "Core rule" states it: ensure the
1058
+ * `<slug>-app` project, ensure the mail domain, restrict that domain to the
1059
+ * org's own projects, publish its DNS records, trigger verification, then
1060
+ * delete every stale `<slug>-app` project. New projects are created and the
1061
+ * domain restricted to them before any old project is deleted, so the
1062
+ * restriction list never becomes empty.
878
1063
  *
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.
1064
+ * Mint rule ("Credential model"): the project is healthy when the app env
1065
+ * holds `LETTERMINT_API_TOKEN` and Lettermint lists exactly one project
1066
+ * named `<slug>-app` -- unlike Resend's key, a Lettermint project's name
1067
+ * carries no domain id, since a domain switch is handled entirely by
1068
+ * {@link LettermintPort.restrictDomainToProjects} below and never needs a
1069
+ * fresh project. Otherwise a fresh project is created and its token
1070
+ * written to the env first, and only then is every other `<slug>-app`
1071
+ * project deleted -- a lost mint response and a duplicate both end the
1072
+ * same way. An app that was already running is restarted before that
1073
+ * delete, since a container reads its env only at start (`running`, the
1074
+ * same reason {@link ensureLogVolume} restarts one).
1075
+ *
1076
+ * The domain's restriction is rebuilt from every project named `<slug>-app`
1077
+ * or `<slug>-relay` (the relay project itself is #149, not built yet) --
1078
+ * never patched, so it can never end up scoped only to a project about to
1079
+ * be deleted.
889
1080
  *
890
1081
  * `MAIL_FROM` is upserted every time: it depends only on the mail domain
891
- * `coolify:mail` resolved (`RESEND_MAIL_DOMAIN_SCOPE_KEY`).
1082
+ * `coolify:mail` resolved.
892
1083
  *
893
1084
  * The token goes straight to `bulkUpsertEnvs` and nowhere else: never
894
1085
  * returned, never recorded in `serviceIds` (`recordScopeId`'s own doc
@@ -896,7 +1087,20 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
896
1087
  * same discipline `pushDatabaseUrl`'s doc comment states for a database's
897
1088
  * own connection string.
898
1089
  */
899
- private ensureAppResendKey;
1090
+ private ensureAppLettermintMail;
1091
+ /**
1092
+ * Review finding (post-#140 rework): {@link LettermintPort.findDomain}'s
1093
+ * own match is partial and case-insensitive, so a raw result can be a
1094
+ * lookalike (`notacme.com`, `mail.acme.com`) rather than `name` itself --
1095
+ * and every caller here full-replaces the found domain's project
1096
+ * restriction ({@link LettermintPort.restrictDomainToProjects}), so taking
1097
+ * a lookalike as a match would let it be taken over. Every domain lookup
1098
+ * in this class (`ensureAppLettermintMail`, `parkLettermintMail`,
1099
+ * `deleteStaleLettermintDomain`) goes through this instead of the port
1100
+ * directly, and treats anything but an exact, case-insensitive match as
1101
+ * not found.
1102
+ */
1103
+ private findExactLettermintDomain;
900
1104
  /**
901
1105
  * `plan/diff.ts`'s `planSite` (#116, slicing #99's "provision the cms app,
902
1106
  * database and site app per org"): the 2026-09-25 decision
@@ -1096,6 +1300,56 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1096
1300
  * `config.mjs`'s own regex requires.
1097
1301
  */
1098
1302
  private ensureCallsBindingSecret;
1303
+ /**
1304
+ * `plan/diff.ts`'s `planAgora`: provisions the org's own Agora
1305
+ * (https://github.com/wtfalch/agora) as one more Coolify application, from
1306
+ * that repo's `apps/agora/Dockerfile` ({@link AGORA_REPO}'s doc comment),
1307
+ * and pushes its env. `findOrCreateCallsApp` is reused as-is for the
1308
+ * find-by-name-then-start-or-create shape, like {@link coolifyCallsService}.
1309
+ * There is no park branch: Agora is always on.
1310
+ *
1311
+ * The env comes from `plan/agora.ts`'s `desiredAgoraEnv`, the same function
1312
+ * the plan compares the live app against, so a re-plan after this step is
1313
+ * empty. The step never writes a partial config: when any input is missing
1314
+ * (the org's ZITADEL org id or Agora client id, or an issuer or
1315
+ * mail/messaging project id the host does not supply,
1316
+ * `AgoraHostPort`, read by the plan) it throws naming each one, before
1317
+ * it creates anything.
1318
+ *
1319
+ * `AGORA_CANONICAL_HOST` is always pushed. `AGORA_MAIL_UPSTREAM`/
1320
+ * `AGORA_FILES_UPSTREAM` are pushed once the platform's own mail/files
1321
+ * hosts are on and applied (#177's `platform-org.ts`'s
1322
+ * `agoraHostValuesFromPlatform`, read by `plan-apply.ts` when no host
1323
+ * `agora` port overrides it) -- absent otherwise. The chat upstreams are
1324
+ * still never pushed: chat has no admin API yet
1325
+ * (https://github.com/wtfalch/chat/issues/40). Agora answers 502 on those
1326
+ * routes rather than serving anything wrong.
1327
+ */
1328
+ private coolifyAgora;
1329
+ /**
1330
+ * `plan/diff.ts`'s `planJobsRegistration`: registers the org's own app with
1331
+ * the platform's jobs scheduler (`JobsSchedulerPort.registerProject`, the
1332
+ * scheduler's `POST /admin/projects`), then hands the freshly minted
1333
+ * `secret` and `syncKey` to the host through `deliverSecret` -- but only
1334
+ * when this call actually minted them. The admin API is itself idempotent
1335
+ * by `(name, baseUrl)`: a repeat call for an already-registered project
1336
+ * resolves with just the `id`, and this never re-delivers a secret or sync
1337
+ * key it did not just mint, the same "registers an app once" rule
1338
+ * `diff.ts`'s own plan-level gate (recordedIds, mirroring `planOwner`)
1339
+ * already provides on a normal re-plan -- this check is what keeps that
1340
+ * true even if this step somehow runs twice on the same apply.
1341
+ *
1342
+ * Never logs, returns or records either credential (this file's own top
1343
+ * doc comment's "a secret is never a step's return value" rule) -- only the
1344
+ * scheduler's own non-secret project id comes back as `resourceId`.
1345
+ */
1346
+ /** #177: the host's own port when it supplies one, else the platform's, built from its address and held key. */
1347
+ private filesPort;
1348
+ private aiPort;
1349
+ private jobsPort;
1350
+ private jobsRegistration;
1351
+ /** `deliverSecret`, tried up to {@link JOBS_DELIVERY_ATTEMPTS} times; true once one attempt succeeds. */
1352
+ private deliverWithRetry;
1099
1353
  private cloudflareTunnel;
1100
1354
  /**
1101
1355
  * `plan/diff.ts`'s `planZone`: finds or creates the Cloudflare zone for the
@@ -1118,67 +1372,90 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1118
1372
  * `plan/diff.ts`'s `planMail`: gives an org mail, moves it between modes,
1119
1373
  * or parks it on the one shared Stalwart instance (`this.ports.mail`,
1120
1374
  * `@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`).
1375
+ * publishes whatever Stalwart DNS records the call returns, and records the
1376
+ * 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.
1385
+ *
1386
+ * Review finding: a mail domain switch (`org.domain` changes, or
1387
+ * `usesDomain` flips) must also delete the *old* Lettermint domain here,
1388
+ * 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}
1391
+ * does this by comparing the previously recorded domain (case-insensitive)
1392
+ * against the one this apply just resolved.
1393
+ *
1394
+ * #162: once `applyOrgMail` has given the org a domain, {@link ensureOwnerMailbox}
1395
+ * gives its declared owner a mailbox on it, so Agora's Mail section is not
1396
+ * empty for them after the org's first apply.
1129
1397
  */
1130
1398
  private coolifyMail;
1399
+ /**
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.
1407
+ */
1408
+ private ensureOwnerMailbox;
1409
+ /**
1410
+ * Deletes the org's previous Lettermint domain once the mail domain it was
1411
+ * registered under has changed -- otherwise it stays registered and
1412
+ * restricted to the org's still-live `<slug>-app` project after the switch
1413
+ * (review finding, post-#140 rework). A no-op on first apply (no previous
1414
+ * domain recorded) and on a same-domain re-apply (compared
1415
+ * case-insensitively, the same rule {@link findExactLettermintDomain}
1416
+ * applies to the lookup itself).
1417
+ */
1418
+ private deleteStaleLettermintDomain;
1131
1419
  /**
1132
1420
  * Upserts each DNS record `applyOrgMail` says foundry must publish, found
1133
1421
  * by an exact name+type match (`CloudflarePort.findDnsRecord`'s own doc
1134
1422
  * comment) so update never mistakes a stale record of a different type for
1135
1423
  * 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.
1424
+ * mints the key on a background task; the next apply publishes it.
1139
1425
  */
1140
1426
  private publishMailDnsRecords;
1141
1427
  /**
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`.
1428
+ * Parking mail (2026-09-28 lettermint-org-mail swap, "Core rule": "Parking
1429
+ * 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.
1433
+ * A no-op until a previous apply recorded {@link MAIL_DOMAIN_SCOPE_KEY}:
1434
+ * nothing was ever provisioned for an org whose mail went straight from
1435
+ * never-applied to off.
1167
1436
  */
1168
- private publishResendDnsRecords;
1437
+ private parkLettermintMail;
1169
1438
  /**
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.
1439
+ * Upserts each DNS record Lettermint's domain names, the same find-then-
1440
+ * upsert `publishMailDnsRecords` above uses for Stalwart's own records --
1441
+ * except an existing `_dmarc` record is never overwritten (a stricter
1442
+ * policy an org already set, `p=quarantine`/`p=reject`, should not be
1443
+ * relaxed back to Lettermint's own default). Resend's own domain also
1444
+ * carried an apex `SPF` TXT record that had to be merged into whatever SPF
1445
+ * record already existed there (RFC 7208: two `v=spf1` TXT records on one
1446
+ * name is a permerror); Lettermint's own DNS records
1447
+ * (`.plans/2026-09-28-lettermint-api.md` §3) have no such record -- its
1448
+ * `purpose` enum is `return_path`/`dmarc`/`dkim_legacy`/`dkim_primary`/
1449
+ * `dkim_secondary`, none of them SPF -- so that merge has no case left to
1450
+ * handle here and is dropped rather than kept as dead code.
1174
1451
  */
1175
- private mergeResendSpfRecord;
1452
+ private publishLettermintDnsRecords;
1176
1453
  /**
1177
1454
  * Never overwrites an existing `_dmarc` record -- a stricter policy an org
1178
1455
  * already set (`p=quarantine`/`p=reject`) should not be relaxed back to
1179
- * Resend's own `p=none` default.
1456
+ * Lettermint's own `p=none` default.
1180
1457
  */
1181
- private ensureResendDmarcRecord;
1458
+ private ensureLettermintDmarcRecord;
1182
1459
  /**
1183
1460
  * #80: provisions the org's own organisation on Files, before
1184
1461
  * {@link filesStorage} can set its spend limit. `provisionOrganisation` is
@@ -1197,6 +1474,16 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1197
1474
  */
1198
1475
  private aiOrganisation;
1199
1476
  private aiBudget;
1477
+ /**
1478
+ * #80 step 3: mints the org's own ai key, once, and hands its one-time
1479
+ * secret straight to {@link FoundryStepExecutorPorts.deliverSecret} --
1480
+ * never returned, stored in `serviceIds` or logged, the same discipline
1481
+ * `zitadelApp`'s own `created.clientSecret` follows. `diff.ts`'s
1482
+ * `planAiKey` never plans this step again once its resource id
1483
+ * (the key's id, not its secret) is recorded, since ai's own API offers no
1484
+ * way to check for an already-minted key.
1485
+ */
1486
+ private aiKey;
1200
1487
  /**
1201
1488
  * {@link StepDestroyer.destroy}: deletes the one resource
1202
1489
  * `FoundryPlanApply.destroy` names. A provider 404 (already gone) counts as
@@ -1209,3 +1496,23 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1209
1496
  readonly recordedIds: Readonly<Record<string, string>>;
1210
1497
  }): Promise<void>;
1211
1498
  }
1499
+ /**
1500
+ * True for a provider's own "already gone" response, however it surfaces --
1501
+ * `@wtfalch/providers`' `ZitadelError` carries a structured `status: number`
1502
+ * (`zitadel.ts`), while its Coolify and Cloudflare clients throw a plain
1503
+ * `Error` whose message embeds the HTTP status (`` `Coolify ${method} ${path}
1504
+ * → ${status}: ${text}` ``, `` `CF ${method} ${path} → ${status}: ${msg}` ``).
1505
+ * Ports are structural here (this file's own top comment: never import a
1506
+ * concrete provider class), so this cannot `instanceof`-check a real error
1507
+ * type -- it duck-types a `status` field first.
1508
+ *
1509
+ * A prior version fell back to scanning the *whole* message for a standalone
1510
+ * `404` when no `status` field was present. That is unsafe: the Coolify and
1511
+ * Cloudflare clients put the resource path -- which can contain a hex UUID
1512
+ * -- ahead of the status in the message, so a UUID that happens to contain
1513
+ * the digits `404` (`9a404b12-...`) made a genuine 500 read as a 404. The
1514
+ * fallback below instead anchors to the exact position both clients put the
1515
+ * status in: the first `→ `, after the client, method and path, and before
1516
+ * `:`. It never scans the path or the response body.
1517
+ */
1518
+ export declare function isNotFoundError(error: unknown): boolean;