{
  "schema": "leumas.docs.page/1",
  "id": "pkg:@leumas/app-kit",
  "slug": "packages/app-kit",
  "kind": "capabilities",
  "bucket": "package",
  "title": "@leumas/app-kit",
  "name": "@leumas/app-kit",
  "eyebrow": null,
  "chip": null,
  "summary": "The ONE contract every Leumas app complies with. Declare an app's actions once and get five surfaces free: adapter functions, Leviathan/chatbot functioncalls, an MCP server, a REST router, and a...",
  "keywords": [
    "app-kit",
    "permission vocabulary",
    "declare",
    "functioncalls",
    "actions",
    "app kit api",
    "leumas app kit",
    "rest"
  ],
  "audience": "both",
  "funnel": {
    "product": null,
    "cta": null
  },
  "body": "# @leumas/app-kit\n\nOne declaration → a store listing, a public catalog row, an MCP server, Leviathan functioncalls, a\nREST router and a generated SDK.\n\nTwo files carry the whole package:\n\n- **`src/define.js`** — `defineLeumasApp(spec)`, what an app IS.\n- **`src/host.js`** — `appFacade()` and `mountApps()`, what an app may DO. **This is the security core\n  of the app system**; read its header before changing anything in it.\n\nAuthoring guide (including the sandboxed tier, which this package does not cover):\n`shared/services/knowledge/build-knowledge/build-a-plugin.md`.\n\n---\n\n## `defineLeumasApp(spec)`\n\n| Field | Type | Notes |\n|---|---|---|\n| `id` | string | **required**, slugged. Becomes the namespace for everything below. |\n| `name`, `description`, `icon`, `version`, `category` | string | store-facing identity |\n| `permissions` | `string[]` | **validated at definition time** — an unknown value throws on import |\n| `collections` | `Array<string \\| {name, scope, admin}>` | → table `app_<id>_<name>`; `scope` defaults `'tenant'` |\n| `actions` | `{ [name]: { description, inputs, price, http, run } }` | needs `actions` **or** `routes` |\n| `routes` | `[{ path, surface, index }]` | Studio surfaces |\n| `nav`, `surfaces` | — | the mount manifest |\n| `basePath` / `apiBase` | string | default `/apps/<id>` and `/api/apps/<id>` |\n| `mountType` | `'module' \\| 'iframe'` | third-party is always `iframe`, whatever this says |\n| `pricing` | `{ model, price, unit, planKey, meterTargets }` | **declarative** — see below |\n| `catalog` | object | storefront copy; `summary`, `tags`, `screenshots[]`, … |\n| `domains` | `string[]` | Studio domains this unlocks — **honoured for first-party only** |\n| `publisher`, `homepage`, `repo`, `featureKey`, `entitlements` | — | |\n\nReturns `{ metadata, adapters, manifest, toMcpServer(), functioncalls(), createRouter(), createSdk(),\nlisting(), catalogEntry(), setContext(), getContext() }`.\n\n### `pricing` is a request, not an enforcement\n\nWhat is enforced is a `passnode_rules` row. For a **third-party** app it is written by the review desk\nat publish, with `owner` taken from the *stored* listing — so a submission can neither price itself\nnor nominate someone else as payee. For a **first-party** app it is written at boot by\n`hydrateAppCatalogs({ writeRule })`, because a first-party app never goes through review.\n\nBefore that boot hook existed, `pricing` on a first-party app was a number on a store card and the\naction was free.\n\n### `collections[].scope` is load-bearing\n\n`'tenant'` (the default) makes `ctx.db` filter by tenant: list injects it, get/update/remove verify\nownership first, and a foreign row reads as **`null`, not 403** — a 403 would let an app enumerate\nwhich ids exist in another customer's data.\n\n`'global'` opts out. It has to be declared, so sharing a table across every customer on an instance is\na decision rather than an oversight.\n\n---\n\n## The permission vocabulary\n\nValidated against `PERMISSIONS` in `define.js`; an unknown one **throws at definition time**, because\na silently-ungranted permission fails later as a confusing `undefined`.\n\n| Permission | Grants | Present as |\n|---|---|---|\n| `db:own` | the app's own namespaced collections | `ctx.db` |\n| `db:read:<collection>` | one named collection, read-only | `ctx.readOnly` |\n| `storage` | a prefixed file area (`apps/<id>`) | `ctx.storage` |\n| `jobs`, `triggers` | background work, trigger firing | `ctx.jobs`, `ctx.triggers` |\n| `llm` | model calls, metered to `plugin:<id>.llm` | `ctx.llm` |\n| `adapters:<system>.<fn>` | exactly that function | `ctx.adapters.run` |\n| `mcp:<server>` | an MCP server | — |\n| `network:<host>` | outbound fetch to that host | `ctx.fetch` |\n| `fs`, `devices`, `compute` | appliance-only, **also** gated by the deployment's `ROLE_CAPS` | `ctx.fs` etc. |\n\n---\n\n## `appFacade(app, deps, extra)` — absent, not refused\n\nThe one sentence that matters: **an app never receives the raw dependency context. It receives a\nfacade built from the permissions it declared, and anything it did not declare is ABSENT from that\nobject rather than merely refused.**\n\n```js\nconst ctx = appFacade(app, deps, { user, tenantId });\nctx.db     // present iff `db:own` AND deps.adapter\nctx.fs     // undefined unless BOTH the app declared `fs` and the deployment offers it\n```\n\nAbsent rather than throwing is deliberate three ways: a `ctx.db` that exists and throws teaches an app\nto try; an undefined one cannot be reached for at all; and a permission audit becomes a property check\nrather than a code review.\n\n**Declaring is necessary, never sufficient.** A capability appears only when the app asked for it AND\nthe host passed the dependency — so an approved listing cannot import filesystem access into a\ndeployment that has none.\n\n### The install record, not the listing\n\nFor a sandboxed plugin the facade is built with `permissions: install.grantedPermissions`. That single\nchoice is what makes the update prompt a boundary: a v1.1 that adds `fs` has no `ctx.fs` on an\ninstance that agreed to v1.0, whatever the listing now says.\n\n---\n\n## `mountApps(apps, opts)`\n\n```js\nconst host = mountApps(FIRST_PARTY_APPS, {\n  Router: express.Router,\n  deps: { adapter, adapterRegistry, triggers },\n  gate: requireAuth,\n  meter: (target) => passnode.guard(() => target),\n  requestScope: (req) => ({ user: req.user, tenantId: req.user?.tenantId }),\n});\n```\n\n`requestScope` builds a **per-request** facade, and without it the tenant scoping above is dead code:\nthe module-level context is bound once at boot and knows no caller, so `tenantId` is null and nothing\nis filtered.\n\n### `isInstalled` is the wrong tool for install state\n\n`mountApps` accepts it, and it is only correct for build-or-deployment facts (\"is this app in this\nedition?\"). For INSTALL state it fails three ways, and the third is fatal:\n\n1. it runs at boot, before `backfillInstalls` — so on the upgrade that introduced install gating,\n   every router vanishes for an operator who uninstalled nothing;\n2. its signature has no tenant, while installs are per-tenant;\n3. Express has no unmount, so the decision is stale the moment anyone installs anything.\n\nInstall gating happens **per request**, in front of the mounted router — `@leumas/plugin-host`'s\n`createInstallGate`.\n\n---\n\n## Related\n\n| | |\n|---|---|\n| The store, review, install | `shared/services/marketplace/` |\n| Sandboxed tier: bundles, proxy, gate | `shared/services/plugin-host/` |\n| The iframe protocol | `shared/packages/plugin-bridge/` |\n| Guards | `pnpm smoke:apps` · `check:apps` · `smoke:plugin-proxy` · `check:sandbox` |\n",
  "source": {
    "path": "shared/packages/app-kit/README.md",
    "blobSha": "",
    "commit": "",
    "committedAt": "",
    "provenance": "no-git",
    "bytes": 6789,
    "hash": "6fc8114eb8b7813c02185fbc5a75b1d230e4df68"
  },
  "urls": {
    "html": "/p/packages/app-kit",
    "json": "/docs/packages/app-kit.json",
    "md": "/docs/packages/app-kit.md"
  },
  "links": {
    "composes": [
      "pkg:@leumas/config-registry",
      "pkg:@leumas/mcp-kit"
    ],
    "usedBy": [
      "pkg:@leumas/apps"
    ],
    "product": [
      "pkg:@leumas/admin",
      "pkg:@leumas/studio"
    ],
    "howTo": [
      "how-to:build-a-plugin"
    ],
    "skills": [
      "skill:leumas-domains"
    ]
  },
  "exports": {
    "total": 3,
    "component": 0,
    "hook": 0,
    "helper": 3,
    "names": [
      {
        "n": "appFacade",
        "k": "helper"
      },
      {
        "n": "defineLeumasApp",
        "k": "helper"
      },
      {
        "n": "mountApps",
        "k": "helper"
      }
    ]
  }
}
