# Build-knowledge — item types: what the App Store can sell, and where a purchase lands

The store used to sell one kind of thing. Not by design — by five files each hardcoding the answer, and a sixth question nobody had asked.


The store used to sell one kind of thing. Not by design — by five files each hardcoding the answer,
and a sixth question nobody had asked.

## The one idea

> **An item type is one record that answers "what is this?" and "where does a buyer's copy go?".**

Thirteen of them live in `shared/packages/item-types/src/types/<id>/meta.js`. Everything else —
the wizard's picker, the scaffold, the agent's toolset, the preview, the packer, the install lane,
the Purchased tab's link — reads that record. Adding a fourteenth kind of sellable thing is a
folder and one line in `registry.js`.

## The package is split in half, and the split is load-bearing

| entry | holds | who imports it |
|---|---|---|
| `@leumas/item-types` | `define.js`, `families.js`, `registry.js`, the 15 metas — **no React** | leumas-api, @leumas/workspace, @leumas/coding-agent, @leumas/marketplace, @leumas/plugin-host |
| `@leumas/item-types/catalog` | the 8 structural preview renderers, behind `lazy()` | Studio only |

The API is the heaviest consumer (project kinds, scaffolds, packers, install lanes) and has no DOM.
One `import { lazy } from 'react'` reachable from the main entry and leumas-api fails to boot, so
`check:item-types` fails the build on a server file importing `/catalog`, and on any React import in
the eager half. The JSX half of that rule is proven by the guard *importing* the registry through
bare Node — a JSX tag anywhere would be a SyntaxError before the guard reached its own assertions.

It also keeps the picker cheap: the wizard draws fifteen tiles from fifteen ~40-line objects and
fetches no preview chunk until somebody actually builds something. Same trade `@leumas/inputs` makes.

## The five families — thirteen types, five arrival lanes

Types that arrive the same way share their install code. That is what makes thirteen tractable.

| family | types | where a purchase lands | executes? |
|---|---|---|---|
| **sandbox** | `app`, `os-app` | `/admin/p/:appId` iframe · OS desktop | yes |
| **dynamic** | `server`, `router`, `script`, `schema`, `component`, `input` | Dynamic layer · LMX · form CONTROLS | yes — **arrives OFF** |
| **document** | `workflow`, `grid` | Workflows · Grids | no (interpreted) |
| **machine** | `mcp-server` | the MCP gateway | no |
| **asset** | `media`, `digital-product` | Files › Library, downloaded | no |

`circuit` and `arduino` are also registered, `sellable: false`. They predate the store and are not
sold; they exist here because `PROJECT_KINDS` and the agent's toolset map are now DERIVED from this
registry, and dropping them would strip Circuit Studio and the Arduino IDE of their quota lanes.

## Three rules that are security, not tidiness

1. **The lane comes from the STORED listing's `itemType`, never from a submission.** `upsert` spreads
   `req.body` wholesale and `POST /listings` accepts it from any holder of `distribute_mcp`, so
   `clampItemType` runs on the way IN and again in `publicListing` on the way OUT — twice, because a
   defence that only holds for rows written through one code path is not a defence. An unrecognised
   value becomes `app`. The `delivered` block is DERIVED, never stored: a listing that could describe
   its own arrival would be describing where it gets to install itself.
2. **`enabledOnArrival` is a property of the FAMILY, not the type.** A per-type flag is a per-type
   chance to hand a stranger a running process. Anything in `dynamic` arrives switched off; the buyer
   reads what it declares and enables it.
3. **`install.collection` is an allow-list per family.** `defineItemType` refuses a destination the
   family does not name, because a successful write into the wrong table is indistinguishable from a
   correct one at every layer above it.

Nothing in the delivery path ever executes what it delivers. Reading a packed document is
`JSON.parse`; running it is the destination engine's job, later, under the buyer's own gates.

## What one record controls

```js
defineItemType({
  id, label, blurb, icon, family, category, tags,
  projectKind,          // defaults to `id` — so project.kind, the agent's mode and the item type
                        // are ONE value with nothing to keep in step
  scaffold,             // a folder under factory/boilerplates
  entry,                // what the preview, the packer and `validate` all look for
  quotaCap,             // a dotted entitlement key, or absent = UNMETERED (never invent one)
  preview,              // a host renderer ('component'|'html') or a package one
  wizard,               // extra FormWizard steps, as DATA
  agent: { instructions, toolset },
  validate,             // (files) => { ok, problems[] }
  pack: { strategy, exts },
  install: { collection, path, label },
  pricing: { defaultModel, meterTarget },
});
```

**Previews are keyed by RENDERER, not by type.** Four types (`app`, `os-app`, `component`, `input`)
preview by compiling JSX, and compiling is host-owned: `compileDynamicComponent` runs `new Function`
same-origin with the operator's session, so the decision about whose code goes through it lives in
ONE place next to the sandboxed-iframe fallback it is weighed against. The eight package-owned
renderers are pure functions of a document — they parse and draw, never evaluate — which is what
makes them safe to point at something you have not decided to trust.

## The five seams this replaced

| was | now |
|---|---|
| `CreatePluginWizard`'s 4 cosmetic kinds, *"not stored"* | the wizard picks a real type and **stores it** |
| `PROJECT_KINDS = ['app','circuit','arduino']` | `projectKinds()` |
| `QUOTA_CAP = {…}` | `quotaCaps()` — provably the same three entries |
| `if (mode === 'circuit') / if (mode === 'arduino')` | one lookup over an injected `toolsetFactories` map |
| `packBundle` always making an iframe bundle | one packer parameterised by `exts` + `shell` |
| — | `createDeliverer` — the sixth seam, which did not exist |

`packBundle` was **parameterised, not forked**. Everything that makes it safe is the same for every
kind of thing: the explicit-stack walk that refuses to follow a symlink out of the sandbox, the size
limits checked while reading, and the rule that `leumas.app.json` is generated from the stored listing
rather than copied from a file the publisher controls. Three packers would have been three copies of
all of that, and a security rule in three copies is one that will exist in two.

## Adding a type

1. `src/types/<id>/meta.js` — `defineItemType({...})`.
2. One import + one `ENTRIES` line in `registry.js`.
3. A `factory/boilerplates/<scaffold>/` folder containing the `entry` file.
4. A renderer in `catalog.js`'s `RENDERERS` if it needs a new one.
5. `pnpm check:item-types`.

That guard asserts folder<->registry parity, a React-free eager half, no server file importing
`/catalog`, renderer parity in both directions, a default export on every renderer, no literal colour
in one, that every `install.path` is a route the nav manifest or route table actually declares, and
that **every scaffold folder and entry file exists** — a missing boilerplate is otherwise silent,
because `scaffoldInto` treats an unknown template as "write the blank starter" rather than an error.

## Guards

`pnpm check:item-types` · `smoke:store` (58) · `smoke:apps` (608) · `smoke:app-catalog` (41) ·
`smoke:plugin-proxy` (41) · `check:sandbox` · `check:nav` · `check:huds` · `check:admin`.


---
Source: shared/services/knowledge/build-knowledge/build-an-item-type.md
Canonical: https://docs.leumas.tech/p/how-to/build-an-item-type
