@wtfalch/foundry 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bootstrap/bootstrap.d.ts +111 -0
- package/dist/bootstrap/bootstrap.js +323 -0
- package/dist/bootstrap/declare.d.ts +36 -0
- package/dist/bootstrap/declare.js +99 -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 +17 -6
- package/dist/calls-sfu-deploy-support.js +19 -7
- 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 +36 -12
- package/dist/calls-sfu.js +41 -9
- package/dist/index.d.ts +38 -7
- package/dist/index.js +20 -4
- 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/migrations/0019_mail_owner_mailbox.sql +15 -0
- package/dist/migrations/0020_mail_apps.sql +7 -0
- package/dist/migrations/0021_mail_role_mailboxes.sql +6 -0
- package/dist/org.d.ts +81 -0
- package/dist/org.js +96 -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 +713 -115
- package/dist/plan/executor.d.ts +680 -172
- package/dist/plan/executor.js +755 -189
- package/dist/plan/index.d.ts +28 -5
- package/dist/plan/index.js +15 -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/mail-copy.d.ts +125 -0
- package/dist/plan/mail-copy.js +166 -0
- package/dist/plan/mail-identity-allowlist.d.ts +50 -0
- package/dist/plan/mail-identity-allowlist.js +11 -0
- package/dist/plan/mail-identity.d.ts +68 -0
- package/dist/plan/mail-identity.js +17 -0
- package/dist/plan/plan-apply.d.ts +197 -3
- package/dist/plan/plan-apply.js +278 -9
- package/dist/plan/platform-diff.d.ts +487 -0
- package/dist/plan/platform-diff.js +1220 -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 +121 -0
- package/dist/plan/platform-org.js +115 -0
- package/dist/plan/platform-providers.d.ts +615 -0
- package/dist/plan/platform-providers.js +4410 -0
- package/dist/plan/platform-sfu.d.ts +128 -0
- package/dist/plan/platform-sfu.js +142 -0
- package/dist/plan/providers-readers.js +22 -1
- package/dist/plan/zitadel-machine-user.d.ts +83 -0
- package/dist/plan/zitadel-machine-user.js +238 -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 +118 -0
- package/dist/requested-changes.js +125 -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 +203 -4
- package/dist/store/schema.d.ts +755 -0
- package/dist/store/schema.js +70 -0
- package/dist/store.d.ts +53 -0
- package/package.json +1 -1
package/dist/plan/executor.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
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
|
+
import { type MailCopyPort } from './mail-copy.js';
|
|
5
6
|
import type { DestroyTarget, StepDestroyer, StepExecutionContext, StepExecutor } from './plan-apply.js';
|
|
6
7
|
/**
|
|
7
8
|
* The real {@link StepExecutor} (#20 "provision Company Factory end to
|
|
@@ -60,6 +61,7 @@ export interface CoolifyPort {
|
|
|
60
61
|
uuid: string;
|
|
61
62
|
is_usable: boolean;
|
|
62
63
|
is_reachable: boolean;
|
|
64
|
+
ip?: string;
|
|
63
65
|
}>>;
|
|
64
66
|
requireGithubApp(preferredUuid?: string): Promise<{
|
|
65
67
|
uuid: string;
|
|
@@ -217,6 +219,15 @@ export interface CoolifyPort {
|
|
|
217
219
|
name?: string;
|
|
218
220
|
description?: string;
|
|
219
221
|
instant_deploy?: boolean;
|
|
222
|
+
/**
|
|
223
|
+
* `host:container`, comma separated -- publishes host ports directly
|
|
224
|
+
* (`@wtfalch/providers`' `CoolifyClient.createDockerImageApplication`
|
|
225
|
+
* doc comment: "a tunnelled web app never needs and a mail server cannot
|
|
226
|
+
* do without"). Used by `platform-providers.ts`'s `provisionPlatformMail`
|
|
227
|
+
* (#169) for Stalwart's SMTP/submission ports, which cannot go through
|
|
228
|
+
* Cloudflare's HTTP-only tunnel the way `platform:auth`'s apps do.
|
|
229
|
+
*/
|
|
230
|
+
ports_mappings?: string;
|
|
220
231
|
}): Promise<{
|
|
221
232
|
uuid: string;
|
|
222
233
|
}>;
|
|
@@ -292,6 +303,15 @@ export interface CloudflarePort {
|
|
|
292
303
|
name: string;
|
|
293
304
|
}>;
|
|
294
305
|
putTunnelConfig(tunnelId: string, config: unknown): Promise<unknown>;
|
|
306
|
+
/**
|
|
307
|
+
* The tunnel's connector token (#197) -- a `cloudflared` container reads
|
|
308
|
+
* this at boot to dial into the tunnel. A live credential: matches
|
|
309
|
+
* `@wtfalch/providers`' `CloudflareClient.getTunnelToken` method-for-method.
|
|
310
|
+
* A caller must hand this straight to `bulkUpsertEnvs` and nowhere else --
|
|
311
|
+
* never a log, an error, a return value or a recorded scope id (see
|
|
312
|
+
* `cloudflared-connector.ts`'s `ensureCloudflaredConnector`).
|
|
313
|
+
*/
|
|
314
|
+
getTunnelToken(tunnelId: string): Promise<string>;
|
|
295
315
|
/** `FoundryPlanApply.destroy`'s teardown of the org's own Cloudflare tunnel. */
|
|
296
316
|
deleteTunnel(tunnelId: string): Promise<void>;
|
|
297
317
|
/** `cloudflareAccess` finds the org's Access app by domain before creating one (#84). */
|
|
@@ -310,6 +330,7 @@ export interface CloudflarePort {
|
|
|
310
330
|
listAccessPolicies(appId: string): Promise<Array<{
|
|
311
331
|
id: string;
|
|
312
332
|
name: string;
|
|
333
|
+
include?: unknown[];
|
|
313
334
|
}>>;
|
|
314
335
|
createAccessPolicy(appId: string, policy: {
|
|
315
336
|
name: string;
|
|
@@ -371,83 +392,103 @@ export interface CloudflarePort {
|
|
|
371
392
|
request<T>(method: string, path: string, body?: unknown, missingOkay?: boolean): Promise<T>;
|
|
372
393
|
}
|
|
373
394
|
/**
|
|
374
|
-
* One DNS record a
|
|
375
|
-
* `
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
* https://
|
|
395
|
+
* One DNS record a Lettermint domain's own response says must be published --
|
|
396
|
+
* `purpose` is Lettermint's own label (`return_path`, `dmarc`, `dkim_legacy`,
|
|
397
|
+
* `dkim_primary`, `dkim_secondary`), replacing Resend's `record` field, from
|
|
398
|
+
* {@link LettermintPort.createDomain}/{@link LettermintPort.getDomain}, the
|
|
399
|
+
* same shape https://lettermint.co/docs/api-reference/team/0.0.1/lettermint-team-openapi.json's
|
|
400
|
+
* `DomainDnsRecordData` documents (`fqdn`/`content` renamed to this port's own
|
|
401
|
+
* `name`/`value`, matching {@link publishLettermintDnsRecords}'s sibling
|
|
402
|
+
* `publishMailDnsRecords` naming).
|
|
379
403
|
*/
|
|
380
|
-
export interface
|
|
381
|
-
readonly
|
|
382
|
-
readonly name: string;
|
|
404
|
+
export interface LettermintDnsRecordSpec {
|
|
405
|
+
readonly purpose: 'return_path' | 'dmarc' | 'dkim_legacy' | 'dkim_primary' | 'dkim_secondary';
|
|
383
406
|
readonly type: 'TXT' | 'CNAME' | 'MX';
|
|
407
|
+
readonly name: string;
|
|
384
408
|
readonly value: string;
|
|
385
|
-
readonly priority?: number;
|
|
386
409
|
}
|
|
387
|
-
export interface
|
|
410
|
+
export interface LettermintDomain {
|
|
411
|
+
readonly id: string;
|
|
412
|
+
readonly domain: string;
|
|
413
|
+
readonly status: 'verified' | 'partially_verified' | 'pending_verification' | 'failed_verification';
|
|
414
|
+
readonly dnsRecords: readonly LettermintDnsRecordSpec[];
|
|
415
|
+
}
|
|
416
|
+
export interface LettermintProject {
|
|
388
417
|
readonly id: string;
|
|
389
418
|
readonly name: string;
|
|
390
|
-
readonly status: string;
|
|
391
|
-
readonly records?: readonly ResendDnsRecordSpec[];
|
|
392
419
|
}
|
|
393
420
|
/**
|
|
394
|
-
* The
|
|
395
|
-
* and mint its app's sending
|
|
396
|
-
*
|
|
421
|
+
* The Lettermint calls this executor makes to verify an org's mail domain
|
|
422
|
+
* (#138) and mint its app's sending project (#140 rework, 2026-09-28
|
|
423
|
+
* lettermint-org-mail swap) -- the shared Stalwart relays every outbound
|
|
424
|
+
* message through Lettermint
|
|
397
425
|
* (https://github.com/wtfalch/email/blob/main/packages/email/src/postmaster/objects.ts#L149-L168),
|
|
398
|
-
* which refuses mail from a domain
|
|
399
|
-
* has no
|
|
400
|
-
* port names
|
|
401
|
-
* (
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
-
*
|
|
405
|
-
*
|
|
406
|
-
*
|
|
407
|
-
*
|
|
408
|
-
*
|
|
426
|
+
* which refuses mail from a domain it has not verified. `@wtfalch/providers`
|
|
427
|
+
* has no Lettermint client yet, unlike Coolify/Cloudflare/ZITADEL above, so
|
|
428
|
+
* this port names Lettermint's own REST shapes directly
|
|
429
|
+
* (`.plans/2026-09-28-lettermint-api.md`, its own primary-source citations).
|
|
430
|
+
* Replaces `ResendPort` (#138/#140), trimmed to what `coolifyApp`/`coolifyMail`
|
|
431
|
+
* below actually call.
|
|
432
|
+
*
|
|
433
|
+
* Unlike Resend's per-domain sending key, a Lettermint domain is
|
|
434
|
+
* unrestricted by default -- any project in the team could send from it --
|
|
435
|
+
* so a project's own domain access is a separate call
|
|
436
|
+
* ({@link restrictDomainToProjects}), and the credential unit is a *project*,
|
|
437
|
+
* not a per-domain key: Lettermint's API mints, at most, one live token per
|
|
438
|
+
* project and has no call to list or revoke an individual token within one
|
|
439
|
+
* (`.plans/2026-09-28-lettermint-api.md` §4), so `<slug>-app` is a project
|
|
440
|
+
* name, never suffixed with a domain id the way Resend's key name was.
|
|
409
441
|
*/
|
|
410
|
-
export interface
|
|
411
|
-
/**
|
|
442
|
+
export interface LettermintPort {
|
|
443
|
+
/**
|
|
444
|
+
* Lettermint's own `filter[domain]` match is partial and case-insensitive
|
|
445
|
+
* (`.plans/2026-09-28-lettermint-api.md` §3: "partial, case-insensitive"),
|
|
446
|
+
* so a result here can be a lookalike (`notacme.com`, `mail.acme.com`) --
|
|
447
|
+
* `domain` is returned precisely so every caller can reject one via
|
|
448
|
+
* `FoundryStepExecutor.findExactLettermintDomain` rather than a listing
|
|
449
|
+
* scan of its own.
|
|
450
|
+
*/
|
|
412
451
|
findDomain(name: string): Promise<{
|
|
413
452
|
id: string;
|
|
414
|
-
|
|
415
|
-
status:
|
|
453
|
+
domain: string;
|
|
454
|
+
status: LettermintDomain['status'];
|
|
416
455
|
} | undefined>;
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
456
|
+
/** `POST /domains`, its response already carrying `dnsRecords`. */
|
|
457
|
+
createDomain(name: string): Promise<LettermintDomain>;
|
|
458
|
+
/** Re-reads a domain already found by {@link findDomain}, for its current DNS records. */
|
|
459
|
+
getDomain(id: string): Promise<LettermintDomain>;
|
|
420
460
|
/**
|
|
421
|
-
* Asks
|
|
422
|
-
*
|
|
423
|
-
* record the status, and let the next
|
|
424
|
-
*
|
|
461
|
+
* Asks Lettermint to re-check DNS now. Never polled or awaited to
|
|
462
|
+
* completion -- the same "do not block the plan on DNS propagation" rule
|
|
463
|
+
* `ResendPort.verifyDomain` followed: record the status, and let the next
|
|
464
|
+
* apply re-check it.
|
|
425
465
|
*/
|
|
426
466
|
verifyDomain(id: string): Promise<{
|
|
427
|
-
status:
|
|
467
|
+
status: LettermintDomain['status'];
|
|
428
468
|
}>;
|
|
469
|
+
/** `parkLettermintMail`'s own cleanup once an org's mail is parked (mode `'off'`). */
|
|
470
|
+
deleteDomain(id: string): Promise<void>;
|
|
429
471
|
/**
|
|
430
|
-
*
|
|
431
|
-
*
|
|
432
|
-
*
|
|
433
|
-
*
|
|
434
|
-
* spells the rule out.
|
|
472
|
+
* `POST /projects`. `token` is the one-time secret -- Lettermint shows a
|
|
473
|
+
* project's token once, at creation, and the API has no call to mint a
|
|
474
|
+
* second one on the same project -- so a caller that loses it creates a
|
|
475
|
+
* fresh project instead (`ensureAppLettermintMail`'s own mint rule).
|
|
435
476
|
*/
|
|
436
|
-
|
|
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;
|
|
477
|
+
createProject(name: string): Promise<{
|
|
478
|
+
project: LettermintProject;
|
|
447
479
|
token: string;
|
|
448
480
|
}>;
|
|
449
|
-
/**
|
|
450
|
-
|
|
481
|
+
/** Every project in the team, `id`+`name` only -- the mint rule's own health check and `parkLettermintMail`'s orphan scan both filter this by name themselves. */
|
|
482
|
+
listProjects(): Promise<readonly LettermintProject[]>;
|
|
483
|
+
/** Deletes one project by id -- the mint rule's own cleanup of an orphan or a duplicate, and `parkLettermintMail`'s teardown. */
|
|
484
|
+
deleteProject(id: string): Promise<void>;
|
|
485
|
+
/**
|
|
486
|
+
* `PUT /domains/{domainId}/projects`. Rebuilds the domain's whole
|
|
487
|
+
* restriction from `projectIds` -- never patched -- so a domain never ends
|
|
488
|
+
* up unrestricted (any team project could then send as the org) nor
|
|
489
|
+
* restricted only to a project already deleted.
|
|
490
|
+
*/
|
|
491
|
+
restrictDomainToProjects(domainId: string, projectIds: readonly string[]): Promise<void>;
|
|
451
492
|
}
|
|
452
493
|
/**
|
|
453
494
|
* The Stalwart calls this executor makes for one org's mail, shaped to match
|
|
@@ -458,21 +499,71 @@ export interface ResendPort {
|
|
|
458
499
|
export interface MailPort {
|
|
459
500
|
applyOrgMail(org: OrgMailOrg, options: OrgMailOptions): Promise<OrgMailResult>;
|
|
460
501
|
parkOrgMail(org: OrgMailOrg, options: OrgMailOptions): Promise<void>;
|
|
502
|
+
/**
|
|
503
|
+
* Gives one person a mailbox on the org's already-resolved mail domain
|
|
504
|
+
* (#162). The host implements this with `@wtfalch/email/postmaster`'s own
|
|
505
|
+
* `createMailbox`, resolving `org`'s domain to a `domainId` the same way
|
|
506
|
+
* `applyOrgMail` does -- that function already refuses a duplicate `local`
|
|
507
|
+
* on the resolved domain, so this port method never has to. `ensureOwnerMailbox`
|
|
508
|
+
* and `ensureRoleMailboxes` (#230) below are this executor's callers, and each checks its own recorded scope
|
|
509
|
+
* id first, so a second apply never calls this at all.
|
|
510
|
+
*/
|
|
511
|
+
createMailbox(org: OrgMailOrg, mailbox: {
|
|
512
|
+
address: string;
|
|
513
|
+
name: string;
|
|
514
|
+
}): Promise<void>;
|
|
515
|
+
/**
|
|
516
|
+
* Upserts this org's own outbound relay route on Stalwart (#149): an
|
|
517
|
+
* `MtaRoute` through Lettermint, restricted to `org`'s domain, keyed by
|
|
518
|
+
* `org.slug` -- the host implements this with `@wtfalch/email/postmaster`'s
|
|
519
|
+
* own `applyOrgRelay` (https://github.com/wtfalch/email/issues/118), which
|
|
520
|
+
* rebuilds the shared routing expression so two orgs' routes never
|
|
521
|
+
* collide. `secret` is the Lettermint project token this executor just
|
|
522
|
+
* minted (`ensureOrgLettermintRelay`'s own mint rule) -- it goes straight
|
|
523
|
+
* here and nowhere else: never returned, never recorded, never logged, the
|
|
524
|
+
* same discipline `ensureAppLettermintMail`'s own doc comment states for
|
|
525
|
+
* `LETTERMINT_API_TOKEN`.
|
|
526
|
+
*/
|
|
527
|
+
applyOrgRelay(org: OrgMailOrg, secret: string): Promise<void>;
|
|
528
|
+
/**
|
|
529
|
+
* Drops this org's relay route (#149), implemented with
|
|
530
|
+
* `@wtfalch/email/postmaster`'s own `removeOrgRelay` -- a missing route is
|
|
531
|
+
* a no-op, so `parkLettermintMail` can call this unconditionally before it
|
|
532
|
+
* deletes the Lettermint project the route pointed at.
|
|
533
|
+
*/
|
|
534
|
+
removeOrgRelay(org: OrgMailOrg): Promise<void>;
|
|
535
|
+
/**
|
|
536
|
+
* True once this org's relay route exists on Stalwart with a secret set
|
|
537
|
+
* (#149). `ensureOrgLettermintRelay`'s own mint rule reads this alongside
|
|
538
|
+
* Lettermint's own project listing: a project without a live route (the
|
|
539
|
+
* route recorded a token this executor never minted) is exactly as
|
|
540
|
+
* unhealthy as a route with no project behind it, so a fresh mint fixes
|
|
541
|
+
* either.
|
|
542
|
+
*/
|
|
543
|
+
hasOrgRelay(org: OrgMailOrg): Promise<boolean>;
|
|
461
544
|
}
|
|
462
545
|
/**
|
|
463
|
-
* The shared calls SFU's own LiveKit API credentials (#122, #127
|
|
464
|
-
*
|
|
465
|
-
* `
|
|
466
|
-
*
|
|
467
|
-
*
|
|
468
|
-
*
|
|
469
|
-
*
|
|
470
|
-
*
|
|
471
|
-
*
|
|
472
|
-
*
|
|
546
|
+
* The shared calls SFU's own per-org LiveKit API credentials (#122, #127,
|
|
547
|
+
* #145, #152). Before #170, a human minted one pair by hand, shared by every
|
|
548
|
+
* org, and `resolveCredentials` read wherever `scripts/calls-sfu-deploy.mjs`'s
|
|
549
|
+
* own `CallsSfuConfig` delivered it from -- this package never minted or
|
|
550
|
+
* invented a LiveKit key/secret pair itself.
|
|
551
|
+
*
|
|
552
|
+
* As of #170 the platform's own `sfu:livekit` step
|
|
553
|
+
* (`plan/platform-sfu.ts`'s `applyCallsSfuPlatformStep`) mints the pair, and
|
|
554
|
+
* as of #152 it mints one *per org*, the first time that org applies, and
|
|
555
|
+
* delivers it to held keys through `CallsSfuKeysPort.deliverLivekitKeys`,
|
|
556
|
+
* keyed by the org's slug. `resolveCredentials` reads a specific org's own
|
|
557
|
+
* held-keys entry back -- still resolved by the host (archon), never minted
|
|
558
|
+
* or read directly by this port itself, the same "the executor is
|
|
559
|
+
* constructed with narrow ports ... a host supplies real clients" rule this
|
|
560
|
+
* file's own top doc comment states for every provider credential.
|
|
561
|
+
* `resolveCredentials` returns `null` before the platform's `sfu` step has
|
|
562
|
+
* ever applied for that org, the same "nothing recorded" signal a bare
|
|
563
|
+
* `CoolifyPort.listEnvs` gives `ensureCallsMatrixSecrets`.
|
|
473
564
|
*/
|
|
474
565
|
export interface CallsSfuPort {
|
|
475
|
-
resolveCredentials(): Promise<{
|
|
566
|
+
resolveCredentials(orgSlug: string): Promise<{
|
|
476
567
|
apiKey: string;
|
|
477
568
|
apiSecret: string;
|
|
478
569
|
} | null>;
|
|
@@ -573,26 +664,108 @@ export interface ZitadelPort {
|
|
|
573
664
|
deleteProject(orgId: string, projectId: string): Promise<void>;
|
|
574
665
|
/** `FoundryPlanApply.destroy`'s last step: removes the whole ZITADEL org and everything foundry made inside it. */
|
|
575
666
|
deleteOrg(orgId: string): Promise<void>;
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
667
|
+
/**
|
|
668
|
+
* #168: mints foundry's own machine user, on the platform's own freshly
|
|
669
|
+
* built ZITADEL instance, with the instance-wide admin role, and a fresh
|
|
670
|
+
* Personal Access Token for it -- "Foundry mints this key; nobody pastes
|
|
671
|
+
* it." `orgId` is the ZITADEL org the machine user lives in (every ZITADEL
|
|
672
|
+
* user belongs to some org, the same shape {@link inviteOrgOwner} already
|
|
673
|
+
* has for a human, even though the IAM_OWNER role this user gets is
|
|
674
|
+
* instance-wide once granted, not scoped to that org). Idempotent on the
|
|
675
|
+
* user itself -- a real client finds or creates it by `userName` -- but
|
|
676
|
+
* never on the token: ZITADEL shows a PAT's value once, at mint, and never
|
|
677
|
+
* again, the same "minted once, delivered once" rule {@link createApiApp}'s
|
|
678
|
+
* own doc comment follows for a client secret. The caller
|
|
679
|
+
* (`platform-providers.ts`'s `FoundryPlatformStepExecutor`) hands the token
|
|
680
|
+
* straight to {@link FoundryStepExecutorPorts.deliverSecret} and never
|
|
681
|
+
* returns, stores or logs it, and only calls this once per platform,
|
|
682
|
+
* guarded by its own recorded scope id -- never by anything this method
|
|
683
|
+
* does internally.
|
|
684
|
+
*
|
|
685
|
+
* `@wtfalch/providers` has no machine-user support at all yet (checked
|
|
686
|
+
* 2026-09-29) -- "being added to providers in parallel", the same
|
|
687
|
+
* precedent {@link createOidcApp}/{@link createApiApp} set above -- so this
|
|
688
|
+
* port states the shape it needs to land with; a real client that gains
|
|
689
|
+
* exactly this method satisfies it with no adapter.
|
|
690
|
+
*/
|
|
691
|
+
mintAdminMachineUser(orgId: string, input: {
|
|
692
|
+
userName: string;
|
|
693
|
+
name: string;
|
|
694
|
+
}): Promise<{
|
|
695
|
+
userId: string;
|
|
696
|
+
token: string;
|
|
697
|
+
}>;
|
|
698
|
+
/**
|
|
699
|
+
* #216 (building on #209/#215): mints a second, narrower machine user on
|
|
700
|
+
* the same instance, with only the `IAM_LOGIN_CLIENT` role -- never
|
|
701
|
+
* `IAM_OWNER`, which {@link mintAdminMachineUser} already covers and which
|
|
702
|
+
* this method must not reuse or grant in addition. The login app and the
|
|
703
|
+
* broker both refuse to start without this token
|
|
704
|
+
* (`platform-providers.ts`'s `provisionPlatformAuth`, "Known gaps").
|
|
705
|
+
*
|
|
706
|
+
* Idempotent on the user by `userName`, like {@link mintAdminMachineUser},
|
|
707
|
+
* but never on the token the same way: ZITADEL shows a PAT's value once,
|
|
708
|
+
* and a crash or a failed delivery between a prior mint and the caller
|
|
709
|
+
* recording it would otherwise leave that lost token live forever. Unlike
|
|
710
|
+
* {@link mintAdminMachineUser}, revoking this user's PAT destroys nothing
|
|
711
|
+
* -- nothing else was ever encrypted with it, the way ZITADEL's own
|
|
712
|
+
* masterkey protects the admin path's data -- so a real implementation
|
|
713
|
+
* lists and deletes every PAT this user already holds before minting a
|
|
714
|
+
* fresh one, leaving exactly one live token no matter how many times this
|
|
715
|
+
* is called for the same user. This is what lets the caller
|
|
716
|
+
* (`provisionPlatformAuth`) simply re-mint on every rerun until delivery
|
|
717
|
+
* succeeds, rather than blocking forever the way an undelivered admin key
|
|
718
|
+
* does.
|
|
719
|
+
*
|
|
720
|
+
* Refuses (a named error, never a silent reuse) when a `foundry-login-client`
|
|
721
|
+
* user already exists with any IAM role other than exactly
|
|
722
|
+
* `IAM_LOGIN_CLIENT` -- that user was not created by this method, and
|
|
723
|
+
* granting it an additional role, or reusing its PAT, would widen access
|
|
724
|
+
* this caller never asked for.
|
|
725
|
+
*
|
|
726
|
+
* #216 round 2: also returns `patId`, the minted PAT's own id, so the
|
|
727
|
+
* caller can hand it to {@link listLoginClientPats} after delivering the
|
|
728
|
+
* token and confirm nothing else revoked or replaced it in the meantime.
|
|
729
|
+
*
|
|
730
|
+
* #216 (third PR review): also returns `revokedPriorPat`, true exactly
|
|
731
|
+
* when a live PAT existed on this user and was revoked before this mint --
|
|
732
|
+
* never true on a first-ever mint, where nothing was live to revoke. The
|
|
733
|
+
* caller uses this to word a delivery-failure error correctly: on a
|
|
734
|
+
* re-mint the login app and broker are left with no live token at all
|
|
735
|
+
* (the old one is already gone); on a first mint they simply never had one
|
|
736
|
+
* yet.
|
|
737
|
+
*/
|
|
738
|
+
mintLoginClientMachineUser(orgId: string, input: {
|
|
739
|
+
userName: string;
|
|
740
|
+
name: string;
|
|
741
|
+
}): Promise<{
|
|
742
|
+
userId: string;
|
|
743
|
+
token: string;
|
|
744
|
+
patId: string;
|
|
745
|
+
revokedPriorPat: boolean;
|
|
746
|
+
}>;
|
|
747
|
+
/**
|
|
748
|
+
* #216 round 2: every live PAT id on the `foundry-login-client` machine
|
|
749
|
+
* user, read straight from ZITADEL. `provisionPlatformAuth`
|
|
750
|
+
* (`platform-providers.ts`) calls this right after it delivers a freshly
|
|
751
|
+
* minted token to the login app and the broker, to close a race two
|
|
752
|
+
* concurrent applies can otherwise hit: run A mints token a and is about to
|
|
753
|
+
* deliver it; run B's own mint (`mintLoginClientMachineUser`, idempotent on
|
|
754
|
+
* the same `userName`) revokes a and mints token b first; run A then
|
|
755
|
+
* delivers the now-revoked a and would, without this check, still record
|
|
756
|
+
* success. Expecting the result to be exactly `[patId]` (the id
|
|
757
|
+
* {@link mintLoginClientMachineUser} returned) catches that: anything else
|
|
758
|
+
* means some other mint ran in between, so this run must fail rather than
|
|
759
|
+
* record delivery, leaving the next apply to re-mint and re-deliver.
|
|
760
|
+
*
|
|
761
|
+
* #216 round 3: a single call made once, after every write, cannot catch a
|
|
762
|
+
* write that lands after it -- `provisionPlatformAuth` now calls this
|
|
763
|
+
* again immediately before each individual write (each retry attempt of
|
|
764
|
+
* each of the login app's env, the broker's env, and each app's deploy),
|
|
765
|
+
* not only once at the end, so a write about to deliver a token this call
|
|
766
|
+
* reports no longer live is never attempted at all.
|
|
767
|
+
*/
|
|
768
|
+
listLoginClientPats(orgId: string, userId: string): Promise<string[]>;
|
|
596
769
|
}
|
|
597
770
|
/**
|
|
598
771
|
* Provisions one org's organisation on Files (#80), matching
|
|
@@ -600,9 +773,8 @@ export interface AiOrgLimits {
|
|
|
600
773
|
* `createFilesManagementClient().provisionOrganisation`
|
|
601
774
|
* (`ProvisionOrganisationInput`/`ProvisionOrganisationResult`,
|
|
602
775
|
* `files/packages/sdk/src/types.ts:146-156`). Flat, not a factory, like
|
|
603
|
-
* {@link ZitadelPort}: `organisationId` is an explicit call argument
|
|
604
|
-
*
|
|
605
|
-
* caller's own organisation from its signed request instead.
|
|
776
|
+
* {@link ZitadelPort}: `organisationId` is an explicit call argument on every
|
|
777
|
+
* method here.
|
|
606
778
|
*
|
|
607
779
|
* Finding (this campaign's report): unlike {@link AiOrganisationPort}'s bearer
|
|
608
780
|
* credential, `createFilesManagementClient`'s whole transport is "a signed
|
|
@@ -611,11 +783,17 @@ export interface AiOrgLimits {
|
|
|
611
783
|
* request (`files/apps/host/src/lib/authz/service-assertion.ts`'s
|
|
612
784
|
* `signManagementRequest`/`AssertionTrust`). A host can construct this without
|
|
613
785
|
* any human session: it only needs the Ed25519 private key (`SigningIdentity`)
|
|
614
|
-
* an operator's `MANAGEMENT_PUBLIC_KEYS`/`MANAGEMENT_ISSUER` pins
|
|
615
|
-
*
|
|
616
|
-
* `
|
|
617
|
-
*
|
|
618
|
-
*
|
|
786
|
+
* an operator's `MANAGEMENT_PUBLIC_KEYS`/`MANAGEMENT_ISSUER` pins.
|
|
787
|
+
*
|
|
788
|
+
* #177: `setQuota`/`setBudget` (`files/packages/sdk/src/client.ts:154-166`)
|
|
789
|
+
* used to live on a separate `FilesOrgLimits` a host built per org through a
|
|
790
|
+
* `filesLimitsFor(orgId)` factory on `FoundryStepExecutorPorts` — ceremony
|
|
791
|
+
* left over from nothing but currying an id this port's own callers already
|
|
792
|
+
* have in hand. The signed claim's `organisation` field selects the org per
|
|
793
|
+
* call, so one shared keypair signs for every org already, the same signer
|
|
794
|
+
* `provisionOrganisation` uses; there was never a per-org client to build,
|
|
795
|
+
* only an org id to pass. Both methods now take it explicitly, like
|
|
796
|
+
* `provisionOrganisation` always did, and `filesLimitsFor` is gone.
|
|
619
797
|
*/
|
|
620
798
|
export interface FilesOrganisationPort {
|
|
621
799
|
provisionOrganisation(input: {
|
|
@@ -634,6 +812,9 @@ export interface FilesOrganisationPort {
|
|
|
634
812
|
shareHost: string | null;
|
|
635
813
|
created: boolean;
|
|
636
814
|
}>;
|
|
815
|
+
/** #27, #177: the org's own spend limit on Files -- see this interface's own doc comment for why `organisationId` is explicit here. */
|
|
816
|
+
setQuota(organisationId: string, limitBytes: string | null): Promise<unknown>;
|
|
817
|
+
setBudget(organisationId: string, limitMicros: string | null): Promise<unknown>;
|
|
637
818
|
}
|
|
638
819
|
/**
|
|
639
820
|
* Provisions one org's organisation on AI and switches its AI use on (#80),
|
|
@@ -650,6 +831,13 @@ export interface FilesOrganisationPort {
|
|
|
650
831
|
* `ai.organisations:offer`, `ai.budgets:update`, among others) copied onto
|
|
651
832
|
* it — used directly with `createAiClient`, unlike Files' signed-assertion
|
|
652
833
|
* transport above.
|
|
834
|
+
*
|
|
835
|
+
* #172: `platform:ai` (`platform-providers.ts`'s `provisionPlatformAi`)
|
|
836
|
+
* deploys the ai host this port talks to, records its internal address
|
|
837
|
+
* (`PLATFORM_AI_INTERNAL_URL_SCOPE_KEY`) and delivers foundry's own
|
|
838
|
+
* management key under `PLATFORM_AI_MANAGEMENT_KEY_REF` -- but only through a
|
|
839
|
+
* host-supplied `AiManagementKeyMinter`, since that same script-only mint is
|
|
840
|
+
* still the one way ai issues its first platform credential.
|
|
653
841
|
*/
|
|
654
842
|
export interface AiOrganisationPort {
|
|
655
843
|
provisionOrganisation(input: {
|
|
@@ -664,21 +852,113 @@ export interface AiOrganisationPort {
|
|
|
664
852
|
organisationId: string;
|
|
665
853
|
offers: string[];
|
|
666
854
|
}>;
|
|
855
|
+
/**
|
|
856
|
+
* Mints a key inside `organisationId` (#80 step 3, `wtfalch/ai#108`/`#113`),
|
|
857
|
+
* matching `ai/packages/sdk/src/client.ts`'s `issueOrganisationKey`
|
|
858
|
+
* (`IssueOrganisationKey`/`IssuedOrganisationKey`,
|
|
859
|
+
* `ai/packages/sdk/src/types.ts:150-166`). The secret comes back once, the
|
|
860
|
+
* same as `ZitadelPort.inviteOrgOwner`'s and `zitadelApp`'s own one-time
|
|
861
|
+
* secrets -- `aiKey` hands it straight to `deliverSecret` and returns only
|
|
862
|
+
* the key's id.
|
|
863
|
+
*/
|
|
864
|
+
issueOrganisationKey(organisationId: string, input: {
|
|
865
|
+
name: string;
|
|
866
|
+
expiresDays: number;
|
|
867
|
+
permissions: string[];
|
|
868
|
+
}): Promise<{
|
|
869
|
+
organisationId: string;
|
|
870
|
+
id: string;
|
|
871
|
+
secret: string;
|
|
872
|
+
}>;
|
|
873
|
+
/**
|
|
874
|
+
* #27, #177: the org's own budget on AI, matching
|
|
875
|
+
* `ai/packages/sdk/src/client.ts:94-101`'s `updateBudget` (`UpdateBudget`
|
|
876
|
+
* from `ai/packages/sdk/src/types.ts:134-137`). Used to live on a separate
|
|
877
|
+
* `AiOrgLimits` a host built per org through an `aiLimitsFor(orgId)`
|
|
878
|
+
* factory on `FoundryStepExecutorPorts` -- the same dead currying
|
|
879
|
+
* {@link FilesOrganisationPort}'s own doc comment explains for
|
|
880
|
+
* `setQuota`/`setBudget`. `organisationId` is explicit here now, like
|
|
881
|
+
* every other method on this port.
|
|
882
|
+
*/
|
|
883
|
+
updateBudget(organisationId: string, input: {
|
|
884
|
+
keyId: string | null;
|
|
885
|
+
limitMicros: string;
|
|
886
|
+
}): Promise<unknown>;
|
|
887
|
+
}
|
|
888
|
+
/**
|
|
889
|
+
* #173: the jobs scheduler's own admin API
|
|
890
|
+
* (https://github.com/wtfalch/jobs, "The admin API", jobs#62), authenticated
|
|
891
|
+
* with `SCHEDULER_ADMIN_TOKEN` as a bearer token by whatever real client
|
|
892
|
+
* satisfies this port -- this package holds no credential of its own, the
|
|
893
|
+
* same rule every other port in this file follows. No `@wtfalch/providers`
|
|
894
|
+
* client for this scheduler exists yet, so this port states the shape a real
|
|
895
|
+
* one should satisfy structurally, the same convention `ZitadelPort`'s
|
|
896
|
+
* not-yet-existing methods already follow.
|
|
897
|
+
*
|
|
898
|
+
* Matches `POST /admin/projects` method-for-method: idempotent by
|
|
899
|
+
* `(name, baseUrl)`. A first call returns the freshly minted `secret` and
|
|
900
|
+
* `syncKey`, shown exactly once; a repeat call for the same `(name, baseUrl)`
|
|
901
|
+
* returns just the `id`, with neither -- `jobsRegistration` below never
|
|
902
|
+
* re-delivers a secret it did not just mint, deciding purely on whether this
|
|
903
|
+
* resolves with one. A repeat call naming the same project under a different
|
|
904
|
+
* `baseUrl` is refused (the real API's `409`); this port surfaces that as a
|
|
905
|
+
* thrown error, like any other provider refusal in this file.
|
|
906
|
+
*/
|
|
907
|
+
export interface JobsSchedulerPort {
|
|
908
|
+
registerProject(input: {
|
|
909
|
+
name: string;
|
|
910
|
+
baseUrl: string;
|
|
911
|
+
alertUrl?: string;
|
|
912
|
+
}): Promise<{
|
|
913
|
+
id: string;
|
|
914
|
+
secret?: string;
|
|
915
|
+
syncKey?: string;
|
|
916
|
+
}>;
|
|
917
|
+
}
|
|
918
|
+
/**
|
|
919
|
+
* #177: where the org steps' files, AI and jobs ports come from when the host
|
|
920
|
+
* does not supply them. `platform-org-ports.ts`'s `PlatformOrgPorts` builds
|
|
921
|
+
* them from the platform's recorded addresses and held keys.
|
|
922
|
+
*/
|
|
923
|
+
export interface PlatformOrgPortSource {
|
|
924
|
+
files(): Promise<FilesOrganisationPort>;
|
|
925
|
+
ai(): Promise<AiOrganisationPort>;
|
|
926
|
+
jobs(): Promise<JobsSchedulerPort>;
|
|
667
927
|
}
|
|
668
928
|
export interface FoundryStepExecutorPorts {
|
|
669
929
|
coolify: CoolifyPort;
|
|
670
930
|
cloudflare: CloudflarePort;
|
|
671
931
|
zitadel: ZitadelPort;
|
|
672
932
|
mail: MailPort;
|
|
673
|
-
|
|
933
|
+
lettermint: LettermintPort;
|
|
934
|
+
/**
|
|
935
|
+
* #173: registers an org's own app with the platform's jobs scheduler -- see
|
|
936
|
+
* {@link JobsSchedulerPort}'s own doc comment. Optional: a host with no
|
|
937
|
+
* scheduler deployed builds the executor without one, and tells the planner
|
|
938
|
+
* so (`FoundryPlanApplyOptions.jobsScheduler`), which then reports each
|
|
939
|
+
* org's registration as blocked instead of planning a step that would throw.
|
|
940
|
+
*/
|
|
941
|
+
jobsScheduler?: JobsSchedulerPort;
|
|
674
942
|
/** The shared calls SFU's own LiveKit credentials (#145) -- see {@link CallsSfuPort}'s own doc comment. */
|
|
675
943
|
callsSfu: CallsSfuPort;
|
|
676
|
-
/**
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
944
|
+
/**
|
|
945
|
+
* #113: the org's old-mail copy into Stalwart, ahead of a `received` ->
|
|
946
|
+
* `hosted` MX cutover -- see {@link MailCopyPort}'s own doc comment.
|
|
947
|
+
* Optional the same way `jobsScheduler` is: a host that never runs this
|
|
948
|
+
* transition builds the executor without it, and `coolify:mail-copy`
|
|
949
|
+
* throws a clear "not built" error rather than every other step losing
|
|
950
|
+
* compilation over one dependency nothing else needs.
|
|
951
|
+
*/
|
|
952
|
+
mailCopy?: MailCopyPort;
|
|
953
|
+
/** Flat, not a factory: `organisationId` is an explicit argument on every method of both ports (see each port's own doc comment; #177 dropped the `filesLimitsFor`/`aiLimitsFor` factories this used to need). */
|
|
954
|
+
filesOrganisation?: FilesOrganisationPort;
|
|
955
|
+
aiOrganisation?: AiOrganisationPort;
|
|
956
|
+
/**
|
|
957
|
+
* #177: builds the files, AI and jobs ports from the platform's addresses
|
|
958
|
+
* and held keys. A port set above overrides it (tests); with neither, the
|
|
959
|
+
* step throws. The planner blocks these steps first while a key is not held.
|
|
960
|
+
*/
|
|
961
|
+
platformOrg?: PlatformOrgPortSource;
|
|
682
962
|
/**
|
|
683
963
|
* Hands a just-minted, one-time secret (today: an API app's
|
|
684
964
|
* `clientSecret`) to the host, immediately after the provider returns it
|
|
@@ -721,6 +1001,20 @@ export declare class NotBuiltError extends Error {
|
|
|
721
1001
|
* this file's hostname rules.
|
|
722
1002
|
*/
|
|
723
1003
|
export declare function healthUrls(org: DeclaredOrg): string[];
|
|
1004
|
+
/** The one Access policy foundry puts on its own Access app (#84). */
|
|
1005
|
+
export declare const ACCESS_POLICY_NAME = "foundry: allowed emails";
|
|
1006
|
+
/**
|
|
1007
|
+
* Whether an application's own Coolify `status` (`` `running:healthy`,
|
|
1008
|
+
* `exited:unhealthy` `` -- `@wtfalch/providers`' own doc comment) means it is
|
|
1009
|
+
* already up, or on its way there -- `findOrCreateCmsApp`'s own check of
|
|
1010
|
+
* whether a found-but-parked app still needs `start`ing (#116 review round
|
|
1011
|
+
* 3, Finding 1). Only `exited`/`stopped` (Coolify's own resting-off states;
|
|
1012
|
+
* `stopped` is not one this codebase has verified against a live instance,
|
|
1013
|
+
* included defensively) read as off; a missing `status` defaults to on, the
|
|
1014
|
+
* same "nothing says it's off" convention `planHostingToggle` (`diff.ts`)
|
|
1015
|
+
* uses for the org's own app.
|
|
1016
|
+
*/
|
|
1017
|
+
export declare function isRunningOrStarting(status: string | undefined): boolean;
|
|
724
1018
|
export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer {
|
|
725
1019
|
private readonly ports;
|
|
726
1020
|
constructor(ports: FoundryStepExecutorPorts);
|
|
@@ -850,9 +1144,10 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
850
1144
|
* `zitadel:issuer` (`AUTH_ORGANIZATION_ID`), `zitadel:owner`
|
|
851
1145
|
* (`OWNER_USER_ID`, gap P3) and every `zitadel:app` step
|
|
852
1146
|
* (`AUTH_CLIENT_ID`), all ordered before `coolify:app` for exactly this
|
|
853
|
-
* reason. AUTH_APP_KEY (
|
|
854
|
-
*
|
|
855
|
-
*
|
|
1147
|
+
* reason. `AUTH_APP_KEY` (#206 -- which broker an org's app registers with
|
|
1148
|
+
* is its own design question, left out of #204's own "How" rather than
|
|
1149
|
+
* guessed) and DATABASE_URL_OWNER (a runtime-role split not built yet,
|
|
1150
|
+
* `pushDatabaseUrl`'s own doc comment) are out of scope here (task brief).
|
|
856
1151
|
*/
|
|
857
1152
|
private pushAppEnv;
|
|
858
1153
|
/**
|
|
@@ -863,32 +1158,43 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
863
1158
|
*/
|
|
864
1159
|
private ensureAuthCookieSecret;
|
|
865
1160
|
/**
|
|
866
|
-
* #140 rework
|
|
867
|
-
*
|
|
868
|
-
*
|
|
869
|
-
*
|
|
870
|
-
*
|
|
1161
|
+
* #140 rework (2026-09-28 lettermint-org-mail swap, "Core rule" +
|
|
1162
|
+
* "Credential model"): all of the org's Lettermint work now lives here,
|
|
1163
|
+
* in the app step, since the first project -- and so the first domain
|
|
1164
|
+
* restriction -- can only exist once the token has a home (the app env). A
|
|
1165
|
+
* no-op until `coolify:mail` has recorded {@link MAIL_DOMAIN_SCOPE_KEY} for
|
|
1166
|
+
* this org; the next apply fills it in once `coolify:mail` runs first
|
|
871
1167
|
* (`diff.ts`'s `computePlanDetail` order).
|
|
872
1168
|
*
|
|
873
|
-
*
|
|
874
|
-
*
|
|
875
|
-
*
|
|
876
|
-
*
|
|
877
|
-
*
|
|
1169
|
+
* Order, exactly as the swap's "Core rule" states it: ensure the
|
|
1170
|
+
* `<slug>-app` project, ensure the mail domain, restrict that domain to the
|
|
1171
|
+
* org's own projects, publish its DNS records, trigger verification, then
|
|
1172
|
+
* delete every stale `<slug>-app` project. New projects are created and the
|
|
1173
|
+
* domain restricted to them before any old project is deleted, so the
|
|
1174
|
+
* restriction list never becomes empty.
|
|
1175
|
+
*
|
|
1176
|
+
* Mint rule ("Credential model"): the project is healthy when the app env
|
|
1177
|
+
* holds `LETTERMINT_API_TOKEN` and Lettermint lists exactly one project
|
|
1178
|
+
* named `<slug>-app` -- unlike Resend's key, a Lettermint project's name
|
|
1179
|
+
* carries no domain id, since a domain switch is handled entirely by
|
|
1180
|
+
* {@link LettermintPort.restrictDomainToProjects} below and never needs a
|
|
1181
|
+
* fresh project. Otherwise a fresh project is created and its token
|
|
1182
|
+
* written to the env first, and only then is every other `<slug>-app`
|
|
1183
|
+
* project deleted -- a lost mint response and a duplicate both end the
|
|
1184
|
+
* same way. An app that was already running is restarted before that
|
|
1185
|
+
* delete, since a container reads its env only at start (`running`, the
|
|
1186
|
+
* same reason {@link ensureLogVolume} restarts one).
|
|
878
1187
|
*
|
|
879
|
-
* The
|
|
880
|
-
*
|
|
881
|
-
*
|
|
882
|
-
*
|
|
883
|
-
* then is every other `<slug>-app@` key deleted -- a lost mint response, a
|
|
884
|
-
* duplicate, a hand-made key, and a previous domain's key all end the same
|
|
885
|
-
* way. An app that was already running is restarted before that delete,
|
|
886
|
-
* since a container reads its env only at start (`running`, the same
|
|
887
|
-
* reason {@link ensureLogVolume} restarts one). The restart is not
|
|
888
|
-
* awaited to completion, so the old container can briefly outlive its key.
|
|
1188
|
+
* The domain's restriction is rebuilt from every project named `<slug>-app`
|
|
1189
|
+
* or `<slug>-relay` (the relay project itself is #149, not built yet) --
|
|
1190
|
+
* never patched, so it can never end up scoped only to a project about to
|
|
1191
|
+
* be deleted.
|
|
889
1192
|
*
|
|
890
1193
|
* `MAIL_FROM` is upserted every time: it depends only on the mail domain
|
|
891
|
-
* `coolify:mail` resolved
|
|
1194
|
+
* `coolify:mail` resolved and `org.mail.noReplyLocalPart` (#162, decided
|
|
1195
|
+
* 2026-09-29 -- defaults to `'no-reply'`, restated here rather than
|
|
1196
|
+
* imported from `org.ts`'s `DEFAULT_NO_REPLY_LOCAL_PART`, this file's own
|
|
1197
|
+
* `LOG_DIR`-style convention).
|
|
892
1198
|
*
|
|
893
1199
|
* The token goes straight to `bulkUpsertEnvs` and nowhere else: never
|
|
894
1200
|
* returned, never recorded in `serviceIds` (`recordScopeId`'s own doc
|
|
@@ -896,7 +1202,77 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
896
1202
|
* same discipline `pushDatabaseUrl`'s doc comment states for a database's
|
|
897
1203
|
* own connection string.
|
|
898
1204
|
*/
|
|
899
|
-
private
|
|
1205
|
+
private ensureAppLettermintMail;
|
|
1206
|
+
/**
|
|
1207
|
+
* #149: mints this org's own Lettermint relay credential and writes it to
|
|
1208
|
+
* Stalwart via `MailPort.applyOrgRelay`, the same mint rule
|
|
1209
|
+
* {@link ensureAppLettermintMail} already uses for the org's app token --
|
|
1210
|
+
* "healthy" means Stalwart's own route exists ({@link MailPort.hasOrgRelay}),
|
|
1211
|
+
* AND Lettermint lists a project named `<slug>-relay` whose id
|
|
1212
|
+
* {@link ORG_MAIL_RELAY_SCOPE_KEY} records for this
|
|
1213
|
+
* exact mail domain (review finding: a route with a secret and a
|
|
1214
|
+
* same-named project both present is not enough -- the project could be a
|
|
1215
|
+
* lost-and-reprovisioned one this step never wrote a route for, and a mail
|
|
1216
|
+
* domain switch must re-point the route rather than read as healthy
|
|
1217
|
+
* forever because the old route still has a secret in it). A route with no
|
|
1218
|
+
* live project behind it, a project the route's own secret never reached,
|
|
1219
|
+
* or a recorded project id/domain that no longer matches, are all exactly
|
|
1220
|
+
* as unhealthy as neither existing.
|
|
1221
|
+
*
|
|
1222
|
+
* A fresh project is created and its token written to Stalwart first, and
|
|
1223
|
+
* only then is every other `<slug>-relay` project deleted -- a lost route
|
|
1224
|
+
* and a duplicate project both end the same way. The new project's domain
|
|
1225
|
+
* restriction runs before the token is written, so a failure there never
|
|
1226
|
+
* touches the working route. Review finding: if either the
|
|
1227
|
+
* domain-restriction step or `applyOrgRelay` throws after a
|
|
1228
|
+
* fresh project was minted, that project is deleted before the error
|
|
1229
|
+
* propagates -- otherwise every retry sees more than one `<slug>-relay`
|
|
1230
|
+
* project and mints yet another one, growing the orphan count without
|
|
1231
|
+
* bound.
|
|
1232
|
+
*
|
|
1233
|
+
* `coolify:mail` runs before `coolify:app` (`diff.ts`'s own step order), so
|
|
1234
|
+
* this is very often the first of the two to reach the org's Lettermint
|
|
1235
|
+
* domain: {@link ensureLettermintDomainRestriction} registers and restricts
|
|
1236
|
+
* it the same way the app step does, rather than assuming it already
|
|
1237
|
+
* exists -- the domain must never end up unrestricted, whichever step gets
|
|
1238
|
+
* there first.
|
|
1239
|
+
*/
|
|
1240
|
+
private ensureOrgLettermintRelay;
|
|
1241
|
+
/**
|
|
1242
|
+
* Deletes a just-minted project from inside a `catch`. A failed delete is
|
|
1243
|
+
* dropped so it never replaces the error being rethrown; the orphan is
|
|
1244
|
+
* swept by the next apply's duplicate delete.
|
|
1245
|
+
*/
|
|
1246
|
+
private discardLettermintProject;
|
|
1247
|
+
/**
|
|
1248
|
+
* Ensures this org's Lettermint domain exists, is restricted to exactly
|
|
1249
|
+
* the `<slug>-app`/`<slug>-relay` projects live in `allProjects`, has its
|
|
1250
|
+
* DNS published and a verification triggered. Shared by
|
|
1251
|
+
* {@link ensureAppLettermintMail} and {@link ensureOrgLettermintRelay}
|
|
1252
|
+
* (#149) -- whichever of the two steps runs first on a fresh org creates
|
|
1253
|
+
* the domain, and the other then finds it (`findExactLettermintDomain`).
|
|
1254
|
+
*
|
|
1255
|
+
* A crash between the restriction write and the DNS publish/verify below
|
|
1256
|
+
* is safe even for a freshly created domain: DNS is only published, and
|
|
1257
|
+
* verification only triggered, after the restriction -- so an
|
|
1258
|
+
* unrestricted domain cannot verify and cannot send, whichever project
|
|
1259
|
+
* owns it (post-#140 review finding, restated here rather than only at
|
|
1260
|
+
* the one call site it used to have).
|
|
1261
|
+
*/
|
|
1262
|
+
private ensureLettermintDomainRestriction;
|
|
1263
|
+
/**
|
|
1264
|
+
* Review finding (post-#140 rework): {@link LettermintPort.findDomain}'s
|
|
1265
|
+
* own match is partial and case-insensitive, so a raw result can be a
|
|
1266
|
+
* lookalike (`notacme.com`, `mail.acme.com`) rather than `name` itself --
|
|
1267
|
+
* and every caller here full-replaces the found domain's project
|
|
1268
|
+
* restriction ({@link LettermintPort.restrictDomainToProjects}), so taking
|
|
1269
|
+
* a lookalike as a match would let it be taken over. Every domain lookup
|
|
1270
|
+
* in this class (`ensureAppLettermintMail`, `parkLettermintMail`,
|
|
1271
|
+
* `deleteStaleLettermintDomain`) goes through this instead of the port
|
|
1272
|
+
* directly, and treats anything but an exact, case-insensitive match as
|
|
1273
|
+
* not found.
|
|
1274
|
+
*/
|
|
1275
|
+
private findExactLettermintDomain;
|
|
900
1276
|
/**
|
|
901
1277
|
* `plan/diff.ts`'s `planSite` (#116, slicing #99's "provision the cms app,
|
|
902
1278
|
* database and site app per org"): the 2026-09-25 decision
|
|
@@ -1096,6 +1472,56 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
1096
1472
|
* `config.mjs`'s own regex requires.
|
|
1097
1473
|
*/
|
|
1098
1474
|
private ensureCallsBindingSecret;
|
|
1475
|
+
/**
|
|
1476
|
+
* `plan/diff.ts`'s `planAgora`: provisions the org's own Agora
|
|
1477
|
+
* (https://github.com/wtfalch/agora) as one more Coolify application, from
|
|
1478
|
+
* that repo's `apps/agora/Dockerfile` ({@link AGORA_REPO}'s doc comment),
|
|
1479
|
+
* and pushes its env. `findOrCreateCallsApp` is reused as-is for the
|
|
1480
|
+
* find-by-name-then-start-or-create shape, like {@link coolifyCallsService}.
|
|
1481
|
+
* There is no park branch: Agora is always on.
|
|
1482
|
+
*
|
|
1483
|
+
* The env comes from `plan/agora.ts`'s `desiredAgoraEnv`, the same function
|
|
1484
|
+
* the plan compares the live app against, so a re-plan after this step is
|
|
1485
|
+
* empty. The step never writes a partial config: when any input is missing
|
|
1486
|
+
* (the org's ZITADEL org id or Agora client id, or an issuer or
|
|
1487
|
+
* mail/messaging project id the host does not supply,
|
|
1488
|
+
* `AgoraHostPort`, read by the plan) it throws naming each one, before
|
|
1489
|
+
* it creates anything.
|
|
1490
|
+
*
|
|
1491
|
+
* `AGORA_CANONICAL_HOST` is always pushed. `AGORA_MAIL_UPSTREAM`/
|
|
1492
|
+
* `AGORA_FILES_UPSTREAM` are pushed once the platform's own mail/files
|
|
1493
|
+
* hosts are on and applied (#177's `platform-org.ts`'s
|
|
1494
|
+
* `agoraHostValuesFromPlatform`, read by `plan-apply.ts` when no host
|
|
1495
|
+
* `agora` port overrides it) -- absent otherwise. The chat upstreams are
|
|
1496
|
+
* still never pushed: chat has no admin API yet
|
|
1497
|
+
* (https://github.com/wtfalch/chat/issues/40). Agora answers 502 on those
|
|
1498
|
+
* routes rather than serving anything wrong.
|
|
1499
|
+
*/
|
|
1500
|
+
private coolifyAgora;
|
|
1501
|
+
/**
|
|
1502
|
+
* `plan/diff.ts`'s `planJobsRegistration`: registers the org's own app with
|
|
1503
|
+
* the platform's jobs scheduler (`JobsSchedulerPort.registerProject`, the
|
|
1504
|
+
* scheduler's `POST /admin/projects`), then hands the freshly minted
|
|
1505
|
+
* `secret` and `syncKey` to the host through `deliverSecret` -- but only
|
|
1506
|
+
* when this call actually minted them. The admin API is itself idempotent
|
|
1507
|
+
* by `(name, baseUrl)`: a repeat call for an already-registered project
|
|
1508
|
+
* resolves with just the `id`, and this never re-delivers a secret or sync
|
|
1509
|
+
* key it did not just mint, the same "registers an app once" rule
|
|
1510
|
+
* `diff.ts`'s own plan-level gate (recordedIds, mirroring `planOwner`)
|
|
1511
|
+
* already provides on a normal re-plan -- this check is what keeps that
|
|
1512
|
+
* true even if this step somehow runs twice on the same apply.
|
|
1513
|
+
*
|
|
1514
|
+
* Never logs, returns or records either credential (this file's own top
|
|
1515
|
+
* doc comment's "a secret is never a step's return value" rule) -- only the
|
|
1516
|
+
* scheduler's own non-secret project id comes back as `resourceId`.
|
|
1517
|
+
*/
|
|
1518
|
+
/** #177: the host's own port when it supplies one, else the platform's, built from its address and held key. */
|
|
1519
|
+
private filesPort;
|
|
1520
|
+
private aiPort;
|
|
1521
|
+
private jobsPort;
|
|
1522
|
+
private jobsRegistration;
|
|
1523
|
+
/** `deliverSecret`, tried up to {@link JOBS_DELIVERY_ATTEMPTS} times; true once one attempt succeeds. */
|
|
1524
|
+
private deliverWithRetry;
|
|
1099
1525
|
private cloudflareTunnel;
|
|
1100
1526
|
/**
|
|
1101
1527
|
* `plan/diff.ts`'s `planZone`: finds or creates the Cloudflare zone for the
|
|
@@ -1118,67 +1544,119 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
1118
1544
|
* `plan/diff.ts`'s `planMail`: gives an org mail, moves it between modes,
|
|
1119
1545
|
* or parks it on the one shared Stalwart instance (`this.ports.mail`,
|
|
1120
1546
|
* `@wtfalch/email/postmaster`'s `applyOrgMail`/`parkOrgMail` bound), then
|
|
1121
|
-
* publishes whatever DNS records the call returns,
|
|
1122
|
-
* mail domain
|
|
1123
|
-
*
|
|
1124
|
-
*
|
|
1125
|
-
*
|
|
1126
|
-
*
|
|
1127
|
-
*
|
|
1128
|
-
* (`
|
|
1547
|
+
* publishes whatever Stalwart DNS records the call returns, and records the
|
|
1548
|
+
* resolved mail domain as a scope id ({@link MAIL_DOMAIN_SCOPE_KEY}).
|
|
1549
|
+
*
|
|
1550
|
+
* #149: this step also mints the org's own Lettermint *relay* credential
|
|
1551
|
+
* ({@link ensureOrgLettermintRelay}) -- the app's own *sending* credential
|
|
1552
|
+
* stays in the app step (`ensureAppLettermintMail`, "Core rule"), since the
|
|
1553
|
+
* first app project can only exist once the token has a home. Parking mail
|
|
1554
|
+
* (mode `'off'`) reaches Lettermint too: {@link parkLettermintMail} drops
|
|
1555
|
+
* the relay route, then deletes the org's own projects and its domain, so
|
|
1556
|
+
* a domain never ends up restricted only to dead projects and no route
|
|
1557
|
+
* ever points at a project this apply is about to remove.
|
|
1558
|
+
*
|
|
1559
|
+
* Review finding: a mail domain switch (`org.domain` changes, or
|
|
1560
|
+
* `usesDomain` flips) must also delete the *old* Lettermint domain here,
|
|
1561
|
+
* before recording the new one -- otherwise it stays registered and
|
|
1562
|
+
* restricted to the org's still-live `<slug>-app`/`<slug>-relay` projects,
|
|
1563
|
+
* which could then keep sending as it forever. {@link deleteStaleLettermintDomain}
|
|
1564
|
+
* does this by comparing the previously recorded domain (case-insensitive)
|
|
1565
|
+
* against the one this apply just resolved.
|
|
1566
|
+
*
|
|
1567
|
+
* #162: once `applyOrgMail` has given the org a domain, {@link ensureOwnerMailbox}
|
|
1568
|
+
* gives its declared owner a mailbox on it, so Agora's Mail section is not
|
|
1569
|
+
* empty for them after the org's first apply.
|
|
1129
1570
|
*/
|
|
1130
1571
|
private coolifyMail;
|
|
1572
|
+
/**
|
|
1573
|
+
* `plan/diff.ts`'s `planMailCopy` (#113): this apply's share of copying
|
|
1574
|
+
* the org's old mail into Stalwart, via `mail-copy.ts`'s `runMailCopyStep`
|
|
1575
|
+
* -- the only thing this method itself does is read back what the last
|
|
1576
|
+
* apply recorded, hand it to that function, and record what comes back.
|
|
1577
|
+
* An account that failed to copy records the progress made, never
|
|
1578
|
+
* `mail_copy_done`, then throws with the failing account, phase and
|
|
1579
|
+
* redacted stderr tail, so the apply reports it as not done. A missing
|
|
1580
|
+
* `mailCopy` port throws too: a configuration gap a retry cannot fix.
|
|
1581
|
+
*/
|
|
1582
|
+
private mailCopy;
|
|
1583
|
+
/**
|
|
1584
|
+
* #162 (decided 2026-09-29): gives the org's declared owner a mailbox at
|
|
1585
|
+
* its declared `mail.ownerMailboxAddress` -- nothing is guessed any more
|
|
1586
|
+
* (the hardcoded `owner@<domain>` PR 163 shipped first is gone). A no-op
|
|
1587
|
+
* when the org has no owner yet, when no address is declared yet
|
|
1588
|
+
* (`plan/diff.ts`'s `planMail` reports that case as blocked, not planned,
|
|
1589
|
+
* so this step's own mode/domain work still runs), or once
|
|
1590
|
+
* {@link MAIL_OWNER_MAILBOX_SCOPE_KEY} is already recorded -- checked last
|
|
1591
|
+
* so a second apply never calls `MailPort.createMailbox` again, rather
|
|
1592
|
+
* than relying on that port method's own duplicate refusal.
|
|
1593
|
+
*/
|
|
1594
|
+
private ensureOwnerMailbox;
|
|
1595
|
+
/**
|
|
1596
|
+
* #230: creates each of the org's declared `mail.roleMailboxes` on its
|
|
1597
|
+
* just-resolved mail domain, once. A role mailbox has no person behind it,
|
|
1598
|
+
* so its display name is just its local part, and like the owner's it is
|
|
1599
|
+
* created with no password: `MailPort.createMailbox` takes none, so there
|
|
1600
|
+
* is no secret to store or print. A "already has a mailbox" refusal from the port counts as created. The created set is recorded under
|
|
1601
|
+
* {@link MAIL_ROLE_MAILBOXES_SCOPE_KEY} after every mailbox, so a second
|
|
1602
|
+
* apply (or a retry after a crash) skips what exists rather than relying
|
|
1603
|
+
* on the mail server's own duplicate refusal.
|
|
1604
|
+
*/
|
|
1605
|
+
private ensureRoleMailboxes;
|
|
1606
|
+
/**
|
|
1607
|
+
* Deletes the org's previous Lettermint domain once the mail domain it was
|
|
1608
|
+
* registered under has changed -- otherwise it stays registered and
|
|
1609
|
+
* restricted to the org's still-live `<slug>-app` project after the switch
|
|
1610
|
+
* (review finding, post-#140 rework). A no-op on first apply (no previous
|
|
1611
|
+
* domain recorded) and on a same-domain re-apply (compared
|
|
1612
|
+
* case-insensitively, the same rule {@link findExactLettermintDomain}
|
|
1613
|
+
* applies to the lookup itself).
|
|
1614
|
+
*/
|
|
1615
|
+
private deleteStaleLettermintDomain;
|
|
1131
1616
|
/**
|
|
1132
1617
|
* Upserts each DNS record `applyOrgMail` says foundry must publish, found
|
|
1133
1618
|
* by an exact name+type match (`CloudflarePort.findDnsRecord`'s own doc
|
|
1134
1619
|
* comment) so update never mistakes a stale record of a different type for
|
|
1135
1620
|
* a match. A `DKIM` record can be absent on the first call, since Stalwart
|
|
1136
|
-
* mints the key on a background task; the next apply publishes it.
|
|
1137
|
-
* the zone id so `registerResendDomain` publishes Resend's own records on
|
|
1138
|
-
* the same zone without a second lookup.
|
|
1621
|
+
* mints the key on a background task; the next apply publishes it.
|
|
1139
1622
|
*/
|
|
1140
1623
|
private publishMailDnsRecords;
|
|
1141
1624
|
/**
|
|
1142
|
-
*
|
|
1143
|
-
*
|
|
1144
|
-
*
|
|
1145
|
-
*
|
|
1146
|
-
*
|
|
1147
|
-
*
|
|
1148
|
-
*
|
|
1149
|
-
*
|
|
1150
|
-
* the
|
|
1151
|
-
*
|
|
1152
|
-
*
|
|
1153
|
-
*
|
|
1154
|
-
* (`coolifyApp`) can mint the app's own `<slug>-app@<domainId>` key once the app
|
|
1155
|
-
* exists, without this step or the app step calling Resend to look the
|
|
1156
|
-
* domain back up.
|
|
1157
|
-
*/
|
|
1158
|
-
private registerResendDomain;
|
|
1159
|
-
/**
|
|
1160
|
-
* Upserts each DNS record Resend's domain names, the same find-then-upsert
|
|
1161
|
-
* `publishMailDnsRecords` above uses for Stalwart's own records -- except
|
|
1162
|
-
* SPF is merged into whatever record that call just published rather than
|
|
1163
|
-
* created a second time (RFC 7208 treats two `v=spf1` TXT records on one
|
|
1164
|
-
* name as a permerror and silently breaks SPF for every sender on the
|
|
1165
|
-
* domain), and an existing `_dmarc` record is never overwritten. Prior art:
|
|
1166
|
-
* `wtfalch/app-template`'s `bootstrap/steps/resend-email.ts`.
|
|
1625
|
+
* Parking mail (2026-09-28 lettermint-org-mail swap, "Core rule": "Parking
|
|
1626
|
+
* mail deletes the org's projects and then the domain itself, so no
|
|
1627
|
+
* restriction points only at dead projects"). #149: first drops the
|
|
1628
|
+
* Stalwart relay route with `MailPort.removeOrgRelay` -- a missing route is
|
|
1629
|
+
* a no-op, per that port method's own contract, so this is safe to call
|
|
1630
|
+
* even for an org whose relay was never minted -- then deletes every
|
|
1631
|
+
* Lettermint project named `<slug>-app` or `<slug>-relay`, then the org's
|
|
1632
|
+
* own Lettermint domain, if one was found -- in that order, never the
|
|
1633
|
+
* reverse, so the route is always gone before the project it pointed at.
|
|
1634
|
+
* A no-op until a previous apply recorded {@link MAIL_DOMAIN_SCOPE_KEY}:
|
|
1635
|
+
* nothing was ever provisioned for an org whose mail went straight from
|
|
1636
|
+
* never-applied to off.
|
|
1167
1637
|
*/
|
|
1168
|
-
private
|
|
1638
|
+
private parkLettermintMail;
|
|
1169
1639
|
/**
|
|
1170
|
-
*
|
|
1171
|
-
* `publishMailDnsRecords`
|
|
1172
|
-
*
|
|
1173
|
-
*
|
|
1640
|
+
* Upserts each DNS record Lettermint's domain names, the same find-then-
|
|
1641
|
+
* upsert `publishMailDnsRecords` above uses for Stalwart's own records --
|
|
1642
|
+
* except an existing `_dmarc` record is never overwritten (a stricter
|
|
1643
|
+
* policy an org already set, `p=quarantine`/`p=reject`, should not be
|
|
1644
|
+
* relaxed back to Lettermint's own default). Resend's own domain also
|
|
1645
|
+
* carried an apex `SPF` TXT record that had to be merged into whatever SPF
|
|
1646
|
+
* record already existed there (RFC 7208: two `v=spf1` TXT records on one
|
|
1647
|
+
* name is a permerror); Lettermint's own DNS records
|
|
1648
|
+
* (`.plans/2026-09-28-lettermint-api.md` §3) have no such record -- its
|
|
1649
|
+
* `purpose` enum is `return_path`/`dmarc`/`dkim_legacy`/`dkim_primary`/
|
|
1650
|
+
* `dkim_secondary`, none of them SPF -- so that merge has no case left to
|
|
1651
|
+
* handle here and is dropped rather than kept as dead code.
|
|
1174
1652
|
*/
|
|
1175
|
-
private
|
|
1653
|
+
private publishLettermintDnsRecords;
|
|
1176
1654
|
/**
|
|
1177
1655
|
* Never overwrites an existing `_dmarc` record -- a stricter policy an org
|
|
1178
1656
|
* already set (`p=quarantine`/`p=reject`) should not be relaxed back to
|
|
1179
|
-
*
|
|
1657
|
+
* Lettermint's own `p=none` default.
|
|
1180
1658
|
*/
|
|
1181
|
-
private
|
|
1659
|
+
private ensureLettermintDmarcRecord;
|
|
1182
1660
|
/**
|
|
1183
1661
|
* #80: provisions the org's own organisation on Files, before
|
|
1184
1662
|
* {@link filesStorage} can set its spend limit. `provisionOrganisation` is
|
|
@@ -1197,6 +1675,16 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
1197
1675
|
*/
|
|
1198
1676
|
private aiOrganisation;
|
|
1199
1677
|
private aiBudget;
|
|
1678
|
+
/**
|
|
1679
|
+
* #80 step 3: mints the org's own ai key, once, and hands its one-time
|
|
1680
|
+
* secret straight to {@link FoundryStepExecutorPorts.deliverSecret} --
|
|
1681
|
+
* never returned, stored in `serviceIds` or logged, the same discipline
|
|
1682
|
+
* `zitadelApp`'s own `created.clientSecret` follows. `diff.ts`'s
|
|
1683
|
+
* `planAiKey` never plans this step again once its resource id
|
|
1684
|
+
* (the key's id, not its secret) is recorded, since ai's own API offers no
|
|
1685
|
+
* way to check for an already-minted key.
|
|
1686
|
+
*/
|
|
1687
|
+
private aiKey;
|
|
1200
1688
|
/**
|
|
1201
1689
|
* {@link StepDestroyer.destroy}: deletes the one resource
|
|
1202
1690
|
* `FoundryPlanApply.destroy` names. A provider 404 (already gone) counts as
|
|
@@ -1209,3 +1697,23 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
1209
1697
|
readonly recordedIds: Readonly<Record<string, string>>;
|
|
1210
1698
|
}): Promise<void>;
|
|
1211
1699
|
}
|
|
1700
|
+
/**
|
|
1701
|
+
* True for a provider's own "already gone" response, however it surfaces --
|
|
1702
|
+
* `@wtfalch/providers`' `ZitadelError` carries a structured `status: number`
|
|
1703
|
+
* (`zitadel.ts`), while its Coolify and Cloudflare clients throw a plain
|
|
1704
|
+
* `Error` whose message embeds the HTTP status (`` `Coolify ${method} ${path}
|
|
1705
|
+
* → ${status}: ${text}` ``, `` `CF ${method} ${path} → ${status}: ${msg}` ``).
|
|
1706
|
+
* Ports are structural here (this file's own top comment: never import a
|
|
1707
|
+
* concrete provider class), so this cannot `instanceof`-check a real error
|
|
1708
|
+
* type -- it duck-types a `status` field first.
|
|
1709
|
+
*
|
|
1710
|
+
* A prior version fell back to scanning the *whole* message for a standalone
|
|
1711
|
+
* `404` when no `status` field was present. That is unsafe: the Coolify and
|
|
1712
|
+
* Cloudflare clients put the resource path -- which can contain a hex UUID
|
|
1713
|
+
* -- ahead of the status in the message, so a UUID that happens to contain
|
|
1714
|
+
* the digits `404` (`9a404b12-...`) made a genuine 500 read as a 404. The
|
|
1715
|
+
* fallback below instead anchors to the exact position both clients put the
|
|
1716
|
+
* status in: the first `→ `, after the client, method and path, and before
|
|
1717
|
+
* `:`. It never scans the path or the response body.
|
|
1718
|
+
*/
|
|
1719
|
+
export declare function isNotFoundError(error: unknown): boolean;
|