@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
@@ -3,7 +3,7 @@ import type { CoolifyClient } from '@wtfalch/providers/coolify';
3
3
  import type { ZitadelClient } from '@wtfalch/providers/zitadel';
4
4
  import type { OrgId } from '../org.js';
5
5
  import type { DeclaredOrgStore } from '../store.js';
6
- import type { CloudflareReader, CoolifyReader, LiveResource, ZitadelReader } from './live.js';
6
+ import type { CloudflareReader, CmsReader, CoolifyReader, LiveResource, ZitadelReader } from './live.js';
7
7
  /**
8
8
  * Real `CoolifyReader`, `CloudflareReader` and `ZitadelReader` implementations
9
9
  * (`live.ts:31-45`), backed by `@wtfalch/providers`' HTTP clients instead of
@@ -22,6 +22,13 @@ import type { CloudflareReader, CoolifyReader, LiveResource, ZitadelReader } fro
22
22
  *
23
23
  * `packages/foundry/package.json` had zero `@wtfalch/*` dependencies before
24
24
  * this file; `@wtfalch/providers` is its first.
25
+ *
26
+ * Each reader also matches a live provider resource back onto the foundry
27
+ * resource it was created for, via {@link RecordedIds} /
28
+ * {@link storeRecordedIds} below, so `computePlanDetail` (`diff.ts`) treats
29
+ * what foundry itself provisioned as matched instead of unmanaged (#10) --
30
+ * see the campaign design doc,
31
+ * `.claude/campaign/2026-09-24-keys-and-memberships.md`, "Design for (1)".
25
32
  */
26
33
  /**
27
34
  * Resolves an estate {@link OrgId} to one provider's own scope id — a
@@ -38,19 +45,44 @@ import type { CloudflareReader, CoolifyReader, LiveResource, ZitadelReader } fro
38
45
  */
39
46
  export type ScopeResolver = (orgId: OrgId) => Promise<string | null>;
40
47
  /**
41
- * A {@link ScopeResolver} backed by `AppliedState.serviceIds`
42
- * (`../state.ts`: "one id per service ... keyed by that service's own name
43
- * for the org") — the one place foundry records a provider's own id for an
44
- * org today, through the same `DeclaredOrgStore` seam #18 built.
48
+ * A {@link ScopeResolver} backed by `AppliedState.serviceIds` (`../state.ts`)
49
+ * — the one place foundry records a provider's own id for an org today,
50
+ * through the same `DeclaredOrgStore` seam #18 built.
45
51
  *
46
- * Nothing writes a `serviceIds[serviceKey]` entry yet — that is the
47
- * `StepExecutor` half of #20, not built here — so every resolver built this
48
- * way returns `null` for every org until it does. That is the correct
49
- * "nothing live yet" answer: a reader passed `null` returns `[]`, which
50
- * `computePlanDetail` (`diff.ts`) already reads as "no live resource",
51
- * exactly right for an org with no Coolify project (etc.) yet.
52
+ * `runSteps` (`plan-apply.ts`) now writes one `serviceIds` entry per
53
+ * resource it creates, keyed `system:kind:key` ({@link storeRecordedIds}
54
+ * below reads those back), and #20's `FoundryStepExecutor` (`executor.ts`)
55
+ * now also calls `StepExecutionContext.recordScopeId` for `coolify_project`,
56
+ * `zitadel_org` and `zitadel_project`, so a resolver built with one of those
57
+ * three `serviceKey`s returns the real id once that step has run. A
58
+ * Cloudflare zone is the one scope id nothing writes yet: `executor.ts`'s
59
+ * `cloudflareZone` records its result only under `cloudflare:zone:<domain>`
60
+ * (a resource id, read back by {@link storeRecordedIds}), not under a plain
61
+ * `cloudflare_zone` scope key — a resolver built with that `serviceKey`
62
+ * still returns `null` for every org. That is the correct "nothing live yet"
63
+ * answer: a reader passed `null` returns `[]`, which `computePlanDetail`
64
+ * (`diff.ts`) already reads as "no live resource".
52
65
  */
53
66
  export declare function storeScopeResolver(store: DeclaredOrgStore, serviceKey: string): ScopeResolver;
