@wtfalch/foundry 0.3.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 (71) 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 +22 -4
  18. package/dist/calls-sfu.js +22 -4
  19. package/dist/index.d.ts +31 -6
  20. package/dist/index.js +16 -3
  21. package/dist/migrations/0014_calls_enabled.sql +7 -0
  22. package/dist/migrations/0015_domain_unique.sql +39 -0
  23. package/dist/migrations/0016_platform.sql +61 -0
  24. package/dist/migrations/0017_requested_changes.sql +42 -0
  25. package/dist/migrations/0018_platform_apply.sql +19 -0
  26. package/dist/org.d.ts +18 -2
  27. package/dist/org.js +56 -0
  28. package/dist/plan/agora.d.ts +77 -0
  29. package/dist/plan/agora.js +127 -0
  30. package/dist/plan/calls-matrix.d.ts +135 -0
  31. package/dist/plan/calls-matrix.js +275 -0
  32. package/dist/plan/cloudflared-connector.d.ts +70 -0
  33. package/dist/plan/cloudflared-connector.js +106 -0
  34. package/dist/plan/diff.d.ts +26 -5
  35. package/dist/plan/diff.js +720 -106
  36. package/dist/plan/executor.d.ts +731 -36
  37. package/dist/plan/executor.js +1180 -41
  38. package/dist/plan/index.d.ts +22 -5
  39. package/dist/plan/index.js +12 -2
  40. package/dist/plan/jobs-scheduler-http.d.ts +29 -0
  41. package/dist/plan/jobs-scheduler-http.js +50 -0
  42. package/dist/plan/live.d.ts +51 -2
  43. package/dist/plan/live.js +8 -0
  44. package/dist/plan/plan-apply.d.ts +192 -3
  45. package/dist/plan/plan-apply.js +290 -14
  46. package/dist/plan/platform-diff.d.ts +312 -0
  47. package/dist/plan/platform-diff.js +867 -0
  48. package/dist/plan/platform-org-ports.d.ts +50 -0
  49. package/dist/plan/platform-org-ports.js +70 -0
  50. package/dist/plan/platform-org.d.ts +120 -0
  51. package/dist/plan/platform-org.js +115 -0
  52. package/dist/plan/platform-providers.d.ts +501 -0
  53. package/dist/plan/platform-providers.js +3029 -0
  54. package/dist/plan/platform-sfu.d.ts +93 -0
  55. package/dist/plan/platform-sfu.js +100 -0
  56. package/dist/plan/providers-readers.js +22 -1
  57. package/dist/plan/zitadel-machine-user.d.ts +34 -0
  58. package/dist/plan/zitadel-machine-user.js +95 -0
  59. package/dist/plan.d.ts +6 -0
  60. package/dist/platform.d.ts +55 -0
  61. package/dist/platform.js +46 -0
  62. package/dist/requested-changes.d.ts +108 -0
  63. package/dist/requested-changes.js +95 -0
  64. package/dist/slug.js +21 -2
  65. package/dist/store/audit.js +4 -2
  66. package/dist/store/postgres.d.ts +40 -7
  67. package/dist/store/postgres.js +257 -29
  68. package/dist/store/schema.d.ts +700 -0
  69. package/dist/store/schema.js +58 -0
  70. package/dist/store.d.ts +56 -0
  71. package/package.json +2 -2
@@ -1,5 +1,6 @@
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
+ import type { DeclaredOrgStore } from '../store.js';
3
4
  import type { StepAction } from './diff.js';
4
5
  import type { DestroyTarget, StepDestroyer, StepExecutionContext, StepExecutor } from './plan-apply.js';
5
6
  /**
@@ -59,6 +60,7 @@ export interface CoolifyPort {
59
60
  uuid: string;
60
61
  is_usable: boolean;
61
62
  is_reachable: boolean;
63
+ ip?: string;
62
64
  }>>;
63
65
  requireGithubApp(preferredUuid?: string): Promise<{
64
66
  uuid: string;
@@ -104,6 +106,7 @@ export interface CoolifyPort {
104
106
  name: string;
105
107
  postgres_db: string;
106
108
  postgres_user: string;
109
+ postgres_initdb_args?: string;
107
110
  }): Promise<{
108
111
  uuid: string;
109
112
  internal_db_url: string;
@@ -199,6 +202,83 @@ export interface CoolifyPort {
199
202
  deleteApplication(uuid: string): Promise<unknown>;
200
203
  /** `FoundryPlanApply.destroy`'s teardown of the org's own database. */
