@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.
package/dist/plan/diff.js CHANGED
@@ -19,36 +19,84 @@ 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
+ // Database before app (hosting-wiring campaign): `plan-apply.ts`'s
77
+ // `runSteps` executes `plannedSteps` in this array order, and the app's own
78
+ // step (`executor.ts`'s `coolifyApp`) pushes the database's connection
79
+ // string into the app's environment once the database exists -- it can
80
+ // only do that if the database step already ran and recorded the
81
+ // database's resource id.
82
+ const database = planDatabase(org, byKey, claimed);
83
+ if (database)
84
+ plannedSteps.push(database);
85
+ const app = planApp(org, byKey, claimed);
86
+ if (app)
87
+ plannedSteps.push(app);
88
+ const tunnel = planTunnel(org, byKey, claimed);
89
+ if (tunnel)
90
+ plannedSteps.push(tunnel);
91
+ const zone = planZone(org, byKey, claimed);
92
+ if (zone)
93
+ plannedSteps.push(zone);
94
+ plannedSteps.push(...planIssuerApps(org, byKey, claimed));
95
+ // After the apps: the customers org is granted the project the first app
96
+ // step creates.
97
+ const customers = planCustomers(org, live, claimed, recordedIds);
98
+ if (customers)
99
+ plannedSteps.push(customers);
52
100
  // #10 (resolved): "foundry manages only what the declared org names";
53
101
  // anything left unclaimed is listed, never proposed for deletion.
54
102
  const unmanaged = live
@@ -58,13 +106,43 @@ export function computePlanDetail(org, live) {
58
106
  }));
59
107
  return { orgId: org.id, plannedSteps, unmanaged };
60
108
  }
109
+ /**
110
+ * The org's own ZITADEL org (issuer), a per-org singleton like every other
111
+ * planner in this file, with one extra wrinkle: unlike mail/chat/site/app,
112
+ * there is nothing to "provision" versus "move" from the same live check --
113
+ * a missing live resource means the org has no ZITADEL org at all yet, which
114
+ * `apply` must create before anything else can act inside it (declared:
115
+ * `.claude/decisions-2026-09-24-executor.md`, "Executor decisions" -- "when
116
+ * no ZITADEL org exists, the planner now plans 'provision the org's ZITADEL
117
+ * org' ... the executor creates it named after the slug"). That provision
118
+ * step is never destructive: nothing live is touched. Once a live issuer
119
+ * resource exists, the original move logic applies unchanged -- moving the
120
+ * issuer onto or off the org's domain invalidates every passkey in the org
121
+ * (#7 corrections 2) and is not built in v1 (decisions file, same section:
122
+ * "domain verification and passkey invalidation need their own design"), so
123
+ * `executor.ts` throws `NotBuiltError` on it rather than run it.
124
+ */
61
125
  function planIssuer(org, byKey, claimed) {
62
126
  const key = resourceKey('zitadel', 'issuer', org.id);
63
127
  const live = byKey.get(key);
64
- if (live)
65
- claimed.add(key);
66
128
  const desiredUsesDomain = org.issuer.usesDomain;
67
- const liveUsesDomain = live ? Boolean(live.config.usesDomain) : false;
129
+ if (!live) {
130
+ return {
131
+ action: {
132
+ system: 'zitadel',
133
+ kind: 'issuer',
134
+ key: org.id,
135
+ desired: { provision: true, usesDomain: desiredUsesDomain },
136
+ },
137
+ step: {
138
+ description: `provision ${org.slug}'s own ZITADEL org`,
139
+ destroys: false,
140
+ migrates: false,
141
+ },
142
+ };
143
+ }
144
+ claimed.add(key);
145
+ const liveUsesDomain = Boolean(live.config.usesDomain);
68
146
  if (liveUsesDomain === desiredUsesDomain)
69
147
  return null;
70
148
  const domain = org.domain ?? 'the org domain';
@@ -87,6 +165,58 @@ function planIssuer(org, byKey, claimed) {
87
165
  },
88
166
  };
89
167
  }