67
+ /**
68
+ * Resolves an estate {@link OrgId} to the provider ids this org's applied
69
+ * state has recorded for one reader's `system`, inverted so a reader can
70
+ * look a live resource's own id up directly: the provider's id -> the
71
+ * foundry `kind:key` it was recorded under.
72
+ *
73
+ * Backed by the same `AppliedState.serviceIds` {@link storeScopeResolver}
74
+ * reads, but a different slice of it: `plan-apply.ts`'s `runSteps` records a
75
+ * completed step's `resourceId` under `serviceIds[`${system}:${kind}:${key}`]`
76
+ * (`../state.ts`'s doc comment; design:
77
+ * `.claude/campaign/2026-09-24-keys-and-memberships.md`, "Design for (1)").
78
+ * Only entries in that three-part form, for this `system`, are kept — a
79
+ * scope id (`coolify_project`, etc.) has no `:` in it and is filtered out
80
+ * rather than mistaken for a resource id. `key` is rejoined with `:` rather
81
+ * than taken as a single segment because it is not always the org id: a
82
+ * declared ZITADEL app is keyed by its own name (#71), not `orgId`.
83
+ */
84
+ export type RecordedIds = (orgId: OrgId) => Promise<Record<string, string>>;
85
+ export declare function storeRecordedIds(store: DeclaredOrgStore, system: LiveResource['system']): RecordedIds;
54
86
  /**
55
87
  * Backed by `@wtfalch/providers/coolify`'s `CoolifyClient.listResources`: a
56
88
  * Coolify project's environments and in-scope applications (v1 scope — see
@@ -59,7 +91,8 @@ export declare function storeScopeResolver(store: DeclaredOrgStore, serviceKey:
59
91
  export declare class ProvidersCoolifyReader implements CoolifyReader {
60
92
  private readonly client;
61
93
  private readonly resolveScope;
62
- constructor(client: CoolifyClient, resolveScope: ScopeResolver);
94
+ private readonly resolveRecordedIds;
95
+ constructor(client: CoolifyClient, resolveScope: ScopeResolver, resolveRecordedIds: RecordedIds);
63
96
  listResources(orgId: OrgId): Promise<LiveResource[]>;
64
97
  }
65
98
  /**
@@ -70,7 +103,8 @@ export declare class ProvidersCoolifyReader implements CoolifyReader {
70
103
  export declare class ProvidersCloudflareReader implements CloudflareReader {
71
104
  private readonly client;
72
105
  private readonly resolveScope;
73
- constructor(client: CloudflareClient, resolveScope: ScopeResolver);
106
+ private readonly resolveRecordedIds;
107
+ constructor(client: CloudflareClient, resolveScope: ScopeResolver, resolveRecordedIds: RecordedIds);
74
108
  listResources(orgId: OrgId): Promise<LiveResource[]>;
75
109
  }
76
110
  /**
@@ -81,6 +115,60 @@ export declare class ProvidersCloudflareReader implements CloudflareReader {
81
115
  export declare class ProvidersZitadelReader implements ZitadelReader {
82
116
  private readonly client;
83
117
  private readonly resolveScope;
84
- constructor(client: ZitadelClient, resolveScope: ScopeResolver);
118
+ private readonly resolveRecordedIds;
119
+ constructor(client: ZitadelClient, resolveScope: ScopeResolver, resolveRecordedIds: RecordedIds);
120
+ listResources(orgId: OrgId): Promise<LiveResource[]>;
121
+ }
122
+ /**
123
+ * Backed by the same `@wtfalch/providers/coolify`'s `CoolifyClient.listResources`
124
+ * call {@link ProvidersCoolifyReader} makes, not a real cms admin API --
125
+ * `executor.ts`'s `cmsSite` (#116) provisions the org's cms app and site app
126
+ * as two ordinary Coolify applications in the *same* Coolify project the
127
+ * org's own hosted app would use (`ensureCoolifyProject`), so this reader
128
+ * resolves that project's scope the same way, then finds the two
129
+ * applications this org recorded there -- by the flat scope ids `cmsSite`
130
+ * records them under (`coolify_cms_app`/`coolify_site_app`, `executor.ts`'s
131
+ * `CMS_APP_SCOPE_KEY`/`SITE_APP_SCOPE_KEY` -- restated here rather than
132
+ * imported, the same way `hostnameFor`'s hostname rule is restated across
133
+ * files rather than shared), matched by id the same way every other reader
134
+ * in this file matches a recorded resource, never by name.
135
+ *
136
+ * Without this reader nothing ever reports a live `cms:site:<orgId>`
137
+ * resource, so `diff.ts`'s `planSite` sees `!live` forever and re-plans
138
+ * "provision a cms site" on every apply even after one has already
139
+ * succeeded -- #116 review Finding 1, breaking #14's "repeat until plan
140
+ * returns empty" invariant.
141
+ *
142
+ * `planSite` reads this resource's `config.enabled`/`config.usesDomain` with
143
+ * plain truthiness (`Boolean(live.config.enabled)`), unlike
144
+ * `planHostingToggle`'s own `!== false` convention for the org's *own* app
145
+ * -- so, unlike {@link ProvidersCoolifyReader}, which only ever passes
146
+ * Coolify's raw fields through untouched, this reader has to synthesize an
147
+ * explicit `enabled` boolean itself, or the plan would never converge in
148
+ * either direction. Both apps must exist and count as up, or on their way
149
+ * there ({@link isRunningApplication}), for `enabled` to be `true`; not
150
+ * found, or found but actually stopped, reads as `false`, which is what
151
+ * lets a later park step's own live check (`!live.config.enabled`) settle
152
+ * once both apps have actually stopped.
153
+ * `usesDomain` is always `true`: `validateSite` ("site.enabled requires
154
+ * site.usesDomain") guarantees it whenever this resource can exist at all.
155
+ *
156
+ * Nothing recorded yet (no Coolify project scope, or either app id still
157
+ * unrecorded -- `cmsSite`'s own doc comment: a crash partway through a first
158
+ * apply can leave only one recorded) or either application missing from
159
+ * Coolify's own listing reads as "nothing live" (`[]`), the same "not
160
+ * provisioned yet" answer `computePlanDetail` already knows how to plan
161
+ * against. A failing `client.listResources` call is not caught here, the
162
+ * same way it is not caught in `ProvidersCoolifyReader`/
163
+ * `ProvidersCloudflareReader`/`ProvidersZitadelReader` either -- only a
164
+ * bolt-on *extra* read (`withLogVolumeConfig`'s own doc comment) gets a
165
+ * try/catch, and this call is this reader's entire job, not an extra.
166
+ */
167
+ export declare class ProvidersCmsReader implements CmsReader {
168
+ private readonly client;
169
+ private readonly resolveProjectScope;
170
+ private readonly resolveCmsAppId;
171
+ private readonly resolveSiteAppId;
172
+ constructor(client: CoolifyClient, resolveProjectScope: ScopeResolver, resolveCmsAppId: ScopeResolver, resolveSiteAppId: ScopeResolver);
85
173
  listResources(orgId: OrgId): Promise<LiveResource[]>;
86
174
  }
