@wtfalch/foundry 0.1.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 (49) hide show
  1. package/LICENSE +21 -0
  2. package/dist/apply.d.ts +27 -0
  3. package/dist/apply.js +1 -0
  4. package/dist/index.d.ts +17 -0
  5. package/dist/index.js +7 -0
  6. package/dist/migrations/0001_declared_org.sql +61 -0
  7. package/dist/migrations/0002_applied_state.sql +43 -0
  8. package/dist/migrations/0003_foundry_runs.sql +53 -0
  9. package/dist/migrations/0004_slug_unique.sql +26 -0
  10. package/dist/migrations/0005_spend_limits.sql +34 -0
  11. package/dist/migrations/0006_buyer_identity.sql +31 -0
  12. package/dist/migrations/0007_site.sql +18 -0
  13. package/dist/org.d.ts +276 -0
  14. package/dist/org.js +29 -0
  15. package/dist/plan/diff.d.ts +52 -0
  16. package/dist/plan/diff.js +302 -0
  17. package/dist/plan/index.d.ts +8 -0
  18. package/dist/plan/index.js +4 -0
  19. package/dist/plan/live.d.ts +62 -0
  20. package/dist/plan/live.js +12 -0
  21. package/dist/plan/plan-apply.d.ts +107 -0
  22. package/dist/plan/plan-apply.js +172 -0
  23. package/dist/plan/providers-readers.d.ts +86 -0
  24. package/dist/plan/providers-readers.js +83 -0
  25. package/dist/plan.d.ts +50 -0
  26. package/dist/plan.js +1 -0
  27. package/dist/runner/db.d.ts +8 -0
  28. package/dist/runner/db.js +1 -0
  29. package/dist/runner/errors.d.ts +37 -0
  30. package/dist/runner/errors.js +57 -0
  31. package/dist/runner/index.d.ts +4 -0
  32. package/dist/runner/index.js +2 -0
  33. package/dist/runner/runner.d.ts +6 -0
  34. package/dist/runner/runner.js +137 -0
  35. package/dist/runner/schema.d.ts +163 -0
  36. package/dist/runner/schema.js +25 -0
  37. package/dist/runner/types.d.ts +97 -0
  38. package/dist/runner/types.js +1 -0
  39. package/dist/slug.d.ts +38 -0
  40. package/dist/slug.js +141 -0
  41. package/dist/state.d.ts +63 -0
  42. package/dist/state.js +1 -0
  43. package/dist/store/postgres.d.ts +43 -0
  44. package/dist/store/postgres.js +267 -0
  45. package/dist/store/schema.d.ts +615 -0
  46. package/dist/store/schema.js +59 -0
  47. package/dist/store.d.ts +23 -0
  48. package/dist/store.js +1 -0
  49. package/package.json +49 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 William Tallis Falch
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,27 @@
1
+ import type { OrgId } from './org.js';
2
+ import type { Plan } from './plan.js';
3
+ import type { AppliedState } from './state.js';
4
+ /**
5
+ * The plan/apply seam https://github.com/wtfalch/foundry/issues/19
6
+ * implements.
7
+ *
8
+ * Decided in #9 (resolved): plan then apply, with the org's name typed for
9
+ * anything destructive, and an apply refused when its plan no longer
10
+ * matches reality. #19 adds: an authz permission gates an apply, and
11
+ * `@wtfalch/audit` records every plan and apply — who, when, the plan, the
12
+ * result.
13
+ */
14
+ export interface PlanApply {
15
+ /** Reads live state from Zitadel, Coolify and Cloudflare and diffs it
16
+ * against the declared org (#19 "Done when"). */
17
+ plan(id: OrgId): Promise<Plan>;
18
+ /**
19
+ * Runs exactly the plan it was given.
20
+ *
21
+ * @param confirmedOrgName The org's name as typed by the operator,
22
+ * required whenever `plan` contains a step with `destroys` or `migrates`
23
+ * true (#9). Implementations must refuse to apply when the live state a
24
+ * fresh plan would compute no longer matches this `plan` (#9, #10).
25
+ */
26
+ apply(plan: Plan, confirmedOrgName: string): Promise<AppliedState>;
27
+ }
package/dist/apply.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,17 @@
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';
3
+ export type { SlugRejectionReason, SlugRejection } from './slug.js';
4
+ export { validateSlug, RESERVED_SLUGS } from './slug.js';
5
+ export type { Checkpoint, LastApplied, AppliedState } from './state.js';
6
+ export type { PlanStep, UnmanagedItem, Plan } from './plan.js';
7
+ export type { DeclaredOrgStore } from './store.js';
8
+ export type { PlanApply } from './apply.js';
9
+ export { createPostgresDeclaredOrgStore, DeclaredOrgError } from './store/postgres.js';
10
+ export type { FoundryStoreDb } from './store/postgres.js';
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';
14
+ export type { RunnerDb } from './runner/db.js';
15
+ export { AlreadyRunningError, RunNotFoundError, StepFailedError, StepMismatchError, } from './runner/errors.js';
16
+ export { createJobRunner } from './runner/runner.js';
17
+ export type { CreateJobRunnerOptions, JobRunner, RunFailure, RunRecord, RunStatus, RunStepsOptions, RunnerStep, } from './runner/types.js';
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ export { validateBuyerIdentity } from './org.js';
2
+ export { validateSlug, RESERVED_SLUGS } from './slug.js';
3
+ export { createPostgresDeclaredOrgStore, DeclaredOrgError } from './store/postgres.js';
4
+ export { declaredOrgs, foundryAppliedState, } from './store/schema.js';
5
+ export { readLiveResources, computePlanDetail, toPlan, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, } from './plan/index.js';
6
+ export { AlreadyRunningError, RunNotFoundError, StepFailedError, StepMismatchError, } from './runner/errors.js';
7
+ export { createJobRunner } from './runner/runner.js';
@@ -0,0 +1,61 @@
1
+ -- @wtfalch/foundry: the declared org a human edits, stored in foundry's own
2
+ -- database (#10: "not git" — the operator page is the only editor). Mirrors
3
+ -- src/store/schema.ts's `declaredOrgs` table.
4
+ --
5
+ -- The full schema exists from day one, including columns nothing implements
6
+ -- yet (#7 "Shape", #18 "Done when"), so a field never comes to describe
7
+ -- something real without a column already here to say so. `apply` (#19) is
8
+ -- what refuses a field it cannot yet make true; this migration only stores
9
+ -- whatever the declared file says.
10
+ --
11
+ -- id is the opaque id foundry mints at creation (#7 "Identity", org.ts's
12
+ -- OrgId) — never derived from Zitadel or any other live system. slug and
13
+ -- display_name are mutable; slug additionally freezes once the org has mail
14
+ -- (mail_mode <> 'off'), enforced in src/store/postgres.ts's `update`, not
15
+ -- here — the two-file split (#10) makes the store, not the database, the
16
+ -- single writer of the declared org, so the rule lives in the code path that
17
+ -- already has to reject "combinations foundry knows are impossible" (#18)
18
+ -- with a reason a caller can read, which a bare CHECK constraint cannot give.
19
+
20
+ create table if not exists foundry_declared_orgs (
21
+ id text not null,
22
+ slug text not null,
23
+ display_name text not null,
24
+
25
+ -- A fact, nothing more (#7 amendment 1): recording it never starts a
26
+ -- service change on its own. null until the org has a domain.
27
+ domain text,
28
+
29
+ -- MailConfig (org.ts). 'off' is not a decided mail state; it says mail is
30
+ -- not foundry's concern for this org yet, rather than the column silently
31
+ -- defaulting to one of the two that are.
32
+ mail_mode text not null default 'off',
33
+ mail_uses_domain boolean not null default false,
34
+
35
+ chat_uses_domain boolean not null default false,
36
+ calls_uses_domain boolean not null default false,
37
+ issuer_uses_domain boolean not null default false,
38
+
39
+ sso_enabled boolean not null default false,
40
+ guests_enabled boolean not null default false,
41
+ solo_enabled boolean not null default false,
42
+
43
+ -- null until #11's module-to-package rewrite ships (org.ts's doc on
44
+ -- `bespokeAppRepo`); the column exists now per #7's "full schema exists
45
+ -- from day one".
46
+ bespoke_app_repo text,
47
+
48
+ -- AdoptedApp | null (org.ts). null when the org has no adopted app; when
49
+ -- it does, its form is 'bundle' (ships first) or 'repo' (declared, refused
50
+ -- by apply until it exists, per #7's #15 refinement comment).
51
+ adopted_app_form text,
52
+
53
+ created_at timestamptz not null default now(),
54
+ updated_at timestamptz not null default now(),
55
+
56
+ constraint foundry_declared_orgs_pkey primary key (id),
57
+ constraint foundry_declared_orgs_mail_mode_check
58
+ check (mail_mode in ('off', 'received', 'hosted')),
59
+ constraint foundry_declared_orgs_adopted_app_form_check
60
+ check (adopted_app_form is null or adopted_app_form in ('bundle', 'repo'))
61
+ );
@@ -0,0 +1,43 @@
1
+ -- @wtfalch/foundry: foundry's own record of what has actually been made true
2
+ -- for an org, separate from the human-edited declared org #0001 creates
3
+ -- (#7 "Shape", #10 resolved). Mirrors src/store/schema.ts's
4
+ -- `foundryAppliedState` table.
5
+ --
6
+ -- One row per org, replaced in place by an upsert (src/store/postgres.ts's
7
+ -- `putAppliedState`) rather than appended to, matching state.ts's
8
+ -- `AppliedState`: it is a single current snapshot, not a history. Audit rows
9
+ -- for the history are `@wtfalch/audit`'s job (#9), not this table's.
10
+
11
+ create table if not exists foundry_applied_state (
12
+ org_id text not null,
13
+
14
+ -- Record<string, string>: one id per service that takes jobs from many
15
+ -- orgs plus the per-org services, each keyed by that service's own name
16
+ -- (state.ts's `serviceIds`, https://github.com/wtfalch/foundry/issues/14
17
+ -- resolved research #4).
18
+ service_ids jsonb not null default '{}'::jsonb,
19
+
20
+ -- Checkpoint[]: completed steps of the current or most recent apply
21
+ -- (state.ts's `checkpoints`, #7 "Shape" / research #5's check-apply-with-
22
+ -- checkpoints shape).
23
+ checkpoints jsonb not null default '[]'::jsonb,
24
+
25
+ -- string[]: the name of every declared-org field the most recent apply
26
+ -- could not make true because nothing implements it yet (state.ts's
27
+ -- `failedFields`, #18's "Done when": "apply fails loudly and by name on
28
+ -- any field it cannot yet make true").
29
+ failed_fields jsonb not null default '[]'::jsonb,
30
+
31
+ -- LastApplied | null (state.ts). Both columns are null together before
32
+ -- this org has ever had a completed apply; never one without the other.
33
+ last_applied_at timestamptz,
34
+ last_applied_declared_org_version text,
35
+
36
+ constraint foundry_applied_state_pkey primary key (org_id),
37
+ constraint foundry_applied_state_org_fkey foreign key (org_id)
38
+ references foundry_declared_orgs (id),
39
+ constraint foundry_applied_state_last_applied_check
40
+ check (
41
+ (last_applied_at is null) = (last_applied_declared_org_version is null)
42
+ )
43
+ );
@@ -0,0 +1,53 @@
1
+ -- @wtfalch/foundry ./runner: the run/lock table the job runner uses to
2
+ -- checkpoint an apply and hold its lock as a database row, never a
3
+ -- lockfile (#9, resolved; #17). Mirrors src/runner/schema.ts, which is not
4
+ -- the source of truth -- this file is, following @wtfalch/keys' ./held
5
+ -- convention (src/held/schema.ts's own header says the same of its SQL).
6
+ --
7
+ -- One row per apply run. `status` doubles as the lock: the partial unique
8
+ -- index below, on (org_id) where status is not 'succeeded', means a second
9
+ -- apply for an org that already has a run in flight -- or one that failed
10
+ -- and has not been resolved -- is refused by Postgres itself, from any
11
+ -- number of containers. #9's rejected alternative was a PID lockfile next
12
+ -- to the state file, which cannot do that: "a worker can run in more than
13
+ -- one container."
14
+ --
15
+ -- `step_ids` is the run's plan, fixed at enqueue time (`foundry_runs`
16
+ -- stores no function -- a worker resuming a run supplies its own step
17
+ -- objects again and this array is what they are checked against).
18
+ -- `checkpoints` is the record of what has already run, in order, shaped
19
+ -- like src/state.ts's `Checkpoint`. `failure`, once set, names the step
20
+ -- that stopped the run and why; nothing here clears it automatically --
21
+ -- there is no automatic retry (#9).
22
+ --
23
+ -- Numbered 0003 in foundry's single migration sequence. It was the runner
24
+ -- module's own 0001 while #17, #18 and #19 were in flight in parallel
25
+ -- branches; a host applies one ordered sequence, not one per module, so the
26
+ -- three directories became `src/migrations` once all three merged. Nothing
27
+ -- here depends on 0001 or 0002 -- `foundry_runs.org_id` deliberately carries
28
+ -- no foreign key to `foundry_declared_orgs`, so a run can be recorded for an
29
+ -- org this database has never declared.
30
+ --
31
+ -- Applied by whatever migrates a host's database (app-template's
32
+ -- `scripts/migrate-and-start.sh` pattern: copied in as the host's own next
33
+ -- numbered file, never edited in place). This module's own tests apply it
34
+ -- directly to PGlite (src/runner/test/db.ts).
35
+
36
+ create table if not exists foundry_runs (
37
+ id text primary key,
38
+ org_id text not null,
39
+ status text not null default 'running',
40
+ step_ids jsonb not null,
41
+ checkpoints jsonb not null default '[]',
42
+ failure jsonb,
43
+ created_at timestamptz not null default now(),
44
+ updated_at timestamptz not null default now(),
45
+ constraint foundry_runs_status_check check (status in ('running', 'succeeded', 'failed')),
46
+ constraint foundry_runs_org_id_check check (org_id ~ '^[A-Za-z0-9_-]{1,128}$')
47
+ );
48
+
49
+ -- The lock itself: at most one non-succeeded run per org, enforced by
50
+ -- Postgres regardless of how many containers or processes are writing.
51
+ create unique index if not exists foundry_runs_org_lock_idx
52
+ on foundry_runs (org_id)
53
+ where status <> 'succeeded';
@@ -0,0 +1,26 @@
1
+ -- @wtfalch/foundry: the declared org's slug is globally unique (#28), on
2
+ -- top of #0001's `foundry_declared_orgs` table. Mirrors
3
+ -- src/store/schema.ts's `declaredOrgs` table, which has no index of its own
4
+ -- to express -- drizzle-kit has no journal here (this package's convention,
5
+ -- #0001's header), so this file, not the schema, is the index's source of
6
+ -- truth.
7
+ --
8
+ -- Forced by the level-1 apex `oikos.host` being live (#28 "Why"): every
9
+ -- level-1 org's hostname is flat off that apex (`acme.oikos.host`), so a
10
+ -- duplicate slug is a hostname collision from the moment the second org is
11
+ -- created, not a cosmetic clash to resolve later. src/store/postgres.ts's
12
+ -- `create` and `update` already call src/slug.ts's `validateSlug` before
13
+ -- this index is ever reached; the index exists to catch the race the
14
+ -- pre-check cannot -- two concurrent creates for the same slug -- which
15
+ -- postgres.ts maps back to the same `DeclaredOrgError` a pre-check would
16
+ -- have thrown, per #18's rule that a caller gets a reason by name either
17
+ -- way.
18
+ --
19
+ -- DNS-label validity and the reserved/environment-suffix rules are
20
+ -- src/slug.ts's job, not a CHECK constraint's -- #0001's header already
21
+ -- gives the reasoning for keeping "combinations foundry knows are
22
+ -- impossible" in the code path that can explain itself, not in SQL that
23
+ -- can't.
24
+
25
+ create unique index if not exists foundry_declared_orgs_slug_idx
26
+ on foundry_declared_orgs (slug);
@@ -0,0 +1,34 @@
1
+ -- @wtfalch/foundry: an org's own spend limits for storage and AI, declared
2
+ -- alongside everything else in `foundry_declared_orgs` (#0001) and
3
+ -- reconciled onto Files and AI by `apply` (#27). Mirrors
4
+ -- src/store/schema.ts's `declaredOrgs` table additions.
5
+ --
6
+ -- storage_limit_bytes / storage_limit_micros are the org's OWN declared
7
+ -- limit on Files -- never Files' operator-set ceiling
8
+ -- (files/packages/sdk/src/types.ts's `ceilingBytes`/`ceilingMicros`), which
9
+ -- is a separate, operator-driven hard cap this table has no opinion on.
10
+ -- ai_limit_micros is the same shape for `@wtfalch/ai`'s per-organisation
11
+ -- budget (ai/packages/service/src/budget.ts's `limit_micros`).
12
+ --
13
+ -- All three are bigint-safe decimal strings of non-negative integers, stored
14
+ -- as text like every other declared-org string field, never a numeric
15
+ -- column -- the value only ever round-trips here, it is never computed on,
16
+ -- and a limit can exceed what a JS `number` or this store's own arithmetic
17
+ -- would safely hold. null means uncapped, additive with every existing row
18
+ -- (#0001's "full schema exists from day one" pattern -- a new nullable
19
+ -- column, not a reshape).
20
+ --
21
+ -- The non-negative-integer-string format is checked in
22
+ -- src/store/postgres.ts, the same place src/slug.ts's rules are checked
23
+ -- (#0004's header), not in a CHECK constraint here: the reason for a refusal
24
+ -- belongs in the code path that can explain itself, same as the slug rules
25
+ -- and the mail-freeze rule before it.
26
+ --
27
+ -- Spend itself (usage, money spent) is never declared (#27 "Done when") --
28
+ -- it stays a runtime fact read live from Files' metering and AI's usage
29
+ -- ledger, so no column for it exists here.
30
+
31
+ alter table foundry_declared_orgs
32
+ add column if not exists storage_limit_bytes text,
33
+ add column if not exists storage_limit_micros text,
34
+ add column if not exists ai_limit_micros text;
@@ -0,0 +1,31 @@
1
+ -- @wtfalch/foundry: the customer's own buyer identity for invoices (#67,
2
+ -- part of #59), declared alongside everything else in `foundry_declared_orgs`
3
+ -- (#0001). Mirrors `src/store/schema.ts`'s `declaredOrgs` table additions.
4
+ --
5
+ -- Shape matches `@wtfalch/invoicing`'s `Party`
6
+ -- (invoicing/packages/invoicing/src/parties.ts) field for field, so archon
7
+ -- can build a buyer from this row without mapping: legal_name, org_number,
8
+ -- an address of four parts, and vat_registered. Set by archon only (#60,
9
+ -- resolved), never by the estate's own seller identity, which lives in
10
+ -- archon's own config, not here (#60).
11
+ --
12
+ -- All seven columns are nullable -- a brand new org has no buyer identity at
13
+ -- all, the same "full schema exists from day one" pattern #0001 and #0005
14
+ -- established: a new nullable column, never a reshape. org.ts's validation
15
+ -- (not a CHECK constraint here, same reasoning as #0005's header) enforces:
16
+ -- org_number is nine digits when present, the four address parts are all
17
+ -- present or all absent, and legal_name is required once any other buyer
18
+ -- field is set.
19
+ --
20
+ -- No live system ever holds a second copy of this data to reconcile against
21
+ -- (#66: "no foundry provisioning" for the first charge+invoice flow) --
22
+ -- archon reads this row directly at invoice time.
23
+
24
+ alter table foundry_declared_orgs
25
+ add column if not exists legal_name text,
26
+ add column if not exists org_number text,
27
+ add column if not exists address_street text,
28
+ add column if not exists address_postal_code text,
29
+ add column if not exists address_city text,
30
+ add column if not exists address_country text,
31
+ add column if not exists vat_registered boolean;
@@ -0,0 +1,18 @@
1
+ -- @wtfalch/foundry: an org's cms site (T10, "teach foundry that an org can
2
+ -- declare a website"), declared alongside everything else in
3
+ -- `foundry_declared_orgs` (#0001). Mirrors `src/store/schema.ts`'s
4
+ -- `declaredOrgs` table additions.
5
+ --
6
+ -- SiteConfig (org.ts) is the same `usesDomain`-opts-into-`domain` shape as
7
+ -- mail/chat/calls/issuer (#0001's `mail_uses_domain` etc.), plus its own
8
+ -- on/off switch: site_enabled turns the site on at all, served on the org's
9
+ -- `<slug>.oikos.host` hostname (src/slug.ts) by default, or on its own
10
+ -- `domain` once site_uses_domain is true and `domain` is set.
11
+ --
12
+ -- Both booleans default false -- a brand new org has no cms site, the same
13
+ -- "full schema exists from day one" pattern #0001 and #0005 established: a
14
+ -- new column with a decided default, never a reshape.
15
+
16
+ alter table foundry_declared_orgs
17
+ add column if not exists site_enabled boolean not null default false,
18
+ add column if not exists site_uses_domain boolean not null default false;
package/dist/org.d.ts ADDED
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Opaque, minted by foundry when an org is created. Keys the declared org,
3
+ * the applied state, every estate-wide service's id map, and the chat
4
+ * `server_name`.
5
+ *
6
+ * Decided in https://github.com/wtfalch/foundry/issues/7 (amendment 5),
7
+ * which rejected keying on Zitadel's own org id instead: the declared file
8
+ * is written before foundry has called Zitadel at all, so keying to Zitadel
9
+ * would mean an org can't exist in the file until that call happens, and
10
+ * replacing Zitadel later would orphan every id map.
11
+ */
12
+ export type OrgId = string & {
13
+ readonly __brand: 'OrgId';
14
+ };
15
+ /**
16
+ * Mail hosting mode for an org's addresses, on the one shared Stalwart
17
+ * process every org's mail lives on (no per-org instance).
18
+ *
19
+ * Decided in #7 ("Mail") and https://github.com/wtfalch/foundry/issues/14's
20
+ * resolved research #2:
21
+ * - `received` — level 2: MX stays with Google or Microsoft, dual delivery
22
+ * brings mail in, Stalwart sends as the domain.
23
+ * - `hosted` — level 3: Stalwart holds the domain's MX outright, reached by
24
+ * a one-time copy then MX cutover from `received`.
25
+ * `off` is not a decided state — it exists so a declared org can say mail is
26
+ * not foundry's concern here, rather than the field silently defaulting.
27
+ */
28
+ export type MailMode = 'off' | 'received' | 'hosted';
29
+ /**
30
+ * Mail's per-service opt-in into the org's `domain` fact.
31
+ *
32
+ * Decided in #7 amendment 1: setting `domain` alone never starts a mail
33
+ * change. `usesDomain` is mail's own field for that, independent of
34
+ * `mode` — a domain can be recorded before mail is ever pointed at it.
35
+ */
36
+ export interface MailConfig {
37
+ mode: MailMode;
38
+ usesDomain: boolean;
39
+ }
40
+ /**
41
+ * Chat's per-service opt-in into the org's `domain` fact.
42
+ *
43
+ * Chat is provisioned at level 1 on a wtfalch `server_name` regardless of
44
+ * `usesDomain` (#7 "Chat"). Flipping `usesDomain` on fires a re-key onto a
45
+ * new Synapse+MAS that copies rooms and messages across so history reads
46
+ * continuous; flipping it off reverses onto the original `server_name` —
47
+ * legal in the schema in both directions, but the reverse merge is not
48
+ * built yet (#7 amendment 3) and `apply` fails loudly on it until it is.
49
+ * Never fired by `domain` alone (#7 amendment 1).
50
+ */
51
+ export interface ChatConfig {
52
+ usesDomain: boolean;
53
+ }
54
+ /**
55
+ * Calls' per-service opt-in into the org's `domain` fact, in the same shape
56
+ * as mail, chat and the issuer so that none of them are coupled to `domain`
57
+ * being set (#7 amendment 1, carried into
58
+ * https://github.com/wtfalch/foundry/issues/18's "Done when" list). Nothing
59
+ * about calls' own provisioning is decided yet beyond this.
60
+ */
61
+ export interface CallsConfig {
62
+ usesDomain: boolean;
63
+ }
64
+ /**
65
+ * The issuer's per-service opt-in into the org's `domain` fact.
66
+ *
67
+ * Decided in #7's corrections (2): moving the issuer onto the org's own
68
+ * domain invalidates every passkey in the org, so it is at least as
69
+ * disruptive as the chat re-key and gets the same rule — `domain` alone
70
+ * never moves it. Doing so is a destructive step under #9's rules and needs
71
+ * the org's name typed before `apply` runs it.
72
+ */
73
+ export interface IssuerConfig {
74
+ usesDomain: boolean;
75
+ }
76
+ /**
77
+ * The cms site's per-service opt-in into the org's `domain` fact, plus its
78
+ * own on/off switch, in the same shape as mail, chat, calls and the issuer
79
+ * so that recording a domain never starts a site change on its own (#7
80
+ * amendment 1).
81
+ *
82
+ * Decided for foundry to teach an org it can declare a website: `enabled`
83
+ * turns on the "standard" app form #14 names ("our code, their CMS
84
+ * content") -- a `@wtfalch/cms` site served on the org's `<slug>.oikos.host`
85
+ * hostname (`../slug.ts`) by default, or on its own `domain` once
86
+ * `usesDomain` is true and `DeclaredOrg.domain` is set.
87
+ */
88
+ export interface SiteConfig {
89
+ enabled: boolean;
90
+ usesDomain: boolean;
91
+ }
92
+ /**
93
+ * Per-org SSO. Decided to exist in #14 ("Also in scope") and its resolved
94
+ * research #3 ("per-org SSO code exists unused"); nothing about its
95
+ * configuration is decided beyond that it can be turned on.
96
+ */
97
+ export interface SsoConfig {
98
+ enabled: boolean;
99
+ }
100
+ /**
101
+ * Guests from other orgs. Decided to exist in #14 ("Also in scope"); its
102
+ * resolved research #3 notes org access needs project grants that don't
103
+ * exist yet, so nothing about how a guest is granted access is decided.
104
+ */
105
+ export interface GuestsConfig {
106
+ enabled: boolean;
107
+ }
108
+ /**
109
+ * A solo person as an org of one. Explicitly left open in #7 amendment 6:
110
+ * whether solo earns its own field or is just an org with one member waits
111
+ * on #11 and #13. The field exists now, per #7's "full schema exists from
112
+ * day one", so that decision doesn't force a schema reshape later.
113
+ */
114
+ export interface SoloConfig {
115
+ enabled: boolean;
116
+ }
117
+ /**
118
+ * The org's own spend limit on Files, declared here and reconciled onto
119
+ * Files by `apply` (#27).
120
+ *
121
+ * `limitMicros` is the org's own declared limit -- NOT Files' operator-set
122
+ * `ceilingMicros` (files/packages/sdk/src/types.ts:118's `QuotaSummary`,
123
+ * :132's `BudgetSummary`, and `UpdateOrganisationCeilingInput`), which is a
124
+ * separate, operator-driven hard cap this field has no opinion on and never
125
+ * writes to. `apply` pushes `limitBytes`/`limitMicros` onto Files as
126
+ * `QuotaSummary.organisation.limitBytes` / `BudgetSummary.organisation.limitMicros`.
127
+ *
128
+ * Both are bigint-safe decimal strings of non-negative integers, never
129
+ * converted to a number -- a limit can exceed what a JS `number` safely
130
+ * holds. `null` means uncapped. Spend itself (usage, money spent) is never
131
+ * declared (#27 "Done when"): it is a runtime fact read live from Files'
132
+ * metering, shown beside this limit, never stored here.
133
+ */
134
+ export interface StorageConfig {
135
+ limitBytes: string | null;
136
+ limitMicros: string | null;
137
+ }
138
+ /**
139
+ * The org's own spend limit on `@wtfalch/ai`, declared here and reconciled
140
+ * onto AI by `apply` (#27). AI already carries a per-organisation budget with
141
+ * this exact shape (ai/packages/service/src/budget.ts:34's `Budget.limit_micros`);
142
+ * this is the declared value `apply` pushes onto it.
143
+ *
144
+ * A bigint-safe decimal string of a non-negative integer, never converted to
145
+ * a number. `null` means uncapped. Spend itself (usage, money spent) is
146
+ * never declared (#27 "Done when"): it is a runtime fact read live from AI's
147
+ * usage ledger, shown beside this limit, never stored here.
148
+ */
149
+ export interface AiConfig {
150
+ limitMicros: string | null;
151
+ }
152
+ /**
153
+ * The customer's own postal address for invoicing -- all four parts or none
154
+ * (org.ts's `validateBuyerIdentity`), matching
155
+ * `@wtfalch/invoicing`'s `Address` (invoicing/packages/invoicing/src/parties.ts).
156
+ */
157
+ export interface BuyerAddress {
158
+ street: string;
159
+ postalCode: string;
160
+ city: string;
161
+ country: string;
162
+ }
163
+ /**
164
+ * The customer's buyer identity for invoices (#67, part of #59), set by
165
+ * archon only (#60, resolved) -- the estate's own seller identity lives in
166
+ * archon's own config, never here. Shape matches `@wtfalch/invoicing`'s
167
+ * `Party` (invoicing/packages/invoicing/src/parties.ts) field for field, so
168
+ * archon can build a buyer from this row without mapping.
169
+ *
170
+ * All four fields are independently nullable -- a brand new org has none of
171
+ * them -- but not independently valid in every combination:
172
+ * `validateBuyerIdentity` requires `legalName` once any other field is set,
173
+ * an `orgNumber` of exactly nine digits when present, and an `address` with
174
+ * all four parts or none.
175
+ *
176
+ * No live system holds a second copy of this to reconcile against (#66: "no
177
+ * foundry provisioning" for the first charge+invoice flow) -- archon reads
178
+ * this row directly at invoice time.
179
+ */
180
+ export interface BuyerIdentity {
181
+ legalName: string | null;
182
+ orgNumber: string | null;
183
+ address: BuyerAddress | null;
184
+ vatRegistered: boolean | null;
185
+ }
186
+ /**
187
+ * `null` when `buyer` is valid; otherwise the reason it is not, for a caller
188
+ * (the store, on `create`/`update`) to report rather than throw blind (same
189
+ * shape as `../slug.ts`'s `validateSlug`).
190
+ *
191
+ * - `orgNumber`, when present, is exactly nine digits (matches
192
+ * `@wtfalch/invoicing`'s `orgNumberSchema`).
193
+ * - `address` is all four parts or none -- never partial.
194
+ * - `legalName` is required once any other buyer field is set; a bare
195
+ * `legalName` with nothing else is valid on its own (a customer known by
196
+ * name before its other details are typed).
197
+ */
198
+ export declare function validateBuyerIdentity(buyer: BuyerIdentity): string | null;
199
+ /**
200
+ * The form an app foundry does not own takes. Decided in #7's `#15`
201
+ * refinement comment:
202
+ * - `bundle` — a built artefact we serve on the org's domain, runs only in
203
+ * the browser, and is the one that ships first.
204
+ * - `repo` — we build their source; declared but refused by `apply` until
205
+ * it exists.
206
+ * The boundary this encodes: a `bundle` app reaches our services over HTTP
207
+ * and never server-side.
208
+ */
209
+ export type AdoptedAppForm = 'bundle' | 'repo';
210
+ /**
211
+ * An app whose code foundry does not own — the adopted line, the first
212
+ * sale. Decided in #7's `#15` schema-addition comment: added on day one
213
+ * because the full schema exists from day one and a retrofit would be a
214
+ * reshape, even though nothing is built behind it for v1.
215
+ */
216
+ export interface AdoptedApp {
217
+ form: AdoptedAppForm;
218
+ }
219
+ /**
220
+ * The org a human declares, edited from the operator page and stored in
221
+ * foundry's own database — never git (#10, resolved: "The declared org
222
+ * lives in foundry's own database with the operator page as its editor").
223
+ *
224
+ * `apply` (see {@link PlanApply} in `./apply.ts`) reads live state from
225
+ * Zitadel, Coolify and Cloudflare and drives it to match this file, failing
226
+ * loudly and by name on any field it cannot yet make true (#7 "Shape").
227
+ *
228
+ * The full schema exists from day one, including fields nothing implements
229
+ * yet, so the file never comes to describe something that became real
230
+ * without a matching field already being here to say so (#7 "Shape").
231
+ */
232
+ export interface DeclaredOrg {
233
+ id: OrgId;
234
+ /**
235
+ * Mutable at any time — except: once `mail.mode` is not `'off'`, the slug
236
+ * is frozen. The level-1 mail address is built from it
237
+ * (`{slug}@{wtfalch mail apex}`), and an address people cannot read aloud
238
+ * is not an address (#7 corrections 3). An org on its own domain is
239
+ * unaffected, since its addresses read the domain, not the slug.
240
+ */
241
+ slug: string;
242
+ /** Mutable at any time, no restrictions (#7 "Identity"). */
243
+ displayName: string;
244
+ /**
245
+ * A fact, nothing more. Recording a domain here never starts a service
246
+ * change on its own — each service above opts in for itself via its own
247
+ * `usesDomain` field (#7 amendment 1). An org can therefore have mail and
248
+ * chat on different domains at once; `apply` must report that plainly
249
+ * rather than treat it as an error.
250
+ */
251
+ domain: string | null;
252
+ mail: MailConfig;
253
+ chat: ChatConfig;
254
+ calls: CallsConfig;
255
+ issuer: IssuerConfig;
256
+ /** `enabled: false` by default -- a brand new org has no cms site. */
257
+ site: SiteConfig;
258
+ sso: SsoConfig;
259
+ guests: GuestsConfig;
260
+ solo: SoloConfig;
261
+ /** The org's own spend limit on Files (#27). */
262
+ storage: StorageConfig;
263
+ /** The org's own spend limit on AI (#27). */
264
+ ai: AiConfig;
265
+ /** The customer's own buyer identity for invoices (#67). `validateBuyerIdentity` above. */
266
+ buyer: BuyerIdentity;
267
+ /**
268
+ * A foundry-generated repo for this org's bespoke app — one of the three
269
+ * ways a customer can have an app, decided 2026-09-22 on the map (#14):
270
+ * adopted, standard, bespoke. `null` until #11's module-to-package rewrite
271
+ * ships; the field exists now per #7's "full schema exists from day one".
272
+ */
273
+ bespokeAppRepo: string | null;
274
+ /** `null` when the org has none. */
275
+ adoptedApp: AdoptedApp | null;
276
+ }
package/dist/org.js ADDED
@@ -0,0 +1,29 @@
1
+ const ORG_NUMBER_PATTERN = /^\d{9}$/;
2
+ /**
3
+ * `null` when `buyer` is valid; otherwise the reason it is not, for a caller
4
+ * (the store, on `create`/`update`) to report rather than throw blind (same
5
+ * shape as `../slug.ts`'s `validateSlug`).
6
+ *
7
+ * - `orgNumber`, when present, is exactly nine digits (matches
8
+ * `@wtfalch/invoicing`'s `orgNumberSchema`).
9
+ * - `address` is all four parts or none -- never partial.
10
+ * - `legalName` is required once any other buyer field is set; a bare
11
+ * `legalName` with nothing else is valid on its own (a customer known by
12
+ * name before its other details are typed).
13
+ */
14
+ export function validateBuyerIdentity(buyer) {
15
+ if (buyer.orgNumber !== null && !ORG_NUMBER_PATTERN.test(buyer.orgNumber)) {
16
+ return `orgNumber '${buyer.orgNumber}' must be exactly nine digits`;
17
+ }
18
+ if (buyer.address !== null) {
19
+ const { street, postalCode, city, country } = buyer.address;
20
+ if (!street || !postalCode || !city || !country) {
21
+ return 'address must have all four parts (street, postalCode, city, country) or none';
22
+ }
23
+ }
24
+ const anyOtherFieldSet = buyer.orgNumber !== null || buyer.address !== null || buyer.vatRegistered !== null;
25
+ if (buyer.legalName === null && anyOtherFieldSet) {
26
+ return 'legalName is required once any other buyer field is set';
27
+ }
28
+ return null;
29
+ }