@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.
- package/dist/bootstrap/bootstrap.d.ts +96 -0
- package/dist/bootstrap/bootstrap.js +285 -0
- package/dist/bootstrap/declare.d.ts +36 -0
- package/dist/bootstrap/declare.js +94 -0
- package/dist/bootstrap/drive.d.ts +40 -0
- package/dist/bootstrap/drive.js +51 -0
- package/dist/bootstrap/index.d.ts +3 -0
- package/dist/bootstrap/index.js +1 -0
- package/dist/calls-sfu-deploy-support.d.ts +9 -6
- package/dist/calls-sfu-deploy-support.js +9 -6
- package/dist/calls-sfu-keys.d.ts +21 -0
- package/dist/calls-sfu-keys.js +27 -0
- package/dist/calls-sfu-ssh-adapter.d.ts +55 -0
- package/dist/calls-sfu-ssh-adapter.js +78 -0
- package/dist/calls-sfu-ssh-client.d.ts +49 -0
- package/dist/calls-sfu-ssh-client.js +82 -0
- package/dist/calls-sfu.d.ts +5 -4
- package/dist/calls-sfu.js +5 -4
- package/dist/index.d.ts +31 -6
- package/dist/index.js +16 -3
- package/dist/migrations/0016_platform.sql +61 -0
- package/dist/migrations/0017_requested_changes.sql +42 -0
- package/dist/migrations/0018_platform_apply.sql +19 -0
- package/dist/org.js +8 -0
- package/dist/plan/agora.d.ts +77 -0
- package/dist/plan/agora.js +127 -0
- package/dist/plan/cloudflared-connector.d.ts +70 -0
- package/dist/plan/cloudflared-connector.js +106 -0
- package/dist/plan/diff.d.ts +20 -3
- package/dist/plan/diff.js +631 -116
- package/dist/plan/executor.d.ts +475 -168
- package/dist/plan/executor.js +543 -175
- package/dist/plan/index.d.ts +22 -5
- package/dist/plan/index.js +12 -2
- package/dist/plan/jobs-scheduler-http.d.ts +29 -0
- package/dist/plan/jobs-scheduler-http.js +50 -0
- package/dist/plan/live.d.ts +51 -2
- package/dist/plan/live.js +8 -0
- package/dist/plan/plan-apply.d.ts +192 -3
- package/dist/plan/plan-apply.js +278 -9
- package/dist/plan/platform-diff.d.ts +312 -0
- package/dist/plan/platform-diff.js +867 -0
- package/dist/plan/platform-org-ports.d.ts +50 -0
- package/dist/plan/platform-org-ports.js +70 -0
- package/dist/plan/platform-org.d.ts +120 -0
- package/dist/plan/platform-org.js +115 -0
- package/dist/plan/platform-providers.d.ts +501 -0
- package/dist/plan/platform-providers.js +3029 -0
- package/dist/plan/platform-sfu.d.ts +93 -0
- package/dist/plan/platform-sfu.js +100 -0
- package/dist/plan/providers-readers.js +22 -1
- package/dist/plan/zitadel-machine-user.d.ts +34 -0
- package/dist/plan/zitadel-machine-user.js +95 -0
- package/dist/plan.d.ts +6 -0
- package/dist/platform.d.ts +55 -0
- package/dist/platform.js +46 -0
- package/dist/requested-changes.d.ts +108 -0
- package/dist/requested-changes.js +95 -0
- package/dist/slug.js +15 -2
- package/dist/store/audit.js +4 -2
- package/dist/store/postgres.d.ts +29 -1
- package/dist/store/postgres.js +172 -2
- package/dist/store/schema.d.ts +683 -0
- package/dist/store/schema.js +57 -0
- package/dist/store.d.ts +46 -0
- package/package.json +1 -1
package/dist/plan/executor.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { OrgMailOptions, OrgMailOrg, OrgMailResult } from '@wtfalch/email/postmaster';
|
|
2
|
-
import type { DeclaredOrg, HostingBuildPack
|
|
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
|
|
375
|
-
* `
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
* https://
|
|
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
|
|
381
|
-
readonly
|
|
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
|
|
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
|
|
395
|
-
* and mint its app's sending
|
|
396
|
-
*
|
|
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
|
|
399
|
-
* has no
|
|
400
|
-
* port names
|
|
401
|
-
* (
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
-
*
|
|
405
|
-
*
|
|
406
|
-
*
|
|
407
|
-
*
|
|
408
|
-
*
|
|
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
|
|
411
|
-
/**
|
|
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
|
-
|
|
415
|
-
status:
|
|
452
|
+
domain: string;
|
|
453
|
+
status: LettermintDomain['status'];
|
|
416
454
|
} | undefined>;
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
|
422
|
-
*
|
|
423
|
-
* record the status, and let the next
|
|
424
|
-
*
|
|
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:
|
|
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
|
-
*
|
|
431
|
-
*
|
|
432
|
-
*
|
|
433
|
-
*
|
|
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
|
-
|
|
437
|
-
|
|
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
|
-
/**
|
|
450
|
-
|
|
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
|
|
464
|
-
*
|
|
465
|
-
* `
|
|
466
|
-
*
|
|
467
|
-
*
|
|
468
|
-
*
|
|
469
|
-
*
|
|
470
|
-
*
|
|
471
|
-
*
|
|
472
|
-
*
|
|
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
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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
|
|
604
|
-
*
|
|
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
|
|
615
|
-
*
|
|
616
|
-
* `
|
|
617
|
-
*
|
|
618
|
-
*
|
|
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
|
-
|
|
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
|
-
/**
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
/**
|
|
680
|
-
|
|
681
|
-
|
|
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
|
|
867
|
-
*
|
|
868
|
-
*
|
|
869
|
-
*
|
|
870
|
-
*
|
|
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
|
-
*
|
|
874
|
-
*
|
|
875
|
-
*
|
|
876
|
-
*
|
|
877
|
-
*
|
|
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
|
-
*
|
|
880
|
-
*
|
|
881
|
-
* `
|
|
882
|
-
*
|
|
883
|
-
*
|
|
884
|
-
*
|
|
885
|
-
*
|
|
886
|
-
*
|
|
887
|
-
*
|
|
888
|
-
*
|
|
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
|
|
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
|
|
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,
|
|
1122
|
-
* mail domain
|
|
1123
|
-
*
|
|
1124
|
-
*
|
|
1125
|
-
*
|
|
1126
|
-
*
|
|
1127
|
-
*
|
|
1128
|
-
*
|
|
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.
|
|
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
|
-
*
|
|
1143
|
-
*
|
|
1144
|
-
*
|
|
1145
|
-
*
|
|
1146
|
-
*
|
|
1147
|
-
*
|
|
1148
|
-
*
|
|
1149
|
-
*
|
|
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
|
|
1437
|
+
private parkLettermintMail;
|
|
1169
1438
|
/**
|
|
1170
|
-
*
|
|
1171
|
-
* `publishMailDnsRecords`
|
|
1172
|
-
*
|
|
1173
|
-
*
|
|
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
|
|
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
|
-
*
|
|
1456
|
+
* Lettermint's own `p=none` default.
|
|
1180
1457
|
*/
|
|
1181
|
-
private
|
|
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;
|