item types
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.
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 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
- The lane comes from the STORED listing's
itemType, never from a submission.upsertspreads
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.
enabledOnArrivalis 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.
install.collectionis an allow-list per family.defineItemTyperefuses 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
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
src/types/<id>/meta.js—defineItemType({...}).- One import + one
ENTRIESline inregistry.js. - A
factory/boilerplates/<scaffold>/folder containing theentryfile. - A renderer in
catalog.js'sRENDERERSif it needs a new one. 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.