the Leumas app store
The store is where "everything else is a plugin" becomes real. A Studio is both a customer (it installs apps) and a publisher (it builds and submits them), and both halves are UI now.
Build-knowledge — the Leumas app store: browsing it, and publishing to it
The store is where "everything else is a plugin" becomes real. A Studio is both a customer (it installs apps) and a publisher (it builds and submits them), and both halves are UI now.
The three layers, and which one to touch
| layer | where | what it knows |
|---|---|---|
| presentation | @leumas/ui/store — AppListingCard, AppListingDetail, ScreenshotStrip, listingMedia, priceLabel | nothing. Props in, callbacks out: no api, no <Link>, no router. That is what lets Studio, leumas-web and the control-plane review desk all mount the same card |
| surface | @leumas/features/store — AppStoreBrowser, useStoreCatalog | useApi, debouncing, facets, install semantics |
| data | shared/services/marketplace — store.js, router.js, sdk.js, hydrate.js, revenue.js | the collections, the review state machine, the money |
A new store screen composes AppStoreBrowser; it does not write cards. Studio's /admin/marketplace and its Apps landing are both shells around it — they previously carried near-identical card markup with two different search implementations behind it, so the catalog and the landing disagreed about which apps existed.
Import the browser by SUBPATH (@leumas/features/store), never the main barrel: it side-effect-imports @leumas/ui/store.css, and a barrel re-export would land that sheet in leumas-web's and leumas-ios's entry CSS. Same rule as HudShell.
Screenshots
catalog.screenshots[] — { url, alt, w, h, capturedAt, source }. A real screenshot beats the authored catalog.imageUrl hero on a card; a listing with neither renders its glyph, which is a designed fallback, not a broken image.
publicCatalog's CATALOG_FIELDS is a security boundary, not a convenience list. upsert spreads req.body wholesale and POST /listings accepts it from any holder of distribute_mcp, so that whitelist is the only thing between attacker-chosen keys and the public, unauthenticated GET /marketplace/listings. A new field gets enumerated, with a sanitiser — screenshots needed its own (safeShot) because LIST_FIELDS String()s each entry to "[object Object]" and URL_FIELDS only clamps a scalar.
GET /listings projects with { media: 'first' }; GET /listings/:appId sends the gallery. Not polish: the list route pages up to 200 rows across ~750 listings on every debounced keystroke.
Two capture lanes, and the split is load-bearing
| first-party | third-party | |
|---|---|---|
| how | node ops/tools/app-art/index.mjs shoot (signs itself in; no cookie to paste) | POST /marketplace/mine/:appId/screenshots (a URL to capture, or contentBase64) |
| lands in | shared/services/datacenter/src/library-assets/shots/ — committed | <dataRoot>/library/shots/ — written at runtime |
| declared in | shared/apps/domains.js (paste the printed block) | the listing row |
Why first-party cannot be a runtime job: bulkUpsert recomputes a content hash from each app's declaration on every boot and rewrites the row when it differs. A screenshot written onto a first-party listing at runtime survives until the next API restart, then vanishes with nothing in the logs. The declaration has to own it.
pnpm check:apps asserts every /library/ screenshot is really on disk.
Filenames are content-addressed (<appId>-<sha8>.webp). /library/* serves immutable, max-age=31536000 — a stable name would make a recapture invisible for a year.
Uploads are re-encoded, never passed through (products/leumas-api/src/marketplaceMedia.js): the type is sniffed from the decoded bytes rather than any filename, everything goes through sharp, and the output is always webp. That is what makes an SVG structurally impossible to store — an SVG served same-origin with no CSP is stored XSS, and /library/* sets no CSP.
Publishing: the vibe-coder loop
/admin/store/new NewBuildWizard → pick an ITEM TYPE → a draft listing + a scaffolded sandbox
/admin/app-builder/:id AppBuilder → chat · preview (· code, behind a toggle)
/admin/store/mine MyListings → submit, track, earnings
/admin/store/purchased Purchased → what you took, and where each one landed
(leumas-admin /market) → staff approve → published
Thirteen kinds of thing, not one. The first wizard step picks a @leumas/item-types record and that answer is STORED on the listing, where it decides the scaffold, the agent's extra toolset, the preview renderer, the packer's extension set, and — the thing that did not exist before — where a buyer's copy lands. A media pack downloads to their Files; a server registers in their Dynamic layer, switched off; a grid opens in Grids. See build-an-item-type.md.
/admin/plugin-studio redirects to /admin/app-builder rather than 404ing: the name had to change once it stopped building only plugins, but every bookmark and every line of store copy pointed at it.
The code editor is not in the default view. Chat + preview is the default; [Code] reveals the editor. That is the difference between an AI IDE and an IDE with a chat pane bolted on.
distribute_mcp gates every publisher route. It is a permission, not an entitlement — check it with useAuth().permissions, because useCapability() reports false for it and would hide the "you need publisher access" notice from exactly the people who need it. (useHasCapability() is also the wrong shape: it takes no argument and returns {has, ready}.)
The App Builder reuses everything
| piece | from |
|---|---|
| the agent | @leumas/coding-agent over /api/coding (SSE), via AgentChatPane — the same component the Builder uses |
| the sandbox | /api/projects, confined by resolveWithinRoot. Scoping a run to one build is a different projectId and nothing else |
| the join | project.appId → the listing. Written at create time, enumerated in the workspace router rather than riding PATCH's pass-through |
| the agent's extra tools | the project's kind, which IS the item type id, looked up in an injected toolsetFactories map |
| the editor | CodeEditor (CodeMirror 6) |
| the preview | compileDynamicComponent + createDynamicHost for code; the item type's own structural renderer for a document |
| the assistant | useRegisterCapability, scope app-builder:<appId> |
The preview's trust boundary. compileDynamicComponent runs new Function same-origin with the operator's session. That is fine for code the operator's own agent just wrote in their own sandbox (the DynamicComponentEditor already operates there). It is not fine for a listing fetched from the store: third-party code previews in the sandboxed iframe (allow-scripts, no allow-same-origin), never in the host.
The assistant cannot submit. check_plugin_ready reports what is missing and stops. Submitting is irreversible and outward-facing, so it stays a button. It now reports TWO halves: the listing fields, and the item type's own validate(files) — a media pack with a perfect listing and an empty pack.json used to report "ready".
Do NOT build this on @cursor/sdk
shared/engines/adapters/providers/src/cursor-agent/index.js declares capabilities.tools: false and exports no adapter, so coding-agent/src/runtime.js short-circuits it to a single completion with no tools — it cannot write a file into the sandbox. It also passes local: { cwd: process.cwd() }, the whole monorepo, while every other lane is confined. AgentChatPane filters tool-less providers out of the picker for this reason.
Guards
pnpm smoke:store (58 checks, incl. the public projection) · pnpm smoke:app-catalog · pnpm smoke:apps · pnpm smoke:install-gate · pnpm check:apps · pnpm check:nav · pnpm check:item-types · node ops/infra/scripts/check-ui-kit.mjs.