168
+ /**
169
+ * The org's owner, invited by ZITADEL email once the org's ZITADEL org
170
+ * exists (goal line: "the org's owner is invited by ZITADEL email and sets a
171
+ * passkey — no password passes through foundry"). A per-org singleton like
172
+ * every other planner here, but idempotent on a different signal: the
173
+ * *recorded* resource id (`recordedIds['zitadel:owner:<orgId>']`, written by
174
+ * `plan-apply.ts`'s `runSteps` from this step's own `resourceId` — the
175
+ * invited user id) rather than a live resource. `ZitadelReader.listResources`
176
+ * (`@wtfalch/providers`' `ZitadelClient.listResources`) reads projects and
177
+ * grants, never a human user, so there is nothing in `live` an invited owner
178
+ * could ever appear as; this step neither claims nor reads from `byKey` for
179
+ * that reason.
180
+ *
181
+ * Ordered right after `planIssuer` in `computePlanDetail` (not after mail,
182
+ * chat, or any hosting step) so that on a first-ever apply — when nothing is
183
+ * live yet — the ZITADEL org this step's executor needs
184
+ * (`context.recordedIds().zitadel_org`) has already been recorded by the
185
+ * issuer step immediately before it in the same run: `plan-apply.ts`'s
186
+ * `runSteps` awaits each planned step in array order, one at a time.
187
+ *
188
+ * `desired` carries the owner's email so the executor can pass it to
189
+ * `ZitadelClient.inviteOrgOwner` — but the step's own `description` never
190
+ * does. `Plan` (the public contract `AuditLog.recordPlan`/`recordApply`
191
+ * store) carries only `PlanStep`, never `StepAction`, so `desired` never
192
+ * reaches the audit log; keeping the email out of `description` too means
193
+ * nothing here puts it anywhere beyond what audit already records for the
194
+ * org.
195
+ */
196
+ function planOwner(org, recordedIds) {
197
+ if (!org.owner)
198
+ return null;
199
+ const key = resourceKey('zitadel', 'owner', org.id);
200
+ if (recordedIds[key])
201
+ return null; // already invited -- a re-run plans nothing.
202
+ return {
203
+ action: {
204
+ system: 'zitadel',
205
+ kind: 'owner',
206
+ key: org.id,
207
+ desired: {
208
+ email: org.owner.email,
209
+ givenName: org.owner.givenName,
210
+ familyName: org.owner.familyName,
211
+ },
212
+ },
213
+ step: {
214
+ description: `invite ${org.slug}'s owner by email`,
215
+ destroys: false,
216
+ migrates: false,
217
+ },
218
+ };
219
+ }
90
220
  function planMail(org, byKey, claimed) {
91
221
  const key = resourceKey('coolify', 'mail', org.id);
92
222
  const live = byKey.get(key);
@@ -188,6 +318,92 @@ function planChat(org, byKey, claimed) {
188
318
  },
189
319
  };
190
320
  }
