@wtfalch/foundry 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/apply.d.ts +14 -0
  2. package/dist/calls-sfu-deploy-support.d.ts +57 -0
  3. package/dist/calls-sfu-deploy-support.js +82 -0
  4. package/dist/calls-sfu.d.ts +175 -0
  5. package/dist/calls-sfu.js +239 -0
  6. package/dist/index.d.ts +11 -6
  7. package/dist/index.js +7 -4
  8. package/dist/migrate.d.ts +38 -0
  9. package/dist/migrate.js +47 -0
  10. package/dist/migrations/0008_hosting.sql +24 -0
  11. package/dist/migrations/0009_hosting_git_branch_build_pack.sql +14 -0
  12. package/dist/migrations/0010_owner.sql +13 -0
  13. package/dist/migrations/0011_test_org.sql +12 -0
  14. package/dist/migrations/0012_audit.sql +21 -0
  15. package/dist/migrations/0013_access_emails.sql +6 -0
  16. package/dist/org.d.ts +130 -0
  17. package/dist/org.js +102 -0
  18. package/dist/plan/diff.d.ts +20 -5
  19. package/dist/plan/diff.js +533 -9
  20. package/dist/plan/executor.d.ts +823 -0
  21. package/dist/plan/executor.js +1242 -0
  22. package/dist/plan/index.d.ts +6 -4
  23. package/dist/plan/index.js +3 -2
  24. package/dist/plan/live.d.ts +8 -4
  25. package/dist/plan/plan-apply.d.ts +114 -4
  26. package/dist/plan/plan-apply.js +266 -14
  27. package/dist/plan/providers-readers.d.ts +102 -14
  28. package/dist/plan/providers-readers.js +245 -16
  29. package/dist/provision.d.ts +56 -0
  30. package/dist/provision.js +69 -0
  31. package/dist/slug.d.ts +12 -18
  32. package/dist/slug.js +66 -0
  33. package/dist/state.d.ts +26 -5
  34. package/dist/store/audit.d.ts +8 -0
  35. package/dist/store/audit.js +43 -0
  36. package/dist/store/postgres.d.ts +3 -1
  37. package/dist/store/postgres.js +84 -2
  38. package/dist/store/schema.d.ts +378 -0
  39. package/dist/store/schema.js +32 -1
  40. package/dist/store.d.ts +12 -0
  41. package/package.json +15 -11