@@ -1,15 +1,21 @@
1
1
  /**
2
- * A {@link ScopeResolver} backed by `AppliedState.serviceIds`
3
- * (`../state.ts`: "one id per service ... keyed by that service's own name
4
- * for the org") — the one place foundry records a provider's own id for an
5
- * org today, through the same `DeclaredOrgStore` seam #18 built.
2
+ * A {@link ScopeResolver} backed by `AppliedState.serviceIds` (`../state.ts`)
3
+ * — the one place foundry records a provider's own id for an org today,
4
+ * through the same `DeclaredOrgStore` seam #18 built.
6
5
  *
7
- * Nothing writes a `serviceIds[serviceKey]` entry yet — that is the
8
- * `StepExecutor` half of #20, not built here — so every resolver built this
9
- * way returns `null` for every org until it does. That is the correct
10
- * "nothing live yet" answer: a reader passed `null` returns `[]`, which
11
- * `computePlanDetail` (`diff.ts`) already reads as "no live resource",
12
- * exactly right for an org with no Coolify project (etc.) yet.
6
+ * `runSteps` (`plan-apply.ts`) now writes one `serviceIds` entry per
7
+ * resource it creates, keyed `system:kind:key` ({@link storeRecordedIds}
8
+ * below reads those back), and #20's `FoundryStepExecutor` (`executor.ts`)
9
+ * now also calls `StepExecutionContext.recordScopeId` for `coolify_project`,
10
+ * `zitadel_org` and `zitadel_project`, so a resolver built with one of those
11
+ * three `serviceKey`s returns the real id once that step has run. A
12
+ * Cloudflare zone is the one scope id nothing writes yet: `executor.ts`'s
13
+ * `cloudflareZone` records its result only under `cloudflare:zone:<domain>`
14
+ * (a resource id, read back by {@link storeRecordedIds}), not under a plain
15
+ * `cloudflare_zone` scope key — a resolver built with that `serviceKey`
16
+ * still returns `null` for every org. That is the correct "nothing live yet"
17
+ * answer: a reader passed `null` returns `[]`, which `computePlanDetail`
18
+ * (`diff.ts`) already reads as "no live resource".
13
19
  */
