# 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...


`@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:

1. **Path containment.** Every `filePath`/`dir` passes `assertInsideRoots()` (`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.
2. **No mount may shadow Leumas.** `assertMountPath()` refuses `/auth`, `/db`, `/api`, `/ws` and 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.
3. **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.
4. **The registries are admin-only.** `dynamic_routers`, `ssr_components` and `collection_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:

```jsx
// a React app — server-rendered, then hydrated with YOUR React
import { LeumasComponent } from '@leumas/dynamic/client';
<LeumasComponent id="hero" props={{ title: 'Hi' }} />
```

```html
<!-- 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>
```

```sh
# 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:

```js
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/lmx` `runScript()`: 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:

1. **`inputsToJsonSchema` read the presentation `x-` 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.
2. **`toFields` derived the control from `type` and ignored a declared `control`.** So `x-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.
3. **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`, `/diff` and `/mock` contain 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'`, so `isTrusted()` 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

```sh
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.


---
Source: shared/services/knowledge/build-knowledge/dynamic-layer.md
Canonical: https://docs.leumas.tech/p/how-to/dynamic-layer