@@ -0,0 +1,823 @@
1
+ import type { OrgMailOptions, OrgMailOrg, OrgMailResult } from '@wtfalch/email/postmaster';
2
+ import type { DeclaredOrg, HostingBuildPack, OrgId } from '../org.js';
3
+ import type { StepAction } from './diff.js';
4
+ import type { DestroyTarget, StepDestroyer, StepExecutionContext, StepExecutor } from './plan-apply.js';
5
+ /**
6
+ * The real {@link StepExecutor} (#20 "provision Company Factory end to
7
+ * end"): makes each {@link StepAction} `diff.ts` plans true against a real
8
+ * Coolify, Cloudflare and ZITADEL, plus per-org organisations, spend limits
9
+ * and (for AI) offers on Files and AI (#80). Scope and design are exactly
10
+ * what `.claude/decisions-2026-09-24-executor.md`'s "Executor decisions"
11
+ * section settled, referenced by name at each dispatch branch below.
12
+ *
13
+ * Built entirely from narrow ports (interfaces), never a concrete
14
+ * `@wtfalch/providers` client: this package holds no credential and never
15
+ * will (executor decisions: "the executor is constructed with narrow ports
16
+ * ... a host [archon] supplies real clients. How the host gets provider
17
+ * tokens from the keys Worker is the host's job, not this campaign's").
18
+ * Every port method here is named and shaped to match a real method on
19
+ * `@wtfalch/providers`' `CoolifyClient`/`CloudflareClient`/`ZitadelClient`
20
+ * (checked against `providers` `origin/main`, 2026-09-24), or on
21
+ * `@wtfalch/files`'/`@wtfalch/ai`'s own SDK clients (#80, checked against
22
+ * each repo's own `main`, 2026-09-25), so a real client satisfies the port
23
+ * structurally, with no adapter needed — see each port's own doc comment for
24
+ * the one real method it does not yet have.
25
+ */
26
+ /**
27
+ * The Coolify calls this executor makes, shaped to match
28
+ * `@wtfalch/providers/coolify`'s `CoolifyClient` method-for-method — a real
29
+ * `CoolifyClient` instance satisfies this port with no adapter.
30
+ *
31
+ * There is no single `findOrCreateProject`/stop/start on `CoolifyClient`
32
+ * today (unlike ZITADEL's `findOrCreateOrg`/`findOrCreateProject`, which
33
+ * already exist): `findProjectByName` + `createProject` compose into
34
+ * "find or create" inside this executor instead of the port, and `raw` —
35
+ * `CoolifyClient`'s own documented escape hatch for "use — or add — a typed
36
+ * method instead" — carries the stop/start calls Coolify's REST API has
37
+ * (`POST /applications/{uuid}/stop`, `POST /databases/{uuid}/stop`) that
38
+ * `CoolifyClient` has not wrapped yet. Logged as a Decision in this
39
+ * campaign's report rather than inventing port methods (`findOrCreateProject`,
40
+ * `stop`, `start`) no real client could structurally satisfy.
41
+ */
42
+ export interface CoolifyPort {
43
+ findProjectByName(name: string): Promise<{
44
+ uuid: string;
45
+ name: string;
46
+ } | null>;
47
+ createProject(name: string, description?: string): Promise<{
48
+ uuid: string;
49
+ }>;
50
+ findOrCreateEnvironment(projectUuid: string, name: string): Promise<{
51
+ environment: {
52
+ id: number;
53
+ uuid: string;
54
+ name: string;
55
+ };
56
+ created: boolean;
57
+ }>;
58
+ listServers(): Promise<Array<{
59
+ uuid: string;
60
+ is_usable: boolean;
61
+ is_reachable: boolean;
62
+ }>>;
63
+ requireGithubApp(preferredUuid?: string): Promise<{
64
+ uuid: string;
65
+ name: string;
66
+ }>;
67
+ findApplicationByName(name: string, scope?: {
68
+ environmentIds: number[];
69
+ }): Promise<{
70
+ uuid: string;
71
+ } | null>;
72
+ /**
73
+ * One application's own live status (`` `running:healthy`, `exited:unhealthy`
74
+ * `` -- `@wtfalch/providers`' `CoolifyApplication.status` doc comment) --
75
+ * `findOrCreateCmsApp`'s own check of whether an app it found by name
76
+ * (rather than just created) needs starting again (#116 review round 3,
77
+ * Finding 1).
78
+ */
79
+ getApplication(uuid: string): Promise<{
80
+ uuid: string;
81
+ status?: string;
82
+ }>;
83
+ createGithubAppApplication(input: {
84
+ project_uuid: string;
85
+ server_uuid: string;
86
+ environment_name: string;
87
+ environment_uuid: string;
88
+ github_app_uuid: string;
89
+ git_repository: string;
90
+ git_branch: string;
91
+ build_pack: HostingBuildPack;
92
+ name?: string;
93
+ domains?: string;
94
+ /** Selects which of two Dockerfiles in one repo to build (`cmsSite`'s own site/cms apps). */
95
+ dockerfile_location?: string;
96
+ }): Promise<{
97
+ uuid: string;
98
+ }>;
99
+ createPostgresDatabase(input: {
100
+ project_uuid: string;
101
+ server_uuid: string;
102
+ environment_name: string;
103
+ environment_uuid: string;
104
+ name: string;
105
+ postgres_db: string;
106
+ postgres_user: string;
107
+ }): Promise<{
108
+ uuid: string;
109
+ internal_db_url: string;
110
+ }>;
111
+ /**
112
+ * Coolify's own record of a database, re-read here (rather than trusted
113
+ * from `createPostgresDatabase`'s return value) so the app's env is wired
114
+ * from the database's live state whether this run just created it or found
115
+ * it already there (`coolifyApp`/`coolifyDatabase` below, hosting-wiring
116
+ * campaign; `cmsSite`'s own cms database, #117). `internal_db_url` is
117
+ * `postgres://user:password@host/db` — a
118
+ * live credential, present in the raw Coolify API response and reachable
119
+ * only from inside the Coolify Docker network (`@wtfalch/providers`'
120
+ * `CoolifyClient.createPostgresDatabase` doc comment).
121
+ */
122
+ getDatabase(uuid: string): Promise<{
123
+ uuid: string;
124
+ internal_db_url?: string;
125
+ }>;
126
+ /**
127
+ * Every database Coolify holds. `coolifyDatabase` finds its own by name and
128
+ * environment, so a crash between create and persist does not make a second
129
+ * one on the next apply. Only `uuid`, `name` and `environment_id` are read;
130
+ * the raw response also carries `internal_db_url`, which is never used here.
131
+ */
132
+ listDatabases(): Promise<Array<{
133
+ uuid: string;
134
+ name: string;
135
+ environment_id?: number;
136
+ }>>;
137
+ /**
138
+ * Upsert environment variables on an application in one call — used here to
139
+ * push the database's `internal_db_url` in as the app's `DATABASE_URL`
140
+ * (hosting-wiring campaign), and, for the cms app, its `DATABASE_URL_OWNER`
141
+ * plus a freshly minted `DATABASE_URL` runtime-role credential (#117). Each
142
+ * value is a live credential: it must reach this call and nothing else —
143
+ * never a step's return value, `serviceIds` (hence the store), an audit
144
+ * record, a log or a thrown error. See `coolifyApp`'s `pushDatabaseUrl` and
145
+ * `cmsSite`'s `ensureCmsRuntimeUrl`.
146
+ */
147
+ bulkUpsertEnvs(applicationUuid: string, envs: Array<{
148
+ key: string;
149
+ value: string;
150
+ buildTime?: boolean;
151
+ literal?: boolean;
152
+ multiline?: boolean;
153
+ }>): Promise<unknown>;
154
+ /**
155
+ * Every environment variable currently on the application — read before
156
+ * generating `AUTH_COOKIE_SECRET` (`ensureAuthCookieSecret`, level-1
157
+ * app-env campaign) so that value is only ever generated once, never
158
+ * overwritten. Every key comes back twice (a production row and an
159
+ * `is_preview` duplicate, `@wtfalch/providers`' `CoolifyClient.listEnvs`
160
+ * doc comment), so a caller checking "does this key already exist" filters
161
+ * `is_preview === false` the same way a deployed-config reader must.
162
+ */
163
+ listEnvs(applicationUuid: string): Promise<Array<{
164
+ key: string;
165
+ value: string;
166
+ is_preview: boolean;
167
+ }>>;
168
+ /** Escape hatch (`CoolifyClient.raw`) — used for stop/start, which have no typed method yet. */
169
+ raw(method: string, path: string, body?: unknown): Promise<unknown>;
170
+ /**
171
+ * Persistent (named volume) and file storages already attached to an
172
+ * application (#75) — `ensureLogVolume` reads this before creating one, so
173
+ * a re-run never mounts a second volume at the same path.
174
+ */
175
+ listStorages(applicationUuid: string): Promise<{
176
+ persistent_storages?: Array<{
177
+ uuid: string;
178
+ name?: string;
179
+ mount_path: string;
180
+ }>;
181
+ file_storages?: Array<{
182
+ uuid: string;
183
+ mount_path: string;
184
+ }>;
185
+ }>;
186
+ /** Mounts a named volume on an application (#75) — `ensureLogVolume`'s create path. */
187
+ createPersistentStorage(applicationUuid: string, input: {
188
+ name: string;
189
+ mount_path: string;
190
+ }): Promise<unknown>;
191
+ /**
192
+ * Restarts the running container without a rebuild (#75) — `ensureLogVolume`
193
+ * calls this after mounting a volume on an app that was already running,
194
+ * since Coolify only binds a newly created storage into the container at
195
+ * its next start.
196
+ */
197
+ restart(applicationUuid: string): Promise<unknown>;
198
+ /** `FoundryPlanApply.destroy`'s teardown of the org's own app. */
199
+ deleteApplication(uuid: string): Promise<unknown>;
200
+ /** `FoundryPlanApply.destroy`'s teardown of the org's own database. */
201
+ deleteDatabase(uuid: string): Promise<unknown>;
202
+ }
203
+ /**
204
+ * The Cloudflare calls this executor makes, shaped to match
205
+ * `@wtfalch/providers/cloudflare`'s `CloudflareClient` method-for-method.
206
+ */
207
+ export interface CloudflarePort {
208
+ findZoneByApex(domain: string): Promise<{
209
+ id: string;
210
+ name: string;
211
+ } | undefined>;
212
+ createZone(name: string): Promise<{
213
+ id: string;
214
+ name: string;
215
+ }>;
216
+ findTunnelByName(name: string): Promise<{
217
+ id: string;
218
+ name: string;
219
+ } | undefined>;
220
+ createTunnel(name: string): Promise<{
221
+ id: string;
222
+ name: string;
223
+ }>;
224
+ putTunnelConfig(tunnelId: string, config: unknown): Promise<unknown>;
225
+ /** `FoundryPlanApply.destroy`'s teardown of the org's own Cloudflare tunnel. */
226
+ deleteTunnel(tunnelId: string): Promise<void>;
227
+ /** `cloudflareAccess` finds the org's Access app by domain before creating one (#84). */
228
+ listAccessApps(): Promise<Array<{
229
+ id: string;
230
+ name: string;
231
+ domain: string;
232
+ }>>;
233
+ createAccessApp(app: {
234
+ name: string;
235
+ domain: string;
236
+ type: string;
237
+ }): Promise<{
238
+ id: string;
239
+ }>;
240
+ listAccessPolicies(appId: string): Promise<Array<{
241
+ id: string;
242
+ name: string;
243
+ }>>;
244
+ createAccessPolicy(appId: string, policy: {
245
+ name: string;
246
+ decision: string;
247
+ include: unknown[];
248
+ }): Promise<{
249
+ id: string;
250
+ }>;
251
+ deleteAccessApp(appId: string): Promise<void>;
252
+ /**
253
+ * `FoundryPlanApply.destroy`'s teardown of any DNS record foundry created
254
+ * for the org. Nothing in this executor creates or records one yet (Finding,
255
+ * this campaign's report: no `cloudflare:dns` resource kind exists in
256
+ * `diff.ts`/`executor.ts` today), so `destroy` has no dispatch target for
257
+ * it either -- this port method exists ready for whichever step starts
258
+ * creating one.
259
+ */
260
+ deleteDnsRecord(zoneId: string, recordId: string): Promise<void>;
261
+ /** `coolify:mail`'s lookup of an existing MX/TXT record before upserting it, filtered by type so a stale record of a different type is never mistaken for a match. */
262
+ findDnsRecord(zoneId: string, name: string, type?: string): Promise<Array<{
263
+ id: string;
264
+ type: string;
265
+ content: string;
266
+ name: string;
267
+ }>>;
268
+ createDnsRecord(zoneId: string, rec: {
269
+ type: string;
270
+ name: string;
271
+ content: string;
272
+ proxied?: boolean;
273
+ ttl?: number;
274
+ }): Promise<{
275
+ id: string;
276
+ name: string;
277
+ content: string;
278
+ }>;
279
+ updateDnsRecord(zoneId: string, recordId: string, rec: {
280
+ type: string;
281
+ name: string;
282
+ content: string;
283
+ proxied?: boolean;
284
+ ttl?: number;
285
+ }): Promise<{
286
+ id: string;
287
+ name: string;
288
+ content: string;
289
+ }>;
290
+ /**
291
+ * Escape hatch (mirrors `CoolifyPort.raw`'s own precedent above) --
292
+ * `@wtfalch/providers` 0.5.0's `CloudflareClient` has no typed method for
293
+ * Health Checks or zone rulesets/rate-limiting rules
294
+ * (https://github.com/wtfalch/foundry/issues/43), only this public,
295
+ * generic `request<T>(method, path, body?, missingOkay?)`, which
296
+ * this port names directly rather than inventing a narrower method no real
297
+ * client could structurally satisfy. `cloudflareRateLimit` below is its one
298
+ * caller today. `missingOkay` makes a 404 resolve to `undefined` instead of
299
+ * throwing, matching `CloudflareClient`'s own real behavior exactly.
300
+ */
301
+ request<T>(method: string, path: string, body?: unknown, missingOkay?: boolean): Promise<T>;
302
+ }
303
+ /**
304
+ * The Stalwart calls this executor makes for one org's mail, shaped to match
305
+ * `@wtfalch/email/postmaster`'s `applyOrgMail`/`parkOrgMail` method-for-method
306
+ * with the `StalwartClient` bound, the same way {@link CloudflarePort} mirrors
307
+ * `CloudflareClient`.
308
+ */
309
+ export interface MailPort {
310
+ applyOrgMail(org: OrgMailOrg, options: OrgMailOptions): Promise<OrgMailResult>;
311
+ parkOrgMail(org: OrgMailOrg, options: OrgMailOptions): Promise<void>;
312
+ }
313
+ /**
314
+ * The ZITADEL calls this executor makes. `findOrCreateOrg`/`findOrCreateProject`
315
+ * already exist on `@wtfalch/providers/zitadel`'s `ZitadelClient` (returning
316
+ * `{org|project, created}`, not the bare `{id}` this campaign's brief
317
+ * sketched — matched to the real shape here, logged as a Decision).
318
+ * `createOidcApp`/`createApiApp`/`deleteApp` do not exist yet — "being added
319
+ * to providers in parallel" (task brief) — so this port states the shapes
320
+ * they need to land with; a `ZitadelClient` that gains exactly these three
321
+ * methods satisfies this port with no adapter.
322
+ */
323
+ export interface ZitadelPort {
324
+ findOrCreateOrg(name: string): Promise<{
325
+ org: {
326
+ id: string;
327
+ name: string;
328
+ };
329
+ created: boolean;
330
+ }>;
331
+ findOrCreateProject(orgId: string, name: string): Promise<{
332
+ project: {
333
+ id: string;
334
+ name: string;
335
+ };
336
+ created: boolean;
337
+ }>;
338
+ createOidcApp(orgId: string, projectId: string, input: {
339
+ name: string;
340
+ appType: 'web' | 'native';
341
+ redirectUris: string[];
342
+ postLogoutRedirectUris: string[];
343
+ }): Promise<{
344
+ appId: string;
345
+ clientId: string;
346
+ }>;
347
+ /**
348
+ * Returns `clientSecret` alongside `appId`/`clientId` — ZITADEL mints it
349
+ * once, on creation, and never shows it again (`providers`' `ZitadelClient.
350
+ * createApiApp`, commit 1d0fec2). The executor hands it straight to
351
+ * {@link FoundryStepExecutorPorts.deliverSecret} and never returns, stores
352
+ * or logs it itself (see that port's doc comment).
353
+ */
354
+ createApiApp(orgId: string, projectId: string, input: {
355
+ name: string;
356
+ }): Promise<{
357
+ appId: string;
358
+ clientId: string;
359
+ clientSecret: string;
360
+ }>;
361
+ deleteApp(orgId: string, projectId: string, appId: string): Promise<void>;
362
+ /**
363
+ * The org-owner flow (goal line): find-or-create the human user by email in
364
+ * the org, then have ZITADEL email them an invite to set up a passkey.
365
+ * Idempotent on the user; the invite itself is not (a re-run always sends a
366
+ * fresh code) — `plan/diff.ts`'s `planOwner` is what makes a *re-run of
367
+ * foundry's own apply* idempotent, by planning nothing once the returned
368
+ * `userId` is recorded. Returns only ids, matching `createApiApp`'s own
369
+ * rule for anything invite/credential-shaped: the email itself is a
370
+ * parameter here, never part of the return value.
371
+ */
372
+ inviteOrgOwner(orgId: string, owner: {
373
+ givenName: string;
374
+ familyName: string;
375
+ email: string;
376
+ }): Promise<{
377
+ userId: string;
378
+ created: boolean;
379
+ }>;
380
+ /** Lets anyone sign up to the org (plan gap A9). Idempotent. */
381
+ allowSelfRegistration(orgId: string): Promise<{
382
+ changed: boolean;
383
+ }>;
384
+ /** Grants another ZITADEL org access to a project (plan gap A9). Idempotent. */
385
+ findOrCreateProjectGrant(orgId: string, projectId: string, grantedOrgId: string): Promise<{
386
+ created: boolean;
387
+ }>;
388
+ /** `FoundryPlanApply.destroy`'s teardown of the org's ZITADEL project, once every app inside it is gone. */
389
+ deleteProject(orgId: string, projectId: string): Promise<void>;
390
+ /** `FoundryPlanApply.destroy`'s last step: removes the whole ZITADEL org and everything foundry made inside it. */
391
+ deleteOrg(orgId: string): Promise<void>;
392
+ }
393
+ /**
394
+ * One org's spend limit on Files, matching `files/packages/sdk/src/client.ts:154-166`'s
395
+ * `setQuota`/`setBudget` (the caller's own organisation, resolved from its
396
+ * signed request — no organisation id parameter, hence a factory per org
397
+ * rather than one flat port).
398
+ */
399
+ export interface FilesOrgLimits {
400
+ setQuota(limitBytes: string | null): Promise<unknown>;
401
+ setBudget(limitMicros: string | null): Promise<unknown>;
402
+ }
403
+ /**
404
+ * One org's budget on AI, matching `ai/packages/sdk/src/client.ts:94-101`'s
405
+ * `updateBudget` (`UpdateBudget` from `ai/packages/sdk/src/types.ts:134-137`).
406
+ */
407
+ export interface AiOrgLimits {
408
+ updateBudget(input: {
409
+ keyId: string | null;
410
+ limitMicros: string;
411
+ }): Promise<unknown>;
412
+ }
413
+ /**
414
+ * Provisions one org's organisation on Files (#80), matching
415
+ * `files/packages/sdk/src/client.ts:136-145`'s
416
+ * `createFilesManagementClient().provisionOrganisation`
417
+ * (`ProvisionOrganisationInput`/`ProvisionOrganisationResult`,
418
+ * `files/packages/sdk/src/types.ts:146-156`). Flat, not a factory, like
419
+ * {@link ZitadelPort}: `organisationId` is an explicit call argument here,
420
+ * unlike {@link FilesOrgLimits}'s tenant-scoped calls, which resolve the
421
+ * caller's own organisation from its signed request instead.
422
+ *
423
+ * Finding (this campaign's report): unlike {@link AiOrganisationPort}'s bearer
424
+ * credential, `createFilesManagementClient`'s whole transport is "a signed
425
+ * human proof or an issued management key" (`files/packages/sdk/src/
426
+ * client.ts:117-119`) — concretely an Ed25519-signed `x-wtfalch-management`
427
+ * request (`files/apps/host/src/lib/authz/service-assertion.ts`'s
428
+ * `signManagementRequest`/`AssertionTrust`). A host can construct this without
429
+ * any human session: it only needs the Ed25519 private key (`SigningIdentity`)
430
+ * an operator's `MANAGEMENT_PUBLIC_KEYS`/`MANAGEMENT_ISSUER` pins — the same
431
+ * signer {@link FilesOrgLimits} needs for `setQuota`/`setBudget`, since the
432
+ * `organisation` field inside the signed claim selects the org per call, so
433
+ * one shared keypair signs for every org rather than needing a secret per
434
+ * org.
435
+ */
436
+ export interface FilesOrganisationPort {
437
+ provisionOrganisation(input: {
438
+ organisationId: string;
439
+ ceilingBytes: string | null;
440
+ ceilingMicros: string | null;
441
+ shareHost?: string | null;
442
+ }): Promise<{
443
+ organisationId: string;
444
+ ceilingBytes: string | null;
445
+ ceilingMicros: string | null;
446
+ usedBytes: string;
447
+ reservedBytes: string;
448
+ spentMicros: string;
449
+ suspended: boolean;
450
+ shareHost: string | null;
451
+ created: boolean;
452
+ }>;
453
+ }
454
+ /**
455
+ * Provisions one org's organisation on AI and switches its AI use on (#80),
456
+ * matching `ai/packages/sdk/src/client.ts:98-108`'s `provisionOrganisation`/
457
+ * `setOrganisationOffers` (`ProvisionOrganisation`/`ProvisionOrganisationResult`,
458
+ * `SetOrganisationOffers`/`OrganisationOffersView`,
459
+ * `ai/packages/sdk/src/types.ts:121-143`). Flat, not a factory, for the same
460
+ * reason as {@link FilesOrganisationPort}.
461
+ *
462
+ * Finding (this campaign's report): AI's machine credential is a plain bearer
463
+ * key — archon's own `AI_MANAGEMENT_KEY`, minted by
464
+ * `ai/apps/host/scripts/authz/bootstrap-archon.ts`'s `keys:issue-archon` with
465
+ * `ARCHON_AI_PERMISSIONS` (`ai.organisations:provision`,
466
+ * `ai.organisations:offer`, `ai.budgets:update`, among others) copied onto
467
+ * it — used directly with `createAiClient`, unlike Files' signed-assertion
468
+ * transport above.
469
+ */
470
+ export interface AiOrganisationPort {
471
+ provisionOrganisation(input: {
472
+ organisationId: string;
473
+ limitMicros: string | null;
474
+ }): Promise<{
475
+ organisationId: string;
476
+ limitMicros: string | null;
477
+ created: boolean;
478
+ }>;
479
+ setOrganisationOffers(organisationId: string, offers: string[]): Promise<{
480
+ organisationId: string;
481
+ offers: string[];
482
+ }>;
483
+ }
484
+ export interface FoundryStepExecutorPorts {
485
+ coolify: CoolifyPort;
486
+ cloudflare: CloudflarePort;
487
+ zitadel: ZitadelPort;
488
+ mail: MailPort;
489
+ /** A factory, not a flat client: Files/AI are org-scoped credentials the host resolves per org. */
490
+ filesLimitsFor: (orgId: OrgId) => FilesOrgLimits;
491
+ aiLimitsFor: (orgId: OrgId) => AiOrgLimits;
492
+ /** Flat, not a factory: `organisationId` is an explicit argument on both ports (see each port's own doc comment). */
493
+ filesOrganisation: FilesOrganisationPort;
494
+ aiOrganisation: AiOrganisationPort;
495
+ /**
496
+ * Hands a just-minted, one-time secret (today: an API app's
497
+ * `clientSecret`) to the host, immediately after the provider returns it
498
+ * and before the step that created it returns. This package "holds no
499
+ * credential and never will" (this file's own top doc comment), so a
500
+ * secret is never a step's return value, never goes into `serviceIds` (the
501
+ * store, hence the DB), and is never logged or interpolated into a thrown
502
+ * error -- this is its one way out of the executor. `ref` names what the
503
+ * secret belongs to (e.g. `zitadel:app:<org.slug>:<key>`); routing it
504
+ * anywhere durable (a keys Worker, a secret store) is the host's job, the
505
+ * same division `executor.ts`'s top comment draws for provider tokens
506
+ * themselves.
507
+ */
508
+ deliverSecret(ref: string, secret: string): Promise<void>;
509
+ }
510
+ /**
511
+ * Thrown by name for a step this executor deliberately does not implement:
512
+ * chat (no admin API exists yet), plus moving the issuer onto or off the
513
+ * org's domain (built, but not in v1 — domain verification and passkey
514
+ * invalidation need their own design). Never carries a token, secret or key
515
+ * (task brief: "errors carry the step and the provider's status only").
516
+ */
517
+ export declare class NotBuiltError extends Error {
518
+ constructor(step: string, detail: string);
519
+ }
520
+ /**
521
+ * The health URLs an outside prober should call for this org (#43): one per
522
+ * app foundry runs for it. A hosting org's app answers at its own hostname,
523
+ * and a site org's site app and cms app both serve `/api/health`
524
+ * (https://github.com/wtfalch/cms/tree/main/apps). An org that runs neither
525
+ * has nothing to probe. Exported so archon, which owns uptime, never repeats
526
+ * this file's hostname rules.
527
+ */
528
+ export declare function healthUrls(org: DeclaredOrg): string[];
529
+ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer {
530
+ private readonly ports;
531
+ constructor(ports: FoundryStepExecutorPorts);
532
+ execute(action: StepAction, context: StepExecutionContext): Promise<{
533
+ resourceId?: string;
534
+ } | undefined>;
535
+ /**
536
+ * `plan/diff.ts`'s `planAccess` (#84): puts the org's app hostname behind a
537
+ * Cloudflare Access app that lets only `hosting.accessEmails` through.
538
+ * foundry names its Access app `foundry:<orgId>` and reuses only an app with
539
+ * that name, so a re-run after a crash does not duplicate it. It refuses,
540
+ * rather than adopt, when another Access app already holds the hostname:
541
+ * destroy would later delete it. It also refuses when its own app carries
542
+ * any policy but foundry's, since that policy could let others in.
543
+ */
544
+ private cloudflareAccess;
545
+ /**
546
+ * `plan/diff.ts`'s `planRateLimit` (#43): one Cloudflare rate limiting
547
+ * rule, scoped to `SIGN_IN_PATH_EXPRESSION`, on the org's own Cloudflare
548
+ * zone -- only ever called for an own-domain org (`planRateLimit`'s own
549
+ * gate), so `org.domain` is set and that zone is the org's alone, never
550
+ * shared with another org the way `oikos.host` is.
551
+ *
552
+ * Rate limiting rules attach to a zone's `http_ratelimit` phase entry
553
+ * point ruleset, one `GET`/`PUT` pair per zone (`@wtfalch/providers` 0.5.0
554
+ * has no typed method for either -- `CloudflarePort.request`'s own doc
555
+ * comment) -- so "find by name" here means reading that one ruleset's
556
+ * `rules[]` and matching `RATE_LIMIT_RULE_DESCRIPTION`, the same shape
557
+ * `cloudflareAccess` above finds its own Access app by name. The Free
558
+ * plan allows exactly one rule per zone, so a rule already there under a
559
+ * different description is refused, never adopted or replaced, mirroring
560
+ * `cloudflareAccess`'s own refusal when a foreign Access app already
561
+ * holds the hostname.
562
+ */
563
+ private cloudflareRateLimit;
564
+ private zitadelIssuer;
565
+ /**
566
+ * `plan/diff.ts`'s `planOwner`: invites the org's owner into its ZITADEL
567
+ * org by email. Never logs, returns or throws the email itself -- only the
568
+ * invited user id, handed back as `resourceId` so `runSteps` records it
569
+ * under `zitadel:owner:<orgId>`, which is exactly what makes the next
570
+ * `plan()` call idempotent (`planOwner` plans nothing once that key is
571
+ * recorded).
572
+ */
573
+ private zitadelOwner;
574
+ /**
575
+ * `plan/diff.ts`'s `planCustomers` (plan gap A9): the org's customers sign
576
+ * in through a second ZITADEL org, `<slug> customers`, with
577
+ * self-registration on and a grant to the org's app project. Staff stay in
578
+ * the org's own invite-only ZITADEL org. The space keeps the name apart from
579
+ * every issuer org, which is named by a slug and a slug has no spaces. Every call here is idempotent, so
580
+ * a re-run after a crash finishes the job.
581
+ */
582
+ private zitadelCustomers;
583
+ private zitadelApp;
584
+ private ensureZitadelProject;
585
+ private ensureCoolifyProject;
586
+ private resolveServer;
587
+ private coolifyApp;
588
+ /**
589
+ * #75: mounts a persistent volume at {@link LOG_DIR} on the app, unless one
590
+ * is already there. Found by `mount_path`, not a recorded id — no
591
+ * `serviceIds` entry exists for a storage the way one does for the app or
592
+ * database itself, and Coolify's own listing is cheap enough to check
593
+ * first every time, the same way `coolifyDatabase` finds its database by
594
+ * name before creating one. Never called from the `enabled === false`
595
+ * (park) branch above, so parking an app never touches its volume either.
596
+ *
597
+ * Mounting a volume on an application already running does not attach it
598
+ * to the live container -- Coolify only binds a storage in at the
599
+ * container's next start (Finding, refuted against the first version of
600
+ * this method: it created the storage and let a passing `listStorages`
601
+ * read report `hasLogVolume: true` on the very next plan, with the
602
+ * container itself still writing nowhere). `wasAlreadyRunning` tells this
603
+ * method whether `appId` names an app `coolifyApp` found already there
604
+ * (restart it once the volume is created) or one it just created fresh
605
+ * (already about to get its first deploy/start from
606
+ * `createGithubAppApplication`'s own instant-deploy path, so a restart
607
+ * here would only be a redundant extra one).
608
+ */
609
+ private ensureLogVolume;
610
+ private coolifyDatabase;
611
+ /**
612
+ * Pushes the database's connection string into the app's environment as
613
+ * `DATABASE_URL` -- never also as `DATABASE_URL_OWNER` (Decision, this
614
+ * campaign's report). Archon's own boot script
615
+ * (`archon/scripts/migrate-and-start.sh`) treats a bare `DATABASE_URL` with
616
+ * no `DATABASE_URL_OWNER` as "before the split": it migrates and serves as
617
+ * the one role it has, which is exactly what a database Coolify just
618
+ * created (one superuser role, no separate runtime role yet) is. Setting
619
+ * both to the *same* superuser URL would instead take the split branch --
620
+ * `db-role-guard.sh` then refuses to boot, since `DATABASE_URL_OWNER` being
621
+ * set means it expects `DATABASE_URL` to connect as a distinct,
622
+ * non-superuser runtime role (`<database>_rt`), which nothing here creates.
623
+ * Minting that role's own password and URL is a provisioning step of its
624
+ * own, not built here (Finding, this campaign's report).
625
+ *
626
+ * The connection string is a live credential
627
+ * (`postgres://user:password@host/db`): it is read from
628
+ * `CoolifyPort.getDatabase` and handed straight to `bulkUpsertEnvs` and
629
+ * nowhere else in this class -- never returned, never recorded in
630
+ * `serviceIds` (hence never in the store or an audit record), never logged,
631
+ * never interpolated into a thrown error.
632
+ */
633
+ private pushDatabaseUrl;
634
+ /**
635
+ * Pushes what the org's app needs to sign a user in through ZITADEL (gap
636
+ * P2, `.plans/2026-09-26-level-1-org-map.md`) -- a second `bulkUpsertEnvs`
637
+ * call rather than folding into `pushDatabaseUrl`'s, since this one runs
638
+ * unconditionally (`coolifyApp`) where that one runs only once
639
+ * `hosting.database` is declared and provisioned.
640
+ *
641
+ * `APP_URL` and `LOG_DIR` (#75) are pushed unconditionally; every other
642
+ * value is pushed only once its own source step has actually recorded it
643
+ * (`context.recordedIds()`) -- on a first-ever apply,
644
+ * run one step at a time in `diff.ts`'s `computePlanDetail` order, that is
645
+ * `zitadel:issuer` (`AUTH_ORGANIZATION_ID`), `zitadel:owner`
646
+ * (`OWNER_USER_ID`, gap P3) and every `zitadel:app` step
647
+ * (`AUTH_CLIENT_ID`), all ordered before `coolify:app` for exactly this
648
+ * reason. AUTH_APP_KEY (waits on an auth broker registration API) and
649
+ * DATABASE_URL_OWNER (a runtime-role split not built yet, `pushDatabaseUrl`'s
650
+ * own doc comment) are out of scope here (task brief).
651
+ */
652
+ private pushAppEnv;
653
+ /**
654
+ * `null` once the app already has one -- `AUTH_COOKIE_SECRET` is generated
655
+ * only the first time (task brief), never regenerated or overwritten, so a
656
+ * later apply's `bulkUpsertEnvs` call simply omits the key rather than
657
+ * upsert a fresh value over it.
658
+ */
659
+ private ensureAuthCookieSecret;
660
+ /**
661
+ * `plan/diff.ts`'s `planSite` (#116, slicing #99's "provision the cms app,
662
+ * database and site app per org"): the 2026-09-25 decision
663
+ * (https://github.com/wtfalch/cms/blob/main/.plans/2026-09-25-org-sites.md)
664
+ * means foundry never calls a cms admin API for the org's site -- it
665
+ * provisions two ordinary Coolify applications from the one
666
+ * https://github.com/wtfalch/cms repo, the same way `coolifyApp` above
667
+ * provisions the org's own hosted app: a site app (`apps/site`, the
668
+ * reference renderer) at `org.domain`, built from
669
+ * `CMS_SITE_APP_DOCKERFILE`, and a cms app (`apps/host`, single-org mode,
670
+ * https://github.com/wtfalch/cms/blob/main/docs/adr/0013-one-cms-per-org.md)
671
+ * at {@link cmsHostnameFor}, built from the repo's own root
672
+ * `CMS_APP_DOCKERFILE` -- one repo, two applications, told apart by
673
+ * `dockerfile_location`.
674
+ *
675
+ * `action.key` is `org.id` (`planSite`'s own singleton key), so `execute`'s
676
+ * own auto-record has only one `resourceId` slot for this step
677
+ * (`cms:site:<orgId>`) -- returned here as the cms app's id, the resource
678
+ * this step is named for. The site app's id has no such slot, so both ids
679
+ * are also recorded as scope ids ({@link CMS_APP_SCOPE_KEY}/
680
+ * {@link SITE_APP_SCOPE_KEY}), read back by the park branch below on a
681
+ * later apply, when nothing gets provisioned to return one -- read
682
+ * independently of each other there, since a crash partway through a
683
+ * first-ever provision can leave only one of the two actually recorded.
684
+ *
685
+ * #117: this step also finds or creates the cms app's own Postgres
686
+ * database ({@link ensureCmsDatabase}) and pushes its owner and runtime
687
+ * connection strings ({@link ensureCmsRuntimeUrl}) onto the cms app, in the
688
+ * same `bulkUpsertEnvs` call as `APP_URL`/`CMS_ORGANISATION_ID`/
689
+ * `CMS_SITE_URL` below. The database's id is recorded as a third flat scope
690
+ * id ({@link CMS_DATABASE_SCOPE_KEY}), for the same reason the two app ids
691
+ * are: no `system:kind:key` slot of its own. Never touched from the park
692
+ * branch above -- parking stops the two apps only, same as #116's own
693
+ * destroy note; this step never parks or destroys the database.
694
+ */
695
+ private cmsSite;
696
+ /**
697
+ * `cmsSite`'s own find-or-create of a Postgres database for the cms app,
698
+ * modeled on `coolifyDatabase` above: `ensureCoolifyProject` (already run
699
+ * by `cmsSite` before this is called) and `findOrCreateEnvironment` give
700
+ * the project/environment this reuses, then `listDatabases` finds one
701
+ * already created by name before `createPostgresDatabase` makes one.
702
+ * Named `<org.slug>-cms-db`, distinct from `coolifyDatabase`'s own
703
+ * `<org.slug>-db`, so the two never collide in the same project for an org
704
+ * that has both.
705
+ *
706
+ * `context.recordedIds()` is checked first, the same short-circuit
707
+ * `coolifyDatabase` itself does not have (that step's own `resourceId` slot
708
+ * already covers it) -- here it is load-bearing, since this database has
709
+ * no `system:kind:key` slot of its own ({@link CMS_DATABASE_SCOPE_KEY}'s
710
+ * doc comment) and `cms:site` runs on every apply once `org.site.enabled`,
711
+ * not just the first.
712
+ *
713
+ * Destroying this database is out of scope here (#117, same as #116's own
714
+ * destroy note for the cms/site apps).
715
+ */
716
+ private ensureCmsDatabase;
717
+ /**
718
+ * `null` once the cms app already has a `DATABASE_URL` -- never rotated,
719
+ * the same rule `ensureAuthCookieSecret` follows for `AUTH_COOKIE_SECRET`,
720
+ * and for the same kind of reason: `@wtfalch/cms`'s own
721
+ * `apps/host/scripts/migrate.mjs` (`ensureRuntimeRole`) creates or updates
722
+ * the live `cms_rt` Postgres role from exactly the username/password this
723
+ * method mints, once, at container boot -- overwriting `DATABASE_URL` on a
724
+ * later apply would leave that already-created role's real password out of
725
+ * sync with whatever foundry hands the app next.
726
+ *
727
+ * The password is minted with `randomBytes` (the same import
728
+ * `ensureAuthCookieSecret` already uses) and encoded `base64url` -- `+`/`/`
729
+ * swapped for `-`/`_`, no `=` padding -- so it is already URL-safe in a
730
+ * `postgres://` URL's userinfo segment with no further percent-encoding
731
+ * needed. `dbInternalUrl` (`getDatabase`'s own `internal_db_url`) is parsed
732
+ * with `URL` for its host, path and query string; its own superuser
733
+ * username and password are discarded -- this mints a distinct, restricted
734
+ * role's own credential, never reuses the superuser's. The query string
735
+ * (`search`) is kept as-is and appended unchanged (#117 review round 2):
736
+ * a parameter there (e.g. `?sslmode=require`) is a connection requirement
737
+ * Coolify itself put on this database, not something specific to the
738
+ * superuser role, so the migration (`DATABASE_URL_OWNER`) and runtime
739
+ * (`DATABASE_URL`) connections must carry the same one.
740
+ *
741
+ * The password is a live credential: built here and handed straight to
742
+ * `cmsSite`'s own `bulkUpsertEnvs` call and nowhere else in this class --
743
+ * never returned, recorded, logged, or interpolated into a thrown error
744
+ * (the same rule `pushDatabaseUrl`'s doc comment states). That rule is why
745
+ * `URL`'s own parse failure is caught below rather than left to propagate:
746
+ * nothing here states or controls the superuser password's alphabet
747
+ * (Coolify's own, unlike the `cms_rt` one minted just below it), and
748
+ * Node's `TypeError [ERR_INVALID_URL]` on an unescaped `/`, `#` or `?` in
749
+ * it carries the *whole* input -- credential included -- on its own
750
+ * `.input` property (never in `.message`, but still reachable by a logger
751
+ * that inspects the error object, not just its message). Caught and
752
+ * turned into "skip `DATABASE_URL` for this apply" instead: `cmsSite`
753
+ * still pushes `DATABASE_URL_OWNER` and its other envs, and a later apply
754
+ * retries this once `listEnvs` still shows no `DATABASE_URL`.
755
+ */
756
+ private ensureCmsRuntimeUrl;
757
+ /**
758
+ * `cmsSite`'s own find-by-name-then-create flow for one of its two
759
+ * applications -- the same shape `coolifyApp` above inlines for its own,
760
+ * single, application, extracted here since `cmsSite` needs it twice in
761
+ * one step. Both of `cmsSite`'s apps share `CMS_REPO`'s own default branch
762
+ * (`main`) -- there is no per-org `gitBranch`/`buildPack` for this repo
763
+ * the way `org.hosting` carries for the org's *own* app, since the cms
764
+ * site is not that app.
765
+ *
766
+ * An app found by name (rather than just created) is started back up if
767
+ * it is not already running or on its way there (#116 review round 3,
768
+ * Finding 1): parking (`cmsSite`'s own `enabled: false` branch) stops both
769
+ * apps without deleting them, so re-enabling finds them here, still
770
+ * stopped, and `ProvidersCmsReader` would otherwise keep reporting
771
+ * `enabled: false` forever -- the plan never converging back to on.
772
+ * `coolifyApp`'s own analogous gap (it never restarted a found-but-stopped
773
+ * app either) has since closed the same way, inline in that method (#130);
774
+ * this method is `cmsSite`'s only caller.
775
+ */
776
+ private findOrCreateCmsApp;
777
+ private cloudflareTunnel;
778
+ private cloudflareZone;
779
+ /**
780
+ * `plan/diff.ts`'s `planMail`: gives an org mail, moves it between modes,
781
+ * or parks it on the one shared Stalwart instance (`this.ports.mail`,
782
+ * `@wtfalch/email/postmaster`'s `applyOrgMail`/`parkOrgMail` bound), then
783
+ * publishes whatever DNS records the call returns.
784
+ */
785
+ private coolifyMail;
786
+ /**
787
+ * Upserts each DNS record `applyOrgMail` says foundry must publish, found
788
+ * by an exact name+type match (`CloudflarePort.findDnsRecord`'s own doc
789
+ * comment) so update never mistakes a stale record of a different type for
790
+ * a match. A `DKIM` record can be absent on the first call, since Stalwart
791
+ * mints the key on a background task; the next apply publishes it.
792
+ */
793
+ private publishMailDnsRecords;
794
+ /**
795
+ * #80: provisions the org's own organisation on Files, before
796
+ * {@link filesStorage} can set its spend limit. `provisionOrganisation` is
797
+ * safely repeatable on its own (`FilesOrganisationPort`'s doc comment), so
798
+ * this is called every time regardless of whether it already ran --
799
+ * idempotent by find-or-create, not by checking `recordedIds` first.
800
+ */
801
+ private filesOrganisation;
802
+ private filesStorage;
803
+ /**
804
+ * #80: provisions the org's own organisation on AI and switches its AI use
805
+ * on, before {@link aiBudget} can set its own spend limit. Both
806
+ * `provisionOrganisation` and `setOrganisationOffers` are safely repeatable
807
+ * on their own (`AiOrganisationPort`'s doc comment), so both are called
808
+ * every time regardless of whether they already ran.
809
+ */
810
+ private aiOrganisation;
811
+ private aiBudget;
812
+ /**
813
+ * {@link StepDestroyer.destroy}: deletes the one resource
814
+ * `FoundryPlanApply.destroy` names. A provider 404 (already gone) counts as
815
+ * deleted -- `isNotFoundError` below -- since a destroy retried after a
816
+ * partial failure must find an already-deleted resource a no-op, not a new
817
+ * error.
818
+ */
819
+ destroy(target: DestroyTarget, context: {
820
+ readonly org: DeclaredOrg;
821
+ readonly recordedIds: Readonly<Record<string, string>>;
822
+ }): Promise<void>;
823
+ }