201
204
  deleteDatabase(uuid: string): Promise<unknown>;
205
+ /**
206
+ * An application that runs a published image as-is (#128, `coolifyCalls`'s
207
+ * own Postgres) — matches `@wtfalch/providers`' `CoolifyClient.
208
+ * createDockerImageApplication` method-for-method.
209
+ */
210
+ createDockerImageApplication(input: {
211
+ project_uuid: string;
212
+ server_uuid: string;
213
+ environment_name: string;
214
+ environment_uuid: string;
215
+ docker_registry_image_name: string;
216
+ docker_registry_image_tag: string;
217
+ ports_exposes: string;
218
+ name?: string;
219
+ description?: string;
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;
230
+ }): Promise<{
231
+ uuid: string;
232
+ }>;
233
+ /**
234
+ * An application built from an inline Dockerfile (#128, `coolifyCalls`'s own
235
+ * Synapse and MAS — neither's published image takes the module/command this
236
+ * stack needs from a plain image application) — matches `CoolifyClient.
237
+ * createDockerfileApplication` method-for-method.
238
+ */
239
+ createDockerfileApplication(input: {
240
+ project_uuid: string;
241
+ server_uuid: string;
242
+ environment_name: string;
243
+ environment_uuid: string;
244
+ dockerfile: string;
245
+ name?: string;
246
+ description?: string;
247
+ instant_deploy?: boolean;
248
+ }): Promise<{
249
+ uuid: string;
250
+ }>;
251
+ /**
252
+ * A single file mounted into the container (#128) — how `homeserver.yaml`
253
+ * and MAS's `config.yaml` reach a Docker-image application with no git
254
+ * repository of its own to carry them. Matches `CoolifyClient.
255
+ * createFileStorage` method-for-method.
256
+ */
257
+ createFileStorage(applicationUuid: string, input: {
258
+ mount_path: string;
259
+ content: string;
260
+ }): Promise<unknown>;
261
+ /** Updates an existing file storage's content in place. Matches `CoolifyClient.updateFileStorage`. */
262
+ updateFileStorage(applicationUuid: string, storageUuid: string, content: string): Promise<unknown>;
263
+ /**
264
+ * A generic application PATCH (#128) — `coolifyCalls` uses this to give
265
+ * Postgres/Synapse/MAS a fixed `custom_internal_name` so the other two can
266
+ * address each other by a name that never changes across a re-run, instead
267
+ * of guessing at Coolify's own container-naming scheme. Matches
268
+ * `CoolifyClient.updateApplication` method-for-method.
269
+ */
270
+ updateApplication(uuid: string, patch: Record<string, unknown>): Promise<unknown>;
271
+ /**
272
+ * Queues a deployment (#128) — `coolifyCalls` calls this after writing a
273
+ * fresh Synapse/MAS config file, since a file-storage write alone does not
274
+ * reach the running container until its next deploy (issue #128's "a
275
+ * running app is redeployed when its config changed"). Fire-and-forget,
276
+ * like `restart` above: this executor does not poll a deployment to
277
+ * completion anywhere else, and `coolifyCalls` only calls this when the
278
+ * config it just wrote was actually freshly minted. Matches `CoolifyClient.
279
+ * deploy` method-for-method.
280
+ */
281
+ deploy(uuid: string, force?: boolean): Promise<unknown>;
202
282
  }
203
283
  /**
204
284
  * The Cloudflare calls this executor makes, shaped to match
@@ -222,6 +302,15 @@ export interface CloudflarePort {
222
302
  name: string;
223
303
  }>;
224
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>;
225
314
  /** `FoundryPlanApply.destroy`'s teardown of the org's own Cloudflare tunnel. */
226
315
  deleteTunnel(tunnelId: string): Promise<void>;
