@wtfalch/foundry 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/apply.d.ts +14 -0
  2. package/dist/calls-sfu-deploy-support.d.ts +57 -0
  3. package/dist/calls-sfu-deploy-support.js +82 -0
  4. package/dist/calls-sfu.d.ts +175 -0
  5. package/dist/calls-sfu.js +239 -0
  6. package/dist/index.d.ts +11 -6
  7. package/dist/index.js +7 -4
  8. package/dist/migrate.d.ts +38 -0
  9. package/dist/migrate.js +47 -0
  10. package/dist/migrations/0008_hosting.sql +24 -0
  11. package/dist/migrations/0009_hosting_git_branch_build_pack.sql +14 -0
  12. package/dist/migrations/0010_owner.sql +13 -0
  13. package/dist/migrations/0011_test_org.sql +12 -0
  14. package/dist/migrations/0012_audit.sql +21 -0
  15. package/dist/migrations/0013_access_emails.sql +6 -0
  16. package/dist/org.d.ts +130 -0
  17. package/dist/org.js +102 -0
  18. package/dist/plan/diff.d.ts +20 -5
  19. package/dist/plan/diff.js +533 -9
  20. package/dist/plan/executor.d.ts +823 -0
  21. package/dist/plan/executor.js +1242 -0
  22. package/dist/plan/index.d.ts +6 -4
  23. package/dist/plan/index.js +3 -2
  24. package/dist/plan/live.d.ts +8 -4
  25. package/dist/plan/plan-apply.d.ts +114 -4
  26. package/dist/plan/plan-apply.js +266 -14
  27. package/dist/plan/providers-readers.d.ts +102 -14
  28. package/dist/plan/providers-readers.js +245 -16
  29. package/dist/provision.d.ts +56 -0
  30. package/dist/provision.js +69 -0
  31. package/dist/slug.d.ts +12 -18
  32. package/dist/slug.js +66 -0
  33. package/dist/state.d.ts +26 -5
  34. package/dist/store/audit.d.ts +8 -0
  35. package/dist/store/audit.js +43 -0
  36. package/dist/store/postgres.d.ts +3 -1
  37. package/dist/store/postgres.js +84 -2
  38. package/dist/store/schema.d.ts +378 -0
  39. package/dist/store/schema.js +32 -1
  40. package/dist/store.d.ts +12 -0
  41. package/package.json +15 -11
