@wtfalch/foundry 0.1.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/LICENSE +21 -0
- package/dist/apply.d.ts +27 -0
- package/dist/apply.js +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +7 -0
- package/dist/migrations/0001_declared_org.sql +61 -0
- package/dist/migrations/0002_applied_state.sql +43 -0
- package/dist/migrations/0003_foundry_runs.sql +53 -0
- package/dist/migrations/0004_slug_unique.sql +26 -0
- package/dist/migrations/0005_spend_limits.sql +34 -0
- package/dist/migrations/0006_buyer_identity.sql +31 -0
- package/dist/migrations/0007_site.sql +18 -0
- package/dist/org.d.ts +276 -0
- package/dist/org.js +29 -0
- package/dist/plan/diff.d.ts +52 -0
- package/dist/plan/diff.js +302 -0
- package/dist/plan/index.d.ts +8 -0
- package/dist/plan/index.js +4 -0
- package/dist/plan/live.d.ts +62 -0
- package/dist/plan/live.js +12 -0
- package/dist/plan/plan-apply.d.ts +107 -0
- package/dist/plan/plan-apply.js +172 -0
- package/dist/plan/providers-readers.d.ts +86 -0
- package/dist/plan/providers-readers.js +83 -0
- package/dist/plan.d.ts +50 -0
- package/dist/plan.js +1 -0
- package/dist/runner/db.d.ts +8 -0
- package/dist/runner/db.js +1 -0
- package/dist/runner/errors.d.ts +37 -0
- package/dist/runner/errors.js +57 -0
- package/dist/runner/index.d.ts +4 -0
- package/dist/runner/index.js +2 -0
- package/dist/runner/runner.d.ts +6 -0
- package/dist/runner/runner.js +137 -0
- package/dist/runner/schema.d.ts +163 -0
- package/dist/runner/schema.js +25 -0
- package/dist/runner/types.d.ts +97 -0
- package/dist/runner/types.js +1 -0
- package/dist/slug.d.ts +38 -0
- package/dist/slug.js +141 -0
- package/dist/state.d.ts +63 -0
- package/dist/state.js +1 -0
- package/dist/store/postgres.d.ts +43 -0
- package/dist/store/postgres.js +267 -0
- package/dist/store/schema.d.ts +615 -0
- package/dist/store/schema.js +59 -0
- package/dist/store.d.ts +23 -0
- package/dist/store.js +1 -0
- package/package.json +49 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { DeclaredOrg, OrgId } from '../org.js';
|
|
2
|
+
import type { Plan, PlanStep, UnmanagedItem } from '../plan.js';
|
|
3
|
+
import type { LiveResource } from './live.js';
|
|
4
|
+
/**
|
|
5
|
+
* What a {@link StepExecutor} (see `plan-apply.ts`) needs to actually make a
|
|
6
|
+
* step true, alongside the public {@link PlanStep} an operator reads.
|
|
7
|
+
*
|
|
8
|
+
* `PlanStep` (in `../plan.ts`) is deliberately thin — a description plus two
|
|
9
|
+
* gate flags — because that type is #19's shared contract with whoever reads
|
|
10
|
+
* a `Plan` back (the operator page, #17's runner). It carries nothing an
|
|
11
|
+
* executor could act on. Rather than widen that shared type for one
|
|
12
|
+
* consumer, `computePlanDetail` below produces a `PlannedStep` that pairs
|
|
13
|
+
* the public step with this private action, and `FoundryPlanApply` (in
|
|
14
|
+
* `plan-apply.ts`) is the only thing that reads the `action` half.
|
|
15
|
+
*/
|
|
16
|
+
export interface StepAction {
|
|
17
|
+
system: LiveResource['system'];
|
|
18
|
+
kind: string;
|
|
19
|
+
/** The resource's key in that system — see {@link LiveResource}. */
|
|
20
|
+
key: string;
|
|
21
|
+
desired: Record<string, unknown>;
|
|
22
|
+
}
|
|
23
|
+
export interface PlannedStep {
|
|
24
|
+
step: PlanStep;
|
|
25
|
+
action: StepAction;
|
|
26
|
+
}
|
|
27
|
+
export interface PlanDetail {
|
|
28
|
+
orgId: OrgId;
|
|
29
|
+
plannedSteps: PlannedStep[];
|
|
30
|
+
unmanaged: UnmanagedItem[];
|
|
31
|
+
}
|
|
32
|
+
/** The `Plan` (#19's public contract) a `PlanDetail` carries. */
|
|
33
|
+
export declare function toPlan(detail: PlanDetail): Plan;
|
|
34
|
+
/**
|
|
35
|
+
* The diff engine: #19's "a plan reads live state from Zitadel, Coolify and
|
|
36
|
+
* Cloudflare and diffs it against the declared org", built on #10's rule
|
|
37
|
+
* that anything the declared org does not name is reported unmanaged and
|
|
38
|
+
* never turned into a step.
|
|
39
|
+
*
|
|
40
|
+
* Scope for #19: the three per-org singleton resources #7 and #8 decided
|
|
41
|
+
* exist for every org — the issuer, mail, and chat (always provisioned at
|
|
42
|
+
* level 1, per #7 "Chat", regardless of `usesDomain`). #27 adds two more,
|
|
43
|
+
* in the same singleton shape: storage's spend limit on Files and AI's
|
|
44
|
+
* budget. This task adds a sixth, in the same shape: the org's cms site
|
|
45
|
+
* (`org.ts`'s `SiteConfig`). Everything else in `live` that none of these
|
|
46
|
+
* six claim is unmanaged. The full field-by-field provisioning surface (SSO,
|
|
47
|
+
* guests, calls, adopted/bespoke apps, the actual
|
|
48
|
+
* Zitadel/Coolify/Cloudflare/Files/AI/cms shapes) is #20's "provision
|
|
49
|
+
* Company Factory end to end", which wires real readers and executors
|
|
50
|
+
* against this seam.
|
|
51
|
+
*/
|
|
52
|
+
export declare function computePlanDetail(org: DeclaredOrg, live: LiveResource[]): PlanDetail;
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/** The `Plan` (#19's public contract) a `PlanDetail` carries. */
|
|
2
|
+
export function toPlan(detail) {
|
|
3
|
+
return {
|
|
4
|
+
orgId: detail.orgId,
|
|
5
|
+
steps: detail.plannedSteps.map((p) => p.step),
|
|
6
|
+
unmanaged: detail.unmanaged,
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
function resourceKey(system, kind, key) {
|
|
10
|
+
return `${system}:${kind}:${key}`;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The diff engine: #19's "a plan reads live state from Zitadel, Coolify and
|
|
14
|
+
* Cloudflare and diffs it against the declared org", built on #10's rule
|
|
15
|
+
* that anything the declared org does not name is reported unmanaged and
|
|
16
|
+
* never turned into a step.
|
|
17
|
+
*
|
|
18
|
+
* Scope for #19: the three per-org singleton resources #7 and #8 decided
|
|
19
|
+
* exist for every org — the issuer, mail, and chat (always provisioned at
|
|
20
|
+
* level 1, per #7 "Chat", regardless of `usesDomain`). #27 adds two more,
|
|
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
|
|
26
|
+
* Zitadel/Coolify/Cloudflare/Files/AI/cms shapes) is #20's "provision
|
|
27
|
+
* Company Factory end to end", which wires real readers and executors
|
|
28
|
+
* against this seam.
|
|
29
|
+
*/
|
|
30
|
+
export function computePlanDetail(org, live) {
|
|
31
|
+
const byKey = new Map(live.map((r) => [resourceKey(r.system, r.kind, r.key), r]));
|
|
32
|
+
const claimed = new Set();
|
|
33
|
+
const plannedSteps = [];
|
|
34
|
+
const issuer = planIssuer(org, byKey, claimed);
|
|
35
|
+
if (issuer)
|
|
36
|
+
plannedSteps.push(issuer);
|
|
37
|
+
const mail = planMail(org, byKey, claimed);
|
|
38
|
+
if (mail)
|
|
39
|
+
plannedSteps.push(mail);
|
|
40
|
+
const chat = planChat(org, byKey, claimed);
|
|
41
|
+
if (chat)
|
|
42
|
+
plannedSteps.push(chat);
|
|
43
|
+
const storage = planStorage(org, byKey, claimed);
|
|
44
|
+
if (storage)
|
|
45
|
+
plannedSteps.push(storage);
|
|
46
|
+
const aiBudget = planAiBudget(org, byKey, claimed);
|
|
47
|
+
if (aiBudget)
|
|
48
|
+
plannedSteps.push(aiBudget);
|
|
49
|
+
const site = planSite(org, byKey, claimed);
|
|
50
|
+
if (site)
|
|
51
|
+
plannedSteps.push(site);
|
|
52
|
+
// #10 (resolved): "foundry manages only what the declared org names";
|
|
53
|
+
// anything left unclaimed is listed, never proposed for deletion.
|
|
54
|
+
const unmanaged = live
|
|
55
|
+
.filter((r) => !claimed.has(resourceKey(r.system, r.kind, r.key)))
|
|
56
|
+
.map((r) => ({
|
|
57
|
+
description: `${r.system} ${r.kind} "${r.key}" is live but not named by the declared org`,
|
|
58
|
+
}));
|
|
59
|
+
return { orgId: org.id, plannedSteps, unmanaged };
|
|
60
|
+
}
|
|
61
|
+
function planIssuer(org, byKey, claimed) {
|
|
62
|
+
const key = resourceKey('zitadel', 'issuer', org.id);
|
|
63
|
+
const live = byKey.get(key);
|
|
64
|
+
if (live)
|
|
65
|
+
claimed.add(key);
|
|
66
|
+
const desiredUsesDomain = org.issuer.usesDomain;
|
|
67
|
+
const liveUsesDomain = live ? Boolean(live.config.usesDomain) : false;
|
|
68
|
+
if (liveUsesDomain === desiredUsesDomain)
|
|
69
|
+
return null;
|
|
70
|
+
const domain = org.domain ?? 'the org domain';
|
|
71
|
+
return {
|
|
72
|
+
action: {
|
|
73
|
+
system: 'zitadel',
|
|
74
|
+
kind: 'issuer',
|
|
75
|
+
key: org.id,
|
|
76
|
+
desired: { usesDomain: desiredUsesDomain },
|
|
77
|
+
},
|
|
78
|
+
step: {
|
|
79
|
+
description: desiredUsesDomain
|
|
80
|
+
? `move the issuer onto ${domain}, invalidating every passkey in the org`
|
|
81
|
+
: `move the issuer off ${domain} back onto the shared issuer, invalidating every passkey in the org`,
|
|
82
|
+
// Any issuer move invalidates every passkey in the org (#7 corrections
|
|
83
|
+
// 2, #19 "Done when"). It never moves data between systems, so it is
|
|
84
|
+
// never `migrates`.
|
|
85
|
+
destroys: true,
|
|
86
|
+
migrates: false,
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
function planMail(org, byKey, claimed) {
|
|
91
|
+
const key = resourceKey('coolify', 'mail', org.id);
|
|
92
|
+
const live = byKey.get(key);
|
|
93
|
+
// `off` means mail is not foundry's concern here (org.ts's MailMode doc);
|
|
94
|
+
// a live resource under this key, if any, is left unmanaged rather than
|
|
95
|
+
// claimed, since the declared org names nothing for foundry to reconcile
|
|
96
|
+
// it against. Checked before claiming, or an org that turned mail off
|
|
97
|
+
// after having it would silently hide its own leftover mail resource from
|
|
98
|
+
// the unmanaged list.
|
|
99
|
+
if (org.mail.mode === 'off')
|
|
100
|
+
return null;
|
|
101
|
+
if (live)
|
|
102
|
+
claimed.add(key);
|
|
103
|
+
const desired = { mode: org.mail.mode, usesDomain: org.mail.usesDomain };
|
|
104
|
+
if (!live) {
|
|
105
|
+
return {
|
|
106
|
+
action: { system: 'coolify', kind: 'mail', key: org.id, desired },
|
|
107
|
+
step: {
|
|
108
|
+
description: `provision mail (${org.mail.mode}) for ${org.slug}`,
|
|
109
|
+
destroys: false,
|
|
110
|
+
migrates: false,
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
const liveMode = live.config.mode;
|
|
115
|
+
const liveUsesDomain = Boolean(live.config.usesDomain);
|
|
116
|
+
if (liveMode === desired.mode && liveUsesDomain === desired.usesDomain)
|
|
117
|
+
return null;
|
|
118
|
+
if (liveUsesDomain && !desired.usesDomain) {
|
|
119
|
+
// #7 corrections 1: parked, not deleted — mailboxes and messages stay —
|
|
120
|
+
// but still disruptive enough to gate like a destructive step.
|
|
121
|
+
return {
|
|
122
|
+
action: { system: 'coolify', kind: 'mail', key: org.id, desired },
|
|
123
|
+
step: {
|
|
124
|
+
description: `park ${org.slug}'s mail domain (stops receiving; mailboxes and messages are retained, not deleted — #7 corrections 1)`,
|
|
125
|
+
destroys: true,
|
|
126
|
+
migrates: false,
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
// A mode transition (#7 "Transitions") or turning a domain on is a
|
|
131
|
+
// one-time copy, never a deletion.
|
|
132
|
+
return {
|
|
133
|
+
action: { system: 'coolify', kind: 'mail', key: org.id, desired },
|
|
134
|
+
step: {
|
|
135
|
+
description: `move ${org.slug}'s mail from ${String(liveMode)} to ${desired.mode}`,
|
|
136
|
+
destroys: false,
|
|
137
|
+
migrates: true,
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
function planChat(org, byKey, claimed) {
|
|
142
|
+
const key = resourceKey('coolify', 'chat', org.id);
|
|
143
|
+
const live = byKey.get(key);
|
|
144
|
+
if (live)
|
|
145
|
+
claimed.add(key);
|
|
146
|
+
const desiredUsesDomain = org.chat.usesDomain;
|
|
147
|
+
if (!live) {
|
|
148
|
+
// Chat is provisioned at level 1 on a wtfalch `server_name` regardless
|
|
149
|
+
// of `usesDomain` (#7 "Chat").
|
|
150
|
+
return {
|
|
151
|
+
action: {
|
|
152
|
+
system: 'coolify',
|
|
153
|
+
kind: 'chat',
|
|
154
|
+
key: org.id,
|
|
155
|
+
desired: { usesDomain: desiredUsesDomain },
|
|
156
|
+
},
|
|
157
|
+
step: {
|
|
158
|
+
description: `provision chat for ${org.slug} on a wtfalch server_name`,
|
|
159
|
+
destroys: false,
|
|
160
|
+
migrates: false,
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
const liveUsesDomain = Boolean(live.config.usesDomain);
|
|
165
|
+
if (liveUsesDomain === desiredUsesDomain)
|
|
166
|
+
return null;
|
|
167
|
+
const domain = org.domain ?? 'its domain';
|
|
168
|
+
if (!liveUsesDomain && desiredUsesDomain) {
|
|
169
|
+
return {
|
|
170
|
+
action: { system: 'coolify', kind: 'chat', key: org.id, desired: { usesDomain: true } },
|
|
171
|
+
step: {
|
|
172
|
+
description: `re-key ${org.slug}'s chat onto ${domain}, copying rooms and messages so history reads continuous`,
|
|
173
|
+
destroys: true,
|
|
174
|
+
migrates: true,
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
// The reverse (usesDomain true -> false): legal in the schema, but the
|
|
179
|
+
// reverse merge is not built yet (#7 amendment 3). Still surfaced here for
|
|
180
|
+
// visibility; whoever wires a real StepExecutor (#20) must fail loudly on
|
|
181
|
+
// it rather than run it, exactly as #7 amendment 3 requires.
|
|
182
|
+
return {
|
|
183
|
+
action: { system: 'coolify', kind: 'chat', key: org.id, desired: { usesDomain: false } },
|
|
184
|
+
step: {
|
|
185
|
+
description: `reverse ${org.slug}'s chat re-key back onto its original server_name — not yet built (#7 amendment 3)`,
|
|
186
|
+
destroys: true,
|
|
187
|
+
migrates: true,
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* #27: the org's own spend limit on Files, a per-org singleton resource in
|
|
193
|
+
* the same shape as issuer/mail/chat above. Setting a limit is a config
|
|
194
|
+
* write, never destructive or migrating (#27 "Notes": "safe, idempotent and
|
|
195
|
+
* reversible"). A missing live resource reads as uncapped (`null`), the same
|
|
196
|
+
* value a freshly-declared org's `storage` starts at, so a brand new org with
|
|
197
|
+
* no limits declared produces no step.
|
|
198
|
+
*/
|
|
199
|
+
function planStorage(org, byKey, claimed) {
|
|
200
|
+
const key = resourceKey('files', 'storage', org.id);
|
|
201
|
+
const live = byKey.get(key);
|
|
202
|
+
if (live)
|
|
203
|
+
claimed.add(key);
|
|
204
|
+
const desired = { limitBytes: org.storage.limitBytes, limitMicros: org.storage.limitMicros };
|
|
205
|
+
const liveLimitBytes = live ? (live.config.limitBytes ?? null) : null;
|
|
206
|
+
const liveLimitMicros = live ? (live.config.limitMicros ?? null) : null;
|
|
207
|
+
if (liveLimitBytes === desired.limitBytes && liveLimitMicros === desired.limitMicros)
|
|
208
|
+
return null;
|
|
209
|
+
return {
|
|
210
|
+
action: { system: 'files', kind: 'storage', key: org.id, desired },
|
|
211
|
+
step: {
|
|
212
|
+
description: `set ${org.slug}'s storage limit to ${desired.limitBytes ?? 'uncapped'} bytes / ${desired.limitMicros ?? 'uncapped'} micros`,
|
|
213
|
+
destroys: false,
|
|
214
|
+
migrates: false,
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* #27: the org's own spend limit on AI, a per-org singleton resource in the
|
|
220
|
+
* same shape as {@link planStorage}.
|
|
221
|
+
*/
|
|
222
|
+
function planAiBudget(org, byKey, claimed) {
|
|
223
|
+
const key = resourceKey('ai', 'budget', org.id);
|
|
224
|
+
const live = byKey.get(key);
|
|
225
|
+
if (live)
|
|
226
|
+
claimed.add(key);
|
|
227
|
+
const desired = { limitMicros: org.ai.limitMicros };
|
|
228
|
+
const liveLimitMicros = live ? (live.config.limitMicros ?? null) : null;
|
|
229
|
+
if (liveLimitMicros === desired.limitMicros)
|
|
230
|
+
return null;
|
|
231
|
+
return {
|
|
232
|
+
action: { system: 'ai', kind: 'budget', key: org.id, desired },
|
|
233
|
+
step: {
|
|
234
|
+
description: `set ${org.slug}'s AI budget limit to ${desired.limitMicros ?? 'uncapped'} micros`,
|
|
235
|
+
destroys: false,
|
|
236
|
+
migrates: false,
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* The org's cms site (`org.ts`'s `SiteConfig`), a per-org singleton
|
|
242
|
+
* resource modeled on {@link planMail}. Disabling a live site parks it,
|
|
243
|
+
* following mail's own park-not-delete rule (#7 corrections 1) -- a
|
|
244
|
+
* customer's site content is never something a plan deletes on its own. A
|
|
245
|
+
* `usesDomain` change (or re-enabling a previously parked site) is a plain
|
|
246
|
+
* hostname update, not a migration: unlike chat's re-key, nothing about the
|
|
247
|
+
* site's own content moves between two systems when its hostname changes
|
|
248
|
+
* between `<slug>.oikos.host` (`../slug.ts`) and the org's own `domain`.
|
|
249
|
+
*/
|
|
250
|
+
function planSite(org, byKey, claimed) {
|
|
251
|
+
const key = resourceKey('cms', 'site', org.id);
|
|
252
|
+
const live = byKey.get(key);
|
|
253
|
+
if (!org.site.enabled) {
|
|
254
|
+
// Nothing declared and nothing live: there is nothing to park.
|
|
255
|
+
if (!live)
|
|
256
|
+
return null;
|
|
257
|
+
claimed.add(key);
|
|
258
|
+
// Already parked: no step, or a plan would never reach empty (#14
|
|
259
|
+
// "repeat until plan returns empty") once the park step above ran.
|
|
260
|
+
if (!live.config.enabled)
|
|
261
|
+
return null;
|
|
262
|
+
return {
|
|
263
|
+
action: {
|
|
264
|
+
system: 'cms',
|
|
265
|
+
kind: 'site',
|
|
266
|
+
key: org.id,
|
|
267
|
+
desired: { enabled: false, usesDomain: org.site.usesDomain },
|
|
268
|
+
},
|
|
269
|
+
step: {
|
|
270
|
+
description: `park ${org.slug}'s cms site (stops serving; site content is retained, not deleted -- #7 corrections 1)`,
|
|
271
|
+
destroys: true,
|
|
272
|
+
migrates: false,
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
if (live)
|
|
277
|
+
claimed.add(key);
|
|
278
|
+
const desired = { enabled: true, usesDomain: org.site.usesDomain };
|
|
279
|
+
if (!live) {
|
|
280
|
+
return {
|
|
281
|
+
action: { system: 'cms', kind: 'site', key: org.id, desired },
|
|
282
|
+
step: {
|
|
283
|
+
description: `provision a cms site for ${org.slug}`,
|
|
284
|
+
destroys: false,
|
|
285
|
+
migrates: false,
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
const liveEnabled = Boolean(live.config.enabled);
|
|
290
|
+
const liveUsesDomain = Boolean(live.config.usesDomain);
|
|
291
|
+
if (liveEnabled === desired.enabled && liveUsesDomain === desired.usesDomain)
|
|
292
|
+
return null;
|
|
293
|
+
const hostname = desired.usesDomain ? (org.domain ?? 'its domain') : `${org.slug}.oikos.host`;
|
|
294
|
+
return {
|
|
295
|
+
action: { system: 'cms', kind: 'site', key: org.id, desired },
|
|
296
|
+
step: {
|
|
297
|
+
description: `move ${org.slug}'s cms site onto ${hostname}`,
|
|
298
|
+
destroys: false,
|
|
299
|
+
migrates: false,
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type { LiveResource, ZitadelReader, CoolifyReader, CloudflareReader, FilesReader, AiReader, CmsReader, LiveReaders, } from './live.js';
|
|
2
|
+
export { readLiveResources } from './live.js';
|
|
3
|
+
export type { StepAction, PlannedStep, PlanDetail } from './diff.js';
|
|
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';
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { readLiveResources } from './live.js';
|
|
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';
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { OrgId } from '../org.js';
|
|
2
|
+
/**
|
|
3
|
+
* One resource as seen live in one of foundry's six systems.
|
|
4
|
+
*
|
|
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
|
|
10
|
+
* {@link https://github.com/wtfalch/foundry/issues/10 unmanaged}, by
|
|
11
|
+
* `computePlanDetail`.
|
|
12
|
+
*/
|
|
13
|
+
export interface LiveResource {
|
|
14
|
+
system: 'zitadel' | 'coolify' | 'cloudflare' | 'files' | 'ai' | 'cms';
|
|
15
|
+
kind: string;
|
|
16
|
+
key: string;
|
|
17
|
+
config: Record<string, unknown>;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* One narrow interface per system (#19 "Done when"): a single read, scoped
|
|
21
|
+
* to one org, returning everything foundry can see there. Real
|
|
22
|
+
* implementations follow `bootstrap/coolify-api.ts`'s pattern in
|
|
23
|
+
* `wtfalch/app-template` — a token read from the environment or the secrets
|
|
24
|
+
* file, never the command line, never logged. Nothing here calls anything
|
|
25
|
+
* real; `plan-apply.test.ts` and `diff.test.ts` use fakes. Wiring real
|
|
26
|
+
* clients for `FilesReader`/`AiReader` (and every other reader here) is #20,
|
|
27
|
+
* a non-goal of #27.
|
|
28
|
+
*/
|
|
29
|
+
export interface ZitadelReader {
|
|
30
|
+
listResources(orgId: OrgId): Promise<LiveResource[]>;
|
|
31
|
+
}
|
|
32
|
+
export interface CoolifyReader {
|
|
33
|
+
listResources(orgId: OrgId): Promise<LiveResource[]>;
|
|
34
|
+
}
|
|
35
|
+
export interface CloudflareReader {
|
|
36
|
+
listResources(orgId: OrgId): Promise<LiveResource[]>;
|
|
37
|
+
}
|
|
38
|
+
/** #27: reads the org's current storage spend limit as Files reports it. */
|
|
39
|
+
export interface FilesReader {
|
|
40
|
+
listResources(orgId: OrgId): Promise<LiveResource[]>;
|
|
41
|
+
}
|
|
42
|
+
/** #27: reads the org's current AI budget limit as AI reports it. */
|
|
43
|
+
export interface AiReader {
|
|
44
|
+
listResources(orgId: OrgId): Promise<LiveResource[]>;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Reads the org's current cms sites as `@wtfalch/cms` reports them -- the
|
|
48
|
+
* "standard" app form #14 decided ("our code, their CMS content").
|
|
49
|
+
*/
|
|
50
|
+
export interface CmsReader {
|
|
51
|
+
listResources(orgId: OrgId): Promise<LiveResource[]>;
|
|
52
|
+
}
|
|
53
|
+
export interface LiveReaders {
|
|
54
|
+
zitadel: ZitadelReader;
|
|
55
|
+
coolify: CoolifyReader;
|
|
56
|
+
cloudflare: CloudflareReader;
|
|
57
|
+
files: FilesReader;
|
|
58
|
+
ai: AiReader;
|
|
59
|
+
cms: CmsReader;
|
|
60
|
+
}
|
|
61
|
+
/** Every live resource foundry can see for `orgId`, across all six systems. */
|
|
62
|
+
export declare function readLiveResources(orgId: OrgId, readers: LiveReaders): Promise<LiveResource[]>;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Every live resource foundry can see for `orgId`, across all six systems. */
|
|
2
|
+
export async function readLiveResources(orgId, readers) {
|
|
3
|
+
const [zitadel, coolify, cloudflare, files, ai, cms] = await Promise.all([
|
|
4
|
+
readers.zitadel.listResources(orgId),
|
|
5
|
+
readers.coolify.listResources(orgId),
|
|
6
|
+
readers.cloudflare.listResources(orgId),
|
|
7
|
+
readers.files.listResources(orgId),
|
|
8
|
+
readers.ai.listResources(orgId),
|
|
9
|
+
readers.cms.listResources(orgId),
|
|
10
|
+
]);
|
|
11
|
+
return [...zitadel, ...coolify, ...cloudflare, ...files, ...ai, ...cms];
|
|
12
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { PlanApply } from '../apply.js';
|
|
2
|
+
import type { OrgId } from '../org.js';
|
|
3
|
+
import type { Plan } from '../plan.js';
|
|
4
|
+
import type { AppliedState } from '../state.js';
|
|
5
|
+
import type { DeclaredOrgStore } from '../store.js';
|
|
6
|
+
import { type StepAction } from './diff.js';
|
|
7
|
+
import { type LiveReaders } from './live.js';
|
|
8
|
+
/** Who did it, for `@wtfalch/audit`'s "who, when, the plan, the result" (#9, #19). */
|
|
9
|
+
export interface AuditActor {
|
|
10
|
+
readonly id: string;
|
|
11
|
+
readonly display: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The seam onto `@wtfalch/audit`.
|
|
15
|
+
*
|
|
16
|
+
* #19 asks for `@wtfalch/audit` itself, but that package's `record`/`ledger`
|
|
17
|
+
* (see `app-template/src/lib/authz/audit.ts`) is bound to one app's Drizzle
|
|
18
|
+
* table, session model and closed `EventName`/`ActorClass` vocabulary — none
|
|
19
|
+
* of which foundry's SDK package has (it has no database of its own; #18
|
|
20
|
+
* owns the store). Adopting it here is more than a small change, so this is
|
|
21
|
+
* the interface instead, logged as a Finding on #19 rather than a half
|
|
22
|
+
* adoption. A real implementation binds `@wtfalch/audit`'s `ledger.sign` to
|
|
23
|
+
* foundry's own vocabulary and table once foundry has one.
|
|
24
|
+
*/
|
|
25
|
+
export interface AuditLog {
|
|
26
|
+
recordPlan(entry: {
|
|
27
|
+
actor: AuditActor;
|
|
28
|
+
orgId: OrgId;
|
|
29
|
+
plan: Plan;
|
|
30
|
+
at: Date;
|
|
31
|
+
}): Promise<void>;
|
|
32
|
+
recordApply(entry: {
|
|
33
|
+
actor: AuditActor;
|
|
34
|
+
orgId: OrgId;
|
|
35
|
+
plan: Plan;
|
|
36
|
+
outcome: 'applied' | 'refused' | 'failed';
|
|
37
|
+
reason?: string;
|
|
38
|
+
at: Date;
|
|
39
|
+
}): Promise<void>;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The seam onto `@wtfalch/authz`, gating `apply` only — `plan` is a read
|
|
43
|
+
* (managed-settings: "Reads are free"). Same reasoning as {@link AuditLog}
|
|
44
|
+
* for not adopting the package directly here: `@wtfalch/authz`'s
|
|
45
|
+
* `permitsRead`/`Access` are resolved from an app's own session and tenant
|
|
46
|
+
* model, which foundry's SDK package does not have.
|
|
47
|
+
*/
|
|
48
|
+
export interface AuthzGate {
|
|
49
|
+
canApply(actor: AuditActor, orgId: OrgId): Promise<boolean>;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Makes one {@link StepAction} true against a real system. No implementation
|
|
53
|
+
* ships in this package — #19's job is the seam and the refusal rules,
|
|
54
|
+
* proven against fakes; a real executor (calling Coolify/Zitadel/Cloudflare)
|
|
55
|
+
* is #20's "provision Company Factory end to end".
|
|
56
|
+
*/
|
|
57
|
+
export interface StepExecutor {
|
|
58
|
+
execute(action: StepAction): Promise<{
|
|
59
|
+
resourceId?: string;
|
|
60
|
+
} | undefined>;
|
|
61
|
+
}
|
|
62
|
+
export declare class DeclaredOrgNotFoundError extends Error {
|
|
63
|
+
constructor(orgId: OrgId);
|
|
64
|
+
}
|
|
65
|
+
/** #10 (resolved): "an apply is refused when its plan no longer matches reality." */
|
|
66
|
+
export declare class StalePlanError extends Error {
|
|
67
|
+
constructor(orgId: OrgId);
|
|
68
|
+
}
|
|
69
|
+
/** #9 (resolved): "any step that destroys or migrates requires the org's name typed." */
|
|
70
|
+
export declare class ConfirmationRequiredError extends Error {
|
|
71
|
+
constructor(orgId: OrgId);
|
|
72
|
+
}
|
|
73
|
+
export declare class NotAuthorizedError extends Error {
|
|
74
|
+
constructor(actor: AuditActor, orgId: OrgId);
|
|
75
|
+
}
|
|
76
|
+
export interface FoundryPlanApplyOptions {
|
|
77
|
+
store: DeclaredOrgStore;
|
|
78
|
+
readers: LiveReaders;
|
|
79
|
+
executor: StepExecutor;
|
|
80
|
+
audit: AuditLog;
|
|
81
|
+
authz: AuthzGate;
|
|
82
|
+
/** The operator running this plan/apply — every audit row's "who" (#9, #19). */
|
|
83
|
+
actor: AuditActor;
|
|
84
|
+
/** Overridable for tests; defaults to `() => new Date()`. */
|
|
85
|
+
now?: () => Date;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The #19 implementation of {@link PlanApply}: computes a plan against live
|
|
89
|
+
* state, and refuses to apply one that has gone stale, one that touches a
|
|
90
|
+
* destructive or migrating step without the org's name typed, or one the
|
|
91
|
+
* actor is not authorized to run.
|
|
92
|
+
*/
|
|
93
|
+
export declare class FoundryPlanApply implements PlanApply {
|
|
94
|
+
private readonly store;
|
|
95
|
+
private readonly readers;
|
|
96
|
+
private readonly executor;
|
|
97
|
+
private readonly audit;
|
|
98
|
+
private readonly authz;
|
|
99
|
+
private readonly actor;
|
|
100
|
+
private readonly now;
|
|
101
|
+
constructor(options: FoundryPlanApplyOptions);
|
|
102
|
+
plan(id: OrgId): Promise<Plan>;
|
|
103
|
+
apply(plan: Plan, confirmedOrgName: string): Promise<AppliedState>;
|
|
104
|
+
private refuse;
|
|
105
|
+
private runSteps;
|
|
106
|
+
private computeFreshPlan;
|
|
107
|
+
}
|