@wtfalch/foundry 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/calls-sfu-deploy-support.d.ts +57 -0
- package/dist/calls-sfu-deploy-support.js +82 -0
- package/dist/calls-sfu.d.ts +192 -0
- package/dist/calls-sfu.js +256 -0
- package/dist/index.d.ts +8 -5
- package/dist/index.js +6 -4
- package/dist/migrate.d.ts +38 -0
- package/dist/migrate.js +47 -0
- package/dist/migrations/0012_audit.sql +21 -0
- package/dist/migrations/0013_access_emails.sql +6 -0
- package/dist/migrations/0014_calls_enabled.sql +7 -0
- package/dist/migrations/0015_domain_unique.sql +39 -0
- package/dist/org.d.ts +41 -2
- package/dist/org.js +85 -0
- package/dist/plan/calls-matrix.d.ts +135 -0
- package/dist/plan/calls-matrix.js +275 -0
- package/dist/plan/diff.d.ts +8 -4
- package/dist/plan/diff.js +239 -9
- package/dist/plan/executor.d.ts +751 -14
- package/dist/plan/executor.js +1538 -32
- package/dist/plan/index.d.ts +3 -3
- package/dist/plan/index.js +2 -2
- package/dist/plan/plan-apply.js +29 -7
- package/dist/plan/providers-readers.d.ts +54 -1
- package/dist/plan/providers-readers.js +153 -1
- package/dist/provision.d.ts +56 -0
- package/dist/provision.js +69 -0
- package/dist/slug.d.ts +12 -18
- package/dist/slug.js +74 -2
- package/dist/store/audit.d.ts +8 -0
- package/dist/store/audit.js +43 -0
- package/dist/store/postgres.d.ts +11 -6
- package/dist/store/postgres.js +104 -27
- package/dist/store/schema.d.ts +205 -0
- package/dist/store/schema.js +15 -1
- package/dist/store.d.ts +12 -0
- package/package.json +9 -3
package/dist/plan/index.d.ts
CHANGED
|
@@ -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';
|
|
9
|
-
export type { CoolifyPort, CloudflarePort, ZitadelPort, FilesOrgLimits, AiOrgLimits, FilesOrganisationPort, AiOrganisationPort, FoundryStepExecutorPorts, } from './executor.js';
|
|
10
|
-
export { FoundryStepExecutor, NotBuiltError,
|
|
8
|
+
export { ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
|
|
9
|
+
export type { CoolifyPort, CloudflarePort, ZitadelPort, CallsSfuPort, FilesOrgLimits, AiOrgLimits, FilesOrganisationPort, AiOrganisationPort, FoundryStepExecutorPorts, } from './executor.js';
|
|
10
|
+
export { FoundryStepExecutor, NotBuiltError, healthUrls } from './executor.js';
|
package/dist/plan/index.js
CHANGED
|
@@ -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,
|
|
4
|
+
export { ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
|
|
5
|
+
export { FoundryStepExecutor, NotBuiltError, healthUrls } from './executor.js';
|
package/dist/plan/plan-apply.js
CHANGED
|
@@ -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:',
|
|
@@ -327,6 +333,8 @@ const STEP_FIELDS = {
|
|
|
327
333
|
'zitadel:customers': 'hosting.issuerApps',
|
|
328
334
|
'coolify:mail': 'mail',
|
|
329
335
|
'coolify:chat': 'chat.usesDomain',
|
|
336
|
+
'coolify:calls': 'calls.enabled',
|
|
337
|
+
'coolify:calls-service': 'calls.enabled',
|
|
330
338
|
'files:storage': 'storage',
|
|
331
339
|
'ai:budget': 'ai',
|
|
332
340
|
'cms:site': 'site',
|
|
@@ -334,6 +342,8 @@ const STEP_FIELDS = {
|
|
|
334
342
|
'coolify:database': 'hosting.database',
|
|
335
343
|
'cloudflare:tunnel': 'hosting.tunnel',
|
|
336
344
|
'cloudflare:zone': 'hosting.usesDomain',
|
|
345
|
+
'cloudflare:access': 'hosting.accessEmails',
|
|
346
|
+
'cloudflare:ratelimit': 'hosting.usesDomain',
|
|
337
347
|
};
|
|
338
348
|
function stepField(action) {
|
|
339
349
|
// An org declares several issuer apps, each keyed by its name (#71).
|
|
@@ -348,10 +358,12 @@ function stepField(action) {
|
|
|
348
358
|
*
|
|
349
359
|
* Three sources, in this order:
|
|
350
360
|
* - fields no planner step exists for yet, so no apply can make them true:
|
|
351
|
-
* SSO, guests and solo when turned on,
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
*
|
|
361
|
+
* SSO, guests and solo when turned on, a bespoke app repo, an adopted app
|
|
362
|
+
* in the `repo` form (#7's `#15` comment: "declared but refused by
|
|
363
|
+
* `apply`"), and calls or hosting on a domain the org has not recorded
|
|
364
|
+
* (#145: calls on the org's own domain now has a planner step, the same
|
|
365
|
+
* way hosting's own domain does, so it only fails for that one missing-
|
|
366
|
+
* domain reason, not unconditionally);
|
|
355
367
|
* - chat's reverse re-key, which the planner surfaces but which is not built
|
|
356
368
|
* (#7 amendment 3), so it counts as failed even when no step threw;
|
|
357
369
|
* - on a failed apply, the field of every planned step from the one that
|
|
@@ -369,11 +381,21 @@ function failedFields(org, detail, completed) {
|
|
|
369
381
|
failed.add('bespokeAppRepo');
|
|
370
382
|
if (org.adoptedApp?.form === 'repo')
|
|
371
383
|
failed.add('adoptedApp');
|
|
372
|
-
|
|
384
|
+
// `planCallsService`/`planTunnel`/`planZone` all share this same domain
|
|
385
|
+
// requirement (#145): a declared `usesDomain: true` with no recorded
|
|
386
|
+
// domain plans no step for any of them either.
|
|
387
|
+
if (org.calls.usesDomain && org.domain === null)
|
|
373
388
|
failed.add('calls.usesDomain');
|
|
374
389
|
// `planZone` plans nothing without a domain to put the zone on.
|
|
375
390
|
if (org.hosting.usesDomain && org.domain === null)
|
|
376
391
|
failed.add('hosting.usesDomain');
|
|
392
|
+
// `planTunnel` shares that same domain requirement (#71, map gap N3): a
|
|
393
|
+
// declared `usesDomain: true` with no recorded domain plans no tunnel
|
|
394
|
+
// step either. A domainless org (`usesDomain: false`) is not a failure --
|
|
395
|
+
// it is the level-1 case N3 decided gets no per-org tunnel at all.
|
|
396
|
+
if (org.hosting.tunnel && org.hosting.usesDomain && org.domain === null) {
|
|
397
|
+
failed.add('hosting.tunnel');
|
|
398
|
+
}
|
|
377
399
|
for (const { action, step } of detail.plannedSteps) {
|
|
378
400
|
// Provisioning chat also desires `usesDomain: false`; only the reverse
|
|
379
401
|
// 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
|
-
|
|
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
|
*
|
|
@@ -31,11 +32,15 @@ const RESERVED_ESTATE_SERVICES = [
|
|
|
31
32
|
'keys',
|
|
32
33
|
'chat',
|
|
33
34
|
'calls',
|
|
35
|
+
'calls-sfu',
|
|
34
36
|
'calendar',
|
|
35
37
|
'threads',
|
|
36
38
|
'audit',
|
|
37
39
|
'authz',
|
|
40
|
+
// Manage was renamed Boule on every surface (#147); 'manage' stays
|
|
41
|
+
// reserved so no org can take it while the old hostname still redirects.
|
|
38
42
|
'manage',
|
|
43
|
+
'boule',
|
|
39
44
|
'operator',
|
|
40
45
|
'mailbox',
|
|
41
46
|
'oikos',
|
|
@@ -111,6 +116,8 @@ const DNS_LABEL = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
|
111
116
|
const PR_SUFFIX = /-pr-\d+$/;
|
|
112
117
|
/** `<something>-stage` — the stage-hostname shape (#28). */
|
|
113
118
|
const STAGE_SUFFIX = /-stage$/;
|
|
119
|
+
/** `<something>-calls` — a level-1 org's own calls service hostname (foundry#145's "Decided", 2026-09-27). */
|
|
120
|
+
const CALLS_SUFFIX = /-calls$/;
|
|
114
121
|
/**
|
|
115
122
|
* Validates a candidate org slug against every rule #28 decided. Returns
|
|
116
123
|
* `null` when the slug is acceptable, or a {@link SlugRejection} naming
|
|
@@ -131,11 +138,76 @@ export function validateSlug(slug) {
|
|
|
131
138
|
message: `slug '${slug}' is reserved by the estate (#28) and cannot be taken by an org`,
|
|
132
139
|
};
|
|
133
140
|
}
|
|
134
|
-
if (STAGE_SUFFIX.test(slug) || PR_SUFFIX.test(slug)) {
|
|
141
|
+
if (STAGE_SUFFIX.test(slug) || PR_SUFFIX.test(slug) || CALLS_SUFFIX.test(slug)) {
|
|
135
142
|
return {
|
|
136
143
|
reason: 'environment-suffix',
|
|
137
|
-
message: `slug '${slug}' ends with an environment suffix ('-stage'
|
|
144
|
+
message: `slug '${slug}' ends with an environment suffix ('-stage', '-pr-<digits>' or '-calls') reserved for another org's own hostname (#28, #145) — 'acme-calls' is only legal as acme's calls hostname, never as an org slug in its own right`,
|
|
138
145
|
};
|
|
139
146
|
}
|
|
140
147
|
return null;
|
|
141
148
|
}
|
|
149
|
+
/** Letters a plain accent strip gets wrong, mostly Norwegian. */
|
|
150
|
+
const SPELLED_OUT = {
|
|
151
|
+
æ: 'ae',
|
|
152
|
+
ø: 'o',
|
|
153
|
+
å: 'a',
|
|
154
|
+
ß: 'ss',
|
|
155
|
+
œ: 'oe',
|
|
156
|
+
þ: 'th',
|
|
157
|
+
ð: 'd',
|
|
158
|
+
ı: 'i',
|
|
159
|
+
ł: 'l',
|
|
160
|
+
đ: 'd',
|
|
161
|
+
};
|
|
162
|
+
/** Room left for `-xxxx` after the base, so a suffixed slug stays one DNS label. */
|
|
163
|
+
const BASE_MAX = 58;
|
|
164
|
+
function randomSuffix() {
|
|
165
|
+
const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
|
166
|
+
let out = '';
|
|
167
|
+
for (let i = 0; i < 4; i++)
|
|
168
|
+
out += alphabet[Math.floor(Math.random() * alphabet.length)];
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* The sign-in broker's name check, with the length and character rules
|
|
173
|
+
* loosened: a display name has spaces and accents, and a slug may be 63 long.
|
|
174
|
+
* Only its `reserved` and `disallowed` verdicts apply here.
|
|
175
|
+
*/
|
|
176
|
+
const ANY_TEXT = { minLength: 0, maxLength: Number.MAX_SAFE_INTEGER, pattern: /^/ };
|
|
177
|
+
/**
|
|
178
|
+
* The slug for a new org, derived from its first display name (William,
|
|
179
|
+
* 2026-09-26): "Good Company" becomes `good-company`, and `good-company.oikos.host`
|
|
180
|
+
* is its address. When that slug is taken, reserved or has an environment
|
|
181
|
+
* suffix, a short random suffix is added: `bruh-7k3q`. The display name stays
|
|
182
|
+
* separate, so an org can rename itself without moving its address.
|
|
183
|
+
*
|
|
184
|
+
* An offensive name is refused outright, by the same check the sign-in broker
|
|
185
|
+
* runs on usernames (https://github.com/wtfalch/foundry/issues/103, N1b). A
|
|
186
|
+
* name that could pass as infra (`admin`, `help`) only costs the slug a suffix.
|
|
187
|
+
*/
|
|
188
|
+
export async function slugFromName(name, slugTaken, suffix = randomSuffix) {
|
|
189
|
+
const base = name
|
|
190
|
+
.toLowerCase()
|
|
191
|
+
.replace(/[æøåßœþðıłđ]/g, (c) => SPELLED_OUT[c] ?? c)
|
|
192
|
+
.normalize('NFKD')
|
|
193
|
+
.replace(/\p{M}/gu, '')
|
|
194
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
195
|
+
.replace(/^-+|-+$/g, '')
|
|
196
|
+
.slice(0, BASE_MAX)
|
|
197
|
+
.replace(/-+$/, '') || 'org';
|
|
198
|
+
const nameVerdict = checkName(name, ANY_TEXT);
|
|
199
|
+
const baseVerdict = checkName(base, ANY_TEXT);
|
|
200
|
+
if ((!nameVerdict.ok && nameVerdict.reason === 'disallowed') ||
|
|
201
|
+
(!baseVerdict.ok && baseVerdict.reason === 'disallowed')) {
|
|
202
|
+
throw new Error(`org name '${name}' is not allowed`);
|
|
203
|
+
}
|
|
204
|
+
const free = async (slug) => validateSlug(slug) === null && checkName(slug, ANY_TEXT).ok && !(await slugTaken(slug));
|
|
205
|
+
if (await free(base))
|
|
206
|
+
return base;
|
|
207
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
208
|
+
const candidate = `${base}-${suffix()}`;
|
|
209
|
+
if (await free(candidate))
|
|
210
|
+
return candidate;
|
|
211
|
+
}
|
|
212
|
+
throw new Error(`no free slug found for '${name}' after 10 tries`);
|
|
213
|
+
}
|
|
@@ -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;
|