@wtfalch/foundry 0.2.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.
@@ -5,6 +5,6 @@ export { computePlanDetail, toPlan } from './diff.js';
5
5
  export type { AuditActor, AuditLog, AuthzGate, StepExecutor, StepDestroyer, DestroyTarget, StepExecutionContext, FoundryPlanApplyOptions, } from './plan-apply.js';
6
6
  export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, } from './plan-apply.js';
7
7
  export type { ScopeResolver, RecordedIds } from './providers-readers.js';
8
- export { ProvidersCloudflareReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
8
+ export { ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
9
9
  export type { CoolifyPort, CloudflarePort, ZitadelPort, FilesOrgLimits, AiOrgLimits, FilesOrganisationPort, AiOrganisationPort, FoundryStepExecutorPorts, } from './executor.js';
10
- export { FoundryStepExecutor, NotBuiltError, MissingBespokeAppRepoError } from './executor.js';
10
+ export { FoundryStepExecutor, NotBuiltError, healthUrls } from './executor.js';
@@ -1,5 +1,5 @@
1
1
  export { readLiveResources } from './live.js';
2
2
  export { computePlanDetail, toPlan } from './diff.js';
3
3
  export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, } from './plan-apply.js';
4
- export { ProvidersCloudflareReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
5
- export { FoundryStepExecutor, NotBuiltError, MissingBespokeAppRepoError } from './executor.js';
4
+ export { ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
5
+ export { FoundryStepExecutor, NotBuiltError, healthUrls } from './executor.js';
@@ -252,7 +252,7 @@ const ZITADEL_ORG_SCOPE_KEY = 'zitadel_org';
252
252
  /**
253
253
  * Every resource `FoundryPlanApply.destroy` tears down, built from
254
254
  * `AppliedState.serviceIds`, in the order the goal line and this campaign's
255
- * report settle on: the Cloudflare tunnel first (stop routing traffic to the
255
+ * report settle on: the Cloudflare tunnel first, then the Access gate (stop routing traffic to the
256
256
  * app before tearing it down), then the org's own Coolify database and app,
257
257
  * then the org's customers ZITADEL org (plan gap A9), then every ZITADEL app
258
258
  * foundry recorded, then the ZITADEL project, then the
@@ -269,7 +269,12 @@ const ZITADEL_ORG_SCOPE_KEY = 'zitadel_org';
269
269
  *
270
270
  * The Cloudflare zone and the Coolify project are deliberately absent: no
271
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.
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.
273
278
  */
274
279
  function destroyTargets(serviceIds) {
275
280
  // Reverse-teardown order: everything under one of these prefixes is
@@ -277,6 +282,7 @@ function destroyTargets(serviceIds) {
277
282
  // #71), then the two scope ids last.
278
283
  const ORDERED_PREFIXES = [
279
284
  'cloudflare:tunnel:',
285
+ 'cloudflare:access:',
280
286
  'coolify:database:',
281
287
  'coolify:app:',
282
288
  'zitadel:customers:',
@@ -334,6 +340,8 @@ const STEP_FIELDS = {
334
340
  'coolify:database': 'hosting.database',
335
341
  'cloudflare:tunnel': 'hosting.tunnel',
336
342
  'cloudflare:zone': 'hosting.usesDomain',
343
+ 'cloudflare:access': 'hosting.accessEmails',
344
+ 'cloudflare:ratelimit': 'hosting.usesDomain',
337
345
  };
338
346
  function stepField(action) {
339
347
  // An org declares several issuer apps, each keyed by its name (#71).
@@ -374,6 +382,13 @@ function failedFields(org, detail, completed) {
374
382
  // `planZone` plans nothing without a domain to put the zone on.
375
383
  if (org.hosting.usesDomain && org.domain === null)
376
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
+ }
377
392
  for (const { action, step } of detail.plannedSteps) {
378
393
  // Provisioning chat also desires `usesDomain: false`; only the reverse
379
394
  // re-key is a destructive step with that value.
@@ -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
@@ -119,3 +119,56 @@ export declare class ProvidersZitadelReader implements ZitadelReader {
119
119
  constructor(client: ZitadelClient, resolveScope: ScopeResolver, resolveRecordedIds: RecordedIds);
120
120
  listResources(orgId: OrgId): Promise<LiveResource[]>;
121
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);
173
+ listResources(orgId: OrgId): Promise<LiveResource[]>;
174
+ }
@@ -76,6 +76,60 @@ function applyRecordedIds(resources, recorded, dropUnrecordedKinds = new Set())
76
76
  }
77
77
  /** Cloudflare tunnels this org has not recorded are dropped — see {@link applyRecordedIds}. */
78
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
+ }
79
133
  /** `@wtfalch/providers`' own `LiveResource` (`resource.ts`) has no `system` field — foundry's adds it at this boundary. */
80
134
  function toFoundryResources(system, resources) {
81
135
  return resources.map((r) => ({ system, kind: r.kind, key: r.key, config: r.config }));
@@ -102,7 +156,8 @@ export class ProvidersCoolifyReader {
102
156
  this.client.listResources(projectUuid),
103
157
  this.resolveRecordedIds(orgId),
104
158
  ]);
105
- return applyRecordedIds(toFoundryResources('coolify', resources), recorded);
159
+ const withLogConfig = await Promise.all(toFoundryResources('coolify', resources).map((r) => withLogVolumeConfig(this.client, r, recorded)));
160
+ return applyRecordedIds(withLogConfig, recorded);
106
161
  }
107
162
  }
108
163
  /**
@@ -158,3 +213,100 @@ export class ProvidersZitadelReader {
158
213
  return applyRecordedIds(toFoundryResources('zitadel', resources), recorded);
159
214
  }
160
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 } }];
311
+ }
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>;
package/dist/slug.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { checkName } from '@wtfalch/auth/names';
1
2
  /**
2
3
  * The declared org's slug: a pure validator, no I/O, no store access.
3
4
  *
@@ -139,3 +140,68 @@ export function validateSlug(slug) {
139
140
  }
140
141
  return null;
141
142
  }
143
+ /** Letters a plain accent strip gets wrong, mostly Norwegian. */
144
+ const SPELLED_OUT = {
145
+ æ: 'ae',
146
+ ø: 'o',
147
+ å: 'a',
148
+ ß: 'ss',
149
+ œ: 'oe',
150
+ þ: 'th',
151
+ ð: 'd',
152
+ ı: 'i',
153
+ ł: 'l',
154
+ đ: 'd',
155
+ };
156
+ /** Room left for `-xxxx` after the base, so a suffixed slug stays one DNS label. */
157
+ const BASE_MAX = 58;
158
+ function randomSuffix() {
159
+ const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
160
+ let out = '';
161
+ for (let i = 0; i < 4; i++)
162
+ out += alphabet[Math.floor(Math.random() * alphabet.length)];
163
+ return out;
164
+ }
165
+ /**
166
+ * The sign-in broker's name check, with the length and character rules
167
+ * loosened: a display name has spaces and accents, and a slug may be 63 long.
168
+ * Only its `reserved` and `disallowed` verdicts apply here.
169
+ */
170
+ const ANY_TEXT = { minLength: 0, maxLength: Number.MAX_SAFE_INTEGER, pattern: /^/ };
171
+ /**
172
+ * The slug for a new org, derived from its first display name (William,
173
+ * 2026-09-26): "Good Company" becomes `good-company`, and `good-company.oikos.host`
174
+ * is its address. When that slug is taken, reserved or has an environment
175
+ * suffix, a short random suffix is added: `bruh-7k3q`. The display name stays
176
+ * separate, so an org can rename itself without moving its address.
177
+ *
178
+ * An offensive name is refused outright, by the same check the sign-in broker
179
+ * runs on usernames (https://github.com/wtfalch/foundry/issues/103, N1b). A
180
+ * name that could pass as infra (`admin`, `help`) only costs the slug a suffix.
181
+ */
182
+ export async function slugFromName(name, slugTaken, suffix = randomSuffix) {
183
+ const base = name
184
+ .toLowerCase()
185
+ .replace(/[æøåßœþðıłđ]/g, (c) => SPELLED_OUT[c] ?? c)
186
+ .normalize('NFKD')
187
+ .replace(/\p{M}/gu, '')
188
+ .replace(/[^a-z0-9]+/g, '-')
189
+ .replace(/^-+|-+$/g, '')
190
+ .slice(0, BASE_MAX)
191
+ .replace(/-+$/, '') || 'org';
192
+ const nameVerdict = checkName(name, ANY_TEXT);
193
+ const baseVerdict = checkName(base, ANY_TEXT);
194
+ if ((!nameVerdict.ok && nameVerdict.reason === 'disallowed') ||
195
+ (!baseVerdict.ok && baseVerdict.reason === 'disallowed')) {
196
+ throw new Error(`org name '${name}' is not allowed`);
197
+ }
198
+ const free = async (slug) => validateSlug(slug) === null && checkName(slug, ANY_TEXT).ok && !(await slugTaken(slug));
199
+ if (await free(base))
200
+ return base;
201
+ for (let attempt = 0; attempt < 10; attempt++) {
202
+ const candidate = `${base}-${suffix()}`;
203
+ if (await free(candidate))
204
+ return candidate;
205
+ }
206
+ throw new Error(`no free slug found for '${name}' after 10 tries`);
207
+ }
@@ -0,0 +1,8 @@
1
+ import type { AuditLog } from '../plan/plan-apply.js';
2
+ import type { FoundryStoreDb } from './postgres.js';
3
+ /**
4
+ * foundry's own {@link AuditLog}, one row per plan, apply or destroy in
5
+ * `foundry_audit_events` (#85). The CLI uses it before any archon exists; an
6
+ * attached archon reads the same table. It only inserts.
7
+ */
8
+ export declare function createPostgresAuditLog(db: FoundryStoreDb): AuditLog;
@@ -0,0 +1,43 @@
1
+ import { foundryAuditEvents } from './schema.js';
2
+ /**
3
+ * foundry's own {@link AuditLog}, one row per plan, apply or destroy in
4
+ * `foundry_audit_events` (#85). The CLI uses it before any archon exists; an
5
+ * attached archon reads the same table. It only inserts.
6
+ */
7
+ export function createPostgresAuditLog(db) {
8
+ return {
9
+ async recordPlan({ actor, orgId, plan, at }) {
10
+ await db.insert(foundryAuditEvents).values({
11
+ at,
12
+ kind: 'plan',
13
+ actorId: actor.id,
14
+ actorDisplay: actor.display,
15
+ orgId,
16
+ plan,
17
+ });
18
+ },
19
+ async recordApply({ actor, orgId, plan, outcome, reason, at }) {
20
+ await db.insert(foundryAuditEvents).values({
21
+ at,
22
+ kind: 'apply',
23
+ actorId: actor.id,
24
+ actorDisplay: actor.display,
25
+ orgId,
26
+ outcome,
27
+ reason: reason ?? null,
28
+ plan,
29
+ });
30
+ },
31
+ async recordDestroy({ actor, orgId, outcome, reason, at }) {
32
+ await db.insert(foundryAuditEvents).values({
33
+ at,
34
+ kind: 'destroy',
35
+ actorId: actor.id,
36
+ actorDisplay: actor.display,
37
+ orgId,
38
+ outcome,
39
+ reason: reason ?? null,
40
+ });
41
+ },
42
+ };
43
+ }