@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.
- package/dist/apply.d.ts +14 -0
- package/dist/calls-sfu-deploy-support.d.ts +57 -0
- package/dist/calls-sfu-deploy-support.js +82 -0
- package/dist/calls-sfu.d.ts +175 -0
- package/dist/calls-sfu.js +239 -0
- package/dist/index.d.ts +11 -6
- package/dist/index.js +7 -4
- package/dist/migrate.d.ts +38 -0
- package/dist/migrate.js +47 -0
- package/dist/migrations/0008_hosting.sql +24 -0
- package/dist/migrations/0009_hosting_git_branch_build_pack.sql +14 -0
- package/dist/migrations/0010_owner.sql +13 -0
- package/dist/migrations/0011_test_org.sql +12 -0
- package/dist/migrations/0012_audit.sql +21 -0
- package/dist/migrations/0013_access_emails.sql +6 -0
- package/dist/org.d.ts +130 -0
- package/dist/org.js +102 -0
- package/dist/plan/diff.d.ts +20 -5
- package/dist/plan/diff.js +533 -9
- package/dist/plan/executor.d.ts +823 -0
- package/dist/plan/executor.js +1242 -0
- package/dist/plan/index.d.ts +6 -4
- package/dist/plan/index.js +3 -2
- package/dist/plan/live.d.ts +8 -4
- package/dist/plan/plan-apply.d.ts +114 -4
- package/dist/plan/plan-apply.js +266 -14
- package/dist/plan/providers-readers.d.ts +102 -14
- package/dist/plan/providers-readers.js +245 -16
- package/dist/provision.d.ts +56 -0
- package/dist/provision.js +69 -0
- package/dist/slug.d.ts +12 -18
- package/dist/slug.js +66 -0
- package/dist/state.d.ts +26 -5
- package/dist/store/audit.d.ts +8 -0
- package/dist/store/audit.js +43 -0
- package/dist/store/postgres.d.ts +3 -1
- package/dist/store/postgres.js +84 -2
- package/dist/store/schema.d.ts +378 -0
- package/dist/store/schema.js +32 -1
- package/dist/store.d.ts +12 -0
- 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.
|
|
23
|
-
* (`org.ts`'s `SiteConfig`).
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
-
|
|
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
|
+
}
|