the Dynamic layer
@leumas/dynamic, mounted at /api/dynamic, surfaced as Studio's Dynamic domain (/admin/d/dynamic). It is the third kind of code a Studio can run. The other two are first-party...
Build-knowledge — the Dynamic layer: a Studio owner's own code
@leumas/dynamic, mounted at /api/dynamic, surfaced as Studio's Dynamic domain (/admin/d/dynamic). It is the third kind of code a Studio can run. The other two are first-party (shared/apps/registry.js, enumerated by hand) and sandboxed marketplace plugins (shared/services/plugin-host, opaque-origin iframes). This one is neither: it is code the person who runs the instance wrote, on their own disk, that they want their own Studio to serve and run.
Four surfaces, one prefix, one gate. Each is a portal: the operator points Studio at something that already exists outside it, and Studio becomes parent to it — serving it, mounting it, running it or enforcing it — without the thing being rewritten as a Leumas feature.
| Tab | Route | What the operator registers | What Studio does with it |
|---|---|---|---|
| SSR | /admin/dynamic · /api/dynamic/ssr | a .jsx / page / file, by local path | compiles it, server-renders it, and serves it to every app on the machine |
| Routers | /admin/dynamic/routers · /api/dynamic/routers | an express router, a whole server, a static folder, a proxy target | answers on the mount path, from startup |
| Scripts | /admin/dynamic/scripts · /api/dynamic/scripts | a script + a typed argument schema | runs it sandboxed, or natively where the operator opted in |
| Schemas | /admin/dynamic/schemas · /api/dynamic/schemas | a data model, as raw JSON Schema | gives it a collection, enforces it on write, generates its CRUD screens, and exposes it as tools |
The gate, which is the whole safety story
Everything here names a file on the host's disk and then executes it. That is only acceptable on an instance the caller owns, so the entire layer mounts inside if (caps.fileTools) in products/leumas-api/src/app.js — appliance and dev, never platform or control. On a hosted platform the routes are absent, not gated, and the Studio tile disappears with them (the domain carries feature: 'dynamicLayer', published from caps.fileTools).
Four rules the engine enforces itself, with no opt-out:
- Path containment. Every
filePath/dirpassesassertInsideRoots()(src/roots.js) against
the host's registered roots — toolRoots (the workspace + every registered Codex project) plus DYNAMIC_ROOTS. It fails closed: an empty allow-list refuses everything rather than defaulting to the disk. The SSR compiler re-checks every file it reads, not just the entry — a contained entry can relatively-import an uncontained file.
- No mount may shadow Leumas.
assertMountPath()refuses/auth,/db,/api,/wsand the
rest at registration, and the dispatcher is registered after every product route, so there are two independent reasons a user mount cannot answer the session endpoints.
- Execution is out of process. An SSR render is a worker thread with a hard
terminate(); an
LMX script is @leumas/lmx's worker; a native script is a child process with shell: false and a tree-kill on timeout. Nothing runs inline on the request thread.
- The registries are admin-only.
dynamic_routers,ssr_componentsandcollection_schemas
are in PLATFORM_COLLECTIONS (now in products/leumas-api/src/kernel/caps.js, not app.js). The first two because a member who could write one would have code execution on the host; collection_schemas for a different reason, and it is worth knowing which: the connector reads a schema row on every write to the collection it names, by any caller, with no owner check, so an unlisted registry let any signed-in member register {collection:'chatbots', validate:true, schema:{required:['x']}} and 422 every write to a shared collection. That was live. It is closed. lmx_scripts is deliberately not listed: authoring a script is member-level, and the privilege lives on the run.
SSR — four doors onto one registration
GET /api/dynamic/ssr/:key manifest — props schema, build state, the doors
POST /api/dynamic/ssr/:key/render {props} { html, css, head } any language, zero dependencies
GET /api/dynamic/ssr/:key/module.js the compiled factory a React app that wants it LIVE
GET /api/dynamic/ssr/:key/embed.js a generated <script> a plain HTML page
GET /api/dynamic/ssr/:key/raw the file itself kind:'html' and kind:'file'
The compiled module is a factory, not an ESM bundle with imports, and this is the load-bearing decision. module.js default-exports factory(externals) -> moduleExports. A browser cannot resolve a bare import 'react' without an import map, and — the part that actually bites — if the consumer's React and the component's React are two instances, hydration throws "invalid hook call" and every hook is broken. Handing the consumer's own React in is the only shape where server render, browser hydration and a plain import() all get exactly one React.
Consuming it:
// a React app — server-rendered, then hydrated with YOUR React
import { LeumasComponent } from '@leumas/dynamic/client';
<LeumasComponent id="hero" props={{ title: 'Hi' }} />
<!-- a plain page — markup unconditionally, hydration only if React happens to be present -->
<script src="http://localhost:3000/api/dynamic/ssr/hero/embed.js"
data-props='{"title":"Hi"}'></script>
# anything else
curl -X POST .../api/dynamic/ssr/hero/render -H 'content-type: application/json' -d '{"props":{}}'
Public vs private. A visibility: 'public' component is served by a pre-gate router mounted before the authed one, with a per-row allowOrigins CORS list — that is how an app running beside Studio with no Leumas session consumes a component. A private row falls through to the session-gated copy of the same door, so a signed-in operator sees no difference.
Two compile facts worth knowing. Sucrase runs with production: true: the dev runtime needs react/jsx-dev-runtime (which the factory does not supply, so a render fails with an unhelpful Cannot read properties of undefined), and it embeds the host's absolute paths into a bundle that is then served to other apps. Bare imports are an allow-list — an unlisted one is a build error, never a silent undefined that becomes a blank render three steps later.
Routers — four kinds, one dispatcher
kind uses the same vocabulary as Imperium hosting's TARGET_KINDS, because the difference is only the key: hosting resolves a target by hostname, this resolves one by mount path on the API.
| kind | what it is | implementation |
|---|---|---|
router | an express router module | imported into the API process |
server | a folder with its own start command | createLocalAppSupervisor spawns it, the hosting proxy forwards |
static | a built folder | express.static |
proxy | something already running | createHostingProxy |
ONE middleware, not one app.use() per entry. Express cannot unmount middleware, so the legacy boot loop worked exactly once: removing an entry left it serving, editing one did nothing, adding one needed a restart. A single dispatcher that resolves per request makes server/static/proxy live. Only router keeps the unavoidable limitation — its module can be re-imported (the loader cache-busts with ?t=) but the code it already ran cannot be taken back, and the UI says so.
Write a router that works from anywhere. A loose .js outside a node_modules tree cannot import 'express' — Node resolves bare specifiers from the file's own directory. Export a factory and the host hands its own in, the same convention defineLeumasApp uses:
export function createRouter({ Router, express }) {
const router = Router();
router.get('/ping', (req, res) => res.json({ ok: true }));
return router;
}
Failure is per-entry. A broken module answers 503 on its path with the real reason; every other mount and the rest of the API are untouched, and the boot never fails.
Scripts — one library, two runtimes
The rows are lmx_scripts — the same collection the LMX Playground reads and writes, extended with language (supersedes mode; adds node/python/shell) and params (an @leumas/schemas/inputs descriptor array, so RunArgsForm generates the run form with no adapter). mode is still written on every save, so the Playground never sees a row it cannot open.
- LMX modes →
@leumas/lmxrunScript(): worker-isolated, hard timeout, no fs, no net, and an
adapter(system, tool, args) bridge into every Leumas capability. Needs only a signed-in user.
node/python/shell→ a child process, requiring three independent things:
caps.fileTools, an admin, and DYNAMIC_SCRIPTS_EXEC=1. Arguments arrive as LEUMAS_PROPS, one JSON object in the environment — the same delivery in every language, and the reason a prop can never be mistaken for an argv flag.
Both return the same { ok, output, error, result, durationMs, timedOut }, because the UI has one result panel and a second shape would become a second panel within a week.
Schemas — a model is a portal too
The rows are collection_schemas — the same registry the connector has enforced all along (shared/engines/middleware/src/connector/router.js), extended with name, source, expose and notes. Nothing about the enforcement path is new: /db/:collection still validates, still returns 422 naming the offending field, still records before-images when history is on, still answers POST /db/:collection/:id/undo.
GET /api/dynamic/schemas the library, each row decorated with its generated views
GET /api/dynamic/schemas/collections what exists on this instance, and which names are spoken for
POST /api/dynamic/schemas define one (normalise → reserve-check → collision-check)
GET|PATCH|DELETE /:id
POST /:id/validate {doc} dry-run a record, without writing it — the SAME validator /db runs
POST /:id/diff {schema} what a proposed change would do
GET /:id/mock ?count= sample rows, for seeding a preview
POST /infer {data} sample data → a schema
THE BUILDER IS A FORM, NOT A TEXT BOX. Fields · JSON · Preview are three views of ONE document, and Fields is the default. It is a descriptor-of-descriptors handed to SchemaForm's own objectList control — the pattern hosting/modeEditors/FieldSchemaBuilder established — so a new control type appears in the builder the moment the kit learns it. The empty state is worked examples (schemas/starters.js), filtered to collections this instance would accept, and smoke:dynamic walks every one through the real create route and the real validator: a starter that does not actually validate is worse than none.
Three things had to be finished underneath before any of that was true, and each was silently wrong:
inputsToJsonSchemaread the presentationx-keys long before it emitted them. Lossy in
exactly the direction a visual builder uses — set a label, an order and a control, save, watch all three vanish. Both it and normalizeInputs (the pivot EVERY export goes through) now carry them.
toFieldsderived the control fromtypeand ignored a declaredcontrol. Sox-control
round-tripped intact and rendered as a one-line text box anyway: a markdown body and a name are both type: 'string'. A declared control now wins, gated on the known set.
- The connector's schema cache was only invalidated from the CRUD router. Studio writes a model
through ownerStore, so a model defined there was not enforced until the TTL lapsed — a record breaking an enum the operator had just declared was accepted with a 201 and refused seconds later. dbRouter.forgetSchemas() is now called on every model write; the TTL stays as the backstop.
RAW JSON SCHEMA IS THE STORED TRUTH. Presentation rides on the same document in x- extensions (x-label, x-group, x-control, x-order, x-span, x-entity) — the convention inputsToJsonSchema already emitted. There is no sidecar to keep in step. jsonSchemaToDescriptors() in shared/packages/schemas/src/inputs.js is the missing inverse of that pair, and it is what lets SchemaForm render a live form beside the JSON as the operator types.
Three things here that are decisions, not details:
/infer,/diffand/mockcontain no logic. They pass to
shared/engines/adapters/domain/schema, a complete JSON-Schema toolkit that already ships and is already an MCP tool. A second inference function would be a second answer to "what shape is this data", and the two would disagree the first time either improved. (Your "schema out of a scraper" is a-csv.parse → schema.infer. Both already existed.)
- An inferred schema is untrusted. Every field comes back
from: 'inferred', soisTrusted()is
false and validateInputs will not enforce it until a human confirms in the UI. One pasted sample must not start rejecting good data. /infer also unwraps {type:'array', items:{type:'object'}} to the element shape: an operator pasting three records means "this is what a record looks like", and an array schema is something /db could never enforce anyway.
- DELETE removes the contract, not the data. The collection and its rows survive; the response
says collectionKept. Dropping a table because someone removed a validator is the most destructive possible reading of "delete", and dropCollection is deliberately not dispatchable.
A collection name is normalised to [a-z][a-z0-9_]* and collision-checked before it is accepted, because safe() in libsql-adapter.js is not injective — a-b and a_b both become dyn_a_b, so two models an operator believes distinct would silently share one table. The adapter cannot tighten this without orphaning existing data, so the refusal lives here.
The ecosystem doors. On every schema write, createDynamicLayer re-registers the models as one adapter system via registry.register() — which yields MCP tools, chatbot functioncalls, POST /api/adapters/models/orders.create and function-index entries in a single call. Five ops per model (list/get/create/update/remove), each hitting the same connector /db uses. Declare metadata.functions per function: toolRegistry() and mcpServers() copy system-level inputs onto every function, so per-function typing is the only way the live registry agrees with the index. No new invoke kind is needed — crudConfigAction is already generic CRUD over any dynamic collection, and db-record-event already fires on all of them, so triggers and rules are free.
Proving it
pnpm check:dynamic # structural: the gate, the mount order, containment, the registries
pnpm check:dynamic:selftest # proves each of those checks still FIRES on a real violation
pnpm smoke:dynamic # live: register, mount, render, serve, enforce, refuse, absent on platform
The highest-leverage thing still undone
There is no dynamic invoke kind. Adding one (INVOKE_KINDS in shared/packages/invoke/src/index.js, a runner in actionBinders() at app.js, optionally a KIND_SPECS row) would make a saved script or a registered component callable by id as a rule action, a workflow step, a chatbot tool and an MCP tool. Three edits; everything else already exists.