@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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/dist/apply.d.ts +27 -0
  3. package/dist/apply.js +1 -0
  4. package/dist/index.d.ts +17 -0
  5. package/dist/index.js +7 -0
  6. package/dist/migrations/0001_declared_org.sql +61 -0
  7. package/dist/migrations/0002_applied_state.sql +43 -0
  8. package/dist/migrations/0003_foundry_runs.sql +53 -0
  9. package/dist/migrations/0004_slug_unique.sql +26 -0
  10. package/dist/migrations/0005_spend_limits.sql +34 -0
  11. package/dist/migrations/0006_buyer_identity.sql +31 -0
  12. package/dist/migrations/0007_site.sql +18 -0
  13. package/dist/org.d.ts +276 -0
  14. package/dist/org.js +29 -0
  15. package/dist/plan/diff.d.ts +52 -0
  16. package/dist/plan/diff.js +302 -0
  17. package/dist/plan/index.d.ts +8 -0
  18. package/dist/plan/index.js +4 -0
  19. package/dist/plan/live.d.ts +62 -0
  20. package/dist/plan/live.js +12 -0
  21. package/dist/plan/plan-apply.d.ts +107 -0
  22. package/dist/plan/plan-apply.js +172 -0
  23. package/dist/plan/providers-readers.d.ts +86 -0
  24. package/dist/plan/providers-readers.js +83 -0
  25. package/dist/plan.d.ts +50 -0
  26. package/dist/plan.js +1 -0
  27. package/dist/runner/db.d.ts +8 -0
  28. package/dist/runner/db.js +1 -0
  29. package/dist/runner/errors.d.ts +37 -0
  30. package/dist/runner/errors.js +57 -0
  31. package/dist/runner/index.d.ts +4 -0
  32. package/dist/runner/index.js +2 -0
  33. package/dist/runner/runner.d.ts +6 -0
  34. package/dist/runner/runner.js +137 -0
  35. package/dist/runner/schema.d.ts +163 -0
  36. package/dist/runner/schema.js +25 -0
  37. package/dist/runner/types.d.ts +97 -0
  38. package/dist/runner/types.js +1 -0
  39. package/dist/slug.d.ts +38 -0
  40. package/dist/slug.js +141 -0
  41. package/dist/state.d.ts +63 -0
  42. package/dist/state.js +1 -0
  43. package/dist/store/postgres.d.ts +43 -0
  44. package/dist/store/postgres.js +267 -0
  45. package/dist/store/schema.d.ts +615 -0
  46. package/dist/store/schema.js +59 -0
  47. package/dist/store.d.ts +23 -0
  48. package/dist/store.js +1 -0
  49. 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
+ }