@wtfalch/foundry 0.1.0 → 0.2.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.
@@ -0,0 +1,474 @@
1
+ import type { DeclaredOrg, HostingBuildPack, OrgId } from '../org.js';
2
+ import type { StepAction } from './diff.js';
3
+ import type { DestroyTarget, StepDestroyer, StepExecutionContext, StepExecutor } from './plan-apply.js';
4
+ /**
5
+ * The real {@link StepExecutor} (#20 "provision Company Factory end to
6
+ * end"): makes each {@link StepAction} `diff.ts` plans true against a real
7
+ * Coolify, Cloudflare and ZITADEL, plus per-org organisations, spend limits
8
+ * and (for AI) offers on Files and AI (#80). Scope and design are exactly
9
+ * what `.claude/decisions-2026-09-24-executor.md`'s "Executor decisions"
10
+ * section settled, referenced by name at each dispatch branch below.
11
+ *
12
+ * Built entirely from narrow ports (interfaces), never a concrete
13
+ * `@wtfalch/providers` client: this package holds no credential and never
14
+ * will (executor decisions: "the executor is constructed with narrow ports
15
+ * ... a host [archon] supplies real clients. How the host gets provider
16
+ * tokens from the keys Worker is the host's job, not this campaign's").
17
+ * Every port method here is named and shaped to match a real method on
18
+ * `@wtfalch/providers`' `CoolifyClient`/`CloudflareClient`/`ZitadelClient`
19
+ * (checked against `providers` `origin/main`, 2026-09-24), or on
20
+ * `@wtfalch/files`'/`@wtfalch/ai`'s own SDK clients (#80, checked against
21
+ * each repo's own `main`, 2026-09-25), so a real client satisfies the port
22
+ * structurally, with no adapter needed — see each port's own doc comment for
23
+ * the one real method it does not yet have.
24
+ */
25
+ /**
26
+ * The Coolify calls this executor makes, shaped to match
27
+ * `@wtfalch/providers/coolify`'s `CoolifyClient` method-for-method — a real
28
+ * `CoolifyClient` instance satisfies this port with no adapter.
29
+ *
30
+ * There is no single `findOrCreateProject`/stop/start on `CoolifyClient`
31
+ * today (unlike ZITADEL's `findOrCreateOrg`/`findOrCreateProject`, which
32
+ * already exist): `findProjectByName` + `createProject` compose into
33
+ * "find or create" inside this executor instead of the port, and `raw` —
34
+ * `CoolifyClient`'s own documented escape hatch for "use — or add — a typed
35
+ * method instead" — carries the stop/start calls Coolify's REST API has
36
+ * (`POST /applications/{uuid}/stop`, `POST /databases/{uuid}/stop`) that
37
+ * `CoolifyClient` has not wrapped yet. Logged as a Decision in this
38
+ * campaign's report rather than inventing port methods (`findOrCreateProject`,
39
+ * `stop`, `start`) no real client could structurally satisfy.
40
+ */
41
+ export interface CoolifyPort {
42
+ findProjectByName(name: string): Promise<{
43
+ uuid: string;
44
+ name: string;
45
+ } | null>;
46
+ createProject(name: string, description?: string): Promise<{
47
+ uuid: string;
48
+ }>;
49
+ findOrCreateEnvironment(projectUuid: string, name: string): Promise<{
50
+ environment: {
51
+ id: number;
52
+ uuid: string;
53
+ name: string;
54
+ };
55
+ created: boolean;
56
+ }>;
57
+ listServers(): Promise<Array<{
58
+ uuid: string;
59
+ is_usable: boolean;
60
+ is_reachable: boolean;
61
+ }>>;
62
+ requireGithubApp(preferredUuid?: string): Promise<{
63
+ uuid: string;
64
+ name: string;
65
+ }>;
66
+ findApplicationByName(name: string, scope?: {
67
+ environmentIds: number[];
68
+ }): Promise<{
69
+ uuid: string;
70
+ } | null>;
71
+ createGithubAppApplication(input: {
72
+ project_uuid: string;
73
+ server_uuid: string;
74
+ environment_name: string;
75
+ environment_uuid: string;
76
+ github_app_uuid: string;
77
+ git_repository: string;
78
+ git_branch: string;
79
+ build_pack: HostingBuildPack;
80
+ name?: string;
81
+ domains?: string;
82
+ }): Promise<{
83
+ uuid: string;
84
+ }>;
85
+ createPostgresDatabase(input: {
86
+ project_uuid: string;
87
+ server_uuid: string;
88
+ environment_name: string;
89
+ environment_uuid: string;
90
+ name: string;
91
+ postgres_db: string;
92
+ postgres_user: string;
93
+ }): Promise<{
94
+ uuid: string;
95
+ internal_db_url: string;
96
+ }>;
97
+ /**
98
+ * Coolify's own record of a database, re-read here (rather than trusted
99
+ * from `createPostgresDatabase`'s return value) so the app's env is wired
100
+ * from the database's live state whether this run just created it or found
101
+ * it already there (`coolifyApp`/`coolifyDatabase` below, hosting-wiring
102
+ * campaign). `internal_db_url` is `postgres://user:password@host/db` — a
103
+ * live credential, present in the raw Coolify API response and reachable
104
+ * only from inside the Coolify Docker network (`@wtfalch/providers`'
105
+ * `CoolifyClient.createPostgresDatabase` doc comment).
106
+ */
107
+ getDatabase(uuid: string): Promise<{
108
+ uuid: string;
109
+ internal_db_url?: string;
110
+ }>;
111
+ /**
112
+ * Every database Coolify holds. `coolifyDatabase` finds its own by name and
113
+ * environment, so a crash between create and persist does not make a second
114
+ * one on the next apply. Only `uuid`, `name` and `environment_id` are read;
115
+ * the raw response also carries `internal_db_url`, which is never used here.
116
+ */
117
+ listDatabases(): Promise<Array<{
118
+ uuid: string;
119
+ name: string;
120
+ environment_id?: number;
121
+ }>>;
122
+ /**
123
+ * Upsert environment variables on an application in one call — used here to
124
+ * push the database's `internal_db_url` in as the app's `DATABASE_URL`
125
+ * (hosting-wiring campaign). The value is a live credential: it must reach
126
+ * this call and nothing else — never a step's return value, `serviceIds`
127
+ * (hence the store), an audit record, a log or a thrown error. See
128
+ * `coolifyApp`'s `pushDatabaseUrl`.
129
+ */
130
+ bulkUpsertEnvs(applicationUuid: string, envs: Array<{
131
+ key: string;
132
+ value: string;
133
+ buildTime?: boolean;
134
+ literal?: boolean;
135
+ multiline?: boolean;
136
+ }>): Promise<unknown>;
137
+ /** Escape hatch (`CoolifyClient.raw`) — used for stop/start, which have no typed method yet. */
138
+ raw(method: string, path: string, body?: unknown): Promise<unknown>;
139
+ /** `FoundryPlanApply.destroy`'s teardown of the org's own app. */
140
+ deleteApplication(uuid: string): Promise<unknown>;
141
+ /** `FoundryPlanApply.destroy`'s teardown of the org's own database. */
142
+ deleteDatabase(uuid: string): Promise<unknown>;
143
+ }
144
+ /**
145
+ * The Cloudflare calls this executor makes, shaped to match
146
+ * `@wtfalch/providers/cloudflare`'s `CloudflareClient` method-for-method.
147
+ */
148
+ export interface CloudflarePort {
149
+ findZoneByApex(domain: string): Promise<{
150
+ id: string;
151
+ name: string;
152
+ } | undefined>;
153
+ createZone(name: string): Promise<{
154
+ id: string;
155
+ name: string;
156
+ }>;
157
+ findTunnelByName(name: string): Promise<{
158
+ id: string;
159
+ name: string;
160
+ } | undefined>;
161
+ createTunnel(name: string): Promise<{
162
+ id: string;
163
+ name: string;
164
+ }>;
165
+ putTunnelConfig(tunnelId: string, config: unknown): Promise<unknown>;
166
+ /** `FoundryPlanApply.destroy`'s teardown of the org's own Cloudflare tunnel. */
167
+ deleteTunnel(tunnelId: string): Promise<void>;
168
+ /**
169
+ * `FoundryPlanApply.destroy`'s teardown of any DNS record foundry created
170
+ * for the org. Nothing in this executor creates or records one yet (Finding,
171
+ * this campaign's report: no `cloudflare:dns` resource kind exists in
172
+ * `diff.ts`/`executor.ts` today), so `destroy` has no dispatch target for
173
+ * it either -- this port method exists ready for whichever step starts
174
+ * creating one.
175
+ */
176
+ deleteDnsRecord(zoneId: string, recordId: string): Promise<void>;
177
+ }
178
+ /**
179
+ * The ZITADEL calls this executor makes. `findOrCreateOrg`/`findOrCreateProject`
180
+ * already exist on `@wtfalch/providers/zitadel`'s `ZitadelClient` (returning
181
+ * `{org|project, created}`, not the bare `{id}` this campaign's brief
182
+ * sketched — matched to the real shape here, logged as a Decision).
183
+ * `createOidcApp`/`createApiApp`/`deleteApp` do not exist yet — "being added
184
+ * to providers in parallel" (task brief) — so this port states the shapes
185
+ * they need to land with; a `ZitadelClient` that gains exactly these three
186
+ * methods satisfies this port with no adapter.
187
+ */
188
+ export interface ZitadelPort {
189
+ findOrCreateOrg(name: string): Promise<{
190
+ org: {
191
+ id: string;
192
+ name: string;
193
+ };
194
+ created: boolean;
195
+ }>;
196
+ findOrCreateProject(orgId: string, name: string): Promise<{
197
+ project: {
198
+ id: string;
199
+ name: string;
200
+ };
201
+ created: boolean;
202
+ }>;
203
+ createOidcApp(orgId: string, projectId: string, input: {
204
+ name: string;
205
+ appType: 'web' | 'native';
206
+ redirectUris: string[];
207
+ postLogoutRedirectUris: string[];
208
+ }): Promise<{
209
+ appId: string;
210
+ clientId: string;
211
+ }>;
212
+ /**
213
+ * Returns `clientSecret` alongside `appId`/`clientId` — ZITADEL mints it
214
+ * once, on creation, and never shows it again (`providers`' `ZitadelClient.
215
+ * createApiApp`, commit 1d0fec2). The executor hands it straight to
216
+ * {@link FoundryStepExecutorPorts.deliverSecret} and never returns, stores
217
+ * or logs it itself (see that port's doc comment).
218
+ */
219
+ createApiApp(orgId: string, projectId: string, input: {
220
+ name: string;
221
+ }): Promise<{
222
+ appId: string;
223
+ clientId: string;
224
+ clientSecret: string;
225
+ }>;
226
+ deleteApp(orgId: string, projectId: string, appId: string): Promise<void>;
227
+ /**
228
+ * The org-owner flow (goal line): find-or-create the human user by email in
229
+ * the org, then have ZITADEL email them an invite to set up a passkey.
230
+ * Idempotent on the user; the invite itself is not (a re-run always sends a
231
+ * fresh code) — `plan/diff.ts`'s `planOwner` is what makes a *re-run of
232
+ * foundry's own apply* idempotent, by planning nothing once the returned
233
+ * `userId` is recorded. Returns only ids, matching `createApiApp`'s own
234
+ * rule for anything invite/credential-shaped: the email itself is a
235
+ * parameter here, never part of the return value.
236
+ */
237
+ inviteOrgOwner(orgId: string, owner: {
238
+ givenName: string;
239
+ familyName: string;
240
+ email: string;
241
+ }): Promise<{
242
+ userId: string;
243
+ created: boolean;
244
+ }>;
245
+ /** Lets anyone sign up to the org (plan gap A9). Idempotent. */
246
+ allowSelfRegistration(orgId: string): Promise<{
247
+ changed: boolean;
248
+ }>;
249
+ /** Grants another ZITADEL org access to a project (plan gap A9). Idempotent. */
250
+ findOrCreateProjectGrant(orgId: string, projectId: string, grantedOrgId: string): Promise<{
251
+ created: boolean;
252
+ }>;
253
+ /** `FoundryPlanApply.destroy`'s teardown of the org's ZITADEL project, once every app inside it is gone. */
254
+ deleteProject(orgId: string, projectId: string): Promise<void>;
255
+ /** `FoundryPlanApply.destroy`'s last step: removes the whole ZITADEL org and everything foundry made inside it. */
256
+ deleteOrg(orgId: string): Promise<void>;
257
+ }
258
+ /**
259
+ * One org's spend limit on Files, matching `files/packages/sdk/src/client.ts:154-166`'s
260
+ * `setQuota`/`setBudget` (the caller's own organisation, resolved from its
261
+ * signed request — no organisation id parameter, hence a factory per org
262
+ * rather than one flat port).
263
+ */
264
+ export interface FilesOrgLimits {
265
+ setQuota(limitBytes: string | null): Promise<unknown>;
266
+ setBudget(limitMicros: string | null): Promise<unknown>;
267
+ }
268
+ /**
269
+ * One org's budget on AI, matching `ai/packages/sdk/src/client.ts:94-101`'s
270
+ * `updateBudget` (`UpdateBudget` from `ai/packages/sdk/src/types.ts:134-137`).
271
+ */
272
+ export interface AiOrgLimits {
273
+ updateBudget(input: {
274
+ keyId: string | null;
275
+ limitMicros: string;
276
+ }): Promise<unknown>;
277
+ }
278
+ /**
279
+ * Provisions one org's organisation on Files (#80), matching
280
+ * `files/packages/sdk/src/client.ts:136-145`'s
281
+ * `createFilesManagementClient().provisionOrganisation`
282
+ * (`ProvisionOrganisationInput`/`ProvisionOrganisationResult`,
283
+ * `files/packages/sdk/src/types.ts:146-156`). Flat, not a factory, like
284
+ * {@link ZitadelPort}: `organisationId` is an explicit call argument here,
285
+ * unlike {@link FilesOrgLimits}'s tenant-scoped calls, which resolve the
286
+ * caller's own organisation from its signed request instead.
287
+ *
288
+ * Finding (this campaign's report): unlike {@link AiOrganisationPort}'s bearer
289
+ * credential, `createFilesManagementClient`'s whole transport is "a signed
290
+ * human proof or an issued management key" (`files/packages/sdk/src/
291
+ * client.ts:117-119`) — concretely an Ed25519-signed `x-wtfalch-management`
292
+ * request (`files/apps/host/src/lib/authz/service-assertion.ts`'s
293
+ * `signManagementRequest`/`AssertionTrust`). A host can construct this without
294
+ * any human session: it only needs the Ed25519 private key (`SigningIdentity`)
295
+ * an operator's `MANAGEMENT_PUBLIC_KEYS`/`MANAGEMENT_ISSUER` pins — the same
296
+ * signer {@link FilesOrgLimits} needs for `setQuota`/`setBudget`, since the
297
+ * `organisation` field inside the signed claim selects the org per call, so
298
+ * one shared keypair signs for every org rather than needing a secret per
299
+ * org.
300
+ */
301
+ export interface FilesOrganisationPort {
302
+ provisionOrganisation(input: {
303
+ organisationId: string;
304
+ ceilingBytes: string | null;
305
+ ceilingMicros: string | null;
306
+ shareHost?: string | null;
307
+ }): Promise<{
308
+ organisationId: string;
309
+ ceilingBytes: string | null;
310
+ ceilingMicros: string | null;
311
+ usedBytes: string;
312
+ reservedBytes: string;
313
+ spentMicros: string;
314
+ suspended: boolean;
315
+ shareHost: string | null;
316
+ created: boolean;
317
+ }>;
318
+ }
319
+ /**
320
+ * Provisions one org's organisation on AI and switches its AI use on (#80),
321
+ * matching `ai/packages/sdk/src/client.ts:98-108`'s `provisionOrganisation`/
322
+ * `setOrganisationOffers` (`ProvisionOrganisation`/`ProvisionOrganisationResult`,
323
+ * `SetOrganisationOffers`/`OrganisationOffersView`,
324
+ * `ai/packages/sdk/src/types.ts:121-143`). Flat, not a factory, for the same
325
+ * reason as {@link FilesOrganisationPort}.
326
+ *
327
+ * Finding (this campaign's report): AI's machine credential is a plain bearer
328
+ * key — archon's own `AI_MANAGEMENT_KEY`, minted by
329
+ * `ai/apps/host/scripts/authz/bootstrap-archon.ts`'s `keys:issue-archon` with
330
+ * `ARCHON_AI_PERMISSIONS` (`ai.organisations:provision`,
331
+ * `ai.organisations:offer`, `ai.budgets:update`, among others) copied onto
332
+ * it — used directly with `createAiClient`, unlike Files' signed-assertion
333
+ * transport above.
334
+ */
335
+ export interface AiOrganisationPort {
336
+ provisionOrganisation(input: {
337
+ organisationId: string;
338
+ limitMicros: string | null;
339
+ }): Promise<{
340
+ organisationId: string;
341
+ limitMicros: string | null;
342
+ created: boolean;
343
+ }>;
344
+ setOrganisationOffers(organisationId: string, offers: string[]): Promise<{
345
+ organisationId: string;
346
+ offers: string[];
347
+ }>;
348
+ }
349
+ export interface FoundryStepExecutorPorts {
350
+ coolify: CoolifyPort;
351
+ cloudflare: CloudflarePort;
352
+ zitadel: ZitadelPort;
353
+ /** A factory, not a flat client: Files/AI are org-scoped credentials the host resolves per org. */
354
+ filesLimitsFor: (orgId: OrgId) => FilesOrgLimits;
355
+ aiLimitsFor: (orgId: OrgId) => AiOrgLimits;
356
+ /** Flat, not a factory: `organisationId` is an explicit argument on both ports (see each port's own doc comment). */
357
+ filesOrganisation: FilesOrganisationPort;
358
+ aiOrganisation: AiOrganisationPort;
359
+ /**
360
+ * Hands a just-minted, one-time secret (today: an API app's
361
+ * `clientSecret`) to the host, immediately after the provider returns it
362
+ * and before the step that created it returns. This package "holds no
363
+ * credential and never will" (this file's own top doc comment), so a
364
+ * secret is never a step's return value, never goes into `serviceIds` (the
365
+ * store, hence the DB), and is never logged or interpolated into a thrown
366
+ * error -- this is its one way out of the executor. `ref` names what the
367
+ * secret belongs to (e.g. `zitadel:app:<org.slug>:<key>`); routing it
368
+ * anywhere durable (a keys Worker, a secret store) is the host's job, the
369
+ * same division `executor.ts`'s top comment draws for provider tokens
370
+ * themselves.
371
+ */
372
+ deliverSecret(ref: string, secret: string): Promise<void>;
373
+ }
374
+ /**
375
+ * Thrown by name for a step this executor deliberately does not implement:
376
+ * mail and chat (no admin API exists yet in either service) and the cms
377
+ * site (same), plus moving the issuer onto or off the org's domain (built,
378
+ * but not in v1 — domain verification and passkey invalidation need their
379
+ * own design). Never carries a token, secret or key (task brief: "errors
380
+ * carry the step and the provider's status only").
381
+ */
382
+ export declare class NotBuiltError extends Error {
383
+ constructor(step: string, detail: string);
384
+ }
385
+ /** `coolify:app` needs source code to build from; `DeclaredOrg.bespokeAppRepo` is the only source this executor knows (#71, #20). */
386
+ export declare class MissingBespokeAppRepoError extends Error {
387
+ constructor(slug: string);
388
+ }
389
+ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer {
390
+ private readonly ports;
391
+ constructor(ports: FoundryStepExecutorPorts);
392
+ execute(action: StepAction, context: StepExecutionContext): Promise<{
393
+ resourceId?: string;
394
+ } | undefined>;
395
+ private zitadelIssuer;
396
+ /**
397
+ * `plan/diff.ts`'s `planOwner`: invites the org's owner into its ZITADEL
398
+ * org by email. Never logs, returns or throws the email itself -- only the
399
+ * invited user id, handed back as `resourceId` so `runSteps` records it
400
+ * under `zitadel:owner:<orgId>`, which is exactly what makes the next
401
+ * `plan()` call idempotent (`planOwner` plans nothing once that key is
402
+ * recorded).
403
+ */
404
+ private zitadelOwner;
405
+ /**
406
+ * `plan/diff.ts`'s `planCustomers` (plan gap A9): the org's customers sign
407
+ * in through a second ZITADEL org, `<slug> customers`, with
408
+ * self-registration on and a grant to the org's app project. Staff stay in
409
+ * the org's own invite-only ZITADEL org. The space keeps the name apart from
410
+ * every issuer org, which is named by a slug and a slug has no spaces. Every call here is idempotent, so
411
+ * a re-run after a crash finishes the job.
412
+ */
413
+ private zitadelCustomers;
414
+ private zitadelApp;
415
+ private ensureZitadelProject;
416
+ private ensureCoolifyProject;
417
+ private resolveServer;
418
+ private coolifyApp;
419
+ private coolifyDatabase;
420
+ /**
421
+ * Pushes the database's connection string into the app's environment as
422
+ * `DATABASE_URL` -- never also as `DATABASE_URL_OWNER` (Decision, this
423
+ * campaign's report). Archon's own boot script
424
+ * (`archon/scripts/migrate-and-start.sh`) treats a bare `DATABASE_URL` with
425
+ * no `DATABASE_URL_OWNER` as "before the split": it migrates and serves as
426
+ * the one role it has, which is exactly what a database Coolify just
427
+ * created (one superuser role, no separate runtime role yet) is. Setting
428
+ * both to the *same* superuser URL would instead take the split branch --
429
+ * `db-role-guard.sh` then refuses to boot, since `DATABASE_URL_OWNER` being
430
+ * set means it expects `DATABASE_URL` to connect as a distinct,
431
+ * non-superuser runtime role (`<database>_rt`), which nothing here creates.
432
+ * Minting that role's own password and URL is a provisioning step of its
433
+ * own, not built here (Finding, this campaign's report).
434
+ *
435
+ * The connection string is a live credential
436
+ * (`postgres://user:password@host/db`): it is read from
437
+ * `CoolifyPort.getDatabase` and handed straight to `bulkUpsertEnvs` and
438
+ * nowhere else in this class -- never returned, never recorded in
439
+ * `serviceIds` (hence never in the store or an audit record), never logged,
440
+ * never interpolated into a thrown error.
441
+ */
442
+ private pushDatabaseUrl;
443
+ private cloudflareTunnel;
444
+ private cloudflareZone;
445
+ /**
446
+ * #80: provisions the org's own organisation on Files, before
447
+ * {@link filesStorage} can set its spend limit. `provisionOrganisation` is
448
+ * safely repeatable on its own (`FilesOrganisationPort`'s doc comment), so
449
+ * this is called every time regardless of whether it already ran --
450
+ * idempotent by find-or-create, not by checking `recordedIds` first.
451
+ */
452
+ private filesOrganisation;
453
+ private filesStorage;
454
+ /**
455
+ * #80: provisions the org's own organisation on AI and switches its AI use
456
+ * on, before {@link aiBudget} can set its own spend limit. Both
457
+ * `provisionOrganisation` and `setOrganisationOffers` are safely repeatable
458
+ * on their own (`AiOrganisationPort`'s doc comment), so both are called
459
+ * every time regardless of whether they already ran.
460
+ */
461
+ private aiOrganisation;
462
+ private aiBudget;
463
+ /**
464
+ * {@link StepDestroyer.destroy}: deletes the one resource
465
+ * `FoundryPlanApply.destroy` names. A provider 404 (already gone) counts as
466
+ * deleted -- `isNotFoundError` below -- since a destroy retried after a
467
+ * partial failure must find an already-deleted resource a no-op, not a new
468
+ * error.
469
+ */
470
+ destroy(target: DestroyTarget, context: {
471
+ readonly org: DeclaredOrg;
472
+ readonly recordedIds: Readonly<Record<string, string>>;
473
+ }): Promise<void>;
474
+ }