321
+ /**
322
+ * Deduped `ai.*` permissions offered to every organisation by default
323
+ * (Decision, this campaign's report): the minimum set that lets an org
324
+ * actually run jobs against the platform's own offered models --
325
+ * `ai.offerings:invoke` to invoke an offering, `ai.runs:create` to submit the
326
+ * run itself. `ai/packages/service/src/catalogue.ts`'s five `offered: true`
327
+ * permissions also include the three `ai.connections:*` ones, which let an
328
+ * organisation bring its own provider credentials (BYOK) -- a separate,
329
+ * explicit opt-in this default deliberately leaves out.
330
+ */
331
+ const AI_DEFAULT_OFFERS = ['ai.offerings:invoke', 'ai.runs:create'];
332
+ /**
333
+ * #80: every org's own organisation on Files, a per-org singleton resource
334
+ * planned before {@link planStorage} -- `setQuota`/`setBudget` 404 against an
335
+ * org Files has never provisioned (`files/packages/sdk/src/client.ts`'s
336
+ * tenant routes need the row `provisionOrganisation` creates). Modeled on
337
+ * {@link planIssuer}: nothing to diff once it exists -- Files' own
338
+ * `provisionOrganisation` is safely repeatable (`executor.ts`'s
339
+ * `filesOrganisation` calls it every time regardless), so the only planned
340
+ * state is "not provisioned yet". The operator-driven `ceilingBytes`/
341
+ * `ceilingMicros` hard cap (`OrganisationSummary`) is never this planner's
342
+ * concern (`org.ts`'s `StorageConfig` doc: "a separate, operator-driven hard
343
+ * cap this field has no opinion on"), so provisioning always asks for an
344
+ * uncapped ceiling -- Decision, this campaign's report.
345
+ */
346
+ function planFilesOrganisation(org, byKey, claimed) {
347
+ const key = resourceKey('files', 'organisation', org.id);
348
+ const live = byKey.get(key);
349
+ if (live) {
350
+ claimed.add(key);
351
+ return null;
352
+ }
353
+ return {
354
+ action: {
355
+ system: 'files',
356
+ kind: 'organisation',
357
+ key: org.id,
358
+ desired: { provision: true, ceilingBytes: null, ceilingMicros: null },
359
+ },
360
+ step: {
361
+ description: `provision ${org.slug}'s own organisation on Files`,
362
+ destroys: false,
363
+ migrates: false,
364
+ },
365
+ };
366
+ }
367
+ /**
368
+ * #80: every org's own organisation on AI, a per-org singleton resource
369
+ * planned before {@link planAiBudget} -- a provisioned-but-unoffered
370
+ * organisation holds no `ai.*` permission, so running a job is refused
371
+ * (foundry issue #80). Modeled on {@link planIssuer} and
372
+ * {@link planFilesOrganisation}: nothing to diff once it exists -- AI's own
373
+ * `provisionOrganisation` and `setOrganisationOffers` are both safely
374
+ * repeatable (`executor.ts`'s `aiOrganisation` calls both every time
375
+ * regardless), so the only planned state is "not provisioned yet".
376
+ * `AI_DEFAULT_OFFERS` is this campaign's Decision for what "AI use is
377
+ * switched on" means by default; `limitMicros` seeds the organisation's
378
+ * first budget row with whatever the org already declares (including
379
+ * `null` for uncapped), so a brand new org never needs a follow-up
380
+ * {@link planAiBudget} step just to reach its own declared default.
381
+ */
382
+ function planAiOrganisation(org, byKey, claimed) {
383
+ const key = resourceKey('ai', 'organisation', org.id);
384
+ const live = byKey.get(key);
385
+ if (live) {
386
+ claimed.add(key);
387
+ return null;
388
+ }
389
+ return {
390
+ action: {
391
+ system: 'ai',
392
+ kind: 'organisation',
393
+ key: org.id,
394
+ desired: {
395
+ provision: true,
396
+ limitMicros: org.ai.limitMicros,
397
+ offers: [...AI_DEFAULT_OFFERS],
398
+ },
399
+ },
400
+ step: {
401
+ description: `provision ${org.slug}'s own organisation on AI and switch AI use on`,
402
+ destroys: false,
403
+ migrates: false,
404
+ },
405
+ };
406
+ }
191
407
  /**
192
408
  * #27: the org's own spend limit on Files, a per-org singleton resource in
193
409
  * the same shape as issuer/mail/chat above. Setting a limit is a config
@@ -217,7 +433,10 @@ function planStorage(org, byKey, claimed) {
217
433
  }
218
434
  /**
219
435
  * #27: the org's own spend limit on AI, a per-org singleton resource in the
220
- * same shape as {@link planStorage}.
436
+ * same shape as {@link planStorage}. Planned after {@link planAiOrganisation}
437
+ * (#80), which already seeds a brand new org's first budget row from this
438
+ * same `org.ai.limitMicros` -- this planner only fires again once that row's
439
+ * live value has drifted from what is declared.
221
440
  */
222
441
  function planAiBudget(org, byKey, claimed) {
223
442
  const key = resourceKey('ai', 'budget', org.id);
@@ -300,3 +519,177 @@ function planSite(org, byKey, claimed) {
300
519
  },
301
520
  };
