@wtfalch/foundry 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +22 -4
- package/dist/calls-sfu.js +22 -4
- package/dist/index.d.ts +31 -6
- package/dist/index.js +16 -3
- package/dist/migrations/0014_calls_enabled.sql +7 -0
- package/dist/migrations/0015_domain_unique.sql +39 -0
- 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.d.ts +18 -2
- package/dist/org.js +56 -0
- package/dist/plan/agora.d.ts +77 -0
- package/dist/plan/agora.js +127 -0
- package/dist/plan/calls-matrix.d.ts +135 -0
- package/dist/plan/calls-matrix.js +275 -0
- package/dist/plan/cloudflared-connector.d.ts +70 -0
- package/dist/plan/cloudflared-connector.js +106 -0
- package/dist/plan/diff.d.ts +26 -5
- package/dist/plan/diff.js +720 -106
- package/dist/plan/executor.d.ts +731 -36
- package/dist/plan/executor.js +1180 -41
- 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 +290 -14
- 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 +21 -2
- package/dist/store/audit.js +4 -2
- package/dist/store/postgres.d.ts +40 -7
- package/dist/store/postgres.js +257 -29
- package/dist/store/schema.d.ts +700 -0
- package/dist/store/schema.js +58 -0
- package/dist/store.d.ts +56 -0
- package/package.json +2 -2
package/dist/plan/executor.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
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
|
+
import type { DeclaredOrgStore } from '../store.js';
|
|
3
4
|
import type { StepAction } from './diff.js';
|
|
4
5
|
import type { DestroyTarget, StepDestroyer, StepExecutionContext, StepExecutor } from './plan-apply.js';
|
|
5
6
|
/**
|
|
@@ -59,6 +60,7 @@ export interface CoolifyPort {
|
|
|
59
60
|
uuid: string;
|
|
60
61
|
is_usable: boolean;
|
|
61
62
|
is_reachable: boolean;
|
|
63
|
+
ip?: string;
|
|
62
64
|
}>>;
|
|
63
65
|
requireGithubApp(preferredUuid?: string): Promise<{
|
|
64
66
|
uuid: string;
|
|
@@ -104,6 +106,7 @@ export interface CoolifyPort {
|
|
|
104
106
|
name: string;
|
|
105
107
|
postgres_db: string;
|
|
106
108
|
postgres_user: string;
|
|
109
|
+
postgres_initdb_args?: string;
|
|
107
110
|
}): Promise<{
|
|
108
111
|
uuid: string;
|
|
109
112
|
internal_db_url: string;
|
|
@@ -199,6 +202,83 @@ export interface CoolifyPort {
|
|
|
199
202
|
deleteApplication(uuid: string): Promise<unknown>;
|
|
200
203
|
/** `FoundryPlanApply.destroy`'s teardown of the org's own database. */
|
|
201
204
|
deleteDatabase(uuid: string): Promise<unknown>;
|
|
205
|
+
/**
|
|
206
|
+
* An application that runs a published image as-is (#128, `coolifyCalls`'s
|
|
207
|
+
* own Postgres) — matches `@wtfalch/providers`' `CoolifyClient.
|
|
208
|
+
* createDockerImageApplication` method-for-method.
|
|
209
|
+
*/
|
|
210
|
+
createDockerImageApplication(input: {
|
|
211
|
+
project_uuid: string;
|
|
212
|
+
server_uuid: string;
|
|
213
|
+
environment_name: string;
|
|
214
|
+
environment_uuid: string;
|
|
215
|
+
docker_registry_image_name: string;
|
|
216
|
+
docker_registry_image_tag: string;
|
|
217
|
+
ports_exposes: string;
|
|
218
|
+
name?: string;
|
|
219
|
+
description?: string;
|
|
220
|
+
instant_deploy?: boolean;
|
|
221
|
+
/**
|
|
222
|
+
* `host:container`, comma separated -- publishes host ports directly
|
|
223
|
+
* (`@wtfalch/providers`' `CoolifyClient.createDockerImageApplication`
|
|
224
|
+
* doc comment: "a tunnelled web app never needs and a mail server cannot
|
|
225
|
+
* do without"). Used by `platform-providers.ts`'s `provisionPlatformMail`
|
|
226
|
+
* (#169) for Stalwart's SMTP/submission ports, which cannot go through
|
|
227
|
+
* Cloudflare's HTTP-only tunnel the way `platform:auth`'s apps do.
|
|
228
|
+
*/
|
|
229
|
+
ports_mappings?: string;
|
|
230
|
+
}): Promise<{
|
|
231
|
+
uuid: string;
|
|
232
|
+
}>;
|
|
233
|
+
/**
|
|
234
|
+
* An application built from an inline Dockerfile (#128, `coolifyCalls`'s own
|
|
235
|
+
* Synapse and MAS — neither's published image takes the module/command this
|
|
236
|
+
* stack needs from a plain image application) — matches `CoolifyClient.
|
|
237
|
+
* createDockerfileApplication` method-for-method.
|
|
238
|
+
*/
|
|
239
|
+
createDockerfileApplication(input: {
|
|
240
|
+
project_uuid: string;
|
|
241
|
+
server_uuid: string;
|
|
242
|
+
environment_name: string;
|
|
243
|
+
environment_uuid: string;
|
|
244
|
+
dockerfile: string;
|
|
245
|
+
name?: string;
|
|
246
|
+
description?: string;
|
|
247
|
+
instant_deploy?: boolean;
|
|
248
|
+
}): Promise<{
|
|
249
|
+
uuid: string;
|
|
250
|
+
}>;
|
|
251
|
+
/**
|
|
252
|
+
* A single file mounted into the container (#128) — how `homeserver.yaml`
|
|
253
|
+
* and MAS's `config.yaml` reach a Docker-image application with no git
|
|
254
|
+
* repository of its own to carry them. Matches `CoolifyClient.
|
|
255
|
+
* createFileStorage` method-for-method.
|
|
256
|
+
*/
|
|
257
|
+
createFileStorage(applicationUuid: string, input: {
|
|
258
|
+
mount_path: string;
|
|
259
|
+
content: string;
|
|
260
|
+
}): Promise<unknown>;
|
|
261
|
+
/** Updates an existing file storage's content in place. Matches `CoolifyClient.updateFileStorage`. */
|
|
262
|
+
updateFileStorage(applicationUuid: string, storageUuid: string, content: string): Promise<unknown>;
|
|
263
|
+
/**
|
|
264
|
+
* A generic application PATCH (#128) — `coolifyCalls` uses this to give
|
|
265
|
+
* Postgres/Synapse/MAS a fixed `custom_internal_name` so the other two can
|
|
266
|
+
* address each other by a name that never changes across a re-run, instead
|
|
267
|
+
* of guessing at Coolify's own container-naming scheme. Matches
|
|
268
|
+
* `CoolifyClient.updateApplication` method-for-method.
|
|
269
|
+
*/
|
|
270
|
+
updateApplication(uuid: string, patch: Record<string, unknown>): Promise<unknown>;
|
|
271
|
+
/**
|
|
272
|
+
* Queues a deployment (#128) — `coolifyCalls` calls this after writing a
|
|
273
|
+
* fresh Synapse/MAS config file, since a file-storage write alone does not
|
|
274
|
+
* reach the running container until its next deploy (issue #128's "a
|
|
275
|
+
* running app is redeployed when its config changed"). Fire-and-forget,
|
|
276
|
+
* like `restart` above: this executor does not poll a deployment to
|
|
277
|
+
* completion anywhere else, and `coolifyCalls` only calls this when the
|
|
278
|
+
* config it just wrote was actually freshly minted. Matches `CoolifyClient.
|
|
279
|
+
* deploy` method-for-method.
|
|
280
|
+
*/
|
|
281
|
+
deploy(uuid: string, force?: boolean): Promise<unknown>;
|
|
202
282
|
}
|
|
203
283
|
/**
|
|
204
284
|
* The Cloudflare calls this executor makes, shaped to match
|
|
@@ -222,6 +302,15 @@ export interface CloudflarePort {
|
|
|
222
302
|
name: string;
|
|
223
303
|
}>;
|
|
224
304
|
putTunnelConfig(tunnelId: string, config: unknown): Promise<unknown>;
|
|
305
|
+
/**
|
|
306
|
+
* The tunnel's connector token (#197) -- a `cloudflared` container reads
|
|
307
|
+
* this at boot to dial into the tunnel. A live credential: matches
|
|
308
|
+
* `@wtfalch/providers`' `CloudflareClient.getTunnelToken` method-for-method.
|
|
309
|
+
* A caller must hand this straight to `bulkUpsertEnvs` and nowhere else --
|
|
310
|
+
* never a log, an error, a return value or a recorded scope id (see
|
|
311
|
+
* `cloudflared-connector.ts`'s `ensureCloudflaredConnector`).
|
|
312
|
+
*/
|
|
313
|
+
getTunnelToken(tunnelId: string): Promise<string>;
|
|
225
314
|
/** `FoundryPlanApply.destroy`'s teardown of the org's own Cloudflare tunnel. */
|
|
226
315
|
deleteTunnel(tunnelId: string): Promise<void>;
|
|
227
316
|
/** `cloudflareAccess` finds the org's Access app by domain before creating one (#84). */
|
|
@@ -240,6 +329,7 @@ export interface CloudflarePort {
|
|
|
240
329
|
listAccessPolicies(appId: string): Promise<Array<{
|
|
241
330
|
id: string;
|
|
242
331
|
name: string;
|
|
332
|
+
include?: unknown[];
|
|
243
333
|
}>>;
|
|
244
334
|
createAccessPolicy(appId: string, policy: {
|
|
245
335
|
name: string;
|
|
@@ -300,6 +390,105 @@ export interface CloudflarePort {
|
|
|
300
390
|
*/
|
|
301
391
|
request<T>(method: string, path: string, body?: unknown, missingOkay?: boolean): Promise<T>;
|
|
302
392
|
}
|
|
393
|
+
/**
|
|
394
|
+
* One DNS record a Lettermint domain's own response says must be published --
|
|
395
|
+
* `purpose` is Lettermint's own label (`return_path`, `dmarc`, `dkim_legacy`,
|
|
396
|
+
* `dkim_primary`, `dkim_secondary`), replacing Resend's `record` field, from
|
|
397
|
+
* {@link LettermintPort.createDomain}/{@link LettermintPort.getDomain}, the
|
|
398
|
+
* same shape https://lettermint.co/docs/api-reference/team/0.0.1/lettermint-team-openapi.json's
|
|
399
|
+
* `DomainDnsRecordData` documents (`fqdn`/`content` renamed to this port's own
|
|
400
|
+
* `name`/`value`, matching {@link publishLettermintDnsRecords}'s sibling
|
|
401
|
+
* `publishMailDnsRecords` naming).
|
|
402
|
+
*/
|
|
403
|
+
export interface LettermintDnsRecordSpec {
|
|
404
|
+
readonly purpose: 'return_path' | 'dmarc' | 'dkim_legacy' | 'dkim_primary' | 'dkim_secondary';
|
|
405
|
+
readonly type: 'TXT' | 'CNAME' | 'MX';
|
|
406
|
+
readonly name: string;
|
|
407
|
+
readonly value: string;
|
|
408
|
+
}
|
|
409
|
+
export interface LettermintDomain {
|
|
410
|
+
readonly id: string;
|
|
411
|
+
readonly domain: string;
|
|
412
|
+
readonly status: 'verified' | 'partially_verified' | 'pending_verification' | 'failed_verification';
|
|
413
|
+
readonly dnsRecords: readonly LettermintDnsRecordSpec[];
|
|
414
|
+
}
|
|
415
|
+
export interface LettermintProject {
|
|
416
|
+
readonly id: string;
|
|
417
|
+
readonly name: string;
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* The Lettermint calls this executor makes to verify an org's mail domain
|
|
421
|
+
* (#138) and mint its app's sending project (#140 rework, 2026-09-28
|
|
422
|
+
* lettermint-org-mail swap) -- the shared Stalwart relays every outbound
|
|
423
|
+
* message through Lettermint
|
|
424
|
+
* (https://github.com/wtfalch/email/blob/main/packages/email/src/postmaster/objects.ts#L149-L168),
|
|
425
|
+
* which refuses mail from a domain it has not verified. `@wtfalch/providers`
|
|
426
|
+
* has no Lettermint client yet, unlike Coolify/Cloudflare/ZITADEL above, so
|
|
427
|
+
* this port names Lettermint's own REST shapes directly
|
|
428
|
+
* (`.plans/2026-09-28-lettermint-api.md`, its own primary-source citations).
|
|
429
|
+
* Replaces `ResendPort` (#138/#140), trimmed to what `coolifyApp`/`coolifyMail`
|
|
430
|
+
* below actually call.
|
|
431
|
+
*
|
|
432
|
+
* Unlike Resend's per-domain sending key, a Lettermint domain is
|
|
433
|
+
* unrestricted by default -- any project in the team could send from it --
|
|
434
|
+
* so a project's own domain access is a separate call
|
|
435
|
+
* ({@link restrictDomainToProjects}), and the credential unit is a *project*,
|
|
436
|
+
* not a per-domain key: Lettermint's API mints, at most, one live token per
|
|
437
|
+
* project and has no call to list or revoke an individual token within one
|
|
438
|
+
* (`.plans/2026-09-28-lettermint-api.md` §4), so `<slug>-app` is a project
|
|
439
|
+
* name, never suffixed with a domain id the way Resend's key name was.
|
|
440
|
+
*/
|
|
441
|
+
export interface LettermintPort {
|
|
442
|
+
/**
|
|
443
|
+
* Lettermint's own `filter[domain]` match is partial and case-insensitive
|
|
444
|
+
* (`.plans/2026-09-28-lettermint-api.md` §3: "partial, case-insensitive"),
|
|
445
|
+
* so a result here can be a lookalike (`notacme.com`, `mail.acme.com`) --
|
|
446
|
+
* `domain` is returned precisely so every caller can reject one via
|
|
447
|
+
* `FoundryStepExecutor.findExactLettermintDomain` rather than a listing
|
|
448
|
+
* scan of its own.
|
|
449
|
+
*/
|
|
450
|
+
findDomain(name: string): Promise<{
|
|
451
|
+
id: string;
|
|
452
|
+
domain: string;
|
|
453
|
+
status: LettermintDomain['status'];
|
|
454
|
+
} | undefined>;
|
|
455
|
+
/** `POST /domains`, its response already carrying `dnsRecords`. */
|
|
456
|
+
createDomain(name: string): Promise<LettermintDomain>;
|
|
457
|
+
/** Re-reads a domain already found by {@link findDomain}, for its current DNS records. */
|
|
458
|
+
getDomain(id: string): Promise<LettermintDomain>;
|
|
459
|
+
/**
|
|
460
|
+
* Asks Lettermint to re-check DNS now. Never polled or awaited to
|
|
461
|
+
* completion -- the same "do not block the plan on DNS propagation" rule
|
|
462
|
+
* `ResendPort.verifyDomain` followed: record the status, and let the next
|
|
463
|
+
* apply re-check it.
|
|
464
|
+
*/
|
|
465
|
+
verifyDomain(id: string): Promise<{
|
|
466
|
+
status: LettermintDomain['status'];
|
|
467
|
+
}>;
|
|
468
|
+
/** `parkLettermintMail`'s own cleanup once an org's mail is parked (mode `'off'`). */
|
|
469
|
+
deleteDomain(id: string): Promise<void>;
|
|
470
|
+
/**
|
|
471
|
+
* `POST /projects`. `token` is the one-time secret -- Lettermint shows a
|
|
472
|
+
* project's token once, at creation, and the API has no call to mint a
|
|
473
|
+
* second one on the same project -- so a caller that loses it creates a
|
|
474
|
+
* fresh project instead (`ensureAppLettermintMail`'s own mint rule).
|
|
475
|
+
*/
|
|
476
|
+
createProject(name: string): Promise<{
|
|
477
|
+
project: LettermintProject;
|
|
478
|
+
token: string;
|
|
479
|
+
}>;
|
|
480
|
+
/** Every project in the team, `id`+`name` only -- the mint rule's own health check and `parkLettermintMail`'s orphan scan both filter this by name themselves. */
|
|
481
|
+
listProjects(): Promise<readonly LettermintProject[]>;
|
|
482
|
+
/** Deletes one project by id -- the mint rule's own cleanup of an orphan or a duplicate, and `parkLettermintMail`'s teardown. */
|
|
483
|
+
deleteProject(id: string): Promise<void>;
|
|
484
|
+
/**
|
|
485
|
+
* `PUT /domains/{domainId}/projects`. Rebuilds the domain's whole
|
|
486
|
+
* restriction from `projectIds` -- never patched -- so a domain never ends
|
|
487
|
+
* up unrestricted (any team project could then send as the org) nor
|
|
488
|
+
* restricted only to a project already deleted.
|
|
489
|
+
*/
|
|
490
|
+
restrictDomainToProjects(domainId: string, projectIds: readonly string[]): Promise<void>;
|
|
491
|
+
}
|
|
303
492
|
/**
|
|
304
493
|
* The Stalwart calls this executor makes for one org's mail, shaped to match
|
|
305
494
|
* `@wtfalch/email/postmaster`'s `applyOrgMail`/`parkOrgMail` method-for-method
|
|
@@ -309,6 +498,44 @@ export interface CloudflarePort {
|
|
|
309
498
|
export interface MailPort {
|
|
310
499
|
applyOrgMail(org: OrgMailOrg, options: OrgMailOptions): Promise<OrgMailResult>;
|
|
311
500
|
parkOrgMail(org: OrgMailOrg, options: OrgMailOptions): Promise<void>;
|
|
501
|
+
/**
|
|
502
|
+
* Gives one person a mailbox on the org's already-resolved mail domain
|
|
503
|
+
* (#162). The host implements this with `@wtfalch/email/postmaster`'s own
|
|
504
|
+
* `createMailbox`, resolving `org`'s domain to a `domainId` the same way
|
|
505
|
+
* `applyOrgMail` does -- that function already refuses a duplicate `local`
|
|
506
|
+
* on the resolved domain, so this port method never has to. `ensureOwnerMailbox`
|
|
507
|
+
* below is this executor's one caller, and it checks its own recorded scope
|
|
508
|
+
* id first, so a second apply never calls this at all.
|
|
509
|
+
*/
|
|
510
|
+
createMailbox(org: OrgMailOrg, mailbox: {
|
|
511
|
+
address: string;
|
|
512
|
+
name: string;
|
|
513
|
+
}): Promise<void>;
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* The shared calls SFU's own LiveKit API credentials (#122, #127, #145).
|
|
517
|
+
* Before #170, a human minted this pair by hand and `resolveCredentials`
|
|
518
|
+
* read wherever `scripts/calls-sfu-deploy.mjs`'s own `CallsSfuConfig`
|
|
519
|
+
* delivered it from -- this package never minted or invented a LiveKit
|
|
520
|
+
* key/secret pair itself.
|
|
521
|
+
*
|
|
522
|
+
* As of #170 the platform's own `sfu:livekit` step
|
|
523
|
+
* (`plan/platform-sfu.ts`'s `applyCallsSfuPlatformStep`) mints the pair, the
|
|
524
|
+
* first time it applies, and delivers it to held keys through
|
|
525
|
+
* `CallsSfuKeysPort.deliverLivekitKeys`. `resolveCredentials` reads that same
|
|
526
|
+
* held-keys entry back -- still resolved by the host (archon), never minted
|
|
527
|
+
* or read directly by this port itself, the same "the executor is
|
|
528
|
+
* constructed with narrow ports ... a host supplies real clients" rule this
|
|
529
|
+
* file's own top doc comment states for every provider credential.
|
|
530
|
+
* `resolveCredentials` returns `null` before the platform's `sfu` step has
|
|
531
|
+
* ever applied, the same "nothing recorded" signal a bare
|
|
532
|
+
* `CoolifyPort.listEnvs` gives `ensureCallsMatrixSecrets`.
|
|
533
|
+
*/
|
|
534
|
+
export interface CallsSfuPort {
|
|
535
|
+
resolveCredentials(): Promise<{
|
|
536
|
+
apiKey: string;
|
|
537
|
+
apiSecret: string;
|
|
538
|
+
} | null>;
|
|
312
539
|
}
|
|
313
540
|
/**
|
|
314
541
|
* The ZITADEL calls this executor makes. `findOrCreateOrg`/`findOrCreateProject`
|
|
@@ -359,6 +586,23 @@ export interface ZitadelPort {
|
|
|
359
586
|
clientSecret: string;
|
|
360
587
|
}>;
|
|
361
588
|
deleteApp(orgId: string, projectId: string, appId: string): Promise<void>;
|
|
589
|
+
/**
|
|
590
|
+
* `zitadelApp`'s kind-change path reads this to find an app a prior,
|
|
591
|
+
* lost-response create already made under the new name, before calling
|
|
592
|
+
* `createOidcApp`/`createApiApp` again and orphaning that one under the
|
|
593
|
+
* same name. Matches `@wtfalch/providers/zitadel`'s `ZitadelClient.
|
|
594
|
+
* listApps` return shape exactly, so that client satisfies this port with
|
|
595
|
+
* no adapter -- it does not carry `clientId`/`clientSecret` (that provider
|
|
596
|
+
* method never parses either, and a secret is minted once, on creation,
|
|
597
|
+
* never handed back again), which is why the reuse path below cannot
|
|
598
|
+
* re-deliver either one.
|
|
599
|
+
*/
|
|
600
|
+
listApps(orgId: string, projectId: string): Promise<Array<{
|
|
601
|
+
id: string;
|
|
602
|
+
name: string;
|
|
603
|
+
appType: 'web' | 'native' | 'api';
|
|
604
|
+
state: string;
|
|
605
|
+
}>>;
|
|
362
606
|
/**
|
|
363
607
|
* The org-owner flow (goal line): find-or-create the human user by email in
|
|
364
608
|
* the org, then have ZITADEL email them an invite to set up a passkey.
|
|
@@ -389,26 +633,37 @@ export interface ZitadelPort {
|
|
|
389
633
|
deleteProject(orgId: string, projectId: string): Promise<void>;
|
|
390
634
|
/** `FoundryPlanApply.destroy`'s last step: removes the whole ZITADEL org and everything foundry made inside it. */
|
|
391
635
|
deleteOrg(orgId: string): Promise<void>;
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
636
|
+
/**
|
|
637
|
+
* #168: mints foundry's own machine user, on the platform's own freshly
|
|
638
|
+
* built ZITADEL instance, with the instance-wide admin role, and a fresh
|
|
639
|
+
* Personal Access Token for it -- "Foundry mints this key; nobody pastes
|
|
640
|
+
* it." `orgId` is the ZITADEL org the machine user lives in (every ZITADEL
|
|
641
|
+
* user belongs to some org, the same shape {@link inviteOrgOwner} already
|
|
642
|
+
* has for a human, even though the IAM_OWNER role this user gets is
|
|
643
|
+
* instance-wide once granted, not scoped to that org). Idempotent on the
|
|
644
|
+
* user itself -- a real client finds or creates it by `userName` -- but
|
|
645
|
+
* never on the token: ZITADEL shows a PAT's value once, at mint, and never
|
|
646
|
+
* again, the same "minted once, delivered once" rule {@link createApiApp}'s
|
|
647
|
+
* own doc comment follows for a client secret. The caller
|
|
648
|
+
* (`platform-providers.ts`'s `FoundryPlatformStepExecutor`) hands the token
|
|
649
|
+
* straight to {@link FoundryStepExecutorPorts.deliverSecret} and never
|
|
650
|
+
* returns, stores or logs it, and only calls this once per platform,
|
|
651
|
+
* guarded by its own recorded scope id -- never by anything this method
|
|
652
|
+
* does internally.
|
|
653
|
+
*
|
|
654
|
+
* `@wtfalch/providers` has no machine-user support at all yet (checked
|
|
655
|
+
* 2026-09-29) -- "being added to providers in parallel", the same
|
|
656
|
+
* precedent {@link createOidcApp}/{@link createApiApp} set above -- so this
|
|
657
|
+
* port states the shape it needs to land with; a real client that gains
|
|
658
|
+
* exactly this method satisfies it with no adapter.
|
|
659
|
+
*/
|
|
660
|
+
mintAdminMachineUser(orgId: string, input: {
|
|
661
|
+
userName: string;
|
|
662
|
+
name: string;
|
|
663
|
+
}): Promise<{
|
|
664
|
+
userId: string;
|
|
665
|
+
token: string;
|
|
666
|
+
}>;
|
|
412
667
|
}
|
|
413
668
|
/**
|
|
414
669
|
* Provisions one org's organisation on Files (#80), matching
|
|
@@ -416,9 +671,8 @@ export interface AiOrgLimits {
|
|
|
416
671
|
* `createFilesManagementClient().provisionOrganisation`
|
|
417
672
|
* (`ProvisionOrganisationInput`/`ProvisionOrganisationResult`,
|
|
418
673
|
* `files/packages/sdk/src/types.ts:146-156`). Flat, not a factory, like
|
|
419
|
-
* {@link ZitadelPort}: `organisationId` is an explicit call argument
|
|
420
|
-
*
|
|
421
|
-
* caller's own organisation from its signed request instead.
|
|
674
|
+
* {@link ZitadelPort}: `organisationId` is an explicit call argument on every
|
|
675
|
+
* method here.
|
|
422
676
|
*
|
|
423
677
|
* Finding (this campaign's report): unlike {@link AiOrganisationPort}'s bearer
|
|
424
678
|
* credential, `createFilesManagementClient`'s whole transport is "a signed
|
|
@@ -427,11 +681,17 @@ export interface AiOrgLimits {
|
|
|
427
681
|
* request (`files/apps/host/src/lib/authz/service-assertion.ts`'s
|
|
428
682
|
* `signManagementRequest`/`AssertionTrust`). A host can construct this without
|
|
429
683
|
* any human session: it only needs the Ed25519 private key (`SigningIdentity`)
|
|
430
|
-
* an operator's `MANAGEMENT_PUBLIC_KEYS`/`MANAGEMENT_ISSUER` pins
|
|
431
|
-
*
|
|
432
|
-
* `
|
|
433
|
-
*
|
|
434
|
-
*
|
|
684
|
+
* an operator's `MANAGEMENT_PUBLIC_KEYS`/`MANAGEMENT_ISSUER` pins.
|
|
685
|
+
*
|
|
686
|
+
* #177: `setQuota`/`setBudget` (`files/packages/sdk/src/client.ts:154-166`)
|
|
687
|
+
* used to live on a separate `FilesOrgLimits` a host built per org through a
|
|
688
|
+
* `filesLimitsFor(orgId)` factory on `FoundryStepExecutorPorts` — ceremony
|
|
689
|
+
* left over from nothing but currying an id this port's own callers already
|
|
690
|
+
* have in hand. The signed claim's `organisation` field selects the org per
|
|
691
|
+
* call, so one shared keypair signs for every org already, the same signer
|
|
692
|
+
* `provisionOrganisation` uses; there was never a per-org client to build,
|
|
693
|
+
* only an org id to pass. Both methods now take it explicitly, like
|
|
694
|
+
* `provisionOrganisation` always did, and `filesLimitsFor` is gone.
|
|
435
695
|
*/
|
|
436
696
|
export interface FilesOrganisationPort {
|
|
437
697
|
provisionOrganisation(input: {
|
|
@@ -450,6 +710,9 @@ export interface FilesOrganisationPort {
|
|
|
450
710
|
shareHost: string | null;
|
|
451
711
|
created: boolean;
|
|
452
712
|
}>;
|
|
713
|
+
/** #27, #177: the org's own spend limit on Files -- see this interface's own doc comment for why `organisationId` is explicit here. */
|
|
714
|
+
setQuota(organisationId: string, limitBytes: string | null): Promise<unknown>;
|
|
715
|
+
setBudget(organisationId: string, limitMicros: string | null): Promise<unknown>;
|
|
453
716
|
}
|
|
454
717
|
/**
|
|
455
718
|
* Provisions one org's organisation on AI and switches its AI use on (#80),
|
|
@@ -466,6 +729,13 @@ export interface FilesOrganisationPort {
|
|
|
466
729
|
* `ai.organisations:offer`, `ai.budgets:update`, among others) copied onto
|
|
467
730
|
* it — used directly with `createAiClient`, unlike Files' signed-assertion
|
|
468
731
|
* transport above.
|
|
732
|
+
*
|
|
733
|
+
* #172: `platform:ai` (`platform-providers.ts`'s `provisionPlatformAi`)
|
|
734
|
+
* deploys the ai host this port talks to, records its internal address
|
|
735
|
+
* (`PLATFORM_AI_INTERNAL_URL_SCOPE_KEY`) and delivers foundry's own
|
|
736
|
+
* management key under `PLATFORM_AI_MANAGEMENT_KEY_REF` -- but only through a
|
|
737
|
+
* host-supplied `AiManagementKeyMinter`, since that same script-only mint is
|
|
738
|
+
* still the one way ai issues its first platform credential.
|
|
469
739
|
*/
|
|
470
740
|
export interface AiOrganisationPort {
|
|
471
741
|
provisionOrganisation(input: {
|
|
@@ -480,18 +750,104 @@ export interface AiOrganisationPort {
|
|
|
480
750
|
organisationId: string;
|
|
481
751
|
offers: string[];
|
|
482
752
|
}>;
|
|
753
|
+
/**
|
|
754
|
+
* Mints a key inside `organisationId` (#80 step 3, `wtfalch/ai#108`/`#113`),
|
|
755
|
+
* matching `ai/packages/sdk/src/client.ts`'s `issueOrganisationKey`
|
|
756
|
+
* (`IssueOrganisationKey`/`IssuedOrganisationKey`,
|
|
757
|
+
* `ai/packages/sdk/src/types.ts:150-166`). The secret comes back once, the
|
|
758
|
+
* same as `ZitadelPort.inviteOrgOwner`'s and `zitadelApp`'s own one-time
|
|
759
|
+
* secrets -- `aiKey` hands it straight to `deliverSecret` and returns only
|
|
760
|
+
* the key's id.
|
|
761
|
+
*/
|
|
762
|
+
issueOrganisationKey(organisationId: string, input: {
|
|
763
|
+
name: string;
|
|
764
|
+
expiresDays: number;
|
|
765
|
+
permissions: string[];
|
|
766
|
+
}): Promise<{
|
|
767
|
+
organisationId: string;
|
|
768
|
+
id: string;
|
|
769
|
+
secret: string;
|
|
770
|
+
}>;
|
|
771
|
+
/**
|
|
772
|
+
* #27, #177: the org's own budget on AI, matching
|
|
773
|
+
* `ai/packages/sdk/src/client.ts:94-101`'s `updateBudget` (`UpdateBudget`
|
|
774
|
+
* from `ai/packages/sdk/src/types.ts:134-137`). Used to live on a separate
|
|
775
|
+
* `AiOrgLimits` a host built per org through an `aiLimitsFor(orgId)`
|
|
776
|
+
* factory on `FoundryStepExecutorPorts` -- the same dead currying
|
|
777
|
+
* {@link FilesOrganisationPort}'s own doc comment explains for
|
|
778
|
+
* `setQuota`/`setBudget`. `organisationId` is explicit here now, like
|
|
779
|
+
* every other method on this port.
|
|
780
|
+
*/
|
|
781
|
+
updateBudget(organisationId: string, input: {
|
|
782
|
+
keyId: string | null;
|
|
783
|
+
limitMicros: string;
|
|
784
|
+
}): Promise<unknown>;
|
|
785
|
+
}
|
|
786
|
+
/**
|
|
787
|
+
* #173: the jobs scheduler's own admin API
|
|
788
|
+
* (https://github.com/wtfalch/jobs, "The admin API", jobs#62), authenticated
|
|
789
|
+
* with `SCHEDULER_ADMIN_TOKEN` as a bearer token by whatever real client
|
|
790
|
+
* satisfies this port -- this package holds no credential of its own, the
|
|
791
|
+
* same rule every other port in this file follows. No `@wtfalch/providers`
|
|
792
|
+
* client for this scheduler exists yet, so this port states the shape a real
|
|
793
|
+
* one should satisfy structurally, the same convention `ZitadelPort`'s
|
|
794
|
+
* not-yet-existing methods already follow.
|
|
795
|
+
*
|
|
796
|
+
* Matches `POST /admin/projects` method-for-method: idempotent by
|
|
797
|
+
* `(name, baseUrl)`. A first call returns the freshly minted `secret` and
|
|
798
|
+
* `syncKey`, shown exactly once; a repeat call for the same `(name, baseUrl)`
|
|
799
|
+
* returns just the `id`, with neither -- `jobsRegistration` below never
|
|
800
|
+
* re-delivers a secret it did not just mint, deciding purely on whether this
|
|
801
|
+
* resolves with one. A repeat call naming the same project under a different
|
|
802
|
+
* `baseUrl` is refused (the real API's `409`); this port surfaces that as a
|
|
803
|
+
* thrown error, like any other provider refusal in this file.
|
|
804
|
+
*/
|
|
805
|
+
export interface JobsSchedulerPort {
|
|
806
|
+
registerProject(input: {
|
|
807
|
+
name: string;
|
|
808
|
+
baseUrl: string;
|
|
809
|
+
alertUrl?: string;
|
|
810
|
+
}): Promise<{
|
|
811
|
+
id: string;
|
|
812
|
+
secret?: string;
|
|
813
|
+
syncKey?: string;
|
|
814
|
+
}>;
|
|
815
|
+
}
|
|
816
|
+
/**
|
|
817
|
+
* #177: where the org steps' files, AI and jobs ports come from when the host
|
|
818
|
+
* does not supply them. `platform-org-ports.ts`'s `PlatformOrgPorts` builds
|
|
819
|
+
* them from the platform's recorded addresses and held keys.
|
|
820
|
+
*/
|
|
821
|
+
export interface PlatformOrgPortSource {
|
|
822
|
+
files(): Promise<FilesOrganisationPort>;
|
|
823
|
+
ai(): Promise<AiOrganisationPort>;
|
|
824
|
+
jobs(): Promise<JobsSchedulerPort>;
|
|
483
825
|
}
|
|
484
826
|
export interface FoundryStepExecutorPorts {
|
|
485
827
|
coolify: CoolifyPort;
|
|
486
828
|
cloudflare: CloudflarePort;
|
|
487
829
|
zitadel: ZitadelPort;
|
|
488
830
|
mail: MailPort;
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
831
|
+
lettermint: LettermintPort;
|
|
832
|
+
/**
|
|
833
|
+
* #173: registers an org's own app with the platform's jobs scheduler -- see
|
|
834
|
+
* {@link JobsSchedulerPort}'s own doc comment. Optional: a host with no
|
|
835
|
+
* scheduler deployed builds the executor without one, and tells the planner
|
|
836
|
+
* so (`FoundryPlanApplyOptions.jobsScheduler`), which then reports each
|
|
837
|
+
* org's registration as blocked instead of planning a step that would throw.
|
|
838
|
+
*/
|
|
839
|
+
jobsScheduler?: JobsSchedulerPort;
|
|
840
|
+
/** The shared calls SFU's own LiveKit credentials (#145) -- see {@link CallsSfuPort}'s own doc comment. */
|
|
841
|
+
callsSfu: CallsSfuPort;
|
|
842
|
+
/** Flat, not a factory: `organisationId` is an explicit argument on every method of both ports (see each port's own doc comment; #177 dropped the `filesLimitsFor`/`aiLimitsFor` factories this used to need). */
|
|
843
|
+
filesOrganisation?: FilesOrganisationPort;
|
|
844
|
+
aiOrganisation?: AiOrganisationPort;
|
|
845
|
+
/**
|
|
846
|
+
* #177: builds the files, AI and jobs ports from the platform's addresses
|
|
847
|
+
* and held keys. A port set above overrides it (tests); with neither, the
|
|
848
|
+
* step throws. The planner blocks these steps first while a key is not held.
|
|
849
|
+
*/
|
|
850
|
+
platformOrg?: PlatformOrgPortSource;
|
|
495
851
|
/**
|
|
496
852
|
* Hands a just-minted, one-time secret (today: an API app's
|
|
497
853
|
* `clientSecret`) to the host, immediately after the provider returns it
|
|
@@ -506,6 +862,14 @@ export interface FoundryStepExecutorPorts {
|
|
|
506
862
|
* themselves.
|
|
507
863
|
*/
|
|
508
864
|
deliverSecret(ref: string, secret: string): Promise<void>;
|
|
865
|
+
/**
|
|
866
|
+
* Read access to the declared-org store, narrowed to the one query
|
|
867
|
+
* `cloudflareZone` needs for its cross-org refusal check (#131) -- see that
|
|
868
|
+
* method's own doc comment for why a zone needs this where
|
|
869
|
+
* `cloudflareAccess` reads Cloudflare's own live state instead. A real host
|
|
870
|
+
* passes the same store instance it constructs `FoundryPlanApply` with.
|
|
871
|
+
*/
|
|
872
|
+
declaredOrgStore: Pick<DeclaredOrgStore, 'findOrgIdForZone'>;
|
|
509
873
|
}
|
|
510
874
|
/**
|
|
511
875
|
* Thrown by name for a step this executor deliberately does not implement:
|
|
@@ -526,6 +890,20 @@ export declare class NotBuiltError extends Error {
|
|
|
526
890
|
* this file's hostname rules.
|
|
527
891
|
*/
|
|
528
892
|
export declare function healthUrls(org: DeclaredOrg): string[];
|
|
893
|
+
/** The one Access policy foundry puts on its own Access app (#84). */
|
|
894
|
+
export declare const ACCESS_POLICY_NAME = "foundry: allowed emails";
|
|
895
|
+
/**
|
|
896
|
+
* Whether an application's own Coolify `status` (`` `running:healthy`,
|
|
897
|
+
* `exited:unhealthy` `` -- `@wtfalch/providers`' own doc comment) means it is
|
|
898
|
+
* already up, or on its way there -- `findOrCreateCmsApp`'s own check of
|
|
899
|
+
* whether a found-but-parked app still needs `start`ing (#116 review round
|
|
900
|
+
* 3, Finding 1). Only `exited`/`stopped` (Coolify's own resting-off states;
|
|
901
|
+
* `stopped` is not one this codebase has verified against a live instance,
|
|
902
|
+
* included defensively) read as off; a missing `status` defaults to on, the
|
|
903
|
+
* same "nothing says it's off" convention `planHostingToggle` (`diff.ts`)
|
|
904
|
+
* uses for the org's own app.
|
|
905
|
+
*/
|
|
906
|
+
export declare function isRunningOrStarting(status: string | undefined): boolean;
|
|
529
907
|
export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer {
|
|
530
908
|
private readonly ports;
|
|
531
909
|
constructor(ports: FoundryStepExecutorPorts);
|
|
@@ -581,6 +959,16 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
581
959
|
*/
|
|
582
960
|
private zitadelCustomers;
|
|
583
961
|
private zitadelApp;
|
|
962
|
+
/**
|
|
963
|
+
* The app an earlier attempt's recreate already made under the same
|
|
964
|
+
* name, whose response this executor never saw -- deleted before the
|
|
965
|
+
* create so no duplicate is left behind. Matches on name and the
|
|
966
|
+
* desired kind, and skips a removed app -- ZITADEL's
|
|
967
|
+
* search can return one that has since been deleted (`isNotFoundError`'s
|
|
968
|
+
* own doc comment covers why a delete can be retried into a 404), and
|
|
969
|
+
* that is not the survivor this is looking for.
|
|
970
|
+
*/
|
|
971
|
+
private findRecreatedApp;
|
|
584
972
|
private ensureZitadelProject;
|
|
585
973
|
private ensureCoolifyProject;
|
|
586
974
|
private resolveServer;
|
|
@@ -657,6 +1045,62 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
657
1045
|
* upsert a fresh value over it.
|
|
658
1046
|
*/
|
|
659
1047
|
private ensureAuthCookieSecret;
|
|
1048
|
+
/**
|
|
1049
|
+
* #140 rework (2026-09-28 lettermint-org-mail swap, "Core rule" +
|
|
1050
|
+
* "Credential model"): all of the org's Lettermint work now lives here,
|
|
1051
|
+
* in the app step, since the first project -- and so the first domain
|
|
1052
|
+
* restriction -- can only exist once the token has a home (the app env). A
|
|
1053
|
+
* no-op until `coolify:mail` has recorded {@link MAIL_DOMAIN_SCOPE_KEY} for
|
|
1054
|
+
* this org; the next apply fills it in once `coolify:mail` runs first
|
|
1055
|
+
* (`diff.ts`'s `computePlanDetail` order).
|
|
1056
|
+
*
|
|
1057
|
+
* Order, exactly as the swap's "Core rule" states it: ensure the
|
|
1058
|
+
* `<slug>-app` project, ensure the mail domain, restrict that domain to the
|
|
1059
|
+
* org's own projects, publish its DNS records, trigger verification, then
|
|
1060
|
+
* delete every stale `<slug>-app` project. New projects are created and the
|
|
1061
|
+
* domain restricted to them before any old project is deleted, so the
|
|
1062
|
+
* restriction list never becomes empty.
|
|
1063
|
+
*
|
|
1064
|
+
* Mint rule ("Credential model"): the project is healthy when the app env
|
|
1065
|
+
* holds `LETTERMINT_API_TOKEN` and Lettermint lists exactly one project
|
|
1066
|
+
* named `<slug>-app` -- unlike Resend's key, a Lettermint project's name
|
|
1067
|
+
* carries no domain id, since a domain switch is handled entirely by
|
|
1068
|
+
* {@link LettermintPort.restrictDomainToProjects} below and never needs a
|
|
1069
|
+
* fresh project. Otherwise a fresh project is created and its token
|
|
1070
|
+
* written to the env first, and only then is every other `<slug>-app`
|
|
1071
|
+
* project deleted -- a lost mint response and a duplicate both end the
|
|
1072
|
+
* same way. An app that was already running is restarted before that
|
|
1073
|
+
* delete, since a container reads its env only at start (`running`, the
|
|
1074
|
+
* same reason {@link ensureLogVolume} restarts one).
|
|
1075
|
+
*
|
|
1076
|
+
* The domain's restriction is rebuilt from every project named `<slug>-app`
|
|
1077
|
+
* or `<slug>-relay` (the relay project itself is #149, not built yet) --
|
|
1078
|
+
* never patched, so it can never end up scoped only to a project about to
|
|
1079
|
+
* be deleted.
|
|
1080
|
+
*
|
|
1081
|
+
* `MAIL_FROM` is upserted every time: it depends only on the mail domain
|
|
1082
|
+
* `coolify:mail` resolved.
|
|
1083
|
+
*
|
|
1084
|
+
* The token goes straight to `bulkUpsertEnvs` and nowhere else: never
|
|
1085
|
+
* returned, never recorded in `serviceIds` (`recordScopeId`'s own doc
|
|
1086
|
+
* comment), never logged, never interpolated into a thrown error -- the
|
|
1087
|
+
* same discipline `pushDatabaseUrl`'s doc comment states for a database's
|
|
1088
|
+
* own connection string.
|
|
1089
|
+
*/
|
|
1090
|
+
private ensureAppLettermintMail;
|
|
1091
|
+
/**
|
|
1092
|
+
* Review finding (post-#140 rework): {@link LettermintPort.findDomain}'s
|
|
1093
|
+
* own match is partial and case-insensitive, so a raw result can be a
|
|
1094
|
+
* lookalike (`notacme.com`, `mail.acme.com`) rather than `name` itself --
|
|
1095
|
+
* and every caller here full-replaces the found domain's project
|
|
1096
|
+
* restriction ({@link LettermintPort.restrictDomainToProjects}), so taking
|
|
1097
|
+
* a lookalike as a match would let it be taken over. Every domain lookup
|
|
1098
|
+
* in this class (`ensureAppLettermintMail`, `parkLettermintMail`,
|
|
1099
|
+
* `deleteStaleLettermintDomain`) goes through this instead of the port
|
|
1100
|
+
* directly, and treats anything but an exact, case-insensitive match as
|
|
1101
|
+
* not found.
|
|
1102
|
+
*/
|
|
1103
|
+
private findExactLettermintDomain;
|
|
660
1104
|
/**
|
|
661
1105
|
* `plan/diff.ts`'s `planSite` (#116, slicing #99's "provision the cms app,
|
|
662
1106
|
* database and site app per org"): the 2026-09-25 decision
|
|
@@ -774,15 +1218,204 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
774
1218
|
* this method is `cmsSite`'s only caller.
|
|
775
1219
|
*/
|
|
776
1220
|
private findOrCreateCmsApp;
|
|
1221
|
+
/**
|
|
1222
|
+
* `plan/diff.ts`'s `planCalls`: the org's own calls Matrix stack, entirely
|
|
1223
|
+
* separate from chat's (`calls-matrix.ts`'s own top doc comment) -- two
|
|
1224
|
+
* Coolify-managed Postgres databases, a Synapse app and an MAS app, no
|
|
1225
|
+
* public hostname of its own (neither app is reachable from outside
|
|
1226
|
+
* Coolify's own Docker network) -- `coolifyCallsService` below (#145, part 2
|
|
1227
|
+
* of #128) is the one that gets a public hostname, since it is the one a
|
|
1228
|
+
* browser actually talks to. Park stops Synapse and MAS only, the same
|
|
1229
|
+
* park-not-delete rule `coolifyApp`/`cmsSite` already follow (#7
|
|
1230
|
+
* corrections 1) -- their databases are never touched from that branch, the
|
|
1231
|
+
* same way `cmsSite`'s own database never is.
|
|
1232
|
+
*/
|
|
1233
|
+
private coolifyCalls;
|
|
1234
|
+
/**
|
|
1235
|
+
* `coolifyCalls`'s own find-or-create for Synapse/MAS, restarting a
|
|
1236
|
+
* found-but-stopped app (#116 review round 3, Finding 1's same fix). Reused
|
|
1237
|
+
* by `coolifyCallsService` (#145) for the calls service app itself -- the
|
|
1238
|
+
* same find-by-name-then-start-or-create shape applies unchanged.
|
|
1239
|
+
*/
|
|
1240
|
+
private findOrCreateCallsApp;
|
|
1241
|
+
/**
|
|
1242
|
+
* `coolifyCalls`'s own database find-or-create, modeled on
|
|
1243
|
+
* `ensureCmsDatabase`: `context.recordedIds()` is checked first since
|
|
1244
|
+
* neither database has a `system:kind:key` slot of its own (this step's one
|
|
1245
|
+
* auto-recorded `resourceId` is the MAS app, not either database --
|
|
1246
|
+
* {@link CALLS_SYNAPSE_SCOPE_KEY}'s own doc comment), then `listDatabases`
|
|
1247
|
+
* finds one already created by name before `createPostgresDatabase` makes
|
|
1248
|
+
* one. Reused by `coolifyCallsService` (#145) for the calls service's own
|
|
1249
|
+
* database, whose {@link CALLS_SERVICE_DB_SCOPE_KEY} has no
|
|
1250
|
+
* `system:kind:key` slot of its own for the same reason.
|
|
1251
|
+
*/
|
|
1252
|
+
private ensureCallsDatabase;
|
|
1253
|
+
/**
|
|
1254
|
+
* Reads back {@link CALLS_SECRET_ENV_KEYS} from the MAS app's own envs
|
|
1255
|
+
* before minting fresh ones -- this stack's only memory across a re-run
|
|
1256
|
+
* (`calls-matrix.ts`'s `CallsMatrixSecrets` doc comment). `changed: true`
|
|
1257
|
+
* only when nothing usable was found: a partial set (a crash mid-mint) is
|
|
1258
|
+
* still treated as "mint fresh", since MAS's shared secret and Synapse's
|
|
1259
|
+
* copy of it must always agree.
|
|
1260
|
+
*/
|
|
1261
|
+
private ensureCallsMatrixSecrets;
|
|
1262
|
+
/**
|
|
1263
|
+
* Upserts a file storage's content, creating it if the application has none
|
|
1264
|
+
* at that mount path yet -- there is no way to read a file storage's
|
|
1265
|
+
* current content back from Coolify to diff it, the same limit
|
|
1266
|
+
* `archon/bootstrap/steps/chat-instance.ts`'s own `ensureFileStorage` notes,
|
|
1267
|
+
* so an existing one is always re-written.
|
|
1268
|
+
*/
|
|
1269
|
+
private ensureCallsFileStorage;
|
|
1270
|
+
/**
|
|
1271
|
+
* `plan/diff.ts`'s `planCallsService`: the org's own calls service --
|
|
1272
|
+
* `wtfalch/calls`'s `packages/service`, wired to the calls Matrix stack
|
|
1273
|
+
* {@link coolifyCalls} provisions and the shared LiveKit SFU (#122/#127).
|
|
1274
|
+
* Static config checks run first, before any Coolify call (#128's own
|
|
1275
|
+
* carried-over PR 344 review fix, restated in #145's own issue body): the
|
|
1276
|
+
* calls Matrix stack must already be recorded, its MAS app must already
|
|
1277
|
+
* carry its client id/secret, and the shared SFU's LiveKit credentials must
|
|
1278
|
+
* already be resolvable -- none of the three is minted or guessed here.
|
|
1279
|
+
* Park stops the app only, the same park-not-delete rule
|
|
1280
|
+
* {@link coolifyCalls}/{@link coolifyApp} already follow (#7 corrections
|
|
1281
|
+
* 1); its own database is never touched from that branch.
|
|
1282
|
+
*/
|
|
1283
|
+
private coolifyCallsService;
|
|
1284
|
+
/**
|
|
1285
|
+
* Reads back the calls Matrix stack's MAS client id/secret from the MAS
|
|
1286
|
+
* app's own envs ({@link CALLS_SECRET_ENV_KEYS}) -- never mints: minting
|
|
1287
|
+
* either is {@link ensureCallsMatrixSecrets}'s own job, and a missing value
|
|
1288
|
+
* here means that stack has not finished provisioning yet, not something
|
|
1289
|
+
* this step can fix on its own.
|
|
1290
|
+
*/
|
|
1291
|
+
private readCallsMasClientCredentials;
|
|
1292
|
+
/**
|
|
1293
|
+
* `null` once the calls service app already has a `CALLS_BINDING_SECRET` --
|
|
1294
|
+
* never rotated, the same rule {@link ensureAuthCookieSecret} follows for
|
|
1295
|
+
* `AUTH_COOKIE_SECRET`: it wraps the provider-token ciphertext already
|
|
1296
|
+
* stored under any binding minted so far
|
|
1297
|
+
* (`calls/packages/service/src/config.mjs`'s own doc comment on
|
|
1298
|
+
* `CALLS_BINDING_SECRET`), so overwriting it would strand every binding
|
|
1299
|
+
* minted under the old value. 32 raw bytes, hex-encoded -- the exact shape
|
|
1300
|
+
* `config.mjs`'s own regex requires.
|
|
1301
|
+
*/
|
|
1302
|
+
private ensureCallsBindingSecret;
|
|
1303
|
+
/**
|
|
1304
|
+
* `plan/diff.ts`'s `planAgora`: provisions the org's own Agora
|
|
1305
|
+
* (https://github.com/wtfalch/agora) as one more Coolify application, from
|
|
1306
|
+
* that repo's `apps/agora/Dockerfile` ({@link AGORA_REPO}'s doc comment),
|
|
1307
|
+
* and pushes its env. `findOrCreateCallsApp` is reused as-is for the
|
|
1308
|
+
* find-by-name-then-start-or-create shape, like {@link coolifyCallsService}.
|
|
1309
|
+
* There is no park branch: Agora is always on.
|
|
1310
|
+
*
|
|
1311
|
+
* The env comes from `plan/agora.ts`'s `desiredAgoraEnv`, the same function
|
|
1312
|
+
* the plan compares the live app against, so a re-plan after this step is
|
|
1313
|
+
* empty. The step never writes a partial config: when any input is missing
|
|
1314
|
+
* (the org's ZITADEL org id or Agora client id, or an issuer or
|
|
1315
|
+
* mail/messaging project id the host does not supply,
|
|
1316
|
+
* `AgoraHostPort`, read by the plan) it throws naming each one, before
|
|
1317
|
+
* it creates anything.
|
|
1318
|
+
*
|
|
1319
|
+
* `AGORA_CANONICAL_HOST` is always pushed. `AGORA_MAIL_UPSTREAM`/
|
|
1320
|
+
* `AGORA_FILES_UPSTREAM` are pushed once the platform's own mail/files
|
|
1321
|
+
* hosts are on and applied (#177's `platform-org.ts`'s
|
|
1322
|
+
* `agoraHostValuesFromPlatform`, read by `plan-apply.ts` when no host
|
|
1323
|
+
* `agora` port overrides it) -- absent otherwise. The chat upstreams are
|
|
1324
|
+
* still never pushed: chat has no admin API yet
|
|
1325
|
+
* (https://github.com/wtfalch/chat/issues/40). Agora answers 502 on those
|
|
1326
|
+
* routes rather than serving anything wrong.
|
|
1327
|
+
*/
|
|
1328
|
+
private coolifyAgora;
|
|
1329
|
+
/**
|
|
1330
|
+
* `plan/diff.ts`'s `planJobsRegistration`: registers the org's own app with
|
|
1331
|
+
* the platform's jobs scheduler (`JobsSchedulerPort.registerProject`, the
|
|
1332
|
+
* scheduler's `POST /admin/projects`), then hands the freshly minted
|
|
1333
|
+
* `secret` and `syncKey` to the host through `deliverSecret` -- but only
|
|
1334
|
+
* when this call actually minted them. The admin API is itself idempotent
|
|
1335
|
+
* by `(name, baseUrl)`: a repeat call for an already-registered project
|
|
1336
|
+
* resolves with just the `id`, and this never re-delivers a secret or sync
|
|
1337
|
+
* key it did not just mint, the same "registers an app once" rule
|
|
1338
|
+
* `diff.ts`'s own plan-level gate (recordedIds, mirroring `planOwner`)
|
|
1339
|
+
* already provides on a normal re-plan -- this check is what keeps that
|
|
1340
|
+
* true even if this step somehow runs twice on the same apply.
|
|
1341
|
+
*
|
|
1342
|
+
* Never logs, returns or records either credential (this file's own top
|
|
1343
|
+
* doc comment's "a secret is never a step's return value" rule) -- only the
|
|
1344
|
+
* scheduler's own non-secret project id comes back as `resourceId`.
|
|
1345
|
+
*/
|
|
1346
|
+
/** #177: the host's own port when it supplies one, else the platform's, built from its address and held key. */
|
|
1347
|
+
private filesPort;
|
|
1348
|
+
private aiPort;
|
|
1349
|
+
private jobsPort;
|
|
1350
|
+
private jobsRegistration;
|
|
1351
|
+
/** `deliverSecret`, tried up to {@link JOBS_DELIVERY_ATTEMPTS} times; true once one attempt succeeds. */
|
|
1352
|
+
private deliverWithRetry;
|
|
777
1353
|
private cloudflareTunnel;
|
|
1354
|
+
/**
|
|
1355
|
+
* `plan/diff.ts`'s `planZone`: finds or creates the Cloudflare zone for the
|
|
1356
|
+
* org's own domain. Refuses to adopt a zone the store already records
|
|
1357
|
+
* under a *different* org's applied state (#131), the same
|
|
1358
|
+
* do-not-adopt-a-foreign-resource rule `cloudflareAccess` above enforces --
|
|
1359
|
+
* but where that method tells "foundry's own" from "foreign" by reading
|
|
1360
|
+
* Cloudflare's own Access-app name live, a Cloudflare zone carries no
|
|
1361
|
+
* org-identifying field of its own, so this asks the store instead. That
|
|
1362
|
+
* matters because two different, DB-unique `domain` values (`org.ts`'s
|
|
1363
|
+
* `validateDomain` plus its unique index) can still resolve to the same
|
|
1364
|
+
* Cloudflare zone -- a zone is keyed by apex, and one org's domain can be a
|
|
1365
|
+
* subdomain of another org's -- which is exactly the shared-zone,
|
|
1366
|
+
* shared-hostname collision this ticket exists to refuse. An org re-running
|
|
1367
|
+
* its own already-recorded zone is unaffected: the store then names this
|
|
1368
|
+
* same org as the owner.
|
|
1369
|
+
*/
|
|
778
1370
|
private cloudflareZone;
|
|
779
1371
|
/**
|
|
780
1372
|
* `plan/diff.ts`'s `planMail`: gives an org mail, moves it between modes,
|
|
781
1373
|
* or parks it on the one shared Stalwart instance (`this.ports.mail`,
|
|
782
1374
|
* `@wtfalch/email/postmaster`'s `applyOrgMail`/`parkOrgMail` bound), then
|
|
783
|
-
* publishes whatever DNS records the call returns
|
|
1375
|
+
* publishes whatever Stalwart DNS records the call returns, and records the
|
|
1376
|
+
* resolved mail domain as a scope id ({@link MAIL_DOMAIN_SCOPE_KEY}).
|
|
1377
|
+
* 2026-09-28 lettermint-org-mail swap: this step no longer calls the mail
|
|
1378
|
+
* relay provider at all -- registering the domain, restricting it and
|
|
1379
|
+
* publishing its own DNS records all moved to the app step
|
|
1380
|
+
* (`ensureAppLettermintMail`, "Core rule"), since the first project can
|
|
1381
|
+
* only exist once the token has a home. Parking mail (mode `'off'`) does
|
|
1382
|
+
* still reach Lettermint, the one exception: {@link parkLettermintMail}
|
|
1383
|
+
* deletes the org's own projects and then its domain, so a domain never
|
|
1384
|
+
* ends up restricted only to dead projects.
|
|
1385
|
+
*
|
|
1386
|
+
* Review finding: a mail domain switch (`org.domain` changes, or
|
|
1387
|
+
* `usesDomain` flips) must also delete the *old* Lettermint domain here,
|
|
1388
|
+
* before recording the new one -- otherwise it stays registered and
|
|
1389
|
+
* restricted to the org's still-live `<slug>-app` project, which could
|
|
1390
|
+
* then keep sending as it forever. {@link deleteStaleLettermintDomain}
|
|
1391
|
+
* does this by comparing the previously recorded domain (case-insensitive)
|
|
1392
|
+
* against the one this apply just resolved.
|
|
1393
|
+
*
|
|
1394
|
+
* #162: once `applyOrgMail` has given the org a domain, {@link ensureOwnerMailbox}
|
|
1395
|
+
* gives its declared owner a mailbox on it, so Agora's Mail section is not
|
|
1396
|
+
* empty for them after the org's first apply.
|
|
784
1397
|
*/
|
|
785
1398
|
private coolifyMail;
|
|
1399
|
+
/**
|
|
1400
|
+
* #162: gives the org's declared owner a mailbox on its just-resolved mail
|
|
1401
|
+
* domain, the one address this executor mints on its own
|
|
1402
|
+
* ({@link MAIL_OWNER_LOCAL_PART}'s own doc comment). A no-op when the org
|
|
1403
|
+
* has no owner yet, or once {@link MAIL_OWNER_MAILBOX_SCOPE_KEY} is already
|
|
1404
|
+
* recorded -- checked first so a second apply never calls
|
|
1405
|
+
* `MailPort.createMailbox` again, rather than relying on that port method's
|
|
1406
|
+
* own duplicate refusal.
|
|
1407
|
+
*/
|
|
1408
|
+
private ensureOwnerMailbox;
|
|
1409
|
+
/**
|
|
1410
|
+
* Deletes the org's previous Lettermint domain once the mail domain it was
|
|
1411
|
+
* registered under has changed -- otherwise it stays registered and
|
|
1412
|
+
* restricted to the org's still-live `<slug>-app` project after the switch
|
|
1413
|
+
* (review finding, post-#140 rework). A no-op on first apply (no previous
|
|
1414
|
+
* domain recorded) and on a same-domain re-apply (compared
|
|
1415
|
+
* case-insensitively, the same rule {@link findExactLettermintDomain}
|
|
1416
|
+
* applies to the lookup itself).
|
|
1417
|
+
*/
|
|
1418
|
+
private deleteStaleLettermintDomain;
|
|
786
1419
|
/**
|
|
787
1420
|
* Upserts each DNS record `applyOrgMail` says foundry must publish, found
|
|
788
1421
|
* by an exact name+type match (`CloudflarePort.findDnsRecord`'s own doc
|
|
@@ -791,6 +1424,38 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
791
1424
|
* mints the key on a background task; the next apply publishes it.
|
|
792
1425
|
*/
|
|
793
1426
|
private publishMailDnsRecords;
|
|
1427
|
+
/**
|
|
1428
|
+
* Parking mail (2026-09-28 lettermint-org-mail swap, "Core rule": "Parking
|
|
1429
|
+
* mail deletes the org's projects and then the domain itself, so no
|
|
1430
|
+
* restriction points only at dead projects"). Deletes every Lettermint
|
|
1431
|
+
* project named `<slug>-app` or `<slug>-relay`, then the org's own
|
|
1432
|
+
* Lettermint domain, if one was found -- in that order, never the reverse.
|
|
1433
|
+
* A no-op until a previous apply recorded {@link MAIL_DOMAIN_SCOPE_KEY}:
|
|
1434
|
+
* nothing was ever provisioned for an org whose mail went straight from
|
|
1435
|
+
* never-applied to off.
|
|
1436
|
+
*/
|
|
1437
|
+
private parkLettermintMail;
|
|
1438
|
+
/**
|
|
1439
|
+
* Upserts each DNS record Lettermint's domain names, the same find-then-
|
|
1440
|
+
* upsert `publishMailDnsRecords` above uses for Stalwart's own records --
|
|
1441
|
+
* except an existing `_dmarc` record is never overwritten (a stricter
|
|
1442
|
+
* policy an org already set, `p=quarantine`/`p=reject`, should not be
|
|
1443
|
+
* relaxed back to Lettermint's own default). Resend's own domain also
|
|
1444
|
+
* carried an apex `SPF` TXT record that had to be merged into whatever SPF
|
|
1445
|
+
* record already existed there (RFC 7208: two `v=spf1` TXT records on one
|
|
1446
|
+
* name is a permerror); Lettermint's own DNS records
|
|
1447
|
+
* (`.plans/2026-09-28-lettermint-api.md` §3) have no such record -- its
|
|
1448
|
+
* `purpose` enum is `return_path`/`dmarc`/`dkim_legacy`/`dkim_primary`/
|
|
1449
|
+
* `dkim_secondary`, none of them SPF -- so that merge has no case left to
|
|
1450
|
+
* handle here and is dropped rather than kept as dead code.
|
|
1451
|
+
*/
|
|
1452
|
+
private publishLettermintDnsRecords;
|
|
1453
|
+
/**
|
|
1454
|
+
* Never overwrites an existing `_dmarc` record -- a stricter policy an org
|
|
1455
|
+
* already set (`p=quarantine`/`p=reject`) should not be relaxed back to
|
|
1456
|
+
* Lettermint's own `p=none` default.
|
|
1457
|
+
*/
|
|
1458
|
+
private ensureLettermintDmarcRecord;
|
|
794
1459
|
/**
|
|
795
1460
|
* #80: provisions the org's own organisation on Files, before
|
|
796
1461
|
* {@link filesStorage} can set its spend limit. `provisionOrganisation` is
|
|
@@ -809,6 +1474,16 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
809
1474
|
*/
|
|
810
1475
|
private aiOrganisation;
|
|
811
1476
|
private aiBudget;
|
|
1477
|
+
/**
|
|
1478
|
+
* #80 step 3: mints the org's own ai key, once, and hands its one-time
|
|
1479
|
+
* secret straight to {@link FoundryStepExecutorPorts.deliverSecret} --
|
|
1480
|
+
* never returned, stored in `serviceIds` or logged, the same discipline
|
|
1481
|
+
* `zitadelApp`'s own `created.clientSecret` follows. `diff.ts`'s
|
|
1482
|
+
* `planAiKey` never plans this step again once its resource id
|
|
1483
|
+
* (the key's id, not its secret) is recorded, since ai's own API offers no
|
|
1484
|
+
* way to check for an already-minted key.
|
|
1485
|
+
*/
|
|
1486
|
+
private aiKey;
|
|
812
1487
|
/**
|
|
813
1488
|
* {@link StepDestroyer.destroy}: deletes the one resource
|
|
814
1489
|
* `FoundryPlanApply.destroy` names. A provider 404 (already gone) counts as
|
|
@@ -821,3 +1496,23 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
821
1496
|
readonly recordedIds: Readonly<Record<string, string>>;
|
|
822
1497
|
}): Promise<void>;
|
|
823
1498
|
}
|
|
1499
|
+
/**
|
|
1500
|
+
* True for a provider's own "already gone" response, however it surfaces --
|
|
1501
|
+
* `@wtfalch/providers`' `ZitadelError` carries a structured `status: number`
|
|
1502
|
+
* (`zitadel.ts`), while its Coolify and Cloudflare clients throw a plain
|
|
1503
|
+
* `Error` whose message embeds the HTTP status (`` `Coolify ${method} ${path}
|
|
1504
|
+
* → ${status}: ${text}` ``, `` `CF ${method} ${path} → ${status}: ${msg}` ``).
|
|
1505
|
+
* Ports are structural here (this file's own top comment: never import a
|
|
1506
|
+
* concrete provider class), so this cannot `instanceof`-check a real error
|
|
1507
|
+
* type -- it duck-types a `status` field first.
|
|
1508
|
+
*
|
|
1509
|
+
* A prior version fell back to scanning the *whole* message for a standalone
|
|
1510
|
+
* `404` when no `status` field was present. That is unsafe: the Coolify and
|
|
1511
|
+
* Cloudflare clients put the resource path -- which can contain a hex UUID
|
|
1512
|
+
* -- ahead of the status in the message, so a UUID that happens to contain
|
|
1513
|
+
* the digits `404` (`9a404b12-...`) made a genuine 500 read as a 404. The
|
|
1514
|
+
* fallback below instead anchors to the exact position both clients put the
|
|
1515
|
+
* status in: the first `→ `, after the client, method and path, and before
|
|
1516
|
+
* `:`. It never scans the path or the response body.
|
|
1517
|
+
*/
|
|
1518
|
+
export declare function isNotFoundError(error: unknown): boolean;
|