@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
package/dist/plan/diff.js CHANGED
@@ -19,36 +19,101 @@ function resourceKey(system, kind, key) {
19
19
  * exist for every org — the issuer, mail, and chat (always provisioned at
20
20
  * level 1, per #7 "Chat", regardless of `usesDomain`). #27 adds two more,
21
21
  * in the same singleton shape: storage's spend limit on Files and AI's
22
- * budget. This task adds a sixth, in the same shape: the org's cms site
23
- * (`org.ts`'s `SiteConfig`). Everything else in `live` that none of these
24
- * six claim is unmanaged. The full field-by-field provisioning surface (SSO,
25
- * guests, calls, adopted/bespoke apps, the actual
22
+ * budget. T10 adds a sixth, in the same shape: the org's cms site
23
+ * (`org.ts`'s `SiteConfig`). #71 adds four more singleton resources, in the
24
+ * same shape — the org's own hosted app, its database, its Cloudflare
25
+ * tunnel and its Cloudflare zone (`org.ts`'s `HostingConfig`) — plus one
26
+ * list-shaped resource: `planIssuerApps` below emits one step per declared
27
+ * `hosting.issuerApps[]` entry rather than a single singleton, since an org
28
+ * can declare more than one ZITADEL app. #80 adds two more singletons,
29
+ * modeled on {@link planIssuer}: `planFilesOrganisation` and
30
+ * `planAiOrganisation`, each planned before its own system's limit step
31
+ * ({@link planStorage}, {@link planAiBudget}) so an org has somewhere for a
32
+ * limit to land, and AI use is switched on, before either limit is ever set.
33
+ * `planOwner` below adds one more singleton, in a different shape from the
34
+ * rest: the org's owner, invited by ZITADEL email, idempotent on the
35
+ * *recorded* resource id (`recordedIds`, sourced from
36
+ * `AppliedState.serviceIds`) rather than a live resource, since a human user
37
+ * is never something `live` can contain. Everything else in `live` that none
38
+ * of these twelve singletons and however many declared issuer apps claim is
39
+ * unmanaged. The full field-by-field provisioning surface (SSO, guests,
40
+ * calls, adopted/bespoke apps, the actual
26
41
  * Zitadel/Coolify/Cloudflare/Files/AI/cms shapes) is #20's "provision
27
42
  * Company Factory end to end", which wires real readers and executors
28
43
  * against this seam.
29
44
  */
30
- export function computePlanDetail(org, live) {
45
+ export function computePlanDetail(org, live, recordedIds = {}) {
31
46
  const byKey = new Map(live.map((r) => [resourceKey(r.system, r.kind, r.key), r]));
32
47
  const claimed = new Set();
33
48
  const plannedSteps = [];
34
49
  const issuer = planIssuer(org, byKey, claimed);
35
50
  if (issuer)
36
51
  plannedSteps.push(issuer);
52
+ const owner = planOwner(org, recordedIds);
53
+ if (owner)
54
+ plannedSteps.push(owner);
37
55
  const mail = planMail(org, byKey, claimed);
38
56
  if (mail)
39
57
  plannedSteps.push(mail);
40
58
  const chat = planChat(org, byKey, claimed);
41
59
  if (chat)
42
60
  plannedSteps.push(chat);
61
+ const filesOrganisation = planFilesOrganisation(org, byKey, claimed);
62
+ if (filesOrganisation)
63
+ plannedSteps.push(filesOrganisation);
43
64
  const storage = planStorage(org, byKey, claimed);
44
65
  if (storage)
45
66
  plannedSteps.push(storage);
67
+ const aiOrganisation = planAiOrganisation(org, byKey, claimed);
68
+ if (aiOrganisation)
69
+ plannedSteps.push(aiOrganisation);
46
70
  const aiBudget = planAiBudget(org, byKey, claimed);
47
71
  if (aiBudget)
48
72
  plannedSteps.push(aiBudget);
49
73
  const site = planSite(org, byKey, claimed);
50
74
  if (site)
51
75
  plannedSteps.push(site);
76
+ // Every declared ZITADEL app, before the app cluster below (level-1
77
+ // app-env campaign, gap P2): `coolifyApp`'s own env push
78
+ // (`executor.ts`'s `pushAppEnv`) reads a web app's `clientId` to set
79
+ // `AUTH_CLIENT_ID`, which only exists once that app's own `zitadel:app`
80
+ // step has run -- `plan-apply.ts`'s `runSteps` executes `plannedSteps` in
81
+ // this array order, one at a time.
82
+ plannedSteps.push(...planIssuerApps(org, byKey, claimed));
83
+ // The Access gate before the database, app, tunnel and zone (#84): on a
84
+ // first apply the hostname is gated before anything answers on it.
85
+ const access = planAccess(org, recordedIds);
86
+ if (access)
87
+ plannedSteps.push(access);
88
+ // Database before app (hosting-wiring campaign): `plan-apply.ts`'s
89
+ // `runSteps` executes `plannedSteps` in this array order, and the app's own
90
+ // step (`executor.ts`'s `coolifyApp`) pushes the database's connection
91
+ // string into the app's environment once the database exists -- it can
92
+ // only do that if the database step already ran and recorded the
93
+ // database's resource id.
94
+ const database = planDatabase(org, byKey, claimed);
95
+ if (database)
96
+ plannedSteps.push(database);
97
+ const app = planApp(org, byKey, claimed);
98
+ if (app)
99
+ plannedSteps.push(app);
100
+ const tunnel = planTunnel(org, byKey, claimed);
101
+ if (tunnel)
102
+ plannedSteps.push(tunnel);
103
+ const zone = planZone(org, byKey, claimed);
104
+ if (zone)
105
+ plannedSteps.push(zone);
106
+ // Right after the zone (#43): `cloudflareRateLimit` resolves the zone by
107
+ // apex domain, so the zone step above must already have run (or the zone
108
+ // must already exist live) by the time this one's action executes.
109
+ const rateLimit = planRateLimit(org, recordedIds);
110
+ if (rateLimit)
111
+ plannedSteps.push(rateLimit);
112
+ // After the apps: the customers org is granted the project the first app
113
+ // step creates.
114
+ const customers = planCustomers(org, live, claimed, recordedIds);
115
+ if (customers)
116
+ plannedSteps.push(customers);
52
117
  // #10 (resolved): "foundry manages only what the declared org names";
53
118
  // anything left unclaimed is listed, never proposed for deletion.
54
119
  const unmanaged = live
@@ -58,13 +123,43 @@ export function computePlanDetail(org, live) {
58
123
  }));
59
124
  return { orgId: org.id, plannedSteps, unmanaged };
60
125
  }
126
+ /**
127
+ * The org's own ZITADEL org (issuer), a per-org singleton like every other
128
+ * planner in this file, with one extra wrinkle: unlike mail/chat/site/app,
129
+ * there is nothing to "provision" versus "move" from the same live check --
130
+ * a missing live resource means the org has no ZITADEL org at all yet, which
131
+ * `apply` must create before anything else can act inside it (declared:
132
+ * `.claude/decisions-2026-09-24-executor.md`, "Executor decisions" -- "when
133
+ * no ZITADEL org exists, the planner now plans 'provision the org's ZITADEL
134
+ * org' ... the executor creates it named after the slug"). That provision
135
+ * step is never destructive: nothing live is touched. Once a live issuer
136
+ * resource exists, the original move logic applies unchanged -- moving the
137
+ * issuer onto or off the org's domain invalidates every passkey in the org
138
+ * (#7 corrections 2) and is not built in v1 (decisions file, same section:
139
+ * "domain verification and passkey invalidation need their own design"), so
140
+ * `executor.ts` throws `NotBuiltError` on it rather than run it.
141
+ */
61
142
  function planIssuer(org, byKey, claimed) {
62
143
  const key = resourceKey('zitadel', 'issuer', org.id);
63
144
  const live = byKey.get(key);
64
- if (live)
65
- claimed.add(key);
66
145
  const desiredUsesDomain = org.issuer.usesDomain;
67
- const liveUsesDomain = live ? Boolean(live.config.usesDomain) : false;
146
+ if (!live) {
147
+ return {
148
+ action: {
149
+ system: 'zitadel',
150
+ kind: 'issuer',
151
+ key: org.id,
152
+ desired: { provision: true, usesDomain: desiredUsesDomain },
153
+ },
154
+ step: {
155
+ description: `provision ${org.slug}'s own ZITADEL org`,
156
+ destroys: false,
157
+ migrates: false,
158
+ },
159
+ };
160
+ }
161
+ claimed.add(key);
162
+ const liveUsesDomain = Boolean(live.config.usesDomain);
68
163
  if (liveUsesDomain === desiredUsesDomain)
69
164
  return null;
70
165
  const domain = org.domain ?? 'the org domain';
@@ -87,6 +182,58 @@ function planIssuer(org, byKey, claimed) {
87
182
  },
88
183
  };
89
184
  }
185
+ /**
186
+ * The org's owner, invited by ZITADEL email once the org's ZITADEL org
187
+ * exists (goal line: "the org's owner is invited by ZITADEL email and sets a
188
+ * passkey — no password passes through foundry"). A per-org singleton like
189
+ * every other planner here, but idempotent on a different signal: the
190
+ * *recorded* resource id (`recordedIds['zitadel:owner:<orgId>']`, written by
191
+ * `plan-apply.ts`'s `runSteps` from this step's own `resourceId` — the
192
+ * invited user id) rather than a live resource. `ZitadelReader.listResources`
193
+ * (`@wtfalch/providers`' `ZitadelClient.listResources`) reads projects and
194
+ * grants, never a human user, so there is nothing in `live` an invited owner
195
+ * could ever appear as; this step neither claims nor reads from `byKey` for
196
+ * that reason.
197
+ *
198
+ * Ordered right after `planIssuer` in `computePlanDetail` (not after mail,
199
+ * chat, or any hosting step) so that on a first-ever apply — when nothing is
200
+ * live yet — the ZITADEL org this step's executor needs
201
+ * (`context.recordedIds().zitadel_org`) has already been recorded by the
202
+ * issuer step immediately before it in the same run: `plan-apply.ts`'s
203
+ * `runSteps` awaits each planned step in array order, one at a time.
204
+ *
205
+ * `desired` carries the owner's email so the executor can pass it to
206
+ * `ZitadelClient.inviteOrgOwner` — but the step's own `description` never
207
+ * does. `Plan` (the public contract `AuditLog.recordPlan`/`recordApply`
208
+ * store) carries only `PlanStep`, never `StepAction`, so `desired` never
209
+ * reaches the audit log; keeping the email out of `description` too means
210
+ * nothing here puts it anywhere beyond what audit already records for the
211
+ * org.
212
+ */
213
+ function planOwner(org, recordedIds) {
214
+ if (!org.owner)
215
+ return null;
216
+ const key = resourceKey('zitadel', 'owner', org.id);
217
+ if (recordedIds[key])
218
+ return null; // already invited -- a re-run plans nothing.
219
+ return {
220
+ action: {
221
+ system: 'zitadel',
222
+ kind: 'owner',
223
+ key: org.id,
224
+ desired: {
225
+ email: org.owner.email,
226
+ givenName: org.owner.givenName,
227
+ familyName: org.owner.familyName,
228
+ },
229
+ },
230
+ step: {
231
+ description: `invite ${org.slug}'s owner by email`,
232
+ destroys: false,
233
+ migrates: false,
234
+ },
235
+ };
236
+ }
90
237
  function planMail(org, byKey, claimed) {
91
238
  const key = resourceKey('coolify', 'mail', org.id);
92
239
  const live = byKey.get(key);
@@ -188,6 +335,92 @@ function planChat(org, byKey, claimed) {
188
335
  },
189
336
  };
190
337
  }
338
+ /**
339
+ * Deduped `ai.*` permissions offered to every organisation by default
340
+ * (Decision, this campaign's report): the minimum set that lets an org
341
+ * actually run jobs against the platform's own offered models --
342
+ * `ai.offerings:invoke` to invoke an offering, `ai.runs:create` to submit the
343
+ * run itself. `ai/packages/service/src/catalogue.ts`'s five `offered: true`
344
+ * permissions also include the three `ai.connections:*` ones, which let an
345
+ * organisation bring its own provider credentials (BYOK) -- a separate,
346
+ * explicit opt-in this default deliberately leaves out.
347
+ */
348
+ const AI_DEFAULT_OFFERS = ['ai.offerings:invoke', 'ai.runs:create'];
349
+ /**
350
+ * #80: every org's own organisation on Files, a per-org singleton resource
351
+ * planned before {@link planStorage} -- `setQuota`/`setBudget` 404 against an
352
+ * org Files has never provisioned (`files/packages/sdk/src/client.ts`'s
353
+ * tenant routes need the row `provisionOrganisation` creates). Modeled on
354
+ * {@link planIssuer}: nothing to diff once it exists -- Files' own
355
+ * `provisionOrganisation` is safely repeatable (`executor.ts`'s
356
+ * `filesOrganisation` calls it every time regardless), so the only planned
357
+ * state is "not provisioned yet". The operator-driven `ceilingBytes`/
358
+ * `ceilingMicros` hard cap (`OrganisationSummary`) is never this planner's
359
+ * concern (`org.ts`'s `StorageConfig` doc: "a separate, operator-driven hard
360
+ * cap this field has no opinion on"), so provisioning always asks for an
361
+ * uncapped ceiling -- Decision, this campaign's report.
362
+ */
363
+ function planFilesOrganisation(org, byKey, claimed) {
364
+ const key = resourceKey('files', 'organisation', org.id);
365
+ const live = byKey.get(key);
366
+ if (live) {
367
+ claimed.add(key);
368
+ return null;
369
+ }
370
+ return {
371
+ action: {
372
+ system: 'files',
373
+ kind: 'organisation',
374
+ key: org.id,
375
+ desired: { provision: true, ceilingBytes: null, ceilingMicros: null },
376
+ },
377
+ step: {
378
+ description: `provision ${org.slug}'s own organisation on Files`,
379
+ destroys: false,
380
+ migrates: false,
381
+ },
382
+ };
383
+ }
384
+ /**
385
+ * #80: every org's own organisation on AI, a per-org singleton resource
386
+ * planned before {@link planAiBudget} -- a provisioned-but-unoffered
387
+ * organisation holds no `ai.*` permission, so running a job is refused
388
+ * (foundry issue #80). Modeled on {@link planIssuer} and
389
+ * {@link planFilesOrganisation}: nothing to diff once it exists -- AI's own
390
+ * `provisionOrganisation` and `setOrganisationOffers` are both safely
391
+ * repeatable (`executor.ts`'s `aiOrganisation` calls both every time
392
+ * regardless), so the only planned state is "not provisioned yet".
393
+ * `AI_DEFAULT_OFFERS` is this campaign's Decision for what "AI use is
394
+ * switched on" means by default; `limitMicros` seeds the organisation's
395
+ * first budget row with whatever the org already declares (including
396
+ * `null` for uncapped), so a brand new org never needs a follow-up
397
+ * {@link planAiBudget} step just to reach its own declared default.
398
+ */
399
+ function planAiOrganisation(org, byKey, claimed) {
400
+ const key = resourceKey('ai', 'organisation', org.id);
401
+ const live = byKey.get(key);
402
+ if (live) {
403
+ claimed.add(key);
404
+ return null;
405
+ }
406
+ return {
407
+ action: {
408
+ system: 'ai',
409
+ kind: 'organisation',
410
+ key: org.id,
411
+ desired: {
412
+ provision: true,
413
+ limitMicros: org.ai.limitMicros,
414
+ offers: [...AI_DEFAULT_OFFERS],
415
+ },
416
+ },
417
+ step: {
418
+ description: `provision ${org.slug}'s own organisation on AI and switch AI use on`,
419
+ destroys: false,
420
+ migrates: false,
421
+ },
422
+ };
423
+ }
191
424
  /**
192
425
  * #27: the org's own spend limit on Files, a per-org singleton resource in
193
426
  * the same shape as issuer/mail/chat above. Setting a limit is a config
@@ -217,7 +450,10 @@ function planStorage(org, byKey, claimed) {
217
450
  }
218
451
  /**
219
452
  * #27: the org's own spend limit on AI, a per-org singleton resource in the
220
- * same shape as {@link planStorage}.
453
+ * same shape as {@link planStorage}. Planned after {@link planAiOrganisation}
454
+ * (#80), which already seeds a brand new org's first budget row from this
455
+ * same `org.ai.limitMicros` -- this planner only fires again once that row's
456
+ * live value has drifted from what is declared.
221
457
  */
222
458
  function planAiBudget(org, byKey, claimed) {
223
459
  const key = resourceKey('ai', 'budget', org.id);
@@ -300,3 +536,291 @@ function planSite(org, byKey, claimed) {
300
536
  },
301
537
  };
302
538
  }
539
+ /**
540
+ * The shared on/off shape behind {@link planApp}, {@link planDatabase} and
541
+ * {@link planTunnel} (#71): a per-org singleton resource, keyed by the org
542
+ * id like {@link planMail} and {@link planSite}, with the same
543
+ * park-not-delete rule (#7 corrections 1) -- turning it off never deletes
544
+ * the org's own app, database or tunnel configuration, it parks it.
545
+ */
546
+ function planHostingToggle(system, kind, desiredEnabled, org, byKey, claimed, describeProvision, describePark) {
547
+ const key = resourceKey(system, kind, org.id);
548
+ const live = byKey.get(key);
549
+ // A live resource is on unless it says it is parked. Providers' own
550
+ // configs carry no `enabled` field at all, so only a park step's
551
+ // explicit `enabled: false` reads as off.
552
+ const liveEnabled = live !== undefined && live.config.enabled !== false;
553
+ if (!desiredEnabled) {
554
+ // Nothing declared and nothing live: there is nothing to park.
555
+ if (!live)
556
+ return null;
557
+ claimed.add(key);
558
+ // Already parked: no step, or a plan would never reach empty (#14
559
+ // "repeat until plan returns empty") once the park step below ran.
560
+ if (!liveEnabled)
561
+ return null;
562
+ return {
563
+ action: { system, kind, key: org.id, desired: { enabled: false } },
564
+ step: { description: describePark(), destroys: true, migrates: false },
565
+ };
566
+ }
567
+ if (live)
568
+ claimed.add(key);
569
+ // Live and already enabled: matches declared, no step. Otherwise
570
+ // (nothing live, or live but parked) this provisions/re-provisions it.
571
+ if (liveEnabled)
572
+ return null;
573
+ return {
574
+ action: { system, kind, key: org.id, desired: { enabled: true } },
575
+ step: { description: describeProvision(), destroys: false, migrates: false },
576
+ };
577
+ }
578
+ /**
579
+ * The org's own app on Coolify (`org.ts`'s `HostingConfig.enabled`, #71), a
580
+ * per-org singleton resource modeled on {@link planSite}: park, not delete,
581
+ * on the way off (#7 corrections 1).
582
+ *
583
+ * #75: every app foundry deploys also carries a durable log directory (a
584
+ * persistent volume mounted at `/app/logs`, `LOG_DIR` in its env) so a
585
+ * database outage does not erase the only other copy of the evidence
586
+ * (`executor.ts`'s `ensureLogVolume`/`pushAppEnv`, lokessmie's own pilot at
587
+ * https://github.com/wtfalch/lokessmie/pull/521). `planHostingToggle` above
588
+ * only tracks on/off, so once it finds nothing to toggle -- app live and
589
+ * already enabled -- this planner adds one more check on top: a live app
590
+ * that predates this feature (`live.config.hasLogVolume === false`,
591
+ * `providers-readers.ts`'s `withLogVolumeConfig`) is planned for it, reusing
592
+ * the same `coolify:app`/`enabled: true` action `planHostingToggle`'s own
593
+ * provision step uses -- `executor.ts`'s `coolifyApp` already runs
594
+ * `ensureLogVolume` unconditionally on that action, whether it just created
595
+ * the app or found it already there, so no new step kind is needed. A
596
+ * missing `hasLogVolume` (rather than an explicit `false`) reads as
597
+ * compliant, the same convention `planHostingToggle` itself uses for a live
598
+ * resource's `enabled` field -- a live resource nothing has actually
599
+ * checked yet is assumed fine rather than replanned every time.
600
+ */
601
+ function planApp(org, byKey, claimed) {
602
+ const toggled = planHostingToggle('coolify', 'app', org.hosting.enabled, org, byKey, claimed, () => `provision ${org.slug}'s app`, () => `park ${org.slug}'s app (stops serving; app data is retained, not deleted -- #7 corrections 1)`);
603
+ if (toggled)
604
+ return toggled;
605
+ if (!org.hosting.enabled)
606
+ return null;
607
+ const live = byKey.get(resourceKey('coolify', 'app', org.id));
608
+ if (!live || live.config.hasLogVolume !== false)
609
+ return null;
610
+ return {
611
+ action: { system: 'coolify', kind: 'app', key: org.id, desired: { enabled: true } },
612
+ step: {
613
+ description: `add a durable log directory to ${org.slug}'s app (persistent volume at /app/logs + LOG_DIR) so a database outage does not erase the evidence (#75)`,
614
+ destroys: false,
615
+ migrates: false,
616
+ },
617
+ };
618
+ }
619
+ /**
620
+ * A Postgres database beside the org's app (`org.ts`'s
621
+ * `HostingConfig.database`, #71) -- `validateHosting` requires
622
+ * `hosting.enabled` before this can be true. Same park-not-delete shape as
623
+ * {@link planApp}.
624
+ */
625
+ function planDatabase(org, byKey, claimed) {
626
+ return planHostingToggle('coolify', 'database', org.hosting.database, org, byKey, claimed, () => `provision a database for ${org.slug}'s app`, () => `park ${org.slug}'s database (data is retained, not deleted -- #7 corrections 1)`);
627
+ }
628
+ /**
629
+ * A Cloudflare tunnel reaching the org's app (`org.ts`'s
630
+ * `HostingConfig.tunnel`, #71) -- `validateHosting` requires
631
+ * `hosting.enabled` before this can be true. Same park-not-delete shape as
632
+ * {@link planApp}, gated on the same `usesDomain`+`domain` pair
633
+ * {@link planZone} checks: a level-1 org (no own domain, served at
634
+ * `<slug>.oikos.host`) gets no per-org tunnel at all -- one shared tunnel
635
+ * with a wildcard `*.oikos.host` record serves every level-1 org instead
636
+ * (William, 2026-09-26, map gap N3). The gate folds into the `desired`
637
+ * flag rather than an early return, so an org that drops its domain while
638
+ * a per-org tunnel is still live gets that tunnel parked, not left
639
+ * unmanaged, through `planHostingToggle`'s own park path.
640
+ *
641
+ * https://github.com/wtfalch/foundry/issues/115: a site-enabled org always
642
+ * needs its tunnel too, routing both its public site and its cms studio
643
+ * (`executor.ts`'s `tunnelIngressHostnames`) -- there is no separate
644
+ * `site.tunnel` toggle to gate on the way `hosting.tunnel` does, since a site
645
+ * has no meaning without one.
646
+ */
647
+ function planTunnel(org, byKey, claimed) {
648
+ const desired = (org.hosting.tunnel && org.hosting.usesDomain && Boolean(org.domain)) ||
649
+ (org.site.enabled && Boolean(org.domain));
650
+ return planHostingToggle('cloudflare', 'tunnel', desired, org, byKey, claimed, () => `provision a Cloudflare tunnel for ${org.slug}'s app`, () => `park ${org.slug}'s Cloudflare tunnel (configuration is retained, not deleted -- #7 corrections 1)`);
651
+ }
652
+ /**
653
+ * The org's Cloudflare zone for its own domain (`org.ts`'s
654
+ * `HostingConfig.usesDomain`, #71). Unlike every other resource in this
655
+ * file, a zone is keyed by the domain name itself, not the org id -- a
656
+ * Cloudflare zone belongs to a domain, not an org. Provision-only: a plan
657
+ * never parks a zone, since nothing about turning `usesDomain` off says
658
+ * foundry should stop managing a domain the org may still own elsewhere, so
659
+ * a live zone under a not-`usesDomain` org is left unmanaged rather than
660
+ * claimed -- the same choice {@link planMail} makes for a `mode: 'off'` org.
661
+ *
662
+ * https://github.com/wtfalch/foundry/issues/115: a site-enabled org
663
+ * (`org.ts`'s `SiteConfig.enabled`) also needs a zone for its domain, even
664
+ * though it can never have `hosting` on at the same time (`validateSite`) --
665
+ * `org.site.enabled` already guarantees both `domain` and `site.usesDomain`
666
+ * are set, so the `org.domain` check below still covers it.
667
+ */
668
+ function planZone(org, byKey, claimed) {
669
+ if (!(org.hosting.usesDomain || org.site.enabled) || !org.domain)
670
+ return null;
671
+ const key = resourceKey('cloudflare', 'zone', org.domain);
672
+ const live = byKey.get(key);
673
+ if (live)
674
+ claimed.add(key);
675
+ if (live)
676
+ return null;
677
+ return {
678
+ action: {
679
+ system: 'cloudflare',
680
+ kind: 'zone',
681
+ key: org.domain,
682
+ desired: { domain: org.domain },
683
+ },
684
+ step: {
685
+ description: `provision a Cloudflare zone for ${org.domain}`,
686
+ destroys: false,
687
+ migrates: false,
688
+ },
689
+ };
690
+ }
691
+ /**
692
+ * The org's declared ZITADEL OIDC applications (`org.ts`'s
693
+ * `HostingConfig.issuerApps`, #71), each keyed by its own declared `name`
694
+ * rather than the org id -- an org can declare more than one. A live
695
+ * ZITADEL `app` resource this list does not name by `name` is left
696
+ * unmanaged, the same #10 rule every other resource in this file follows.
697
+ * There is no park for an issuer app -- ZITADEL has no notion of a
698
+ * disabled-but-retained OIDC client the way Coolify has a parked app or
699
+ * database -- so a declared `kind` that differs from the live `appType` is
700
+ * a destructive step (recreate), never a park, and removing an app from
701
+ * `issuerApps` entirely produces no step at all (nothing here deletes it;
702
+ * it simply stops being claimed and shows up as unmanaged next plan).
703
+ */
704
+ function planIssuerApps(org, byKey, claimed) {
705
+ const steps = [];
706
+ for (const app of org.hosting.issuerApps) {
707
+ const key = resourceKey('zitadel', 'app', app.name);
708
+ const live = byKey.get(key);
709
+ if (live)
710
+ claimed.add(key);
711
+ const desired = { appType: app.kind };
712
+ if (!live) {
713
+ steps.push({
714
+ action: { system: 'zitadel', kind: 'app', key: app.name, desired },
715
+ step: {
716
+ description: `provision the ${app.kind} ZITADEL app '${app.name}' for ${org.slug}`,
717
+ destroys: false,
718
+ migrates: false,
719
+ },
720
+ });
721
+ continue;
722
+ }
723
+ if (live.config.appType === app.kind)
724
+ continue;
725
+ steps.push({
726
+ action: { system: 'zitadel', kind: 'app', key: app.name, desired },
727
+ step: {
728
+ description: `recreate ZITADEL app '${app.name}' for ${org.slug} as ${app.kind} (was ${String(live.config.appType)})`,
729
+ destroys: true,
730
+ migrates: false,
731
+ },
732
+ });
733
+ }
734
+ return steps;
735
+ }
736
+ /**
737
+ * Plan gap A9: an org's customers sign in through a second ZITADEL org with
738
+ * self-registration on, granted the org's app project. Planned once the org
739
+ * declares an issuer app, since customers need an app to sign in to.
740
+ * Idempotent on the recorded customers org id, like `planOwner`. Once
741
+ * recorded, the live project grant to that org is claimed so it is not
742
+ * listed as unmanaged.
743
+ */
744
+ function planCustomers(org, live, claimed, recordedIds) {
745
+ if (org.hosting.issuerApps.length === 0)
746
+ return null;
747
+ const customersOrgId = recordedIds[resourceKey('zitadel', 'customers', org.id)];
748
+ if (customersOrgId) {
749
+ for (const r of live) {
750
+ if (r.system === 'zitadel' &&
751
+ r.kind === 'project_grant' &&
752
+ r.config.grantedOrgId === customersOrgId) {
753
+ claimed.add(resourceKey(r.system, r.kind, r.key));
754
+ }
755
+ }
756
+ return null;
757
+ }
758
+ return {
759
+ action: { system: 'zitadel', kind: 'customers', key: org.id, desired: {} },
760
+ step: {
761
+ description: `provision a self-registration ZITADEL org for ${org.slug}'s customers`,
762
+ destroys: false,
763
+ migrates: false,
764
+ },
765
+ };
766
+ }
767
+ /**
768
+ * #84: a Cloudflare Access gate on the org's app hostname, letting only
769
+ * `hosting.accessEmails` through. Idempotent on the recorded Access app id,
770
+ * like `planOwner`: Cloudflare's live listing has no Access apps. A later
771
+ * change to the email list is not reconciled yet.
772
+ */
773
+ function planAccess(org, recordedIds) {
774
+ if (!org.hosting.enabled || org.hosting.accessEmails.length === 0)
775
+ return null;
776
+ if (recordedIds[resourceKey('cloudflare', 'access', org.id)])
777
+ return null;
778
+ return {
779
+ action: { system: 'cloudflare', kind: 'access', key: org.id, desired: {} },
780
+ step: {
781
+ description: `put ${org.slug}'s app behind Cloudflare Access`,
782
+ destroys: false,
783
+ migrates: false,
784
+ },
785
+ };
786
+ }
787
+ /**
788
+ * https://github.com/wtfalch/foundry/issues/43: one Cloudflare rate-limiting
789
+ * rule on the org's own app hostname's sign-in paths (`@wtfalch/auth`'s
790
+ * `NextAuth.handlers`, mounted at `app/auth/[...auth]/route.ts` -- every
791
+ * sign-in request passes through `/auth/*`, `executor.ts`'s
792
+ * `cloudflareRateLimit`). Gated on the same `usesDomain`/`site.enabled`
793
+ * +`domain` pair {@link planZone} and {@link planTunnel} check (widened for a
794
+ * site-enabled org by #115: the cms studio signs in under `/auth/` too, on
795
+ * the same zone {@link planZone} provisions for it): only an own-domain org
796
+ * gets this step. A level-1 org (no own domain, served at
797
+ * `<slug>.oikos.host`) shares the `oikos.host` zone with every other level-1
798
+ * org, and Cloudflare's Free plan allows exactly one rate limiting rule per
799
+ * zone (developers.cloudflare.com/waf/rate-limiting-rules/, checked
800
+ * 2026-09-26) -- a per-org step here would fight every other level-1 org's
801
+ * for that one slot the moment a second org applied. The shared zone instead
802
+ * gets one rule covering every level-1 org at once, provisioned by
803
+ * `scripts/shared-ingress.mjs`, not by this planner (#43's own task brief).
804
+ *
805
+ * Idempotent on the recorded rule id, like {@link planAccess}: rate limiting
806
+ * rules are not part of any system's `listResources` (`@wtfalch/providers`
807
+ * 0.5.0 has no typed reader for them, only its generic `request` escape
808
+ * hatch -- `executor.ts`'s `CloudflarePort.request` doc comment), so there is
809
+ * no live resource to diff against here. Planned right after {@link planZone}
810
+ * so the zone `cloudflareRateLimit` needs to resolve always exists by the
811
+ * time this step's own action runs.
812
+ */
813
+ function planRateLimit(org, recordedIds) {
814
+ if (!(org.hosting.usesDomain || org.site.enabled) || !org.domain)
815
+ return null;
816
+ if (recordedIds[resourceKey('cloudflare', 'ratelimit', org.id)])
817
+ return null;
818
+ return {
819
+ action: { system: 'cloudflare', kind: 'ratelimit', key: org.id, desired: {} },
820
+ step: {
821
+ description: `rate limit ${org.slug}'s sign-in paths on Cloudflare (free-plan allowance: one rule per zone)`,
822
+ destroys: false,
823
+ migrates: false,
824
+ },
825
+ };
826
+ }