@wtfalch/foundry 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/apply.d.ts +14 -0
  2. package/dist/calls-sfu-deploy-support.d.ts +57 -0
  3. package/dist/calls-sfu-deploy-support.js +82 -0
  4. package/dist/calls-sfu.d.ts +175 -0
  5. package/dist/calls-sfu.js +239 -0
  6. package/dist/index.d.ts +11 -6
  7. package/dist/index.js +7 -4
  8. package/dist/migrate.d.ts +38 -0
  9. package/dist/migrate.js +47 -0
  10. package/dist/migrations/0008_hosting.sql +24 -0
  11. package/dist/migrations/0009_hosting_git_branch_build_pack.sql +14 -0
  12. package/dist/migrations/0010_owner.sql +13 -0
  13. package/dist/migrations/0011_test_org.sql +12 -0
  14. package/dist/migrations/0012_audit.sql +21 -0
  15. package/dist/migrations/0013_access_emails.sql +6 -0
  16. package/dist/org.d.ts +130 -0
  17. package/dist/org.js +102 -0
  18. package/dist/plan/diff.d.ts +20 -5
  19. package/dist/plan/diff.js +533 -9
  20. package/dist/plan/executor.d.ts +823 -0
  21. package/dist/plan/executor.js +1242 -0
  22. package/dist/plan/index.d.ts +6 -4
  23. package/dist/plan/index.js +3 -2
  24. package/dist/plan/live.d.ts +8 -4
  25. package/dist/plan/plan-apply.d.ts +114 -4
  26. package/dist/plan/plan-apply.js +266 -14
  27. package/dist/plan/providers-readers.d.ts +102 -14
  28. package/dist/plan/providers-readers.js +245 -16
  29. package/dist/provision.d.ts +56 -0
  30. package/dist/provision.js +69 -0
  31. package/dist/slug.d.ts +12 -18
  32. package/dist/slug.js +66 -0
  33. package/dist/state.d.ts +26 -5
  34. package/dist/store/audit.d.ts +8 -0
  35. package/dist/store/audit.js +43 -0
  36. package/dist/store/postgres.d.ts +3 -1
  37. package/dist/store/postgres.js +84 -2
  38. package/dist/store/schema.d.ts +378 -0
  39. package/dist/store/schema.js +32 -1
  40. package/dist/store.d.ts +12 -0
  41. package/package.json +15 -11
