@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.
- package/LICENSE +21 -0
- package/dist/apply.d.ts +27 -0
- package/dist/apply.js +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +7 -0
- package/dist/migrations/0001_declared_org.sql +61 -0
- package/dist/migrations/0002_applied_state.sql +43 -0
- package/dist/migrations/0003_foundry_runs.sql +53 -0
- package/dist/migrations/0004_slug_unique.sql +26 -0
- package/dist/migrations/0005_spend_limits.sql +34 -0
- package/dist/migrations/0006_buyer_identity.sql +31 -0
- package/dist/migrations/0007_site.sql +18 -0
- package/dist/org.d.ts +276 -0
- package/dist/org.js +29 -0
- package/dist/plan/diff.d.ts +52 -0
- package/dist/plan/diff.js +302 -0
- package/dist/plan/index.d.ts +8 -0
- package/dist/plan/index.js +4 -0
- package/dist/plan/live.d.ts +62 -0
- package/dist/plan/live.js +12 -0
- package/dist/plan/plan-apply.d.ts +107 -0
- package/dist/plan/plan-apply.js +172 -0
- package/dist/plan/providers-readers.d.ts +86 -0
- package/dist/plan/providers-readers.js +83 -0
- package/dist/plan.d.ts +50 -0
- package/dist/plan.js +1 -0
- package/dist/runner/db.d.ts +8 -0
- package/dist/runner/db.js +1 -0
- package/dist/runner/errors.d.ts +37 -0
- package/dist/runner/errors.js +57 -0
- package/dist/runner/index.d.ts +4 -0
- package/dist/runner/index.js +2 -0
- package/dist/runner/runner.d.ts +6 -0
- package/dist/runner/runner.js +137 -0
- package/dist/runner/schema.d.ts +163 -0
- package/dist/runner/schema.js +25 -0
- package/dist/runner/types.d.ts +97 -0
- package/dist/runner/types.js +1 -0
- package/dist/slug.d.ts +38 -0
- package/dist/slug.js +141 -0
- package/dist/state.d.ts +63 -0
- package/dist/state.js +1 -0
- package/dist/store/postgres.d.ts +43 -0
- package/dist/store/postgres.js +267 -0
- package/dist/store/schema.d.ts +615 -0
- package/dist/store/schema.js +59 -0
- package/dist/store.d.ts +23 -0
- package/dist/store.js +1 -0
- 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.
|
package/dist/apply.d.ts
ADDED
|
@@ -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 {};
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|