@wtfalch/foundry 0.1.0 → 0.2.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/apply.d.ts CHANGED
@@ -24,4 +24,18 @@ export interface PlanApply {
24
24
  * fresh plan would compute no longer matches this `plan` (#9, #10).
25
25
  */
26
26
  apply(plan: Plan, confirmedOrgName: string): Promise<AppliedState>;
27
+ /**
28
+ * Deletes every resource foundry created and recorded for `id`, then the
29
+ * declared org itself.
30
+ *
31
+ * Implementations must refuse unless the stored `DeclaredOrg` has
32
+ * `test: true` (goal line: "only a test org can be deleted") and unless
33
+ * `confirmedOrgName` matches the org's `displayName`, the same
34
+ * confirmation `apply` requires for any destructive step (#9) -- required
35
+ * here unconditionally, since destroying an org is the most destructive
36
+ * step there is. A failure partway through must stop and leave the
37
+ * declared org in place with whatever remains recorded, so a retried
38
+ * `destroy` only tears down what's left.
39
+ */
40
+ destroy(id: OrgId, confirmedOrgName: string): Promise<void>;
27
41
  }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- export type { OrgId, MailMode, MailConfig, ChatConfig, CallsConfig, IssuerConfig, SiteConfig, SsoConfig, GuestsConfig, SoloConfig, StorageConfig, AiConfig, BuyerAddress, BuyerIdentity, AdoptedAppForm, AdoptedApp, DeclaredOrg, } from './org.js';
2
- export { validateBuyerIdentity } from './org.js';
1
+ export type { OrgId, MailMode, MailConfig, ChatConfig, CallsConfig, IssuerConfig, SiteConfig, IssuerAppKind, IssuerApp, HostingConfig, SsoConfig, GuestsConfig, SoloConfig, StorageConfig, AiConfig, BuyerAddress, BuyerIdentity, AdoptedAppForm, AdoptedApp, OwnerIdentity, DeclaredOrg, } from './org.js';
2
+ export { validateBuyerIdentity, validateHosting, validateOwner } from './org.js';
3
3
  export type { SlugRejectionReason, SlugRejection } from './slug.js';
4
4
  export { validateSlug, RESERVED_SLUGS } from './slug.js';
5
5
  export type { Checkpoint, LastApplied, AppliedState } from './state.js';
@@ -9,8 +9,10 @@ export type { PlanApply } from './apply.js';
9
9
  export { createPostgresDeclaredOrgStore, DeclaredOrgError } from './store/postgres.js';
10
10
  export type { FoundryStoreDb } from './store/postgres.js';
11
11
  export { declaredOrgs, foundryAppliedState, type DeclaredOrgRow, type NewDeclaredOrgRow, type AppliedStateRow, type NewAppliedStateRow, } from './store/schema.js';
12
- export type { LiveResource, ZitadelReader, CoolifyReader, CloudflareReader, FilesReader, AiReader, CmsReader, LiveReaders, StepAction, PlannedStep, PlanDetail, AuditActor, AuditLog, AuthzGate, StepExecutor, FoundryPlanApplyOptions, } from './plan/index.js';
13
- export { readLiveResources, computePlanDetail, toPlan, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, } from './plan/index.js';
12
+ export type { LiveResource, ZitadelReader, CoolifyReader, CloudflareReader, FilesReader, AiReader, CmsReader, LiveReaders, StepAction, PlannedStep, PlanDetail, AuditActor, AuditLog, AuthzGate, StepExecutor, StepDestroyer, DestroyTarget, StepExecutionContext, FoundryPlanApplyOptions, } from './plan/index.js';
13
+ export { readLiveResources, computePlanDetail, toPlan, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, } from './plan/index.js';
14
+ export type { CoolifyPort, CloudflarePort, ZitadelPort, FilesOrgLimits, AiOrgLimits, FilesOrganisationPort, AiOrganisationPort, FoundryStepExecutorPorts, } from './plan/index.js';
15
+ export { FoundryStepExecutor, NotBuiltError, MissingBespokeAppRepoError } from './plan/index.js';
14
16
  export type { RunnerDb } from './runner/db.js';
15
17
  export { AlreadyRunningError, RunNotFoundError, StepFailedError, StepMismatchError, } from './runner/errors.js';
16
18
  export { createJobRunner } from './runner/runner.js';
package/dist/index.js CHANGED
@@ -1,7 +1,8 @@
1
- export { validateBuyerIdentity } from './org.js';
1
+ export { validateBuyerIdentity, validateHosting, validateOwner } from './org.js';
2
2
  export { validateSlug, RESERVED_SLUGS } from './slug.js';
3
3
  export { createPostgresDeclaredOrgStore, DeclaredOrgError } from './store/postgres.js';
4
4
  export { declaredOrgs, foundryAppliedState, } from './store/schema.js';
5
- export { readLiveResources, computePlanDetail, toPlan, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, } from './plan/index.js';
5
+ export { readLiveResources, computePlanDetail, toPlan, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, } from './plan/index.js';
6
+ export { FoundryStepExecutor, NotBuiltError, MissingBespokeAppRepoError } from './plan/index.js';
6
7
  export { AlreadyRunningError, RunNotFoundError, StepFailedError, StepMismatchError, } from './runner/errors.js';
7
8
  export { createJobRunner } from './runner/runner.js';
@@ -0,0 +1,24 @@
1
+ -- @wtfalch/foundry: an org's own hosted app, its database, its Cloudflare
2
+ -- tunnel and its declared ZITADEL issuer apps (#71), declared alongside
3
+ -- everything else in `foundry_declared_orgs` (#0001). Mirrors
4
+ -- `src/store/schema.ts`'s `declaredOrgs` table additions.
5
+ --
6
+ -- HostingConfig (org.ts) is the same `usesDomain`-opts-into-`domain` shape as
7
+ -- mail/chat/calls/issuer/site (#0001's `mail_uses_domain` etc, `0007_site.sql`'s
8
+ -- `site_uses_domain`), plus its own on/off switch: hosting_enabled turns the
9
+ -- org's own app on at all, hosting_database and hosting_tunnel each add a
10
+ -- Postgres database and a Cloudflare tunnel beside it (both require
11
+ -- hosting_enabled -- org.ts's `validateHosting`), and hosting_issuer_apps
12
+ -- carries the org's declared ZITADEL OIDC applications (`IssuerApp[]`).
13
+ --
14
+ -- All four booleans default false and hosting_issuer_apps defaults to an
15
+ -- empty array -- a brand new org has no hosted app, the same "full schema
16
+ -- exists from day one" pattern #0001, #0005 and #0007 established: a new
17
+ -- column with a decided default, never a reshape.
18
+
19
+ alter table foundry_declared_orgs
20
+ add column if not exists hosting_enabled boolean not null default false,
21
+ add column if not exists hosting_uses_domain boolean not null default false,
22
+ add column if not exists hosting_database boolean not null default false,
23
+ add column if not exists hosting_tunnel boolean not null default false,
24
+ add column if not exists hosting_issuer_apps jsonb not null default '[]';
@@ -0,0 +1,14 @@
1
+ -- @wtfalch/foundry: the org's own hosted app's git branch and Coolify build
2
+ -- pack (#71, hosting-wiring campaign), declarable on `HostingConfig` where
3
+ -- `executor.ts`'s `coolifyApp` previously hardcoded `'main'`/`'nixpacks'`.
4
+ -- Mirrors `src/store/schema.ts`'s `declaredOrgs` table additions.
5
+ --
6
+ -- Both default to the values `coolify:app` already provisioned with, so an
7
+ -- org that never mentions them keeps getting exactly what shipped before
8
+ -- this migration -- the same "full schema exists from day one, new column
9
+ -- with a decided default" pattern `0008_hosting.sql` and its predecessors
10
+ -- established.
11
+
12
+ alter table foundry_declared_orgs
13
+ add column if not exists hosting_git_branch text not null default 'main',
14
+ add column if not exists hosting_build_pack text not null default 'nixpacks';
@@ -0,0 +1,13 @@
1
+ -- @wtfalch/foundry: the org's owner, invited by ZITADEL email. Declared
2
+ -- alongside everything else in `foundry_declared_orgs`. Mirrors
3
+ -- `src/store/schema.ts`'s `declaredOrgs` table additions.
4
+ --
5
+ -- OwnerIdentity (org.ts) is flattened into three nullable columns the same
6
+ -- way BuyerIdentity's own contact fields are (`0006_buyer_identity.sql`):
7
+ -- present only when an operator has named an owner, reassembled as one
8
+ -- object only once all three are non-null.
9
+
10
+ alter table foundry_declared_orgs
11
+ add column if not exists owner_email text,
12
+ add column if not exists owner_given_name text,
13
+ add column if not exists owner_family_name text;
@@ -0,0 +1,12 @@
1
+ -- @wtfalch/foundry: the test/real flag that gates whether an org can be
2
+ -- destroyed. Declared alongside everything else in
3
+ -- `foundry_declared_orgs`. Mirrors `src/store/schema.ts`'s `declaredOrgs`
4
+ -- table additions.
5
+ --
6
+ -- `is_test` defaults false -- a brand new org is presumed real, matching
7
+ -- every other "full schema exists from day one" column this table has added
8
+ -- (#0001, #0005, #0007, #0008, #0010): a new column with a decided default,
9
+ -- never a reshape.
10
+
11
+ alter table foundry_declared_orgs
12
+ add column if not exists is_test boolean not null default false;
package/dist/org.d.ts CHANGED
@@ -89,6 +89,77 @@ export interface SiteConfig {
89
89
  enabled: boolean;
90
90
  usesDomain: boolean;
91
91
  }
92
+ /**
93
+ * A ZITADEL OIDC application kind foundry can provision inside an org's
94
+ * issuer project (#71): `web` (a browser-based confidential/PKCE client),
95
+ * `native` (a public native-app client) or `api` (an API resource with no
96
+ * login flow of its own).
97
+ */
98
+ export type IssuerAppKind = 'web' | 'native' | 'api';
99
+ /**
100
+ * One ZITADEL OIDC application the org declares (#71), keyed within
101
+ * `HostingConfig.issuerApps` by its own `name` -- `validateHosting` below
102
+ * requires every name non-empty and unique in that list. `kind` decides the
103
+ * ZITADEL `appType` the planner provisions it as (`plan/diff.ts`'s
104
+ * `planIssuerApps`); changing `kind` for an app that already exists live is
105
+ * a destructive step there, since ZITADEL has no in-place app-type change.
106
+ */
107
+ export interface IssuerApp {
108
+ name: string;
109
+ kind: IssuerAppKind;
110
+ }
111
+ /**
112
+ * The Coolify build strategy for the org's own hosted app -- the same union
113
+ * `@wtfalch/providers`' `CoolifyClient.createGithubAppApplication` accepts as
114
+ * `build_pack`, reused here rather than redeclared so a value this package
115
+ * validates is always one Coolify itself accepts.
116
+ */
117
+ export type HostingBuildPack = 'dockerfile' | 'nixpacks' | 'static' | 'dockercompose';
118
+ /**
119
+ * The org's own hosted app and what runs beside it (#71, part of #14's
120
+ * adopted/standard/bespoke map) -- distinct from the cms site (`SiteConfig`
121
+ * above), which is `@wtfalch/cms` content, not the org's own application
122
+ * code. `usesDomain` follows the same opt-in-per-service rule as mail, chat,
123
+ * calls, the issuer and the site (#7 amendment 1): setting `DeclaredOrg.domain`
124
+ * alone never moves the app onto it or starts managing its Cloudflare zone.
125
+ *
126
+ * `database` and `tunnel` are each meaningless without the app itself
127
+ * running, so `validateHosting` below requires `enabled` before either can
128
+ * be true.
129
+ *
130
+ * `gitBranch` and `buildPack` were fixed values `executor.ts` hardcoded
131
+ * (`'main'`/`'nixpacks'`) until this campaign's hosting-wiring work made them
132
+ * declarable; those two strings are their defaults now, kept here so an org
133
+ * that never mentions them still gets exactly what shipped before.
134
+ */
135
+ export interface HostingConfig {
136
+ /** The org's own app runs on Coolify. */
137
+ enabled: boolean;
138
+ /** Served on `DeclaredOrg.domain` (and its Cloudflare zone managed) instead of `<slug>.oikos.host` (`../slug.ts`). */
139
+ usesDomain: boolean;
140
+ /** A Postgres database beside the app. Requires `enabled`. */
141
+ database: boolean;
142
+ /** The app is reached through a Cloudflare tunnel. Requires `enabled`. */
143
+ tunnel: boolean;
144
+ /** The branch `coolify:app` builds and deploys from. Default: `'main'`. */
145
+ gitBranch: string;
146
+ /** The Coolify build strategy `coolify:app` provisions with. Default: `'nixpacks'`. */
147
+ buildPack: HostingBuildPack;
148
+ /** The org's declared ZITADEL OIDC applications (#71). */
149
+ issuerApps: IssuerApp[];
150
+ }
151
+ /**
152
+ * `null` when `hosting` is valid; otherwise the reason it is not (#71), same
153
+ * shape as `validateBuyerIdentity` below.
154
+ *
155
+ * - `database` and `tunnel` each require `enabled` -- neither means anything
156
+ * without the app itself running.
157
+ * - `gitBranch` is non-empty and `buildPack` is one of `HostingBuildPack`'s
158
+ * four values.
159
+ * - Every `issuerApps[]` entry has a non-empty `name`, unique within the
160
+ * list, and a `kind` that is one of `IssuerAppKind`'s three values.
161
+ */
162
+ export declare function validateHosting(hosting: HostingConfig): string | null;
92
163
  /**
93
164
  * Per-org SSO. Decided to exist in #14 ("Also in scope") and its resolved
94
165
  * research #3 ("per-org SSO code exists unused"); nothing about its
@@ -196,6 +267,29 @@ export interface BuyerIdentity {
196
267
  * name before its other details are typed).
197
268
  */
198
269
  export declare function validateBuyerIdentity(buyer: BuyerIdentity): string | null;
270
+ /**
271
+ * The org's owner: the one person a real org is created for, invited into
272
+ * its ZITADEL org by email so they can set their own first credential (a
273
+ * passkey) without a password ever passing through foundry. `null` until an
274
+ * operator names one -- an org can exist declared before its owner is known.
275
+ *
276
+ * `apply`'s `zitadel:owner` step (`plan/diff.ts`'s `planOwner`) calls
277
+ * `ZitadelClient.inviteOrgOwner(zitadelOrgId, owner)` once the org's ZITADEL
278
+ * org exists, then never touches this again -- a re-run is idempotent on the
279
+ * recorded invited user id, not on this declared value, so editing `owner`
280
+ * after the invite has gone out does not send a second one.
281
+ */
282
+ export interface OwnerIdentity {
283
+ email: string;
284
+ givenName: string;
285
+ familyName: string;
286
+ }
287
+ /**
288
+ * `null` when `owner` is valid; otherwise the reason it is not, same shape as
289
+ * {@link validateBuyerIdentity} and `../slug.ts`'s `validateSlug`. `owner:
290
+ * null` (no owner named yet) is always valid.
291
+ */
292
+ export declare function validateOwner(owner: OwnerIdentity | null): string | null;
199
293
  /**
200
294
  * The form an app foundry does not own takes. Decided in #7's `#15`
201
295
  * refinement comment:
@@ -255,6 +349,8 @@ export interface DeclaredOrg {
255
349
  issuer: IssuerConfig;
256
350
  /** `enabled: false` by default -- a brand new org has no cms site. */
257
351
  site: SiteConfig;
352
+ /** `enabled: false`, `issuerApps: []` by default -- a brand new org has no hosted app (#71). */
353
+ hosting: HostingConfig;
258
354
  sso: SsoConfig;
259
355
  guests: GuestsConfig;
260
356
  solo: SoloConfig;
@@ -273,4 +369,15 @@ export interface DeclaredOrg {
273
369
  bespokeAppRepo: string | null;
274
370
  /** `null` when the org has none. */
275
371
  adoptedApp: AdoptedApp | null;
372
+ /** The org's owner, invited by ZITADEL email. `null` until an operator names one. See {@link OwnerIdentity}. */
373
+ owner: OwnerIdentity | null;
374
+ /**
375
+ * Marks this org as a test org rather than a real customer's. Purely
376
+ * declarative -- it changes nothing about what `apply` provisions -- but it
377
+ * is the one gate `FoundryPlanApply.destroy` checks: only a test org
378
+ * (`test: true`) can be destroyed, so deleting every resource foundry made
379
+ * for an org, then the org itself, can never reach a real customer's org by
380
+ * mistake.
381
+ */
382
+ test: boolean;
276
383
  }
package/dist/org.js CHANGED
@@ -1,3 +1,49 @@
1
+ const ISSUER_APP_KINDS = new Set(['web', 'native', 'api']);
2
+ const HOSTING_BUILD_PACKS = new Set([
3
+ 'dockerfile',
4
+ 'nixpacks',
5
+ 'static',
6
+ 'dockercompose',
7
+ ]);
8
+ /**
9
+ * `null` when `hosting` is valid; otherwise the reason it is not (#71), same
10
+ * shape as `validateBuyerIdentity` below.
11
+ *
12
+ * - `database` and `tunnel` each require `enabled` -- neither means anything
13
+ * without the app itself running.
14
+ * - `gitBranch` is non-empty and `buildPack` is one of `HostingBuildPack`'s
15
+ * four values.
16
+ * - Every `issuerApps[]` entry has a non-empty `name`, unique within the
17
+ * list, and a `kind` that is one of `IssuerAppKind`'s three values.
18
+ */
19
+ export function validateHosting(hosting) {
20
+ if (hosting.database && !hosting.enabled) {
21
+ return 'hosting.database requires hosting.enabled';
22
+ }
23
+ if (hosting.tunnel && !hosting.enabled) {
24
+ return 'hosting.tunnel requires hosting.enabled';
25
+ }
26
+ if (!hosting.gitBranch) {
27
+ return 'hosting.gitBranch must be non-empty';
28
+ }
29
+ if (!HOSTING_BUILD_PACKS.has(hosting.buildPack)) {
30
+ return `hosting.buildPack has an unknown value '${hosting.buildPack}'`;
31
+ }
32
+ const seenNames = new Set();
33
+ for (const app of hosting.issuerApps) {
34
+ if (!app.name) {
35
+ return 'every issuerApps entry needs a non-empty name';
36
+ }
37
+ if (seenNames.has(app.name)) {
38
+ return `issuerApps name '${app.name}' is not unique`;
39
+ }
40
+ seenNames.add(app.name);
41
+ if (!ISSUER_APP_KINDS.has(app.kind)) {
42
+ return `issuerApps '${app.name}' has an unknown kind '${app.kind}'`;
43
+ }
44
+ }
45
+ return null;
46
+ }
1
47
  const ORG_NUMBER_PATTERN = /^\d{9}$/;
2
48
  /**
3
49
  * `null` when `buyer` is valid; otherwise the reason it is not, for a caller
@@ -27,3 +73,22 @@ export function validateBuyerIdentity(buyer) {
27
73
  }
28
74
  return null;
29
75
  }
76
+ /** A deliberately loose email shape check -- foundry never sends mail itself, ZITADEL does; this only catches an obviously malformed address before it reaches the invite call. */
77
+ const OWNER_EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
78
+ /**
79
+ * `null` when `owner` is valid; otherwise the reason it is not, same shape as
80
+ * {@link validateBuyerIdentity} and `../slug.ts`'s `validateSlug`. `owner:
81
+ * null` (no owner named yet) is always valid.
82
+ */
83
+ export function validateOwner(owner) {
84
+ if (owner === null)
85
+ return null;
86
+ if (!OWNER_EMAIL_PATTERN.test(owner.email)) {
87
+ return `owner email '${owner.email}' is not a valid email address`;
88
+ }
89
+ if (!owner.givenName.trim())
90
+ return 'owner.givenName must not be empty';
91
+ if (!owner.familyName.trim())
92
+ return 'owner.familyName must not be empty';
93
+ return null;
94
+ }
@@ -41,12 +41,27 @@ export declare function toPlan(detail: PlanDetail): Plan;
41
41
  * exist for every org — the issuer, mail, and chat (always provisioned at
42
42
  * level 1, per #7 "Chat", regardless of `usesDomain`). #27 adds two more,
43
43
  * in the same singleton shape: storage's spend limit on Files and AI's
44
- * budget. This task adds a sixth, in the same shape: the org's cms site
45
- * (`org.ts`'s `SiteConfig`). Everything else in `live` that none of these
46
- * six claim is unmanaged. The full field-by-field provisioning surface (SSO,
47
- * guests, calls, adopted/bespoke apps, the actual
44
+ * budget. T10 adds a sixth, in the same shape: the org's cms site
45
+ * (`org.ts`'s `SiteConfig`). #71 adds four more singleton resources, in the
46
+ * same shape — the org's own hosted app, its database, its Cloudflare
47
+ * tunnel and its Cloudflare zone (`org.ts`'s `HostingConfig`) — plus one
48
+ * list-shaped resource: `planIssuerApps` below emits one step per declared
49
+ * `hosting.issuerApps[]` entry rather than a single singleton, since an org
50
+ * can declare more than one ZITADEL app. #80 adds two more singletons,
51
+ * modeled on {@link planIssuer}: `planFilesOrganisation` and
52
+ * `planAiOrganisation`, each planned before its own system's limit step
53
+ * ({@link planStorage}, {@link planAiBudget}) so an org has somewhere for a
54
+ * limit to land, and AI use is switched on, before either limit is ever set.
55
+ * `planOwner` below adds one more singleton, in a different shape from the
56
+ * rest: the org's owner, invited by ZITADEL email, idempotent on the
57
+ * *recorded* resource id (`recordedIds`, sourced from
58
+ * `AppliedState.serviceIds`) rather than a live resource, since a human user
59
+ * is never something `live` can contain. Everything else in `live` that none
60
+ * of these twelve singletons and however many declared issuer apps claim is
61
+ * unmanaged. The full field-by-field provisioning surface (SSO, guests,
62
+ * calls, adopted/bespoke apps, the actual
48
63
  * Zitadel/Coolify/Cloudflare/Files/AI/cms shapes) is #20's "provision
49
64
  * Company Factory end to end", which wires real readers and executors
50
65
  * against this seam.
51
66
  */
52
- export declare function computePlanDetail(org: DeclaredOrg, live: LiveResource[]): PlanDetail;
67
+ export declare function computePlanDetail(org: DeclaredOrg, live: LiveResource[], recordedIds?: Readonly<Record<string, string>>): PlanDetail;