302
521
  }
522
+ /**
523
+ * The shared on/off shape behind {@link planApp}, {@link planDatabase} and
524
+ * {@link planTunnel} (#71): a per-org singleton resource, keyed by the org
525
+ * id like {@link planMail} and {@link planSite}, with the same
526
+ * park-not-delete rule (#7 corrections 1) -- turning it off never deletes
527
+ * the org's own app, database or tunnel configuration, it parks it.
528
+ */
529
+ function planHostingToggle(system, kind, desiredEnabled, org, byKey, claimed, describeProvision, describePark) {
530
+ const key = resourceKey(system, kind, org.id);
531
+ const live = byKey.get(key);
532
+ // A live resource is on unless it says it is parked. Providers' own
533
+ // configs carry no `enabled` field at all, so only a park step's
534
+ // explicit `enabled: false` reads as off.
535
+ const liveEnabled = live !== undefined && live.config.enabled !== false;
536
+ if (!desiredEnabled) {
537
+ // Nothing declared and nothing live: there is nothing to park.
538
+ if (!live)
539
+ return null;
540
+ claimed.add(key);
541
+ // Already parked: no step, or a plan would never reach empty (#14
542
+ // "repeat until plan returns empty") once the park step below ran.
543
+ if (!liveEnabled)
544
+ return null;
545
+ return {
546
+ action: { system, kind, key: org.id, desired: { enabled: false } },
547
+ step: { description: describePark(), destroys: true, migrates: false },
548
+ };
549
+ }
550
+ if (live)
551
+ claimed.add(key);
552
+ // Live and already enabled: matches declared, no step. Otherwise
553
+ // (nothing live, or live but parked) this provisions/re-provisions it.
554
+ if (liveEnabled)
555
+ return null;
556
+ return {
557
+ action: { system, kind, key: org.id, desired: { enabled: true } },
558
+ step: { description: describeProvision(), destroys: false, migrates: false },
559
+ };
560
+ }
561
+ /**
562
+ * The org's own app on Coolify (`org.ts`'s `HostingConfig.enabled`, #71), a
563
+ * per-org singleton resource modeled on {@link planSite}: park, not delete,
564
+ * on the way off (#7 corrections 1).
565
+ */
566
+ function planApp(org, byKey, claimed) {
567
+ return 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)`);
568
+ }
569
+ /**
570
+ * A Postgres database beside the org's app (`org.ts`'s
571
+ * `HostingConfig.database`, #71) -- `validateHosting` requires
572
+ * `hosting.enabled` before this can be true. Same park-not-delete shape as
573
+ * {@link planApp}.
574
+ */
575
+ function planDatabase(org, byKey, claimed) {
576
+ 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)`);
577
+ }
578
+ /**
579
+ * A Cloudflare tunnel reaching the org's app (`org.ts`'s
580
+ * `HostingConfig.tunnel`, #71) -- `validateHosting` requires
581
+ * `hosting.enabled` before this can be true. Same park-not-delete shape as
582
+ * {@link planApp}.
583
+ */
584
+ function planTunnel(org, byKey, claimed) {
585
+ return planHostingToggle('cloudflare', 'tunnel', org.hosting.tunnel, 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)`);
586
+ }
587
+ /**
588
+ * The org's Cloudflare zone for its own domain (`org.ts`'s
589
+ * `HostingConfig.usesDomain`, #71). Unlike every other resource in this
590
+ * file, a zone is keyed by the domain name itself, not the org id -- a
591
+ * Cloudflare zone belongs to a domain, not an org. Provision-only: a plan
592
+ * never parks a zone, since nothing about turning `usesDomain` off says
593
+ * foundry should stop managing a domain the org may still own elsewhere, so
594
+ * a live zone under a not-`usesDomain` org is left unmanaged rather than
595
+ * claimed -- the same choice {@link planMail} makes for a `mode: 'off'` org.
596
+ */
597
+ function planZone(org, byKey, claimed) {
598
+ if (!org.hosting.usesDomain || !org.domain)
599
+ return null;
600
+ const key = resourceKey('cloudflare', 'zone', org.domain);
601
+ const live = byKey.get(key);
602
+ if (live)
603
+ claimed.add(key);
604
+ if (live)
605
+ return null;
606
+ return {
607
+ action: {
608
+ system: 'cloudflare',
609
+ kind: 'zone',
610
+ key: org.domain,
611
+ desired: { domain: org.domain },
612
+ },
613
+ step: {
614
+ description: `provision a Cloudflare zone for ${org.domain}`,
615
+ destroys: false,
616
+ migrates: false,
617
+ },
618
+ };
619
+ }
620
+ /**
621
+ * The org's declared ZITADEL OIDC applications (`org.ts`'s
622
+ * `HostingConfig.issuerApps`, #71), each keyed by its own declared `name`
623
+ * rather than the org id -- an org can declare more than one. A live
624
+ * ZITADEL `app` resource this list does not name by `name` is left
625
+ * unmanaged, the same #10 rule every other resource in this file follows.
626
+ * There is no park for an issuer app -- ZITADEL has no notion of a
627
+ * disabled-but-retained OIDC client the way Coolify has a parked app or
628
+ * database -- so a declared `kind` that differs from the live `appType` is
629
+ * a destructive step (recreate), never a park, and removing an app from
630
+ * `issuerApps` entirely produces no step at all (nothing here deletes it;
631
+ * it simply stops being claimed and shows up as unmanaged next plan).
632
+ */
633
+ function planIssuerApps(org, byKey, claimed) {
634
+ const steps = [];
635
+ for (const app of org.hosting.issuerApps) {
636
+ const key = resourceKey('zitadel', 'app', app.name);
637
+ const live = byKey.get(key);
638
+ if (live)
639
+ claimed.add(key);
640
+ const desired = { appType: app.kind };
641
+ if (!live) {
642
+ steps.push({
643
+ action: { system: 'zitadel', kind: 'app', key: app.name, desired },
644
+ step: {
645
+ description: `provision the ${app.kind} ZITADEL app '${app.name}' for ${org.slug}`,
646
+ destroys: false,
647
+ migrates: false,
648
+ },
649
+ });
650
+ continue;
651
+ }
652
+ if (live.config.appType === app.kind)
653
+ continue;
654
+ steps.push({
655
+ action: { system: 'zitadel', kind: 'app', key: app.name, desired },
656
+ step: {
657
+ description: `recreate ZITADEL app '${app.name}' for ${org.slug} as ${app.kind} (was ${String(live.config.appType)})`,
658
+ destroys: true,
659
+ migrates: false,
660
+ },
661
+ });
662
+ }
663
+ return steps;
664
+ }
665
+ /**
666
+ * Plan gap A9: an org's customers sign in through a second ZITADEL org with
667
+ * self-registration on, granted the org's app project. Planned once the org
668
+ * declares an issuer app, since customers need an app to sign in to.
669
+ * Idempotent on the recorded customers org id, like `planOwner`. Once
670
+ * recorded, the live project grant to that org is claimed so it is not
671
+ * listed as unmanaged.
672
+ */
673
+ function planCustomers(org, live, claimed, recordedIds) {
674
+ if (org.hosting.issuerApps.length === 0)
675
+ return null;
676
+ const customersOrgId = recordedIds[resourceKey('zitadel', 'customers', org.id)];
677
+ if (customersOrgId) {
678
+ for (const r of live) {
679
+ if (r.system === 'zitadel' &&
680
+ r.kind === 'project_grant' &&
681
+ r.config.grantedOrgId === customersOrgId) {
682
+ claimed.add(resourceKey(r.system, r.kind, r.key));
683
+ }
684
+ }
685
+ return null;
686
+ }
687
+ return {
688
+ action: { system: 'zitadel', kind: 'customers', key: org.id, desired: {} },
689
+ step: {
690
+ description: `provision a self-registration ZITADEL org for ${org.slug}'s customers`,
691
+ destroys: false,
692
+ migrates: false,
693
+ },
694
+ };
695
+ }