227
316
  /** `cloudflareAccess` finds the org's Access app by domain before creating one (#84). */
@@ -240,6 +329,7 @@ export interface CloudflarePort {
240
329
  listAccessPolicies(appId: string): Promise<Array<{
241
330
  id: string;
242
331
  name: string;
332
+ include?: unknown[];
243
333
  }>>;
244
334
  createAccessPolicy(appId: string, policy: {
245
335
  name: string;
@@ -300,6 +390,105 @@ export interface CloudflarePort {
300
390
  */
301
391
  request<T>(method: string, path: string, body?: unknown, missingOkay?: boolean): Promise<T>;
302
392
  }
393
+ /**
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).
402
+ */
403
+ export interface LettermintDnsRecordSpec {
404
+ readonly purpose: 'return_path' | 'dmarc' | 'dkim_legacy' | 'dkim_primary' | 'dkim_secondary';
405
+ readonly type: 'TXT' | 'CNAME' | 'MX';
406
+ readonly name: string;
407
+ readonly value: string;
408
+ }
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 {
416
+ readonly id: string;
417
+ readonly name: string;
418
+ }
419
+ /**
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
424
+ * (https://github.com/wtfalch/email/blob/main/packages/email/src/postmaster/objects.ts#L149-L168),
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.
440
+ */
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
+ */
450
+ findDomain(name: string): Promise<{
451
+ id: string;
452
+ domain: string;
453
+ status: LettermintDomain['status'];
454
+ } | undefined>;
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>;
459
+ /**
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.
464
+ */
465
+ verifyDomain(id: string): Promise<{
466
+ status: LettermintDomain['status'];
467
+ }>;
468
+ /** `parkLettermintMail`'s own cleanup once an org's mail is parked (mode `'off'`). */
469
+ deleteDomain(id: string): Promise<void>;
470
+ /**
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).
475
+ */
476
+ createProject(name: string): Promise<{
477
+ project: LettermintProject;
478
+ token: string;
479
+ }>;
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>;
491
+ }
303
492
  /**
304
493
  * The Stalwart calls this executor makes for one org's mail, shaped to match
305
494
  * `@wtfalch/email/postmaster`'s `applyOrgMail`/`parkOrgMail` method-for-method
@@ -309,6 +498,44 @@ export interface CloudflarePort {
309
498
  export interface MailPort {
310
499
  applyOrgMail(org: OrgMailOrg, options: OrgMailOptions): Promise<OrgMailResult>;
311
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>;
514
+ }
515
+ /**
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`.
533
+ */
534
+ export interface CallsSfuPort {
535
+ resolveCredentials(): Promise<{
536
+ apiKey: string;
537
+ apiSecret: string;
538
+ } | null>;
312
539
  }
313
540
  /**
314
541
  * The ZITADEL calls this executor makes. `findOrCreateOrg`/`findOrCreateProject`
@@ -359,6 +586,23 @@ export interface ZitadelPort {
359
586
  clientSecret: string;
360
587
  }>;
361
588
  deleteApp(orgId: string, projectId: string, appId: string): Promise<void>;
589
+ /**
590
+ * `zitadelApp`'s kind-change path reads this to find an app a prior,
591
+ * lost-response create already made under the new name, before calling
592
+ * `createOidcApp`/`createApiApp` again and orphaning that one under the
593
+ * same name. Matches `@wtfalch/providers/zitadel`'s `ZitadelClient.
594
+ * listApps` return shape exactly, so that client satisfies this port with
595
+ * no adapter -- it does not carry `clientId`/`clientSecret` (that provider
596
+ * method never parses either, and a secret is minted once, on creation,
597
+ * never handed back again), which is why the reuse path below cannot
598
+ * re-deliver either one.
599
+ */
600
+ listApps(orgId: string, projectId: string): Promise<Array<{
601
+ id: string;
602
+ name: string;
603
+ appType: 'web' | 'native' | 'api';
604
+ state: string;
605
+ }>>;
362
606
  /**
363
607
  * The org-owner flow (goal line): find-or-create the human user by email in
364
608
  * the org, then have ZITADEL email them an invite to set up a passkey.
@@ -389,26 +633,37 @@ export interface ZitadelPort {
389
633
  deleteProject(orgId: string, projectId: string): Promise<void>;
390
634
  /** `FoundryPlanApply.destroy`'s last step: removes the whole ZITADEL org and everything foundry made inside it. */
391
635
  deleteOrg(orgId: string): Promise<void>;
392
- }
393
- /**
394
- * One org's spend limit on Files, matching `files/packages/sdk/src/client.ts:154-166`'s
395
- * `setQuota`/`setBudget` (the caller's own organisation, resolved from its
396
- * signed request — no organisation id parameter, hence a factory per org
397
- * rather than one flat port).
398
- */
399
- export interface FilesOrgLimits {
400
- setQuota(limitBytes: string | null): Promise<unknown>;
401
- setBudget(limitMicros: string | null): Promise<unknown>;
402
- }
403
- /**
404
- * One org's budget on AI, matching `ai/packages/sdk/src/client.ts:94-101`'s
405
- * `updateBudget` (`UpdateBudget` from `ai/packages/sdk/src/types.ts:134-137`).
406
- */
407
- export interface AiOrgLimits {
408
- updateBudget(input: {
409
- keyId: string | null;
410
- limitMicros: string;
411
- }): 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
+ }>;
412
667
  }