14
20
  export function storeScopeResolver(store, serviceKey) {
15
21
  return async (orgId) => {
@@ -20,6 +26,110 @@ export function storeScopeResolver(store, serviceKey) {
20
26
  return applied?.serviceIds[serviceKey] || null;
21
27
  };
22
28
  }
29
+ export function storeRecordedIds(store, system) {
30
+ return async (orgId) => {
31
+ const applied = await store.getAppliedState(orgId);
32
+ const recorded = {};
33
+ if (!applied)
34
+ return recorded;
35
+ for (const [resourceKey, providerId] of Object.entries(applied.serviceIds)) {
36
+ const [keySystem, kind, ...rest] = resourceKey.split(':');
37
+ if (keySystem !== system || kind === undefined || rest.length === 0)
38
+ continue;
39
+ recorded[providerId] = `${kind}:${rest.join(':')}`;
40
+ }
41
+ return recorded;
42
+ };
43
+ }
44
+ /**
45
+ * Maps each live resource whose own id is one `recorded` names onto the
46
+ * foundry `kind`/`key` it was recorded under, keeping `config` exactly as
47
+ * the provider gave it (design doc, "Design for (1)": "the reader then maps
48
+ * each provider resource whose `key` equals a recorded provider id onto the
49
+ * recorded foundry `kind` and `key`"). A resource nothing recorded passes
50
+ * through unchanged, so `computePlanDetail` (`diff.ts`) still reports it
51
+ * unmanaged (#10) rather than this boundary guessing at a match.
52
+ *
53
+ * `dropUnrecordedKinds` names kinds that are dropped outright instead of
54
+ * passed through when unrecorded. Cloudflare's `tunnel` is the one case: a
55
+ * tunnel is account-wide, so an unrecorded one belongs to some other org,
56
+ * not this one, and every org's plan would otherwise report the same
57
+ * foreign tunnel as unmanaged.
58
+ */
59
+ function applyRecordedIds(resources, recorded, dropUnrecordedKinds = new Set()) {
60
+ const mapped = [];
61
+ for (const resource of resources) {
62
+ const foundryKey = recorded[resource.key];
63
+ if (!foundryKey) {
64
+ if (!dropUnrecordedKinds.has(resource.kind))
65
+ mapped.push(resource);
66
+ continue;
67
+ }
68
+ const separator = foundryKey.indexOf(':');
69
+ mapped.push({
70
+ ...resource,
71
+ kind: foundryKey.slice(0, separator),
72
+ key: foundryKey.slice(separator + 1),
73
+ });
74
+ }
75
+ return mapped;
76
+ }
77
+ /** Cloudflare tunnels this org has not recorded are dropped — see {@link applyRecordedIds}. */
78
+ const CLOUDFLARE_DROP_UNRECORDED = new Set(['tunnel']);
79
+ /**
80
+ * The persistent-storage mount path #75 requires on every app foundry
81
+ * deploys, and the exact string `executor.ts`'s own `LOG_DIR` constant holds
82
+ * -- restated here rather than imported the same way `executor.ts` restates
83
+ * `diff.ts`'s hostname rule, since neither file in `plan/` imports another's
84
+ * private constants.
85
+ */
86
+ const LOG_DIR = '/app/logs';
87
+ /**
88
+ * #75: whether the org's own recorded app already carries the durable log
89
+ * directory `executor.ts`'s `ensureLogVolume` creates -- an extra
90
+ * `listStorages` read, by the application's own provider uuid
91
+ * (`resource.key`, still the raw Coolify uuid at this point -- called
92
+ * *before* {@link applyRecordedIds} below overwrites `key` with the foundry
93
+ * key it was recorded under, or `listStorages` would be asked for a
94
+ * "storages" listing of the org id instead of the application). Gated on
95
+ * `recorded` mapping this exact uuid onto foundry's `app` kind (the same map
96
+ * {@link applyRecordedIds} itself reads), not just `resource.kind ===
97
+ * 'application'`, so this never fires for some other application in the same
98
+ * project, nor for the project's own database uuid.
99
+ *
100
+ * This lets `diff.ts`'s `planApp` notice a live app that predates this
101
+ * feature and plan the missing volume, the same way `LiveResource.config`
102
+ * (`live.ts`) already carries whatever a planner needs to diff against.
103
+ * `hasLogDir`'s env var itself is not read back here: `executor.ts`'s
104
+ * `coolifyApp` always pushes `LOG_DIR` in the same step that mounts the
105
+ * volume, so the volume's own presence is the one signal this reader needs
106
+ * (Decision: reading `listEnvs` too would only catch a volume manually
107
+ * removed after the env was pushed by hand, which #75's own "no app is
108
+ * configured by hand" rules out).
109
+ *
110
+ * `listStorages` is one extra, unrelated-to-the-plan's-real-purpose read
111
+ * bolted onto every org's plan (#75), so a failure of this one call --
112
+ * Coolify briefly unreachable, a 5xx, anything `CoolifyClient` throws on a
113
+ * non-2xx -- must not abort the whole org's plan the way an unhandled
114
+ * rejection here would (Finding, refuted against the first version of this
115
+ * function: it had no try/catch at all). Caught and treated the same as
116
+ * "nothing has checked this app yet": the resource passes through
117
+ * unchanged, `hasLogVolume` stays absent, and `diff.ts`'s `planApp` already
118
+ * reads a missing `hasLogVolume` as compliant rather than drift.
119
+ */
120
+ async function withLogVolumeConfig(client, resource, recorded) {
121
+ if (resource.kind !== 'application' || !recorded[resource.key]?.startsWith('app:')) {
122
+ return resource;
123
+ }
124
+ try {
125
+ const storages = await client.listStorages(resource.key);
126
+ const hasLogVolume = (storages.persistent_storages ?? []).some((s) => s.mount_path === LOG_DIR);
127
+ return { ...resource, config: { ...resource.config, hasLogVolume } };
128
+ }
129
+ catch {
130
+ return resource;
131
+ }
132
+ }
23
133
  /** `@wtfalch/providers`' own `LiveResource` (`resource.ts`) has no `system` field — foundry's adds it at this boundary. */
24
134
  function toFoundryResources(system, resources) {
25
135
  return resources.map((r) => ({ system, kind: r.kind, key: r.key, config: r.config }));
@@ -32,15 +142,22 @@ function toFoundryResources(system, resources) {
32
142
  export class ProvidersCoolifyReader {
33
143
  client;
34
144
  resolveScope;
35
- constructor(client, resolveScope) {
145
+ resolveRecordedIds;
146
+ constructor(client, resolveScope, resolveRecordedIds) {
36
147
  this.client = client;
37
148
  this.resolveScope = resolveScope;
149
+ this.resolveRecordedIds = resolveRecordedIds;
38
150
  }
39
151
  async listResources(orgId) {
40
152
  const projectUuid = await this.resolveScope(orgId);
41
153
  if (!projectUuid)
42
154
  return [];
43
- return toFoundryResources('coolify', await this.client.listResources(projectUuid));
155
+ const [resources, recorded] = await Promise.all([
156
+ this.client.listResources(projectUuid),
157
+ this.resolveRecordedIds(orgId),
158
+ ]);
159
+ const withLogConfig = await Promise.all(toFoundryResources('coolify', resources).map((r) => withLogVolumeConfig(this.client, r, recorded)));
160
+ return applyRecordedIds(withLogConfig, recorded);
44
161
  }
45
162
  }
46
163
  /**
@@ -51,15 +168,24 @@ export class ProvidersCoolifyReader {
51
168
  export class ProvidersCloudflareReader {
52
169
  client;
53
170
  resolveScope;
54
- constructor(client, resolveScope) {
171
+ resolveRecordedIds;
172
+ constructor(client, resolveScope, resolveRecordedIds) {
55
173
  this.client = client;
56
174
  this.resolveScope = resolveScope;
175
+ this.resolveRecordedIds = resolveRecordedIds;
57
176
  }
58
177
  async listResources(orgId) {
59
178
  const zoneId = await this.resolveScope(orgId);
60
179
  if (!zoneId)
61
180
  return [];
62
- return toFoundryResources('cloudflare', await this.client.listResources(zoneId));
181
+ const [resources, recorded] = await Promise.all([
182
+ this.client.listResources(zoneId),
183
+ this.resolveRecordedIds(orgId),
184
+ ]);
185
+ // A zone needs no mapping: its key is already the zone name, which is
186
+ // foundry's own zone key (`diff.ts`'s `planZone`), so it never matches a
187
+ // recorded provider id and always passes through unchanged.
188
+ return applyRecordedIds(toFoundryResources('cloudflare', resources), recorded, CLOUDFLARE_DROP_UNRECORDED);
63
189
  }
64
190
  }
65
191
  /**
@@ -70,14 +196,117 @@ export class ProvidersCloudflareReader {
70
196
  export class ProvidersZitadelReader {
71
197
  client;
72
198
  resolveScope;
73
- constructor(client, resolveScope) {
199
+ resolveRecordedIds;
200
+ constructor(client, resolveScope, resolveRecordedIds) {
74
201
  this.client = client;
75
202
  this.resolveScope = resolveScope;
203
+ this.resolveRecordedIds = resolveRecordedIds;
76
204
  }
77
205
  async listResources(orgId) {
78
206
  const zitadelOrgId = await this.resolveScope(orgId);
79
207
  if (!zitadelOrgId)
80
208
  return [];
81
- return toFoundryResources('zitadel', await this.client.listResources(zitadelOrgId));
209
+ const [resources, recorded] = await Promise.all([
210
+ this.client.listResources(zitadelOrgId),
211
+ this.resolveRecordedIds(orgId),
212
+ ]);
213
+ return applyRecordedIds(toFoundryResources('zitadel', resources), recorded);
214
+ }
215
+ }
216
+ /**
217
+ * Whether an application counts as up, or on its way there, from its own
218
+ * Coolify `status` (`` `running:healthy`, `running:unknown`,
219
+ * `exited:unhealthy` `` -- `CoolifyClient`'s own `CoolifyApplication.status`
220
+ * doc comment). Anything except `exited`/`stopped` (Coolify's own
221
+ * resting-off states; `stopped` is not one this codebase has verified
222
+ * against a live instance, included defensively) reads as still on --
223
+ * concretely, `starting:*`/`restarting:*` right after `cmsSite` (re)creates
224
+ * or starts an app both count as on too, not just `running:*`, or a status
225
+ * read moments after that call would misreport `enabled: false` and cause a
226
+ * spurious re-plan (#116 review round 3, Finding 2, mirroring
227
+ * `executor.ts`'s own `isRunningOrStarting`, restated here rather than
228
+ * imported the same way `hostnameFor`'s hostname rule is). A missing
229
+ * `status` defaults to on too, the same "nothing says it's off" convention
230
+ * `planHostingToggle` (`diff.ts`) uses for the org's own app.
231
+ */
232
+ function isRunningApplication(resource) {
233
+ const status = resource.config.status;
234
+ if (!status)
235
+ return true;
236
+ return (typeof status === 'string' && !status.startsWith('exited') && !status.startsWith('stopped'));
237
+ }
238
+ /**
239
+ * Backed by the same `@wtfalch/providers/coolify`'s `CoolifyClient.listResources`
240
+ * call {@link ProvidersCoolifyReader} makes, not a real cms admin API --
241
+ * `executor.ts`'s `cmsSite` (#116) provisions the org's cms app and site app
242
+ * as two ordinary Coolify applications in the *same* Coolify project the
243
+ * org's own hosted app would use (`ensureCoolifyProject`), so this reader
244
+ * resolves that project's scope the same way, then finds the two
245
+ * applications this org recorded there -- by the flat scope ids `cmsSite`
246
+ * records them under (`coolify_cms_app`/`coolify_site_app`, `executor.ts`'s
247
+ * `CMS_APP_SCOPE_KEY`/`SITE_APP_SCOPE_KEY` -- restated here rather than
248
+ * imported, the same way `hostnameFor`'s hostname rule is restated across
249
+ * files rather than shared), matched by id the same way every other reader
250
+ * in this file matches a recorded resource, never by name.
251
+ *
252
+ * Without this reader nothing ever reports a live `cms:site:<orgId>`
253
+ * resource, so `diff.ts`'s `planSite` sees `!live` forever and re-plans
254
+ * "provision a cms site" on every apply even after one has already
255
+ * succeeded -- #116 review Finding 1, breaking #14's "repeat until plan
256
+ * returns empty" invariant.
257
+ *
258
+ * `planSite` reads this resource's `config.enabled`/`config.usesDomain` with
259
+ * plain truthiness (`Boolean(live.config.enabled)`), unlike
260
+ * `planHostingToggle`'s own `!== false` convention for the org's *own* app
261
+ * -- so, unlike {@link ProvidersCoolifyReader}, which only ever passes
262
+ * Coolify's raw fields through untouched, this reader has to synthesize an
263
+ * explicit `enabled` boolean itself, or the plan would never converge in
264
+ * either direction. Both apps must exist and count as up, or on their way
265
+ * there ({@link isRunningApplication}), for `enabled` to be `true`; not
266
+ * found, or found but actually stopped, reads as `false`, which is what
267
+ * lets a later park step's own live check (`!live.config.enabled`) settle
268
+ * once both apps have actually stopped.
269
+ * `usesDomain` is always `true`: `validateSite` ("site.enabled requires
270
+ * site.usesDomain") guarantees it whenever this resource can exist at all.
271
+ *
272
+ * Nothing recorded yet (no Coolify project scope, or either app id still
273
+ * unrecorded -- `cmsSite`'s own doc comment: a crash partway through a first
274
+ * apply can leave only one recorded) or either application missing from
275
+ * Coolify's own listing reads as "nothing live" (`[]`), the same "not
276
+ * provisioned yet" answer `computePlanDetail` already knows how to plan
277
+ * against. A failing `client.listResources` call is not caught here, the
278
+ * same way it is not caught in `ProvidersCoolifyReader`/
279
+ * `ProvidersCloudflareReader`/`ProvidersZitadelReader` either -- only a
280
+ * bolt-on *extra* read (`withLogVolumeConfig`'s own doc comment) gets a
281
+ * try/catch, and this call is this reader's entire job, not an extra.
282
+ */
283
+ export class ProvidersCmsReader {
284
+ client;
285
+ resolveProjectScope;
286
+ resolveCmsAppId;
287
+ resolveSiteAppId;
288
+ constructor(client, resolveProjectScope, resolveCmsAppId, resolveSiteAppId) {
289
+ this.client = client;
290
+ this.resolveProjectScope = resolveProjectScope;
291
+ this.resolveCmsAppId = resolveCmsAppId;
292
+ this.resolveSiteAppId = resolveSiteAppId;
293
+ }
294
+ async listResources(orgId) {
295
+ const projectUuid = await this.resolveProjectScope(orgId);
296
+ if (!projectUuid)
297
+ return [];
298
+ const [cmsAppId, siteAppId] = await Promise.all([
299
+ this.resolveCmsAppId(orgId),
300
+ this.resolveSiteAppId(orgId),
301
+ ]);
302
+ if (!cmsAppId || !siteAppId)
303
+ return [];
304
+ const resources = await this.client.listResources(projectUuid);
305
+ const cmsApp = resources.find((r) => r.kind === 'application' && r.key === cmsAppId);
306
+ const siteApp = resources.find((r) => r.kind === 'application' && r.key === siteAppId);
307
+ if (!cmsApp || !siteApp)
308
+ return [];
309
+ const enabled = isRunningApplication(cmsApp) && isRunningApplication(siteApp);
310
+ return [{ system: 'cms', kind: 'site', key: orgId, config: { enabled, usesDomain: true } }];
82
311
  }
83
312
  }
@@ -0,0 +1,56 @@
1
+ import type { CoolifyPort } from './plan/executor.js';
2
+ export declare const PROJECT_NAME = "foundry";
3
+ export declare const ENVIRONMENT_NAME = "production";
4
+ export declare const DATABASE_NAME = "foundry";
5
+ /**
6
+ * The Coolify calls provisioning needs, on top of `plan/executor.ts`'s own
7
+ * `CoolifyPort` (reused by reference below rather than restated): `listEnvironments`,
8
+ * which `CoolifyPort` has no read-only equivalent for (`findOrCreateEnvironment`
9
+ * creates on a miss). A real `CoolifyClient` already has both, so this is
10
+ * still satisfied with no adapter.
11
+ */
12
+ export interface ProvisionCoolifyPort extends Pick<CoolifyPort, 'findProjectByName' | 'createProject' | 'findOrCreateEnvironment' | 'listServers' | 'listDatabases' | 'createPostgresDatabase'> {
13
+ listEnvironments(projectUuid: string): Promise<Array<{
14
+ id: number;
15
+ uuid: string;
16
+ name: string;
17
+ }>>;
18
+ }
19
+ export interface ProvisionPlan {
20
+ projectExists: boolean;
21
+ project?: {
22
+ uuid: string;
23
+ };
24
+ environmentExists: boolean;
25
+ environment?: {
26
+ id: number;
27
+ uuid: string;
28
+ name: string;
29
+ };
30
+ databaseExists: boolean;
31
+ database?: {
32
+ uuid: string;
33
+ name: string;
34
+ environment_id?: number;
35
+ };
36
+ }
37
+ /**
38
+ * Read-only: works out what `applyProvision` would need to do to make the
39
+ * `PROJECT_NAME`/`ENVIRONMENT_NAME`/`DATABASE_NAME` database exist, without
40
+ * creating anything. Mirrors `plan/executor.ts`'s `coolifyDatabase` step --
41
+ * find the database by name scoped to the environment's numeric
42
+ * `environment_id`, the same care about environment scoping
43
+ * `findApplicationByName`'s doc comment explains (an unscoped name match
44
+ * risks a false positive against a same-named database in a different
45
+ * environment).
46
+ */
47
+ export declare function planProvision(coolify: ProvisionCoolifyPort): Promise<ProvisionPlan>;
48
+ /**
49
+ * Makes `plan` true: creates whatever `planProvision` found missing.
50
+ * A no-op read (returns `plan.database`) when the database already exists,
51
+ * so calling this twice never creates a second one.
52
+ */
53
+ export declare function applyProvision(coolify: ProvisionCoolifyPort, plan: ProvisionPlan): Promise<{
54
+ uuid: string;
55
+ name: string;
56
+ }>;
@@ -0,0 +1,69 @@
1
+ // #R2 (map gap: foundry's own Postgres, two commands): find-or-create the
2
+ // Postgres database `scripts/foundry-db-provision.mjs` migrates into.
3
+ // Project, environment and database names are fixed here rather than taken
4
+ // as input -- one foundry install, one project -- so there is nothing to
5
+ // mistype on the command line.
6
+ export const PROJECT_NAME = 'foundry';
7
+ export const ENVIRONMENT_NAME = 'production';
8
+ export const DATABASE_NAME = 'foundry';
9
+ /**
10
+ * Read-only: works out what `applyProvision` would need to do to make the
11
+ * `PROJECT_NAME`/`ENVIRONMENT_NAME`/`DATABASE_NAME` database exist, without
12
+ * creating anything. Mirrors `plan/executor.ts`'s `coolifyDatabase` step --
13
+ * find the database by name scoped to the environment's numeric
14
+ * `environment_id`, the same care about environment scoping
15
+ * `findApplicationByName`'s doc comment explains (an unscoped name match
16
+ * risks a false positive against a same-named database in a different
17
+ * environment).
18
+ */
19
+ export async function planProvision(coolify) {
20
+ const project = await coolify.findProjectByName(PROJECT_NAME);
21
+ if (!project) {
22
+ return { projectExists: false, environmentExists: false, databaseExists: false };
23
+ }
24
+ const environments = await coolify.listEnvironments(project.uuid);
25
+ const environment = environments.find((e) => e.name === ENVIRONMENT_NAME);
26
+ if (!environment) {
27
+ return { projectExists: true, project, environmentExists: false, databaseExists: false };
28
+ }
29
+ const databases = await coolify.listDatabases();
30
+ const database = databases.find((db) => db.name === DATABASE_NAME && db.environment_id === environment.id);
31
+ return {
32
+ projectExists: true,
33
+ project,
34
+ environmentExists: true,
35
+ environment,
36
+ databaseExists: Boolean(database),
37
+ database,
38
+ };
39
+ }
40
+ /**
41
+ * Makes `plan` true: creates whatever `planProvision` found missing.
42
+ * A no-op read (returns `plan.database`) when the database already exists,
43
+ * so calling this twice never creates a second one.
44
+ */
45
+ export async function applyProvision(coolify, plan) {
46
+ if (plan.databaseExists && plan.database)
47
+ return plan.database;
48
+ const project = plan.projectExists && plan.project ? plan.project : await coolify.createProject(PROJECT_NAME);
49
+ const environment = plan.environmentExists && plan.environment
50
+ ? plan.environment
51
+ : (await coolify.findOrCreateEnvironment(project.uuid, ENVIRONMENT_NAME)).environment;
52
+ const servers = await coolify.listServers();
53
+ const server = servers.find((s) => s.is_usable && s.is_reachable) ?? servers[0];
54
+ if (!server)
55
+ throw new Error('No usable Coolify server is registered on this instance.');
56
+ // `createPostgresDatabase` also returns `internal_db_url` -- a live
57
+ // credential -- which is deliberately never read off this result: only
58
+ // `uuid` (and the `name` we already know) leaves this function.
59
+ const created = await coolify.createPostgresDatabase({
60
+ project_uuid: project.uuid,
61
+ server_uuid: server.uuid,
62
+ environment_name: environment.name,
63
+ environment_uuid: environment.uuid,
64
+ name: DATABASE_NAME,
65
+ postgres_db: DATABASE_NAME,
66
+ postgres_user: DATABASE_NAME,
67
+ });
68
+ return { uuid: created.uuid, name: DATABASE_NAME };
69
+ }
package/dist/slug.d.ts CHANGED
@@ -1,21 +1,3 @@
1
- /**
2
- * The declared org's slug: a pure validator, no I/O, no store access.
3
- *
4
- * Decided in https://github.com/wtfalch/foundry/issues/28, forced by the
5
- * level-1 apex `oikos.host` being live: Cloudflare's free-tier Universal SSL
6
- * covers a single wildcard level, so every level-1 org's hostnames are flat
7
- * (`acme.oikos.host`, `acme-stage.oikos.host`, `acme-pr-12.oikos.host`).
8
- * That makes the slug a live hostname from the moment the org exists — #7
9
- * only froze it once the org had mail, which is too late: by then the
10
- * hostname is already serving. This validator runs at creation, not at
11
- * first use (#28 "Done when").
12
- *
13
- * Kept separate from `org.ts` and `store/postgres.ts` so it can be called
14
- * from both without either depending on the other: `store/postgres.ts`
15
- * calls it on `create` and `update` (#28), and anything that needs to
16
- * pre-validate a slug before it ever reaches the store — the operator page,
17
- * one day — can import it too.
18
- */
19
1
  /** The flattened set {@link validateSlug} checks a candidate slug against. */
20
2
  export declare const RESERVED_SLUGS: ReadonlySet<string>;
21
3
  /**
@@ -36,3 +18,15 @@ export interface SlugRejection {
36
18
  * needs one reason reported.
37
19
  */
38
20
  export declare function validateSlug(slug: string): SlugRejection | null;
21
+ /**
22
+ * The slug for a new org, derived from its first display name (William,
23
+ * 2026-09-26): "Good Company" becomes `good-company`, and `good-company.oikos.host`
24
+ * is its address. When that slug is taken, reserved or has an environment
25
+ * suffix, a short random suffix is added: `bruh-7k3q`. The display name stays
26
+ * separate, so an org can rename itself without moving its address.
27
+ *
28
+ * An offensive name is refused outright, by the same check the sign-in broker
29
+ * runs on usernames (https://github.com/wtfalch/foundry/issues/103, N1b). A
30
+ * name that could pass as infra (`admin`, `help`) only costs the slug a suffix.
31
+ */
32
+ export declare function slugFromName(name: string, slugTaken: (slug: string) => Promise<boolean>, suffix?: () => string): Promise<string>;