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