Docs
/
build knowledge

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

layerwherewhat it knows
presentation@leumas/ui/storeAppListingCard, AppListingDetail, ScreenshotStrip, listingMedia, priceLabelnothing. 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/storeAppStoreBrowser, useStoreCataloguseApi, debouncing, facets, install semantics
datashared/services/marketplacestore.js, router.js, sdk.js, hydrate.js, revenue.jsthe 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-partythird-party
hownode 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 inshared/services/datacenter/src/library-assets/shots/committed<dataRoot>/library/shots/ — written at runtime
declared inshared/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

piecefrom
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 joinproject.appId → the listing. Written at create time, enumerated in the workspace router rather than riding PATCH's pass-through
the agent's extra toolsthe project's kind, which IS the item type id, looked up in an injected toolsetFactories map
the editorCodeEditor (CodeMirror 6)
the previewcompileDynamicComponent + createDynamicHost for code; the item type's own structural renderer for a document
the assistantuseRegisterCapability, 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.

Source shared/services/knowledge/build-knowledge/app-store.md (no-git)markdownjson
Generated from the Leumas repository. Every page cites the file it came from.leumas.techllms.txt