@@ -2,7 +2,9 @@ export type { LiveResource, ZitadelReader, CoolifyReader, CloudflareReader, File
2
2
  export { readLiveResources } from './live.js';
3
3
  export type { StepAction, PlannedStep, PlanDetail } from './diff.js';
4
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';
5
+ export type { AuditActor, AuditLog, AuthzGate, StepExecutor, StepDestroyer, DestroyTarget, StepExecutionContext, FoundryPlanApplyOptions, } from './plan-apply.js';
6
+ export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, } from './plan-apply.js';
7
+ export type { ScopeResolver, RecordedIds } from './providers-readers.js';
8
+ export { ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
9
+ export type { CoolifyPort, CloudflarePort, ZitadelPort, FilesOrgLimits, AiOrgLimits, FilesOrganisationPort, AiOrganisationPort, FoundryStepExecutorPorts, } from './executor.js';
10
+ export { FoundryStepExecutor, NotBuiltError, healthUrls } from './executor.js';
@@ -1,4 +1,5 @@
1
1
  export { readLiveResources } from './live.js';
2
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';
3
+ export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, } from './plan-apply.js';
4
+ export { ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
5
+ export { FoundryStepExecutor, NotBuiltError, healthUrls } from './executor.js';
@@ -3,10 +3,14 @@ import type { OrgId } from '../org.js';
3
3
  * One resource as seen live in one of foundry's six systems.
4
4
  *
5
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
6
+ * (issuer, mail, chat, storage's spend limit, AI's budget, the cms site, the
7
+ * org's own app, its database, its Cloudflare tunnel — see `diff.ts`) are
8
+ * keyed by the {@link OrgId} itself. Two resources key by something else
9
+ * instead (#71): the Cloudflare zone is keyed by the domain name, since a
10
+ * zone belongs to a domain rather than an org, and each declared ZITADEL
11
+ * issuer app is keyed by its own declared name, since an org can declare
12
+ * more than one. Anything else foundry can see in that system's account/org
13
+ * is keyed however that system names it, so it can still be matched, or left
10
14
  * {@link https://github.com/wtfalch/foundry/issues/10 unmanaged}, by
11
15
  * `computePlanDetail`.
12
16
  */
@@ -1,10 +1,10 @@
1
1
  import type { PlanApply } from '../apply.js';
2
- import type { OrgId } from '../org.js';
2
+ import type { DeclaredOrg, OrgId } from '../org.js';
3
3
  import type { Plan } from '../plan.js';
4
4
  import type { AppliedState } from '../state.js';
5
5
  import type { DeclaredOrgStore } from '../store.js';
6
6
  import { type StepAction } from './diff.js';
7
- import { type LiveReaders } from './live.js';
7
+ import { type LiveReaders, type LiveResource } from './live.js';
8
8
  /** Who did it, for `@wtfalch/audit`'s "who, when, the plan, the result" (#9, #19). */
9
9
  export interface AuditActor {
10
10
  readonly id: string;
@@ -37,6 +37,14 @@ export interface AuditLog {
37
37
  reason?: string;
38
38
  at: Date;
39
39
  }): Promise<void>;
40
+ /** `FoundryPlanApply.destroy`'s own record -- no `Plan` involved, so this is its own entry shape rather than a reuse of `recordApply`'s. */
41
+ recordDestroy(entry: {
42
+ actor: AuditActor;
43
+ orgId: OrgId;
44
+ outcome: 'destroyed' | 'refused' | 'failed';
45
+ reason?: string;
46
+ at: Date;
47
+ }): Promise<void>;
40
48
  }
41
49
  /**
42
50
  * The seam onto `@wtfalch/authz`, gating `apply` only — `plan` is a read
@@ -47,18 +55,101 @@ export interface AuditLog {
47
55
  */
48
56
  export interface AuthzGate {
49
57
  canApply(actor: AuditActor, orgId: OrgId): Promise<boolean>;
58
+ /**
59
+ * Gates `destroy` -- deleting every resource foundry made for an org, then
60
+ * the org itself, is a stronger action than applying config, so it is its
61
+ * own permission rather than reusing `canApply`.
62
+ */
63
+ canDestroy(actor: AuditActor, orgId: OrgId): Promise<boolean>;
64
+ }
65
+ /**
66
+ * What {@link StepExecutor.execute} needs beyond the {@link StepAction}
67
+ * itself to act: the declared org (a real executor names its own live
68
+ * resources after `org.slug`/`org.bespokeAppRepo`/etc., not just the bare
69
+ * ids a `StepAction` carries) and read/write access to this org's *scope*
70
+ * ids — a Coolify project uuid, the org's own ZITADEL org/project id, a
71
+ * Cloudflare zone id — the ids one step's executor resolves or creates that a
72
+ * later step (or a later apply) needs again (executor decisions,
73
+ * `.claude/decisions-2026-09-24-executor.md`: "Scope ids: a Coolify project
74
+ * per org (`serviceIds['coolify_project']`) ... Created resources are
75
+ * recorded under `system:kind:key`").
76
+ *
77
+ * Decision (executor.ts, #20): rather than backing `recordScopeId` with its
78
+ * own direct `DeclaredOrgStore.putAppliedState` call, `recordedIds`/
79
+ * `recordScopeId` below are backed by the very `serviceIds` map `runSteps`
80
+ * already accumulates and persists once, atomically, at the end of a run. A
81
+ * direct store write from inside `execute` would race that final write:
82
+ * `runSteps` captures `previous?.serviceIds` once before the loop starts, so
83
+ * its own end-of-loop `store.putAppliedState` would silently clobber
84
+ * anything an executor wrote to the store mid-loop. Routing scope ids
85
+ * through the same in-memory map both writes to removes the race entirely,
86
+ * and also means `recordedIds` sees a resource id `runSteps` itself recorded
87
+ * for an *earlier* step in this same run (e.g. the ZITADEL org provisioned
88
+ * by an `issuer` step, needed by a later `app` step) without any extra
89
+ * plumbing.
90
+ */
91
+ export interface StepExecutionContext {
92
+ readonly org: DeclaredOrg;
93
+ /**
94
+ * Every id foundry has recorded for this org so far — both `system:kind:key`
95
+ * resource ids (`runSteps` records these itself from `execute`'s own return
96
+ * value) and flat provider *scope* ids a step recorded via
97
+ * {@link recordScopeId}. A snapshot at the moment it's called, but since
98
+ * `runSteps` runs one step at a time and mutates the same underlying map
99
+ * this reads from, it always reflects everything recorded so far in this
100
+ * run, plus whatever a previous apply already persisted.
101
+ */
102
+ recordedIds(): Readonly<Record<string, string>>;
103
+ /**
104
+ * Records a provider *scope* id — `coolify_project`, `zitadel_org`,
105
+ * `zitadel_project`, `cloudflare_zone` — alongside whatever `system:kind:key`
106
+ * resource id `execute`'s own return value records. `key` must carry no
107
+ * `:`, the same rule `state.ts`'s `AppliedState.serviceIds` documents, so a
108
+ * scope key can never collide with the `system:kind:key` form.
109
+ */
110
+ recordScopeId(key: string, id: string): void;
50
111
  }
51
112
  /**
52
113
  * Makes one {@link StepAction} true against a real system. No implementation
53
114
  * ships in this package — #19's job is the seam and the refusal rules,
54
115
  * proven against fakes; a real executor (calling Coolify/Zitadel/Cloudflare)
55
- * is #20's "provision Company Factory end to end".
116
+ * is #20's "provision Company Factory end to end" (`executor.ts`'s
117
+ * `FoundryStepExecutor`).
56
118
  */
57
119
  export interface StepExecutor {
58
- execute(action: StepAction): Promise<{
120
+ execute(action: StepAction, context: StepExecutionContext): Promise<{
59
121
  resourceId?: string;
60
122
  } | undefined>;
61
123
  }
124
+ /**
125
+ * One resource `FoundryPlanApply.destroy` has decided to tear down: an entry
126
+ * from `AppliedState.serviceIds` (`resourceKey`'s `system:kind:key` form, or
127
+ * one of the two ZITADEL scope keys, `zitadel_project`/`zitadel_org`),
128
+ * resolved back to the provider id it was recorded under.
129
+ */
130
+ export interface DestroyTarget {
131
+ system: LiveResource['system'];
132
+ kind: string;
133
+ /** The foundry key this was recorded under: an org id, a declared issuer app's name, or the scope key itself for `kind: 'project' | 'org'`. */
134
+ key: string;
135
+ /** The provider's own id -- exactly what `AppliedState.serviceIds` holds under this resource's key. */
136
+ id: string;
137
+ }
138
+ /**
139
+ * Deletes one resource {@link DestroyTarget} names. The teardown half of
140
+ * {@link StepExecutor}: where `execute` makes a `StepAction` true, `destroy`
141
+ * undoes exactly the one resource it is given, one at a time, in the order
142
+ * `FoundryPlanApply.destroy` calls it. A provider 404 (already gone) counts
143
+ * as deleted, never an error -- a destroy interrupted after a resource was
144
+ * deleted but before its `serviceIds` entry was removed must not fail the
145
+ * same way twice on retry.
146
+ */
147
+ export interface StepDestroyer {
148
+ destroy(target: DestroyTarget, context: {
149
+ readonly org: DeclaredOrg;
150
+ readonly recordedIds: Readonly<Record<string, string>>;
151
+ }): Promise<void>;
152
+ }
62
153
  export declare class DeclaredOrgNotFoundError extends Error {
63
154
  constructor(orgId: OrgId);
64
155
  }
@@ -73,10 +164,15 @@ export declare class ConfirmationRequiredError extends Error {
73
164
  export declare class NotAuthorizedError extends Error {
74
165
  constructor(actor: AuditActor, orgId: OrgId);
75
166
  }
167
+ /** The goal line's own gate: only a test org can be destroyed. */
168
+ export declare class NotATestOrgError extends Error {
169
+ constructor(orgId: OrgId);
170
+ }
76
171
  export interface FoundryPlanApplyOptions {
77
172
  store: DeclaredOrgStore;
78
173
  readers: LiveReaders;
79
174
  executor: StepExecutor;
175
+ destroyer: StepDestroyer;
80
176
  audit: AuditLog;
81
177
  authz: AuthzGate;
82
178
  /** The operator running this plan/apply — every audit row's "who" (#9, #19). */
@@ -94,6 +190,7 @@ export declare class FoundryPlanApply implements PlanApply {
94
190
  private readonly store;
95
191
  private readonly readers;
96
192
  private readonly executor;
193
+ private readonly destroyer;
97
194
  private readonly audit;
98
195
  private readonly authz;
99
196
  private readonly actor;
@@ -101,7 +198,20 @@ export declare class FoundryPlanApply implements PlanApply {
101
198
  constructor(options: FoundryPlanApplyOptions);
102
199
  plan(id: OrgId): Promise<Plan>;
103
200
  apply(plan: Plan, confirmedOrgName: string): Promise<AppliedState>;
201
+ /**
202
+ * Deletes every resource foundry recorded for `id`, in reverse creation
203
+ * order, then the declared org and its applied state themselves (goal
204
+ * line). Refused unless the stored `DeclaredOrg` has `test: true` and
205
+ * unless `confirmedOrgName` matches the org's `displayName` -- the same
206
+ * confirmation `apply` requires for a destructive step (#9), required here
207
+ * unconditionally since destroying an org is the most destructive step
208
+ * there is. A failure partway through stops and rethrows, leaving the
209
+ * declared org in place with whatever remains recorded in its applied
210
+ * state, so a retried `destroy` only tears down what's left.
211
+ */
212
+ destroy(id: OrgId, confirmedOrgName: string): Promise<void>;
104
213
  private refuse;
214
+ private refuseDestroy;
105
215
  private runSteps;
106
216
  private computeFreshPlan;
107
217
  }
@@ -27,6 +27,13 @@ export class NotAuthorizedError extends Error {
27
27
  this.name = 'NotAuthorizedError';
28
28
  }
29
29
  }
30
+ /** The goal line's own gate: only a test org can be destroyed. */
31
+ export class NotATestOrgError extends Error {
32
+ constructor(orgId) {
33
+ super(`org ${orgId} is not marked test; only a test org can be destroyed`);
34
+ this.name = 'NotATestOrgError';
35
+ }
36
+ }
30
37
  /**
31
38
  * The #19 implementation of {@link PlanApply}: computes a plan against live
32
39
  * state, and refuses to apply one that has gone stale, one that touches a
@@ -37,6 +44,7 @@ export class FoundryPlanApply {
37
44
  store;
38
45
  readers;
39
46
  executor;
47
+ destroyer;
40
48
  audit;
41
49
  authz;
42
50
  actor;
@@ -45,6 +53,7 @@ export class FoundryPlanApply {
45
53
  this.store = options.store;
46
54
  this.readers = options.readers;
47
55
  this.executor = options.executor;
56
+ this.destroyer = options.destroyer;
48
57
  this.audit = options.audit;
49
58
  this.authz = options.authz;
50
59
  this.actor = options.actor;
@@ -74,6 +83,71 @@ export class FoundryPlanApply {
74
83
  }
75
84
  return this.runSteps(org, plan, detail);
76
85
  }
86
+ /**
87
+ * Deletes every resource foundry recorded for `id`, in reverse creation
88
+ * order, then the declared org and its applied state themselves (goal
89
+ * line). Refused unless the stored `DeclaredOrg` has `test: true` and
90
+ * unless `confirmedOrgName` matches the org's `displayName` -- the same
91
+ * confirmation `apply` requires for a destructive step (#9), required here
92
+ * unconditionally since destroying an org is the most destructive step
93
+ * there is. A failure partway through stops and rethrows, leaving the
94
+ * declared org in place with whatever remains recorded in its applied
95
+ * state, so a retried `destroy` only tears down what's left.
96
+ */
97
+ async destroy(id, confirmedOrgName) {
98
+ const authorized = await this.authz.canDestroy(this.actor, id);
99
+ if (!authorized) {
100
+ await this.refuseDestroy(id, 'not authorized');
101
+ throw new NotAuthorizedError(this.actor, id);
102
+ }
103
+ const org = await this.store.get(id);
104
+ if (!org)
105
+ throw new DeclaredOrgNotFoundError(id);
106
+ if (!org.test) {
107
+ await this.refuseDestroy(id, 'org is not marked test; only a test org can be destroyed');
108
+ throw new NotATestOrgError(id);
109
+ }
110
+ if (confirmedOrgName.trim() !== org.displayName.trim()) {
111
+ await this.refuseDestroy(id, "org's name not typed correctly");
112
+ throw new ConfirmationRequiredError(id);
113
+ }
114
+ const state = await this.store.getAppliedState(id);
115
+ const serviceIds = { ...(state?.serviceIds ?? {}) };
116
+ const targets = destroyTargets(serviceIds);
117
+ try {
118
+ for (const target of targets) {
119
+ // A provider 404 counts as deleted -- `StepDestroyer.destroy`'s own
120
+ // doc comment -- so nothing here needs to special-case "already
121
+ // gone" itself.
122
+ await this.destroyer.destroy(target, { org, recordedIds: { ...serviceIds } });
123
+ delete serviceIds[destroyServiceIdKey(target)];
124
+ await this.store.putAppliedState({
125
+ orgId: id,
126
+ serviceIds,
127
+ checkpoints: state?.checkpoints ?? [],
128
+ failedFields: state?.failedFields ?? [],
129
+ lastApplied: state?.lastApplied ?? null,
130
+ });
131
+ }
132
+ await this.store.delete(id);
133
+ }
134
+ catch (e) {
135
+ await this.audit.recordDestroy({
136
+ actor: this.actor,
137
+ orgId: id,
138
+ outcome: 'failed',
139
+ reason: e instanceof Error ? e.message : String(e),
140
+ at: this.now(),
141
+ });
142
+ throw e;
143
+ }
144
+ await this.audit.recordDestroy({
145
+ actor: this.actor,
146
+ orgId: id,
147
+ outcome: 'destroyed',
148
+ at: this.now(),
149
+ });
150
+ }
77
151
  async refuse(plan, reason) {
78
152
  await this.audit.recordApply({
79
153
  actor: this.actor,
@@ -84,16 +158,41 @@ export class FoundryPlanApply {
84
158
  at: this.now(),
85
159
  });
86
160
  }
161
+ async refuseDestroy(orgId, reason) {
162
+ await this.audit.recordDestroy({
163
+ actor: this.actor,
164
+ orgId,
165
+ outcome: 'refused',
166
+ reason,
167
+ at: this.now(),
168
+ });
169
+ }
87
170
  async runSteps(org, plan, detail) {
88
171
  const orgId = plan.orgId;
89
172
  const previous = await this.store.getAppliedState(orgId);
90
173
  const serviceIds = { ...(previous?.serviceIds ?? {}) };
91
174
  const checkpoints = [];
175
+ // Backed by the very `serviceIds` map this method persists once, at the
176
+ // end — see `StepExecutionContext`'s doc comment for why.
177
+ const context = {
178
+ org,
179
+ recordedIds: () => ({ ...serviceIds }),
180
+ recordScopeId: (key, id) => {
181
+ serviceIds[key] = id;
182
+ },
183
+ };
92
184
  try {
93
185
  for (const planned of detail.plannedSteps) {
94
- const result = await this.executor.execute(planned.action);
95
- if (result?.resourceId)
96
- serviceIds[planned.action.kind] = result.resourceId;
186
+ const result = await this.executor.execute(planned.action, context);
187
+ // `system:kind:key`, not just `kind`: two steps can share a `kind`
188
+ // (an org's several declared ZITADEL apps, #71) and would otherwise
189
+ // overwrite each other's id. `providers-readers.ts`'s
190
+ // `storeRecordedIds` reads this same key back to match a plan's live
191
+ // resources to what foundry itself created here.
192
+ if (result?.resourceId) {
193
+ const { system, kind, key } = planned.action;
194
+ serviceIds[`${system}:${kind}:${key}`] = result.resourceId;
195
+ }
97
196
  checkpoints.push({ step: planned.step.description, completedAt: this.now().toISOString() });
98
197
  }
99
198
  }
@@ -105,7 +204,7 @@ export class FoundryPlanApply {
105
204
  orgId,
106
205
  serviceIds,
107
206
  checkpoints,
108
- failedFields: previous?.failedFields ?? [],
207
+ failedFields: failedFields(org, detail, checkpoints.length),
109
208
  lastApplied: previous?.lastApplied ?? null,
110
209
  });
111
210
  await this.audit.recordApply({
@@ -122,14 +221,7 @@ export class FoundryPlanApply {
122
221
  orgId,
123
222
  serviceIds,
124
223
  checkpoints,
125
- // Carried forward, never recomputed. #18 added `failedFields` and left
126
- // "whether apply throws, partially applies, or does both" to #19, but
127
- // this implementation was written before the field existed and has no
128
- // opinion on it: `computePlanDetail` plans only the issuer, mail and
129
- // chat, so it cannot yet say which declared fields it failed to make
130
- // true. Writing `[]` here would claim it had checked. #20 wires the
131
- // full provisioning surface and is what computes this list.
132
- failedFields: previous?.failedFields ?? [],
224
+ failedFields: failedFields(org, detail, detail.plannedSteps.length),
133
225
  lastApplied: { at: this.now().toISOString(), declaredOrgVersion: hashOrg(org) },
134
226
  };
135
227
  await this.store.putAppliedState(applied);
@@ -146,11 +238,171 @@ export class FoundryPlanApply {
146
238
  const org = await this.store.get(id);
147
239
  if (!org)
148
240
  throw new DeclaredOrgNotFoundError(id);
149
- const live = await readLiveResources(id, this.readers);
150
- const detail = computePlanDetail(org, live);
241
+ const [live, applied] = await Promise.all([
242
+ readLiveResources(id, this.readers),
243
+ this.store.getAppliedState(id),
244
+ ]);
245
+ const detail = computePlanDetail(org, live, applied?.serviceIds ?? {});
151
246
  return { org, plan: toPlan(detail), detail };
152
247
  }
153
248
  }
249
+ /** `AppliedState.serviceIds`' two flat ZITADEL scope keys (`executor.ts`'s `ensureZitadelProject`/`zitadelIssuer`) -- no `:`, unlike a resource key. */
250
+ const ZITADEL_PROJECT_SCOPE_KEY = 'zitadel_project';
251
+ const ZITADEL_ORG_SCOPE_KEY = 'zitadel_org';
252
+ /**
253
+ * Every resource `FoundryPlanApply.destroy` tears down, built from
254
+ * `AppliedState.serviceIds`, in the order the goal line and this campaign's
255
+ * report settle on: the Cloudflare tunnel first, then the Access gate (stop routing traffic to the
256
+ * app before tearing it down), then the org's own Coolify database and app,
257
+ * then the org's customers ZITADEL org (plan gap A9), then every ZITADEL app
258
+ * foundry recorded, then the ZITADEL project, then the
259
+ * ZITADEL org itself -- the one resource every other one in this org lives
260
+ * inside, so it goes last.
261
+ *
262
+ * ZITADEL apps are read directly from `serviceIds` (any key with the
263
+ * `zitadel:app:` prefix), not from the declared org's current
264
+ * `hosting.issuerApps` -- destroy tears down everything foundry actually
265
+ * recorded for this org, including an app since removed from the declared
266
+ * list (drift `diff.ts`'s own `planIssuerApps` doc comment already
267
+ * acknowledges: "removing an app from `issuerApps` entirely produces no step
268
+ * at all").
269
+ *
270
+ * The Cloudflare zone and the Coolify project are deliberately absent: no
271
+ * zone-delete or Coolify-project-delete method was named for this campaign
272
+ * (Finding, this campaign's report), so destroy leaves both in place. The
273
+ * Cloudflare rate limiting rule (#43, `diff.ts`'s `planRateLimit`) is absent
274
+ * for the same reason: it lives inside the same zone's ruleset, no rule-
275
+ * delete method exists on `CloudflarePort` (`executor.ts`), and the zone it
276
+ * belongs to is already left in place -- deleting only the rule and not the
277
+ * zone is a decision left for whoever wires teardown in, not made here.
278
+ */
279
+ function destroyTargets(serviceIds) {
280
+ // Reverse-teardown order: everything under one of these prefixes is
281
+ // collected in this order, then every ZITADEL app (there can be several,
282
+ // #71), then the two scope ids last.
283
+ const ORDERED_PREFIXES = [
284
+ 'cloudflare:tunnel:',
285
+ 'cloudflare:access:',
286
+ 'coolify:database:',
287
+ 'coolify:app:',
288
+ 'zitadel:customers:',
289
+ ];
290
+ const targets = [];
291
+ for (const prefix of ORDERED_PREFIXES) {
292
+ for (const [resourceKey, id] of Object.entries(serviceIds)) {
293
+ if (!resourceKey.startsWith(prefix))
294
+ continue;
295
+ const [system, kind] = prefix.split(':');
296
+ targets.push({ system, kind, key: resourceKey.slice(prefix.length), id });
297
+ }
298
+ }
299
+ for (const [resourceKey, id] of Object.entries(serviceIds)) {
300
+ if (!resourceKey.startsWith('zitadel:app:'))
301
+ continue;
302
+ targets.push({
303
+ system: 'zitadel',
304
+ kind: 'app',
305
+ key: resourceKey.slice('zitadel:app:'.length),
306
+ id,
307
+ });
308
+ }
309
+ for (const [kind, scopeKey] of [
310
+ ['project', ZITADEL_PROJECT_SCOPE_KEY],
311
+ ['org', ZITADEL_ORG_SCOPE_KEY],
312
+ ]) {
313
+ const id = serviceIds[scopeKey];
314
+ if (id)
315
+ targets.push({ system: 'zitadel', kind, key: scopeKey, id });
316
+ }
317
+ return targets;
318
+ }
319
+ /** The `AppliedState.serviceIds` key a completed {@link DestroyTarget} should be removed from. */
320
+ function destroyServiceIdKey(target) {
321
+ if (target.kind === 'project' || target.kind === 'org')
322
+ return target.key;
323
+ return `${target.system}:${target.kind}:${target.key}`;
324
+ }
325
+ /**
326
+ * The declared field each planned step makes true, keyed `system:kind` as
327
+ * `diff.ts` plans it. A kind not listed here falls back to `system.kind`, so
328
+ * a new planner step still names something rather than vanishing.
329
+ */
330
+ const STEP_FIELDS = {
331
+ 'zitadel:issuer': 'issuer.usesDomain',
332
+ 'zitadel:owner': 'owner',
333
+ 'zitadel:customers': 'hosting.issuerApps',
334
+ 'coolify:mail': 'mail',
335
+ 'coolify:chat': 'chat.usesDomain',
336
+ 'files:storage': 'storage',
337
+ 'ai:budget': 'ai',
338
+ 'cms:site': 'site',
339
+ 'coolify:app': 'hosting.enabled',
340
+ 'coolify:database': 'hosting.database',
341
+ 'cloudflare:tunnel': 'hosting.tunnel',
342
+ 'cloudflare:zone': 'hosting.usesDomain',
343
+ 'cloudflare:access': 'hosting.accessEmails',
344
+ 'cloudflare:ratelimit': 'hosting.usesDomain',
345
+ };
346
+ function stepField(action) {
347
+ // An org declares several issuer apps, each keyed by its name (#71).
348
+ if (action.system === 'zitadel' && action.kind === 'app') {
349
+ return `hosting.issuerApps.${action.key}`;
350
+ }
351
+ return STEP_FIELDS[`${action.system}:${action.kind}`] ?? `${action.system}.${action.kind}`;
352
+ }
353
+ /**
354
+ * #25: the declared fields this apply could not make true, written fresh on
355
+ * every apply rather than carried forward.
356
+ *
357
+ * Three sources, in this order:
358
+ * - fields no planner step exists for yet, so no apply can make them true:
359
+ * SSO, guests and solo when turned on, calls on the org's domain, a
360
+ * bespoke app repo, an adopted app in the `repo` form (#7's `#15` comment:
361
+ * "declared but refused by `apply`"), and hosting on a domain the org has
362
+ * not recorded;
363
+ * - chat's reverse re-key, which the planner surfaces but which is not built
364
+ * (#7 amendment 3), so it counts as failed even when no step threw;
365
+ * - on a failed apply, the field of every planned step from the one that
366
+ * threw onwards (`completed` is how many steps finished).
367
+ */
368
+ function failedFields(org, detail, completed) {
369
+ const failed = new Set();
370
+ if (org.sso.enabled)
371
+ failed.add('sso.enabled');
372
+ if (org.guests.enabled)
373
+ failed.add('guests.enabled');
374
+ if (org.solo.enabled)
375
+ failed.add('solo.enabled');
376
+ if (org.bespokeAppRepo !== null)
377
+ failed.add('bespokeAppRepo');
378
+ if (org.adoptedApp?.form === 'repo')
379
+ failed.add('adoptedApp');
380
+ if (org.calls.usesDomain)
381
+ failed.add('calls.usesDomain');
382
+ // `planZone` plans nothing without a domain to put the zone on.
383
+ if (org.hosting.usesDomain && org.domain === null)
384
+ failed.add('hosting.usesDomain');
385
+ // `planTunnel` shares that same domain requirement (#71, map gap N3): a
386
+ // declared `usesDomain: true` with no recorded domain plans no tunnel
387
+ // step either. A domainless org (`usesDomain: false`) is not a failure --
388
+ // it is the level-1 case N3 decided gets no per-org tunnel at all.
389
+ if (org.hosting.tunnel && org.hosting.usesDomain && org.domain === null) {
390
+ failed.add('hosting.tunnel');
391
+ }
392
+ for (const { action, step } of detail.plannedSteps) {
393
+ // Provisioning chat also desires `usesDomain: false`; only the reverse
394
+ // re-key is a destructive step with that value.
395
+ const reverseReKey = action.system === 'coolify' &&
396
+ action.kind === 'chat' &&
397
+ action.desired.usesDomain === false &&
398
+ step.destroys;
399
+ if (reverseReKey)
400
+ failed.add(stepField(action));
401
+ }
402
+ for (const { action } of detail.plannedSteps.slice(completed))
403
+ failed.add(stepField(action));
404
+ return [...failed];
405
+ }
154
406
  /** Order-independent structural equality between two plans. */
155
407
  function plansMatch(a, b) {
156
408
  return JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));