# 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 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`.


---
Source: shared/services/knowledge/build-knowledge/app-store.md
Canonical: https://docs.leumas.tech/p/how-to/app-store
