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