@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,507 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Errors
3
+ // ---------------------------------------------------------------------------
4
+ /**
5
+ * Thrown by name for a step this executor deliberately does not implement:
6
+ * mail and chat (no admin API exists yet in either service) and the cms
7
+ * site (same), plus moving the issuer onto or off the org's domain (built,
8
+ * but not in v1 — domain verification and passkey invalidation need their
9
+ * own design). Never carries a token, secret or key (task brief: "errors
10
+ * carry the step and the provider's status only").
11
+ */
12
+ export class NotBuiltError extends Error {
13
+ constructor(step, detail) {
14
+ super(`${step} is not built yet -- ${detail}`);
15
+ this.name = 'NotBuiltError';
16
+ }
17
+ }
18
+ /** `coolify:app` needs source code to build from; `DeclaredOrg.bespokeAppRepo` is the only source this executor knows (#71, #20). */
19
+ export class MissingBespokeAppRepoError extends Error {
20
+ constructor(slug) {
21
+ super(`${slug} has no declared bespokeAppRepo; coolify:app cannot provision a hosted app without one`);
22
+ this.name = 'MissingBespokeAppRepoError';
23
+ }
24
+ }
25
+ // ---------------------------------------------------------------------------
26
+ // Hostnames
27
+ // ---------------------------------------------------------------------------
28
+ /**
29
+ * `<slug>.oikos.host` (`../slug.ts`), or `DeclaredOrg.domain` once
30
+ * `hosting.usesDomain` is true — the same rule `diff.ts`'s `planApp` and
31
+ * `planSite` document, reused here for the org's own hosted app, its
32
+ * Cloudflare tunnel ingress and its ZITADEL issuer apps' redirect URIs
33
+ * (task brief: "Hostnames: `<slug>.oikos.host` ... or `DeclaredOrg.domain`
34
+ * when `hosting.usesDomain`").
35
+ */
36
+ function hostnameFor(org) {
37
+ return org.hosting.usesDomain && org.domain ? org.domain : `${org.slug}.oikos.host`;
38
+ }
39
+ /**
40
+ * Every app on the Coolify box shares one Traefik reverse proxy, and Coolify
41
+ * itself points that proxy's routing at whatever `domains` an application
42
+ * declares (`coolifyApp`'s `hostnameFor(org)`) -- so a tunnel's ingress only
43
+ * has to reach the proxy, never a specific app container.
44
+ * `wtfalch/app-template`'s bootstrap (`bootstrap/steps/cf-tunnel.ts`, prior
45
+ * art for the exact org this file's `cloudflareTunnel` implements, cited by
46
+ * https://github.com/wtfalch/foundry/issues/71) names this same constant and
47
+ * value.
48
+ */
49
+ const COOLIFY_PROXY_ORIGIN = 'http://coolify-proxy:80';
50
+ // ---------------------------------------------------------------------------
51
+ // Executor
52
+ // ---------------------------------------------------------------------------
53
+ export class FoundryStepExecutor {
54
+ ports;
55
+ constructor(ports) {
56
+ this.ports = ports;
57
+ }
58
+ async execute(action, context) {
59
+ const { org } = context;
60
+ switch (`${action.system}:${action.kind}`) {
61
+ case 'zitadel:issuer':
62
+ return this.zitadelIssuer(action, org, context);
63
+ case 'zitadel:owner':
64
+ return this.zitadelOwner(action, org, context);
65
+ case 'zitadel:app':
66
+ return this.zitadelApp(action, org, context);
67
+ case 'zitadel:customers':
68
+ return this.zitadelCustomers(org, context);
69
+ case 'coolify:app':
70
+ return this.coolifyApp(action, org, context);
71
+ case 'coolify:database':
72
+ return this.coolifyDatabase(action, org, context);
73
+ case 'cloudflare:tunnel':
74
+ return this.cloudflareTunnel(action, org, context);
75
+ case 'cloudflare:zone':
76
+ return this.cloudflareZone(action);
77
+ case 'files:organisation':
78
+ return this.filesOrganisation(action, org);
79
+ case 'files:storage':
80
+ return this.filesStorage(action, org);
81
+ case 'ai:organisation':
82
+ return this.aiOrganisation(action, org);
83
+ case 'ai:budget':
84
+ return this.aiBudget(action, org);
85
+ case 'coolify:mail':
86
+ throw new NotBuiltError(`coolify:mail for ${org.slug}`, 'mail has no Stalwart admin API yet; tracked at https://github.com/wtfalch/email/issues/96');
87
+ case 'coolify:chat':
88
+ throw new NotBuiltError(`coolify:chat for ${org.slug}`, "chat's per-org Synapse+MAS has no admin API yet; tracked at https://github.com/wtfalch/chat/issues/40");
89
+ case 'cms:site':
90
+ throw new NotBuiltError(`cms:site for ${org.slug}`, 'the cms site has no admin API yet; tracked at https://github.com/wtfalch/cms/issues/20');
91
+ default:
92
+ throw new Error(`FoundryStepExecutor has no dispatch for ${action.system}:${action.kind}`);
93
+ }
94
+ }
95
+ // ---- zitadel --------------------------------------------------------
96
+ async zitadelIssuer(action, org, context) {
97
+ if (action.desired.provision !== true) {
98
+ // Moving an already-provisioned issuer onto or off the org's domain
99
+ // (decisions file: "domain verification and passkey invalidation need
100
+ // their own design" -- not built in v1).
101
+ throw new NotBuiltError(`zitadel:issuer move for ${org.slug}`, 'moving the issuer onto or off the org\'s own domain is not built in v1; see foundry\'s .claude/decisions-2026-09-24-executor.md, "Executor decisions"');
102
+ }
103
+ const { org: zOrg } = await this.ports.zitadel.findOrCreateOrg(org.slug);
104
+ context.recordScopeId('zitadel_org', zOrg.id);
105
+ return { resourceId: zOrg.id };
106
+ }
107
+ /**
108
+ * `plan/diff.ts`'s `planOwner`: invites the org's owner into its ZITADEL
109
+ * org by email. Never logs, returns or throws the email itself -- only the
110
+ * invited user id, handed back as `resourceId` so `runSteps` records it
111
+ * under `zitadel:owner:<orgId>`, which is exactly what makes the next
112
+ * `plan()` call idempotent (`planOwner` plans nothing once that key is
113
+ * recorded).
114
+ */
115
+ async zitadelOwner(action, org, context) {
116
+ const zitadelOrgId = context.recordedIds().zitadel_org;
117
+ if (!zitadelOrgId) {
118
+ throw new Error(`zitadel:owner for ${org.slug}: no ZITADEL org recorded yet -- the issuer must be provisioned first`);
119
+ }
120
+ const { userId } = await this.ports.zitadel.inviteOrgOwner(zitadelOrgId, {
121
+ givenName: action.desired.givenName,
122
+ familyName: action.desired.familyName,
123
+ email: action.desired.email,
124
+ });
125
+ return { resourceId: userId };
126
+ }
127
+ /**
128
+ * `plan/diff.ts`'s `planCustomers` (plan gap A9): the org's customers sign
129
+ * in through a second ZITADEL org, `<slug> customers`, with
130
+ * self-registration on and a grant to the org's app project. Staff stay in
131
+ * the org's own invite-only ZITADEL org. The space keeps the name apart from
132
+ * every issuer org, which is named by a slug and a slug has no spaces. Every call here is idempotent, so
133
+ * a re-run after a crash finishes the job.
134
+ */
135
+ async zitadelCustomers(org, context) {
136
+ const zitadelOrgId = context.recordedIds().zitadel_org;
137
+ if (!zitadelOrgId) {
138
+ throw new Error(`zitadel:customers for ${org.slug}: no ZITADEL org recorded yet -- the issuer must be provisioned first`);
139
+ }
140
+ const projectId = await this.ensureZitadelProject(org, zitadelOrgId, context);
141
+ const { org: customers } = await this.ports.zitadel.findOrCreateOrg(`${org.slug} customers`);
142
+ await this.ports.zitadel.allowSelfRegistration(customers.id);
143
+ await this.ports.zitadel.findOrCreateProjectGrant(zitadelOrgId, projectId, customers.id);
144
+ return { resourceId: customers.id };
145
+ }
146
+ async zitadelApp(action, org, context) {
147
+ const appType = action.desired.appType;
148
+ const recorded = context.recordedIds();
149
+ const zitadelOrgId = recorded.zitadel_org;
150
+ if (!zitadelOrgId) {
151
+ throw new Error(`zitadel:app '${action.key}' for ${org.slug}: no ZITADEL org recorded yet -- the issuer must be provisioned first`);
152
+ }
153
+ const projectId = await this.ensureZitadelProject(org, zitadelOrgId, context);
154
+ // A kind change deletes the recorded app then recreates it (task
155
+ // brief); the first-ever create for this key has nothing recorded, so
156
+ // this is a no-op then.
157
+ const priorAppId = recorded[`zitadel:app:${action.key}`];
158
+ if (priorAppId) {
159
+ await this.ports.zitadel.deleteApp(zitadelOrgId, projectId, priorAppId);
160
+ }
161
+ if (appType === 'api') {
162
+ const created = await this.ports.zitadel.createApiApp(zitadelOrgId, projectId, {
163
+ name: action.key,
164
+ });
165
+ // Handed off once, right here, before returning -- never in the
166
+ // return value, `serviceIds`, the store, a log or a thrown error (see
167
+ // `FoundryStepExecutorPorts.deliverSecret`'s doc comment).
168
+ await this.ports.deliverSecret(`zitadel:app:${org.slug}:${action.key}`, created.clientSecret);
169
+ return { resourceId: created.appId };
170
+ }
171
+ const hostname = hostnameFor(org);
172
+ // Native apps sign in via device code, with no redirect of their own
173
+ // (auth/scripts/provision.mjs:475-497, prior art cited in the decisions
174
+ // file) -- empty arrays rather than a web app's callback/logout URLs.
175
+ const created = await this.ports.zitadel.createOidcApp(zitadelOrgId, projectId, {
176
+ name: action.key,
177
+ appType,
178
+ redirectUris: appType === 'native' ? [] : [`https://${hostname}/auth/callback`],
179
+ postLogoutRedirectUris: appType === 'native' ? [] : [`https://${hostname}/`],
180
+ });
181
+ return { resourceId: created.appId };
182
+ }
183
+ async ensureZitadelProject(org, zitadelOrgId, context) {
184
+ const recorded = context.recordedIds();
185
+ const existing = recorded.zitadel_project;
186
+ if (existing)
187
+ return existing;
188
+ const { project } = await this.ports.zitadel.findOrCreateProject(zitadelOrgId, org.slug);
189
+ context.recordScopeId('zitadel_project', project.id);
190
+ return project.id;
191
+ }
192
+ // ---- coolify ---------------------------------------------------------
193
+ async ensureCoolifyProject(org, context) {
194
+ const recorded = context.recordedIds();
195
+ const existing = recorded.coolify_project;
196
+ if (existing)
197
+ return existing;
198
+ const found = await this.ports.coolify.findProjectByName(org.slug);
199
+ const projectUuid = found
200
+ ? found.uuid
201
+ : (await this.ports.coolify.createProject(org.slug)).uuid;
202
+ context.recordScopeId('coolify_project', projectUuid);
203
+ return projectUuid;
204
+ }
205
+ async resolveServer() {
206
+ const servers = await this.ports.coolify.listServers();
207
+ const server = servers.find((s) => s.is_usable && s.is_reachable) ?? servers[0];
208
+ if (!server)
209
+ throw new Error('no usable Coolify server is registered on this instance');
210
+ return server;
211
+ }
212
+ async coolifyApp(action, org, context) {
213
+ if (action.desired.enabled === false) {
214
+ const appId = context.recordedIds()[`coolify:app:${action.key}`];
215
+ if (!appId) {
216
+ throw new Error(`coolify:app for ${org.slug}: no live app recorded, nothing to park`);
217
+ }
218
+ await this.ports.coolify.raw('POST', `/applications/${appId}/stop`);
219
+ return { resourceId: appId };
220
+ }
221
+ if (!org.bespokeAppRepo)
222
+ throw new MissingBespokeAppRepoError(org.slug);
223
+ const projectUuid = await this.ensureCoolifyProject(org, context);
224
+ const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
225
+ const existing = await this.ports.coolify.findApplicationByName(org.slug, {
226
+ environmentIds: [environment.id],
227
+ });
228
+ let appId;
229
+ if (existing) {
230
+ appId = existing.uuid;
231
+ }
232
+ else {
233
+ const server = await this.resolveServer();
234
+ const githubApp = await this.ports.coolify.requireGithubApp();
235
+ const created = await this.ports.coolify.createGithubAppApplication({
236
+ project_uuid: projectUuid,
237
+ server_uuid: server.uuid,
238
+ environment_name: environment.name,
239
+ environment_uuid: environment.uuid,
240
+ github_app_uuid: githubApp.uuid,
241
+ git_repository: org.bespokeAppRepo,
242
+ git_branch: org.hosting.gitBranch,
243
+ build_pack: org.hosting.buildPack,
244
+ name: org.slug,
245
+ domains: hostnameFor(org),
246
+ });
247
+ appId = created.uuid;
248
+ }
249
+ // The database plans before the app (`diff.ts`'s `computePlanDetail`,
250
+ // hosting-wiring campaign) so that, on the same apply, the database's
251
+ // resource id is already recorded by the time this runs -- but this also
252
+ // covers the database being turned on after the app already exists (the
253
+ // app step still ran this apply, found the app by name, and lands here).
254
+ if (org.hosting.database) {
255
+ const dbId = context.recordedIds()[`coolify:database:${action.key}`];
256
+ if (dbId)
257
+ await this.pushDatabaseUrl(appId, dbId);
258
+ }
259
+ return { resourceId: appId };
260
+ }
261
+ async coolifyDatabase(action, org, context) {
262
+ if (action.desired.enabled === false) {
263
+ const dbId = context.recordedIds()[`coolify:database:${action.key}`];
264
+ if (!dbId) {
265
+ throw new Error(`coolify:database for ${org.slug}: no live database recorded, nothing to park`);
266
+ }
267
+ await this.ports.coolify.raw('POST', `/databases/${dbId}/stop`);
268
+ return { resourceId: dbId };
269
+ }
270
+ const existing = context.recordedIds()[`coolify:database:${action.key}`];
271
+ let dbId;
272
+ if (existing) {
273
+ dbId = existing;
274
+ }
275
+ else {
276
+ const projectUuid = await this.ensureCoolifyProject(org, context);
277
+ const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
278
+ const name = `${org.slug}-db`;
279
+ const found = (await this.ports.coolify.listDatabases()).find((db) => db.name === name && db.environment_id === environment.id);
280
+ if (found) {
281
+ dbId = found.uuid;
282
+ }
283
+ else {
284
+ const server = await this.resolveServer();
285
+ const dbName = org.slug.replace(/-/g, '_');
286
+ const created = await this.ports.coolify.createPostgresDatabase({
287
+ project_uuid: projectUuid,
288
+ server_uuid: server.uuid,
289
+ environment_name: environment.name,
290
+ environment_uuid: environment.uuid,
291
+ name,
292
+ postgres_db: dbName,
293
+ postgres_user: dbName,
294
+ });
295
+ dbId = created.uuid;
296
+ }
297
+ }
298
+ // Handles the database being provisioned after the app already exists
299
+ // (`coolifyApp`'s own push handles the more common order, database
300
+ // before app on the same apply -- `diff.ts`'s `computePlanDetail`).
301
+ const appId = context.recordedIds()[`coolify:app:${action.key}`];
302
+ if (appId)
303
+ await this.pushDatabaseUrl(appId, dbId);
304
+ return { resourceId: dbId };
305
+ }
306
+ /**
307
+ * Pushes the database's connection string into the app's environment as
308
+ * `DATABASE_URL` -- never also as `DATABASE_URL_OWNER` (Decision, this
309
+ * campaign's report). Archon's own boot script
310
+ * (`archon/scripts/migrate-and-start.sh`) treats a bare `DATABASE_URL` with
311
+ * no `DATABASE_URL_OWNER` as "before the split": it migrates and serves as
312
+ * the one role it has, which is exactly what a database Coolify just
313
+ * created (one superuser role, no separate runtime role yet) is. Setting
314
+ * both to the *same* superuser URL would instead take the split branch --
315
+ * `db-role-guard.sh` then refuses to boot, since `DATABASE_URL_OWNER` being
316
+ * set means it expects `DATABASE_URL` to connect as a distinct,
317
+ * non-superuser runtime role (`<database>_rt`), which nothing here creates.
318
+ * Minting that role's own password and URL is a provisioning step of its
319
+ * own, not built here (Finding, this campaign's report).
320
+ *
321
+ * The connection string is a live credential
322
+ * (`postgres://user:password@host/db`): it is read from
323
+ * `CoolifyPort.getDatabase` and handed straight to `bulkUpsertEnvs` and
324
+ * nowhere else in this class -- never returned, never recorded in
325
+ * `serviceIds` (hence never in the store or an audit record), never logged,
326
+ * never interpolated into a thrown error.
327
+ */
328
+ async pushDatabaseUrl(appId, dbId) {
329
+ const db = await this.ports.coolify.getDatabase(dbId);
330
+ if (!db.internal_db_url)
331
+ return;
332
+ await this.ports.coolify.bulkUpsertEnvs(appId, [
333
+ { key: 'DATABASE_URL', value: db.internal_db_url },
334
+ ]);
335
+ }
336
+ // ---- cloudflare --------------------------------------------------------
337
+ async cloudflareTunnel(action, org, context) {
338
+ if (action.desired.enabled === false) {
339
+ const tunnelId = context.recordedIds()[`cloudflare:tunnel:${action.key}`];
340
+ if (!tunnelId) {
341
+ throw new Error(`cloudflare:tunnel for ${org.slug}: no live tunnel recorded, nothing to park`);
342
+ }
343
+ // Cleared, not deleted (#7 corrections 1): an ingress with only the
344
+ // mandatory catch-all routes nothing.
345
+ await this.ports.cloudflare.putTunnelConfig(tunnelId, {
346
+ ingress: [{ service: 'http_status:404' }],
347
+ });
348
+ return { resourceId: tunnelId };
349
+ }
350
+ const tunnelName = `${org.slug}-tunnel`;
351
+ const existing = await this.ports.cloudflare.findTunnelByName(tunnelName);
352
+ const tunnel = existing ?? (await this.ports.cloudflare.createTunnel(tunnelName));
353
+ const hostname = hostnameFor(org);
354
+ // Routes the app's own hostname to Coolify's own Traefik reverse proxy,
355
+ // not a per-app container address -- `COOLIFY_PROXY_ORIGIN`, the same
356
+ // constant name and value `wtfalch/app-template`'s prior art used
357
+ // (`bootstrap/steps/cf-tunnel.ts`, offline.tf's bootstrap, cited by
358
+ // https://github.com/wtfalch/foundry/issues/71): every app on the
359
+ // Coolify box already gets a Traefik route from the `domains` its
360
+ // `coolify:app` step declared (`hostnameFor(org)`, this file's
361
+ // `coolifyApp`), so a cloudflared connector joined to the same Docker
362
+ // network only has to reach Traefik and let it route by Host header --
363
+ // the connector never needs to know the app's own container name or
364
+ // uuid. Verified evidence, not a guess (see this campaign's report);
365
+ // `http://<slug>:3000`, the placeholder this replaces, named a container
366
+ // and port no `coolify:app` step here ever configures.
367
+ await this.ports.cloudflare.putTunnelConfig(tunnel.id, {
368
+ ingress: [{ hostname, service: COOLIFY_PROXY_ORIGIN }, { service: 'http_status:404' }],
369
+ });
370
+ return { resourceId: tunnel.id };
371
+ }
372
+ async cloudflareZone(action) {
373
+ const domain = action.key;
374
+ const existing = await this.ports.cloudflare.findZoneByApex(domain);
375
+ if (existing)
376
+ return { resourceId: existing.id };
377
+ const created = await this.ports.cloudflare.createZone(domain);
378
+ return { resourceId: created.id };
379
+ }
380
+ // ---- files / ai --------------------------------------------------------
381
+ /**
382
+ * #80: provisions the org's own organisation on Files, before
383
+ * {@link filesStorage} can set its spend limit. `provisionOrganisation` is
384
+ * safely repeatable on its own (`FilesOrganisationPort`'s doc comment), so
385
+ * this is called every time regardless of whether it already ran --
386
+ * idempotent by find-or-create, not by checking `recordedIds` first.
387
+ */
388
+ async filesOrganisation(action, org) {
389
+ const provisioned = await this.ports.filesOrganisation.provisionOrganisation({
390
+ organisationId: org.id,
391
+ ceilingBytes: action.desired.ceilingBytes ?? null,
392
+ ceilingMicros: action.desired.ceilingMicros ?? null,
393
+ });
394
+ return { resourceId: provisioned.organisationId };
395
+ }
396
+ async filesStorage(action, org) {
397
+ const limits = this.ports.filesLimitsFor(org.id);
398
+ await limits.setQuota(action.desired.limitBytes ?? null);
399
+ await limits.setBudget(action.desired.limitMicros ?? null);
400
+ return { resourceId: org.id };
401
+ }
402
+ /**
403
+ * #80: provisions the org's own organisation on AI and switches its AI use
404
+ * on, before {@link aiBudget} can set its own spend limit. Both
405
+ * `provisionOrganisation` and `setOrganisationOffers` are safely repeatable
406
+ * on their own (`AiOrganisationPort`'s doc comment), so both are called
407
+ * every time regardless of whether they already ran.
408
+ */
409
+ async aiOrganisation(action, org) {
410
+ const provisioned = await this.ports.aiOrganisation.provisionOrganisation({
411
+ organisationId: org.id,
412
+ limitMicros: action.desired.limitMicros ?? null,
413
+ });
414
+ await this.ports.aiOrganisation.setOrganisationOffers(org.id, action.desired.offers ?? []);
415
+ return { resourceId: provisioned.organisationId };
416
+ }
417
+ async aiBudget(action, org) {
418
+ const limitMicros = action.desired.limitMicros ?? null;
419
+ if (limitMicros === null) {
420
+ // ai/packages/sdk/src/types.ts:134-137's UpdateBudget.limitMicros is a
421
+ // required string -- there is no "uncapped" this API can express yet
422
+ // (Finding, this campaign's report).
423
+ throw new Error(`ai:budget for ${org.slug}: AI's updateBudget has no uncapped representation (UpdateBudget.limitMicros is a required string); declare a numeric limit until AI's API adds one`);
424
+ }
425
+ const limits = this.ports.aiLimitsFor(org.id);
426
+ await limits.updateBudget({ keyId: null, limitMicros });
427
+ return { resourceId: org.id };
428
+ }
429
+ // ---- destroy -------------------------------------------------------------
430
+ /**
431
+ * {@link StepDestroyer.destroy}: deletes the one resource
432
+ * `FoundryPlanApply.destroy` names. A provider 404 (already gone) counts as
433
+ * deleted -- `isNotFoundError` below -- since a destroy retried after a
434
+ * partial failure must find an already-deleted resource a no-op, not a new
435
+ * error.
436
+ */
437
+ async destroy(target, context) {
438
+ const { org, recordedIds } = context;
439
+ try {
440
+ switch (`${target.system}:${target.kind}`) {
441
+ case 'cloudflare:tunnel':
442
+ await this.ports.cloudflare.deleteTunnel(target.id);
443
+ return;
444
+ case 'coolify:app':
445
+ await this.ports.coolify.deleteApplication(target.id);
446
+ return;
447
+ case 'coolify:database':
448
+ await this.ports.coolify.deleteDatabase(target.id);
449
+ return;
450
+ case 'zitadel:app': {
451
+ const zitadelOrgId = recordedIds.zitadel_org;
452
+ const projectId = recordedIds.zitadel_project;
453
+ if (!zitadelOrgId || !projectId) {
454
+ throw new Error(`zitadel:app '${target.key}' for ${org.slug}: no ZITADEL org/project recorded, cannot delete`);
455
+ }
456
+ await this.ports.zitadel.deleteApp(zitadelOrgId, projectId, target.id);
457
+ return;
458
+ }
459
+ case 'zitadel:project': {
460
+ const zitadelOrgId = recordedIds.zitadel_org;
461
+ if (!zitadelOrgId) {
462
+ throw new Error(`zitadel:project for ${org.slug}: no ZITADEL org recorded, cannot delete project`);
463
+ }
464
+ await this.ports.zitadel.deleteProject(zitadelOrgId, target.id);
465
+ return;
466
+ }
467
+ case 'zitadel:customers':
468
+ case 'zitadel:org':
469
+ await this.ports.zitadel.deleteOrg(target.id);
470
+ return;
471
+ default:
472
+ throw new Error(`FoundryStepExecutor has no destroy dispatch for ${target.system}:${target.kind}`);
473
+ }
474
+ }
475
+ catch (error) {
476
+ if (isNotFoundError(error))
477
+ return;
478
+ throw error;
479
+ }
480
+ }
481
+ }
482
+ /**
483
+ * True for a provider's own "already gone" response, however it surfaces --
484
+ * `@wtfalch/providers`' `ZitadelError` carries a structured `status: number`
485
+ * (`zitadel.ts`), while its Coolify and Cloudflare clients throw a plain
486
+ * `Error` whose message embeds the HTTP status (`` `Coolify ${method} ${path}
487
+ * → ${status}: ${text}` ``, `` `CF ${method} ${path} → ${status}: ${msg}` ``).
488
+ * Ports are structural here (this file's own top comment: never import a
489
+ * concrete provider class), so this cannot `instanceof`-check a real error
490
+ * type -- it duck-types a `status` field first.
491
+ *
492
+ * A prior version fell back to scanning the *whole* message for a standalone
493
+ * `404` when no `status` field was present. That is unsafe: the Coolify and
494
+ * Cloudflare clients put the resource path -- which can contain a hex UUID
495
+ * -- ahead of the status in the message, so a UUID that happens to contain
496
+ * the digits `404` (`9a404b12-...`) made a genuine 500 read as a 404. The
497
+ * fallback below instead anchors to the exact position both clients put the
498
+ * status in: the first `→ `, after the client, method and path, and before
499
+ * `:`. It never scans the path or the response body.
500
+ */
501
+ function isNotFoundError(error) {
502
+ const status = error?.status;
503
+ if (typeof status === 'number')
504
+ return status === 404;
505
+ const message = error instanceof Error ? error.message : String(error);
506
+ return /^\S+ \S+ \S+ → 404:/.test(message);
507
+ }
@@ -2,7 +2,9 @@ export type { LiveResource, ZitadelReader, CoolifyReader, CloudflareReader, File
2
2
  export { readLiveResources } from './live.js';
3
3
  export type { StepAction, PlannedStep, PlanDetail } from './diff.js';
4
4
  export { computePlanDetail, toPlan } from './diff.js';
5
- export type { AuditActor, AuditLog, AuthzGate, StepExecutor, FoundryPlanApplyOptions, } from './plan-apply.js';
6
- export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, } from './plan-apply.js';
7
- export type { ScopeResolver } from './providers-readers.js';
8
- export { ProvidersCloudflareReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, } from './providers-readers.js';
5
+ export type { AuditActor, AuditLog, AuthzGate, StepExecutor, StepDestroyer, DestroyTarget, StepExecutionContext, FoundryPlanApplyOptions, } from './plan-apply.js';
6
+ export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, } from './plan-apply.js';
7
+ export type { ScopeResolver, RecordedIds } from './providers-readers.js';
8
+ export { ProvidersCloudflareReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
9
+ export type { CoolifyPort, CloudflarePort, ZitadelPort, FilesOrgLimits, AiOrgLimits, FilesOrganisationPort, AiOrganisationPort, FoundryStepExecutorPorts, } from './executor.js';
10
+ export { FoundryStepExecutor, NotBuiltError, MissingBespokeAppRepoError } from './executor.js';
@@ -1,4 +1,5 @@
1
1
  export { readLiveResources } from './live.js';
2
2
  export { computePlanDetail, toPlan } from './diff.js';
3
- export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, } from './plan-apply.js';
4
- export { ProvidersCloudflareReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, } from './providers-readers.js';
3
+ export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, } from './plan-apply.js';
4
+ export { ProvidersCloudflareReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
5
+ export { FoundryStepExecutor, NotBuiltError, MissingBespokeAppRepoError } from './executor.js';
@@ -3,10 +3,14 @@ import type { OrgId } from '../org.js';
3
3
  * One resource as seen live in one of foundry's six systems.
4
4
  *
5
5
  * `key` is unique within `(system, kind)`. Per-org singleton resources
6
- * (issuer, mail, chat, storage's spend limit, AI's budget, the cms site —
7
- * see `diff.ts`) are keyed by the {@link OrgId} itself; anything else foundry can see in
8
- * that system's account/org is keyed however that system names it, so it can
9
- * still be matched, or left
6
+ * (issuer, mail, chat, storage's spend limit, AI's budget, the cms site, the
7
+ * org's own app, its database, its Cloudflare tunnel — see `diff.ts`) are
8
+ * keyed by the {@link OrgId} itself. Two resources key by something else
9
+ * instead (#71): the Cloudflare zone is keyed by the domain name, since a
10
+ * zone belongs to a domain rather than an org, and each declared ZITADEL
11
+ * issuer app is keyed by its own declared name, since an org can declare
12
+ * more than one. Anything else foundry can see in that system's account/org
13
+ * is keyed however that system names it, so it can still be matched, or left
10
14
  * {@link https://github.com/wtfalch/foundry/issues/10 unmanaged}, by
11
15
  * `computePlanDetail`.
12
16
  */