@@ -0,0 +1,38 @@
1
+ /** This package's own migrations -- `../migrations` from `src/`, `./migrations` once built to `dist/` (`package.json`'s `build` script copies the `.sql` files there alongside `migrate.js`). */
2
+ export declare const MIGRATIONS_DIR: string;
3
+ /**
4
+ * The one capability `migrate` needs from a driver: run SQL text (one
5
+ * statement or several) and run a callback inside a transaction. Deliberately
6
+ * not a drizzle `PgDatabase` or a concrete driver type -- drizzle's own
7
+ * `.execute()` routes a multi-statement string through a single prepared
8
+ * query on PGlite, which rejects it, so a migration file's raw text has to
9
+ * reach the driver's own multi-statement path (`postgres`'s `.unsafe()`,
10
+ * PGlite's `.exec()`) instead. Same "narrow port, no concrete client type"
11
+ * shape `plan/executor.ts`'s `CoolifyPort` uses, for the same reason: a real
12
+ * driver satisfies this structurally, with a few lines of adapter at the
13
+ * call site (see `scripts/foundry-db-migrate.mjs` and `migrate.test.ts`).
14
+ */
15
+ export interface MigrateSql {
16
+ /** Run SQL text and discard any result -- the tracking table's own DDL, and each migration file's contents. */
17
+ exec(text: string): Promise<void>;
18
+ /**
19
+ * Run one literal SELECT and return its rows. Only ever called here with
20
+ * fixed text this module builds itself (the tracking table's name, and a
21
+ * migration filename already checked against `/^\d{4}_.+\.sql$/`), so
22
+ * there is no external input to inject.
23
+ */
24
+ query<T = Record<string, unknown>>(text: string): Promise<T[]>;
25
+ /** Run `fn` inside one transaction, handing it a `MigrateSql` scoped to it; rolled back if `fn` throws. */
26
+ transaction<T>(fn: (tx: MigrateSql) => Promise<T>): Promise<T>;
27
+ }
28
+ export interface MigrationResult {
29
+ /** Names of the migration files applied this run, in filename order -- empty when the database was already current. */
30
+ applied: string[];
31
+ }
32
+ /**
33
+ * Applies every migration file in `dir` (default: this package's own
34
+ * `../migrations`) not yet recorded in `foundry_schema_migrations`, in
35
+ * filename order, one transaction per file. Safe to call repeatedly: a file
36
+ * already recorded is never re-read, let alone re-run.
37
+ */
38
+ export declare function migrate(sql: MigrateSql, dir?: string): Promise<MigrationResult>;
@@ -0,0 +1,47 @@
1
+ import { readFileSync, readdirSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ // #R2 (map gap: foundry's own Postgres, two commands): a tracked, idempotent
5
+ // migration runner. `./test/db.ts` and `./runner/test/db.ts` already read
6
+ // and sort this same directory to build one concatenated blob for a fresh
7
+ // PGlite -- fine for a test fixture that always starts empty, wrong for a
8
+ // real host that must apply only what a `foundry_schema_migrations` table
9
+ // does not yet list. `migrationFiles` below is that same discovery, reused
10
+ // rather than re-implemented, kept per-file instead of concatenated.
11
+ const here = dirname(fileURLToPath(import.meta.url));
12
+ /** This package's own migrations -- `../migrations` from `src/`, `./migrations` once built to `dist/` (`package.json`'s `build` script copies the `.sql` files there alongside `migrate.js`). */
13
+ export const MIGRATIONS_DIR = join(here, 'migrations');
14
+ const TRACKING_TABLE = 'foundry_schema_migrations';
15
+ function migrationFiles(dir) {
16
+ return readdirSync(dir)
17
+ .filter((f) => /^\d{4}_.+\.sql$/.test(f))
18
+ .sort()
19
+ .map((name) => ({ name, path: join(dir, name) }));
20
+ }
21
+ /**
22
+ * Applies every migration file in `dir` (default: this package's own
23
+ * `../migrations`) not yet recorded in `foundry_schema_migrations`, in
24
+ * filename order, one transaction per file. Safe to call repeatedly: a file
25
+ * already recorded is never re-read, let alone re-run.
26
+ */
27
+ export async function migrate(sql, dir = MIGRATIONS_DIR) {
28
+ await sql.exec(`create table if not exists ${TRACKING_TABLE} (name text primary key, applied_at timestamptz not null default now())`);
29
+ const appliedRows = await sql.query(`select name from ${TRACKING_TABLE}`);
30
+ const applied = new Set(appliedRows.map((row) => row.name));
31
+ const pending = migrationFiles(dir).filter((file) => !applied.has(file.name));
32
+ const done = [];
33
+ for (const file of pending) {
34
+ const text = readFileSync(file.path, 'utf8');
35
+ await sql.transaction(async (tx) => {
36
+ await tx.exec(text);
37
+ // `file.name` is drawn from `migrationFiles`' own regex-filtered
38
+ // `readdirSync`, never from outside this module -- see `MigrateSql`'s
39
+ // doc comment. Quotes are still escaped, so an oddly-named future
40
+ // migration file cannot break the statement.
41
+ const escaped = file.name.replace(/'/g, "''");
42
+ await tx.exec(`insert into ${TRACKING_TABLE} (name) values ('${escaped}')`);
43
+ });
44
+ done.push(file.name);
45
+ }
46
+ return { applied: done };
47
+ }
@@ -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;
@@ -0,0 +1,21 @@
1
+ -- @wtfalch/foundry: who planned, applied or destroyed which org, and how it
2
+ -- went. Mirrors `src/store/schema.ts`'s `foundryAuditEvents`.
3
+ --
4
+ -- Decided on #85: before any archon exists, the foundry CLI records here. An
5
+ -- attached archon reads this table later. Rows are only ever inserted.
6
+ -- `org_id` has no foreign key, so a destroyed org's trail survives it.
7
+
8
+ create table if not exists foundry_audit_events (
9
+ id bigserial primary key,
10
+ at timestamptz not null,
11
+ kind text not null check (kind in ('plan', 'apply', 'destroy')),
12
+ actor_id text not null,
13
+ actor_display text not null,
14
+ org_id text not null,
15
+ outcome text check (outcome in ('applied', 'refused', 'failed', 'destroyed')),
16
+ reason text,
17
+ plan jsonb
18
+ );
19
+
20
+ create index if not exists foundry_audit_events_org_at
21
+ on foundry_audit_events (org_id, at);
@@ -0,0 +1,6 @@
1
+ -- @wtfalch/foundry: emails Cloudflare Access lets through to the org's app
2
+ -- (#84). Mirrors `src/store/schema.ts`'s `hostingAccessEmails`. An empty list
3
+ -- means no Access gate, so every existing org keeps its current behaviour.
4
+
5
+ alter table foundry_declared_orgs
6
+ add column if not exists hosting_access_emails jsonb not null default '[]'::jsonb;
package/dist/org.d.ts CHANGED
@@ -89,6 +89,100 @@ 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
+ * Emails Cloudflare Access lets through to the app's hostname (#84). Empty
152
+ * means no Access gate. Requires `enabled`.
153
+ */
154
+ accessEmails: string[];
155
+ }
156
+ /**
157
+ * `null` when `hosting` is valid; otherwise the reason it is not (#71), same
158
+ * shape as `validateBuyerIdentity` below.
159
+ *
160
+ * - `database` and `tunnel` each require `enabled` -- neither means anything
161
+ * without the app itself running.
162
+ * - `gitBranch` is non-empty and `buildPack` is one of `HostingBuildPack`'s
163
+ * four values.
164
+ * - Every `issuerApps[]` entry has a non-empty `name`, unique within the
165
+ * list, and a `kind` that is one of `IssuerAppKind`'s three values.
166
+ */
167
+ export declare function validateHosting(hosting: HostingConfig): string | null;
168
+ /**
169
+ * `null` when `site` is valid; otherwise the reason it is not (#101).
170
+ *
171
+ * Decided 2026-09-25
172
+ * (https://github.com/wtfalch/cms/blob/main/.plans/2026-09-25-org-sites.md):
173
+ * a cms site is how an org puts content on its own domain, and an org
174
+ * either runs its own app on its domain (`hosting`) or we host its website
175
+ * and cms there (`site`), never both -- both would otherwise fight over the
176
+ * same hostname (https://github.com/wtfalch/foundry/issues/99).
177
+ *
178
+ * - `site.enabled` requires `domain` to be set -- a site with nowhere to
179
+ * live is not a site.
180
+ * - `site.enabled` requires `site.usesDomain` -- unlike mail, chat, calls
181
+ * and the issuer, the site has no meaning anywhere but the org's own
182
+ * domain, so turning it on always means the domain opt-in too.
183
+ * - `site.enabled` and `hosting.enabled` cannot both be on.
184
+ */
185
+ export declare function validateSite(org: DeclaredOrg): string | null;
92
186
  /**
93
187
  * Per-org SSO. Decided to exist in #14 ("Also in scope") and its resolved
94
188
  * research #3 ("per-org SSO code exists unused"); nothing about its
@@ -196,6 +290,29 @@ export interface BuyerIdentity {
196
290
  * name before its other details are typed).
197
291
  */
198
292
  export declare function validateBuyerIdentity(buyer: BuyerIdentity): string | null;
293
+ /**
294
+ * The org's owner: the one person a real org is created for, invited into
295
+ * its ZITADEL org by email so they can set their own first credential (a
296
+ * passkey) without a password ever passing through foundry. `null` until an
297
+ * operator names one -- an org can exist declared before its owner is known.
298
+ *
299
+ * `apply`'s `zitadel:owner` step (`plan/diff.ts`'s `planOwner`) calls
300
+ * `ZitadelClient.inviteOrgOwner(zitadelOrgId, owner)` once the org's ZITADEL
301
+ * org exists, then never touches this again -- a re-run is idempotent on the
302
+ * recorded invited user id, not on this declared value, so editing `owner`
303
+ * after the invite has gone out does not send a second one.
304
+ */
305
+ export interface OwnerIdentity {
306
+ email: string;
307
+ givenName: string;
308
+ familyName: string;
309
+ }
310
+ /**
311
+ * `null` when `owner` is valid; otherwise the reason it is not, same shape as
312
+ * {@link validateBuyerIdentity} and `../slug.ts`'s `validateSlug`. `owner:
313
+ * null` (no owner named yet) is always valid.
314
+ */
315
+ export declare function validateOwner(owner: OwnerIdentity | null): string | null;
199
316
  /**
200
317
  * The form an app foundry does not own takes. Decided in #7's `#15`
201
318
  * refinement comment:
@@ -255,6 +372,8 @@ export interface DeclaredOrg {
255
372
  issuer: IssuerConfig;
256
373
  /** `enabled: false` by default -- a brand new org has no cms site. */
257
374
  site: SiteConfig;
375
+ /** `enabled: false`, `issuerApps: []` by default -- a brand new org has no hosted app (#71). */
376
+ hosting: HostingConfig;
258
377
  sso: SsoConfig;
259
378
  guests: GuestsConfig;
260
379
  solo: SoloConfig;
@@ -273,4 +392,15 @@ export interface DeclaredOrg {
273
392
  bespokeAppRepo: string | null;
274
393
  /** `null` when the org has none. */
275
394
  adoptedApp: AdoptedApp | null;
395
+ /** The org's owner, invited by ZITADEL email. `null` until an operator names one. See {@link OwnerIdentity}. */
396
+ owner: OwnerIdentity | null;
397
+ /**
398
+ * Marks this org as a test org rather than a real customer's. Purely
399
+ * declarative -- it changes nothing about what `apply` provisions -- but it
400
+ * is the one gate `FoundryPlanApply.destroy` checks: only a test org
401
+ * (`test: true`) can be destroyed, so deleting every resource foundry made
402
+ * for an org, then the org itself, can never reach a real customer's org by
403
+ * mistake.
404
+ */
405
+ test: boolean;
276
406
  }
package/dist/org.js CHANGED
@@ -1,3 +1,86 @@
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
+ if (hosting.accessEmails.length > 0 && !hosting.enabled) {
33
+ return 'hosting.accessEmails requires hosting.enabled';
34
+ }
35
+ for (const email of hosting.accessEmails) {
36
+ if (!/^[^@\s]+@[^@\s]+$/.test(email)) {
37
+ return `hosting.accessEmails has an invalid email '${email}'`;
38
+ }
39
+ }
40
+ const seenNames = new Set();
41
+ for (const app of hosting.issuerApps) {
42
+ if (!app.name) {
43
+ return 'every issuerApps entry needs a non-empty name';
44
+ }
45
+ if (seenNames.has(app.name)) {
46
+ return `issuerApps name '${app.name}' is not unique`;
47
+ }
48
+ seenNames.add(app.name);
49
+ if (!ISSUER_APP_KINDS.has(app.kind)) {
50
+ return `issuerApps '${app.name}' has an unknown kind '${app.kind}'`;
51
+ }
52
+ }
53
+ return null;
54
+ }
55
+ /**
56
+ * `null` when `site` is valid; otherwise the reason it is not (#101).
57
+ *
58
+ * Decided 2026-09-25
59
+ * (https://github.com/wtfalch/cms/blob/main/.plans/2026-09-25-org-sites.md):
60
+ * a cms site is how an org puts content on its own domain, and an org
61
+ * either runs its own app on its domain (`hosting`) or we host its website
62
+ * and cms there (`site`), never both -- both would otherwise fight over the
63
+ * same hostname (https://github.com/wtfalch/foundry/issues/99).
64
+ *
65
+ * - `site.enabled` requires `domain` to be set -- a site with nowhere to
66
+ * live is not a site.
67
+ * - `site.enabled` requires `site.usesDomain` -- unlike mail, chat, calls
68
+ * and the issuer, the site has no meaning anywhere but the org's own
69
+ * domain, so turning it on always means the domain opt-in too.
70
+ * - `site.enabled` and `hosting.enabled` cannot both be on.
71
+ */
72
+ export function validateSite(org) {
73
+ if (org.site.enabled && !org.domain) {
74
+ return 'site.enabled requires domain';
75
+ }
76
+ if (org.site.enabled && !org.site.usesDomain) {
77
+ return 'site.enabled requires site.usesDomain';
78
+ }
79
+ if (org.site.enabled && org.hosting.enabled) {
80
+ return 'site.enabled and hosting.enabled cannot both be on';
81
+ }
82
+ return null;
83
+ }
1
84
  const ORG_NUMBER_PATTERN = /^\d{9}$/;
2
85
  /**
3
86
  * `null` when `buyer` is valid; otherwise the reason it is not, for a caller
@@ -27,3 +110,22 @@ export function validateBuyerIdentity(buyer) {
27
110
  }
28
111
  return null;
29
112
  }
113
+ /** 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. */
114
+ const OWNER_EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
115
+ /**
116
+ * `null` when `owner` is valid; otherwise the reason it is not, same shape as
117
+ * {@link validateBuyerIdentity} and `../slug.ts`'s `validateSlug`. `owner:
118
+ * null` (no owner named yet) is always valid.
119
+ */
120
+ export function validateOwner(owner) {
121
+ if (owner === null)
122
+ return null;
123
+ if (!OWNER_EMAIL_PATTERN.test(owner.email)) {
124
+ return `owner email '${owner.email}' is not a valid email address`;
125
+ }
126
+ if (!owner.givenName.trim())
127
+ return 'owner.givenName must not be empty';
128
+ if (!owner.familyName.trim())
129
+ return 'owner.familyName must not be empty';
130
+ return null;
131
+ }
@@ -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;