/** * `defineDomain` — the WORKER-SAFE definition of a domain: what the domain *is* * (its `schema`, `methods`, `deps`, `views`, standalone `functions`) plus its * addressing identity (`origin`, `requires`, `postInstall`). It * deliberately carries NO deployment adapter — the adapter (`cloudflare(...)`, * `astrale(...)`) is node-only code (filesystem, wrangler) that must never enter * the worker bundle. The author wires this in a `domain.ts` the generated worker * imports directly, then attaches the adapter separately with `deploy(domain, * adapter)` in `astrale.config.ts` (see `./deploy`). * * The modules are wired EXPLICITLY here — imported and passed in — not * discovered from magic folder names. A renamed or mistyped module is a compile * error at this call site, never a silently-missing worker route. The adapter * reads this one definition for domain-side codegen; frontend source folders * live in adapter env config. `defineDomain` itself builds no server and boots * no kernel — it validates and packages the declaration. */ import type { Core, Schema } from '@astrale-os/kernel-dsl' import { DomainOrigin } from '@astrale-os/kernel-core/domain' import type { AnyRemoteFunctionDef, ViewDef } from '../define/index.js' import type { SchemaMethodsImpl } from '../method/index.js' // A registry holds views / functions of every deps + param/result shape. Their // per-entry typing is enforced at the `defineView` / `defineRemoteFunction` call // site; here they're held loosely and, crucially, kept OUT of `TDeps` inference // — so `TDeps` is fixed by `methods` alone (the authoritative deps source) and a // view authored with the default `unknown` deps can't fight `methods`'s `Env`. // oxlint-disable-next-line no-explicit-any type AnyViewDef = ViewDef // The function bag is the canonical, variance-correct one from `../define` (it // must accept functions of any auth policy — see the note there). Aliased to // keep the local usages below terse. type AnyFunctionDef = AnyRemoteFunctionDef /** * Optional presentation metadata for the domain, served verbatim on the * worker's `/meta` endpoint (see `../deploy/meta`) so a host UI can brand a * freshly-installed domain. Both the block and every field are optional. */ export interface DomainManifest { /** Domain logo — an inline SVG string or a `data:` URL (no extra validation). */ logo?: string /** * Slug of one of THIS domain's own views to open first — the domain's entry * surface. This is the SERVED form: a plain slug, stamped verbatim onto * `/meta`. Authors don't write the slug directly — they pass the view OBJECT * to `defineDomain` (see {@link DomainManifestConfig.entrypoint}), which * resolves it to this slug. Pinned at app install to the `View` node at * `//views/` via the app's `entrypoint` edge. */ entrypoint?: string /** * The ROLES this domain declares — named capability identities a workspace * registers when an app for this domain is installed, then assigns to users * (`default: true` roles are auto-assigned to the installing user). The * domain itself grants each role its resource permissions (typically in its * `postInstall`); declaring a role here only names it. Slugs are validated * (lowercase slug charset, unique) at `defineDomain`. */ roles?: readonly DomainRoleDecl[] } /** One declared role of the domain (see {@link DomainManifest.roles}). */ export interface DomainRoleDecl { /** Stable role slug, unique within the domain (e.g. `"editor"`). */ slug: string /** Display name; defaults to the slug. */ name?: string /** What holding this role means — shown in permission UIs. */ description?: string /** Auto-assign this role to the user installing an app for this domain. */ default?: boolean } /** * The author-facing manifest accepted by {@link defineDomain} — identical to the * served {@link DomainManifest} except `entrypoint` is the view OBJECT (a value * of the `views` map) rather than its slug. `defineDomain` resolves it to the * slug by identity, so a renamed or dropped view is a compile error at the * reference site, never a stale string (the same guarantee `postInstall` gives * for functions). */ export interface DomainManifestConfig extends Omit { /** * The view to open first — the domain's entry surface. Pass the view OBJECT * from this domain's own `views` map (e.g. `entrypoint: views.welcome`). * Resolved to its slug here and served as {@link DomainManifest.entrypoint}. */ entrypoint?: AnyViewDef } export interface DefineDomainConfig { /** The domain schema (from `schema/`). Its `.domain` seeds the default origin. */ schema: S /** * The domain's method implementations (from `methods/`), one per schema * method. Typed against `schema` — an unimplemented or misnamed method is a * compile error here. */ methods: SchemaMethodsImpl /** Declarative genesis nodes and edges materialized with the domain at install time. */ core?: Core /** * Map the worker `env` to the handler dependency container (`ctx.deps`). * Run ONCE per cold isolate per serving URL (the built app is cached), NOT * per request — so it's the place to construct ports/clients once instead of * re-deriving them in every handler. Its return type IS `TDeps`: type it the * shape your `methods` read (e.g. `(env) => ({ platform: buildPlatform(env) })`) * and the methods get the rich container, not raw env. The same value reaches * view/function handlers and the `install.authorize` hook. * * Omit for the common case: `env` is passed straight through (`TDeps = TEnv`), * so existing domains are unaffected. The function itself is imported by the * generated worker from a fixed `deps` module, mirroring `methods` — wire it * here so the type check binds `env → TDeps` and the adapter knows to emit it. */ deps?: (env: TEnv, url: string) => TDeps /** * The domain's Views (iframe-mountable UIs), keyed by slug. Omit when the * domain has none. Each becomes a `View` node + a worker route. */ views?: Record /** * The domain's standalone Functions (callables not bound to a class), keyed * by slug. Omit when the domain has none. */ functions?: Record /** * Optional presentation metadata stamped onto `/meta`. When `manifest.entrypoint` * is set it MUST be a view from this domain's own `views` map — passed as the * OBJECT (`entrypoint: views.welcome`), resolved to its slug here. A view that * isn't in `views` is a compile error at the reference site, and the * resolution throws loudly if it's somehow absent — so a typo can never dangle * to a NOT_FOUND view path at install. */ manifest?: DomainManifestConfig /** * The domain's **addressing name** (the graph slug it mounts under, e.g. * `'crm.acme.dev'`). Defaults to `schema.domain`. Must be a name, never a * URL. This is **NOT** the cryptographic identity: the `iss` is the worker's * **serving URL**, pinned by the kernel to the URL it fetched the domain at * during install (and verified against that URL's JWKS). `origin` is a free * addressing label, only required to be unique in the graph. */ origin?: string /** Cross-domain deps, by origin. Verified present on the instance at install. */ requires?: readonly string[] /** * Where the Domain node physically lives in the graph TREE — an absolute tree * path whose LAST segment is the origin (e.g. `'/domains/crm.acme.dev'`). * Optional; defaults to `/domains/`. This moves ONLY the physical * `has_parent` position: the domain's `installed_in` edge and EVERY typed * address (`/:`, `/::Class`, …) stay ROOT-mounted, so * addressing is unchanged. A platform domain that must stay top-level sets its * own origin path (e.g. `'/shell.astrale.ai'`). */ path?: string /** * The function the kernel runs once after install, as __SYSTEM__ — where the * domain seeds itself / posts its own grants. Reference it from the `functions` * map: `postInstall: functions.seed`. The SDK derives its path by identity, so a * typo or a renamed key is a compile error here, never a stale string. It is * always a standalone function (a domain bootstrap belongs to the domain, not to * a class) under THIS domain — you never write the origin, and the kernel * resolves it relative to wherever the domain is installed. */ postInstall?: AnyFunctionDef } export interface DomainDefinition { schema: Schema // oxlint-disable-next-line no-explicit-any -- erased at the consumption casts in spec/codegen methods: SchemaMethodsImpl /** Declarative genesis nodes and edges, preserved through codegen and diagnostic builds. */ core?: Core /** * env → deps mapper, when the author supplied one. Held loosely (the worker * imports the real function from its own `deps` module); the CLI reads only * its PRESENCE to set `DomainInfo.hasDeps`, the codegen signal. */ // oxlint-disable-next-line no-explicit-any -- presence is what the CLI consumes; the typed binding lives at the call site deps?: (env: any, url: string) => any views?: Record functions?: Record /** Presentation metadata for `/meta`, validated at definition time. */ manifest?: DomainManifest origin: string requires: readonly string[] postInstall?: string /** Physical tree path for the Domain node; default `/domains/`. */ path?: string } export function defineDomain( config: DefineDomainConfig, ): DomainDefinition { const rawOrigin = config.origin ?? schemaDomain(config.schema) if (!rawOrigin) { throw new Error( 'defineDomain: could not resolve `origin`. Set it explicitly or give the ' + 'schema a base domain (`defineSchema("crm.acme.dev", …)`).', ) } if (/^[a-z][a-z0-9+.-]*:\/\//i.test(rawOrigin)) { throw new Error( `defineDomain: \`origin\` must be a stable name (e.g. "crm.acme.dev"), not a URL — got "${rawOrigin}". ` + 'The deployment URL is an output of deploy, not the identity.', ) } // Canonicalize + validate the origin exactly as the kernel does (lowercased, // FQDN-like, matches DOMAIN_ORIGIN_RE). Failing HERE gives a clear authoring- // time error instead of a cryptic failure deep in codegen / install, and makes // `origin` the same lowercased form the kernel stores graph nodes under. let origin: string try { origin = DomainOrigin(rawOrigin) } catch { throw new Error( `defineDomain: invalid \`origin\` "${rawOrigin}". Use a lowercase FQDN-like slug ` + '(letters, digits, ".", "_", "-"), e.g. "crm.acme.dev".', ) } // `requires` entries are domain origins — validate them with the same rule as // `origin` so a URL or mixed-case entry fails at authoring time, not install. const requires = (config.requires ?? []).map((dep) => { try { return DomainOrigin(dep) } catch { throw new Error( `defineDomain: invalid \`requires\` entry "${dep}". Use the dependency's origin slug ` + '(lowercase FQDN-like, e.g. "example.astrale.ai"), not a URL.', ) } }) // `path`, when set, is the Domain's physical tree path. Validate the // two invariants the serializer/kernel rely on, string-only (no AbsolutePath // import — this module must stay worker-safe): it is absolute with at least one // segment, and its LAST segment equals the origin (the kernel derives origin // from the mount basename in a few places, e.g. has_parent slug + get-access- // token origin recovery). Full structural parse happens at build time. if (config.path !== undefined) { const loc = config.path if (!loc.startsWith('/') || loc === '/') { throw new Error( `defineDomain: \`path\` must be an absolute graph path with at least ` + `one segment (e.g. "/domains/${origin}"), not "${loc}".`, ) } if (loc.split('/').pop() !== origin) { throw new Error( `defineDomain: \`path\` last segment must equal the origin "${origin}" ` + `(e.g. "/domains/${origin}" or "/${origin}"), got "${loc}".`, ) } } // A `manifest.entrypoint` view is resolved to its slug below (in the return), // where a view absent from `views` throws — see `resolveManifest`. // Declared roles must carry valid, unique slugs — a role slug becomes a graph // path segment and an identity subject, so the kernel's reserved separators // (`/`, `:`, `@`) can never appear in one. Fail at authoring time. if (config.manifest?.roles !== undefined) { const seen = new Set() for (const role of config.manifest.roles) { if (!/^[a-z0-9][a-z0-9_-]*$/.test(role.slug)) { throw new Error( `defineDomain: invalid \`manifest.roles\` slug "${role.slug}". ` + 'Use a lowercase slug (letters, digits, "_", "-"), e.g. "editor".', ) } if (seen.has(role.slug)) { throw new Error(`defineDomain: duplicate \`manifest.roles\` slug "${role.slug}".`) } seen.add(role.slug) } } return { schema: config.schema, methods: config.methods as DomainDefinition['methods'], ...(config.core ? { core: config.core as DomainDefinition['core'] } : {}), ...(config.deps ? { deps: config.deps as DomainDefinition['deps'] } : {}), ...(config.views ? { views: config.views as Record } : {}), ...(config.functions ? { functions: config.functions } : {}), ...(config.manifest ? { manifest: resolveManifest(config.manifest, config.views) } : {}), origin, requires, ...(config.path ? { path: config.path } : {}), ...(config.postInstall !== undefined ? { postInstall: normalizePostInstall(config.postInstall, origin, config.functions) } : {}), } } /** * Lower the author-facing manifest (view OBJECT for `entrypoint`) to the served * {@link DomainManifest} (slug for `entrypoint`). The entrypoint view is matched * to its `views`-map key by IDENTITY — the same mechanism `normalizePostInstall` * uses for functions — so a view absent from `views` (one from another domain, * or dropped) throws here instead of dangling to a NOT_FOUND view path at install. */ function resolveManifest( manifest: DomainManifestConfig, views: Record | undefined, ): DomainManifest { const { entrypoint, ...rest } = manifest if (entrypoint === undefined) return rest const slug = views ? Object.entries(views).find(([, def]) => def === entrypoint)?.[0] : undefined if (slug === undefined) { const viewKeys = Object.keys(views ?? {}) throw new Error( "defineDomain: `manifest.entrypoint` must be one of this domain's own views " + '(pass the view object, e.g. `entrypoint: views.welcome`). ' + `Available views: ${viewKeys.length > 0 ? viewKeys.map((k) => `"${k}"`).join(', ') : '(none)'}.`, ) } return { ...rest, entrypoint: slug } } /** * Resolve a `postInstall` function reference to the colon-path the bundle carries. * The slug is the `functions` map key the reference is registered under (found by * identity), so a renamed key is a compile error at the reference site AND the * derived path follows the rename. The origin is never the author's to supply * (postInstall is always a standalone function of THIS domain) and the path is * mount-agnostic — the kernel resolves it wherever the domain is installed. */ function normalizePostInstall( postInstall: AnyFunctionDef, origin: string, functions: Record | undefined, ): string { const slug = functions ? Object.entries(functions).find(([, def]) => def === postInstall)?.[0] : undefined if (slug === undefined) { throw new Error( "defineDomain: `postInstall` must reference a function from this domain's `functions` map " + '(e.g. `postInstall: functions.seed`).', ) } return `/:${origin}:function.${slug}` } function schemaDomain(schema: Schema): string | undefined { const value = (schema as unknown as { domain?: unknown }).domain return typeof value === 'string' && value.length > 0 ? value : undefined }