413
668
  /**
414
669
  * Provisions one org's organisation on Files (#80), matching
@@ -416,9 +671,8 @@ export interface AiOrgLimits {
416
671
  * `createFilesManagementClient().provisionOrganisation`
417
672
  * (`ProvisionOrganisationInput`/`ProvisionOrganisationResult`,
418
673
  * `files/packages/sdk/src/types.ts:146-156`). Flat, not a factory, like
419
- * {@link ZitadelPort}: `organisationId` is an explicit call argument here,
420
- * unlike {@link FilesOrgLimits}'s tenant-scoped calls, which resolve the
421
- * caller's own organisation from its signed request instead.
674
+ * {@link ZitadelPort}: `organisationId` is an explicit call argument on every
675
+ * method here.
422
676
  *
423
677
  * Finding (this campaign's report): unlike {@link AiOrganisationPort}'s bearer
424
678
  * credential, `createFilesManagementClient`'s whole transport is "a signed
@@ -427,11 +681,17 @@ export interface AiOrgLimits {
427
681
  * request (`files/apps/host/src/lib/authz/service-assertion.ts`'s
428
682
  * `signManagementRequest`/`AssertionTrust`). A host can construct this without
429
683
  * any human session: it only needs the Ed25519 private key (`SigningIdentity`)
430
- * an operator's `MANAGEMENT_PUBLIC_KEYS`/`MANAGEMENT_ISSUER` pins — the same
431
- * signer {@link FilesOrgLimits} needs for `setQuota`/`setBudget`, since the
432
- * `organisation` field inside the signed claim selects the org per call, so
433
- * one shared keypair signs for every org rather than needing a secret per
434
- * 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.
435
695
  */
436
696
  export interface FilesOrganisationPort {
437
697
  provisionOrganisation(input: {
@@ -450,6 +710,9 @@ export interface FilesOrganisationPort {
450
710
  shareHost: string | null;
451
711
  created: boolean;
452
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>;
453
716
  }
454
717
  /**
455
718
  * Provisions one org's organisation on AI and switches its AI use on (#80),
@@ -466,6 +729,13 @@ export interface FilesOrganisationPort {
466
729
  * `ai.organisations:offer`, `ai.budgets:update`, among others) copied onto
467
730
  * it — used directly with `createAiClient`, unlike Files' signed-assertion
468
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.
469
739
  */
470
740
  export interface AiOrganisationPort {
471
741
  provisionOrganisation(input: {
@@ -480,18 +750,104 @@ export interface AiOrganisationPort {
480
750
  organisationId: string;
481
751
  offers: string[];
482
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>;
483
825
  }
484
826
  export interface FoundryStepExecutorPorts {
485
827
  coolify: CoolifyPort;
486
828
  cloudflare: CloudflarePort;
487
829
  zitadel: ZitadelPort;
488
830
  mail: MailPort;
489
- /** A factory, not a flat client: Files/AI are org-scoped credentials the host resolves per org. */
490
- filesLimitsFor: (orgId: OrgId) => FilesOrgLimits;
491
- aiLimitsFor: (orgId: OrgId) => AiOrgLimits;
492
- /** Flat, not a factory: `organisationId` is an explicit argument on both ports (see each port's own doc comment). */
493
- filesOrganisation: FilesOrganisationPort;
494
- aiOrganisation: AiOrganisationPort;
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;
840
+ /** The shared calls SFU's own LiveKit credentials (#145) -- see {@link CallsSfuPort}'s own doc comment. */
841
+ callsSfu: CallsSfuPort;
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;
495
851
  /**
496
852
  * Hands a just-minted, one-time secret (today: an API app's
497
853
  * `clientSecret`) to the host, immediately after the provider returns it
@@ -506,6 +862,14 @@ export interface FoundryStepExecutorPorts {
506
862
  * themselves.
507
863
  */
508
864
  deliverSecret(ref: string, secret: string): Promise<void>;
865
+ /**
866
+ * Read access to the declared-org store, narrowed to the one query
867
+ * `cloudflareZone` needs for its cross-org refusal check (#131) -- see that
868
+ * method's own doc comment for why a zone needs this where
869
+ * `cloudflareAccess` reads Cloudflare's own live state instead. A real host
870
+ * passes the same store instance it constructs `FoundryPlanApply` with.
871
+ */
872
+ declaredOrgStore: Pick<DeclaredOrgStore, 'findOrgIdForZone'>;
509
873
  }
510
874
  /**
511
875
  * Thrown by name for a step this executor deliberately does not implement:
@@ -526,6 +890,20 @@ export declare class NotBuiltError extends Error {
526
890
  * this file's hostname rules.
527
891
  */
528
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;
529
907
  export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer {
530
908
  private readonly ports;
531
909
  constructor(ports: FoundryStepExecutorPorts);
@@ -581,6 +959,16 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
581
959
  */
582
960
  private zitadelCustomers;
583
961
  private zitadelApp;
962
+ /**
963
+ * The app an earlier attempt's recreate already made under the same
964
+ * name, whose response this executor never saw -- deleted before the
965
+ * create so no duplicate is left behind. Matches on name and the
966
+ * desired kind, and skips a removed app -- ZITADEL's
967
+ * search can return one that has since been deleted (`isNotFoundError`'s
968
+ * own doc comment covers why a delete can be retried into a 404), and
969
+ * that is not the survivor this is looking for.
970
+ */
971
+ private findRecreatedApp;
584
972
  private ensureZitadelProject;
585
973
  private ensureCoolifyProject;
586
974
  private resolveServer;
@@ -657,6 +1045,62 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
657
1045
  * upsert a fresh value over it.
658
1046
  */
659
1047
  private ensureAuthCookieSecret;
1048
+ /**
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
1055
+ * (`diff.ts`'s `computePlanDetail` order).
1056
+ *
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.
1063
+ *
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.
1080
+ *
1081
+ * `MAIL_FROM` is upserted every time: it depends only on the mail domain
1082
+ * `coolify:mail` resolved.
1083
+ *
1084
+ * The token goes straight to `bulkUpsertEnvs` and nowhere else: never
1085
+ * returned, never recorded in `serviceIds` (`recordScopeId`'s own doc
1086
+ * comment), never logged, never interpolated into a thrown error -- the
1087
+ * same discipline `pushDatabaseUrl`'s doc comment states for a database's
1088
+ * own connection string.
1089
+ */
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;
660
1104
  /**
661
1105
  * `plan/diff.ts`'s `planSite` (#116, slicing #99's "provision the cms app,
662
1106
  * database and site app per org"): the 2026-09-25 decision
@@ -774,15 +1218,204 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
774
1218
  * this method is `cmsSite`'s only caller.
775
1219
  */
776
1220
  private findOrCreateCmsApp;
1221
+ /**
1222
+ * `plan/diff.ts`'s `planCalls`: the org's own calls Matrix stack, entirely
1223
+ * separate from chat's (`calls-matrix.ts`'s own top doc comment) -- two
1224
+ * Coolify-managed Postgres databases, a Synapse app and an MAS app, no
1225
+ * public hostname of its own (neither app is reachable from outside
1226
+ * Coolify's own Docker network) -- `coolifyCallsService` below (#145, part 2
1227
+ * of #128) is the one that gets a public hostname, since it is the one a
1228
+ * browser actually talks to. Park stops Synapse and MAS only, the same
1229
+ * park-not-delete rule `coolifyApp`/`cmsSite` already follow (#7
1230
+ * corrections 1) -- their databases are never touched from that branch, the
1231
+ * same way `cmsSite`'s own database never is.
1232
+ */
1233
+ private coolifyCalls;
1234
+ /**
1235
+ * `coolifyCalls`'s own find-or-create for Synapse/MAS, restarting a
1236
+ * found-but-stopped app (#116 review round 3, Finding 1's same fix). Reused
1237
+ * by `coolifyCallsService` (#145) for the calls service app itself -- the
1238
+ * same find-by-name-then-start-or-create shape applies unchanged.
1239
+ */
1240
+ private findOrCreateCallsApp;
1241
+ /**
1242
+ * `coolifyCalls`'s own database find-or-create, modeled on
1243
+ * `ensureCmsDatabase`: `context.recordedIds()` is checked first since
1244
+ * neither database has a `system:kind:key` slot of its own (this step's one
1245
+ * auto-recorded `resourceId` is the MAS app, not either database --
1246
+ * {@link CALLS_SYNAPSE_SCOPE_KEY}'s own doc comment), then `listDatabases`
1247
+ * finds one already created by name before `createPostgresDatabase` makes
1248
+ * one. Reused by `coolifyCallsService` (#145) for the calls service's own
1249
+ * database, whose {@link CALLS_SERVICE_DB_SCOPE_KEY} has no
1250
+ * `system:kind:key` slot of its own for the same reason.
1251
+ */
1252
+ private ensureCallsDatabase;
1253
+ /**
1254
+ * Reads back {@link CALLS_SECRET_ENV_KEYS} from the MAS app's own envs
1255
+ * before minting fresh ones -- this stack's only memory across a re-run
1256
+ * (`calls-matrix.ts`'s `CallsMatrixSecrets` doc comment). `changed: true`
1257
+ * only when nothing usable was found: a partial set (a crash mid-mint) is
1258
+ * still treated as "mint fresh", since MAS's shared secret and Synapse's
1259
+ * copy of it must always agree.
1260
+ */
1261
+ private ensureCallsMatrixSecrets;
1262
+ /**
1263
+ * Upserts a file storage's content, creating it if the application has none
1264
+ * at that mount path yet -- there is no way to read a file storage's
1265
+ * current content back from Coolify to diff it, the same limit
1266
+ * `archon/bootstrap/steps/chat-instance.ts`'s own `ensureFileStorage` notes,
1267
+ * so an existing one is always re-written.
1268
+ */
1269
+ private ensureCallsFileStorage;
1270
+ /**
1271
+ * `plan/diff.ts`'s `planCallsService`: the org's own calls service --
1272
+ * `wtfalch/calls`'s `packages/service`, wired to the calls Matrix stack
1273
+ * {@link coolifyCalls} provisions and the shared LiveKit SFU (#122/#127).
1274
+ * Static config checks run first, before any Coolify call (#128's own
1275
+ * carried-over PR 344 review fix, restated in #145's own issue body): the
1276
+ * calls Matrix stack must already be recorded, its MAS app must already
1277
+ * carry its client id/secret, and the shared SFU's LiveKit credentials must
1278
+ * already be resolvable -- none of the three is minted or guessed here.
1279
+ * Park stops the app only, the same park-not-delete rule
1280
+ * {@link coolifyCalls}/{@link coolifyApp} already follow (#7 corrections
1281
+ * 1); its own database is never touched from that branch.
1282
+ */
1283
+ private coolifyCallsService;
1284
+ /**
1285
+ * Reads back the calls Matrix stack's MAS client id/secret from the MAS
1286
+ * app's own envs ({@link CALLS_SECRET_ENV_KEYS}) -- never mints: minting
1287
+ * either is {@link ensureCallsMatrixSecrets}'s own job, and a missing value
1288
+ * here means that stack has not finished provisioning yet, not something
1289
+ * this step can fix on its own.
1290
+ */
1291
+ private readCallsMasClientCredentials;
1292
+ /**
1293
+ * `null` once the calls service app already has a `CALLS_BINDING_SECRET` --
1294
+ * never rotated, the same rule {@link ensureAuthCookieSecret} follows for
1295
+ * `AUTH_COOKIE_SECRET`: it wraps the provider-token ciphertext already
1296
+ * stored under any binding minted so far
1297
+ * (`calls/packages/service/src/config.mjs`'s own doc comment on
1298
+ * `CALLS_BINDING_SECRET`), so overwriting it would strand every binding
1299
+ * minted under the old value. 32 raw bytes, hex-encoded -- the exact shape
1300
+ * `config.mjs`'s own regex requires.
1301
+ */
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;
777
1353
  private cloudflareTunnel;
1354
+ /**
1355
+ * `plan/diff.ts`'s `planZone`: finds or creates the Cloudflare zone for the
1356
+ * org's own domain. Refuses to adopt a zone the store already records
1357
+ * under a *different* org's applied state (#131), the same
1358
+ * do-not-adopt-a-foreign-resource rule `cloudflareAccess` above enforces --
1359
+ * but where that method tells "foundry's own" from "foreign" by reading
1360
+ * Cloudflare's own Access-app name live, a Cloudflare zone carries no
1361
+ * org-identifying field of its own, so this asks the store instead. That
1362
+ * matters because two different, DB-unique `domain` values (`org.ts`'s
1363
+ * `validateDomain` plus its unique index) can still resolve to the same
1364
+ * Cloudflare zone -- a zone is keyed by apex, and one org's domain can be a
1365
+ * subdomain of another org's -- which is exactly the shared-zone,
1366
+ * shared-hostname collision this ticket exists to refuse. An org re-running
1367
+ * its own already-recorded zone is unaffected: the store then names this
1368
+ * same org as the owner.
1369
+ */
778
1370
  private cloudflareZone;
779
1371
  /**
780
1372
  * `plan/diff.ts`'s `planMail`: gives an org mail, moves it between modes,
781
1373
  * or parks it on the one shared Stalwart instance (`this.ports.mail`,
782
1374
  * `@wtfalch/email/postmaster`'s `applyOrgMail`/`parkOrgMail` bound), then
783
- * publishes whatever DNS records the call returns.
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.
784
1397
  */
785
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;
786
1419
  /**
787
1420
  * Upserts each DNS record `applyOrgMail` says foundry must publish, found
788
1421
  * by an exact name+type match (`CloudflarePort.findDnsRecord`'s own doc
@@ -791,6 +1424,38 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
791
1424
  * mints the key on a background task; the next apply publishes it.
792
1425
  */
793
1426
  private publishMailDnsRecords;
1427
+ /**
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.
1436
+ */
1437
+ private parkLettermintMail;
1438
+ /**
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.
1451
+ */
1452
+ private publishLettermintDnsRecords;
1453
+ /**
1454
+ * Never overwrites an existing `_dmarc` record -- a stricter policy an org
1455
+ * already set (`p=quarantine`/`p=reject`) should not be relaxed back to
1456
+ * Lettermint's own `p=none` default.
1457
+ */
1458
+ private ensureLettermintDmarcRecord;
794
1459
  /**
795
1460
  * #80: provisions the org's own organisation on Files, before
796
1461
  * {@link filesStorage} can set its spend limit. `provisionOrganisation` is
@@ -809,6 +1474,16 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
809
1474
  */
810
1475
  private aiOrganisation;
811
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;
812
1487
  /**
813
1488
  * {@link StepDestroyer.destroy}: deletes the one resource
814
1489
  * `FoundryPlanApply.destroy` names. A provider 404 (already gone) counts as
@@ -821,3 +1496,23 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
821
1496
  readonly recordedIds: Readonly<Record<string, string>>;
822
1497
  }): Promise<void>;
823
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;