# @leumas/app-kit

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


One declaration → a store listing, a public catalog row, an MCP server, Leviathan functioncalls, a
REST router and a generated SDK.

Two files carry the whole package:

- **`src/define.js`** — `defineLeumasApp(spec)`, what an app IS.
- **`src/host.js`** — `appFacade()` and `mountApps()`, what an app may DO. **This is the security core
  of the app system**; read its header before changing anything in it.

Authoring guide (including the sandboxed tier, which this package does not cover):
`shared/services/knowledge/build-knowledge/build-a-plugin.md`.

---

## `defineLeumasApp(spec)`

| Field | Type | Notes |
|---|---|---|
| `id` | string | **required**, slugged. Becomes the namespace for everything below. |
| `name`, `description`, `icon`, `version`, `category` | string | store-facing identity |
| `permissions` | `string[]` | **validated at definition time** — an unknown value throws on import |
| `collections` | `Array<string \| {name, scope, admin}>` | → table `app_<id>_<name>`; `scope` defaults `'tenant'` |
| `actions` | `{ [name]: { description, inputs, price, http, run } }` | needs `actions` **or** `routes` |
| `routes` | `[{ path, surface, index }]` | Studio surfaces |
| `nav`, `surfaces` | — | the mount manifest |
| `basePath` / `apiBase` | string | default `/apps/<id>` and `/api/apps/<id>` |
| `mountType` | `'module' \| 'iframe'` | third-party is always `iframe`, whatever this says |
| `pricing` | `{ model, price, unit, planKey, meterTargets }` | **declarative** — see below |
| `catalog` | object | storefront copy; `summary`, `tags`, `screenshots[]`, … |
| `domains` | `string[]` | Studio domains this unlocks — **honoured for first-party only** |
| `publisher`, `homepage`, `repo`, `featureKey`, `entitlements` | — | |

Returns `{ metadata, adapters, manifest, toMcpServer(), functioncalls(), createRouter(), createSdk(),
listing(), catalogEntry(), setContext(), getContext() }`.

### `pricing` is a request, not an enforcement

What is enforced is a `passnode_rules` row. For a **third-party** app it is written by the review desk
at publish, with `owner` taken from the *stored* listing — so a submission can neither price itself
nor nominate someone else as payee. For a **first-party** app it is written at boot by
`hydrateAppCatalogs({ writeRule })`, because a first-party app never goes through review.

Before that boot hook existed, `pricing` on a first-party app was a number on a store card and the
action was free.

### `collections[].scope` is load-bearing

`'tenant'` (the default) makes `ctx.db` filter by tenant: list injects it, get/update/remove verify
ownership first, and a foreign row reads as **`null`, not 403** — a 403 would let an app enumerate
which ids exist in another customer's data.

`'global'` opts out. It has to be declared, so sharing a table across every customer on an instance is
a decision rather than an oversight.

---

## The permission vocabulary

Validated against `PERMISSIONS` in `define.js`; an unknown one **throws at definition time**, because
a silently-ungranted permission fails later as a confusing `undefined`.

| Permission | Grants | Present as |
|---|---|---|
| `db:own` | the app's own namespaced collections | `ctx.db` |
| `db:read:<collection>` | one named collection, read-only | `ctx.readOnly` |
| `storage` | a prefixed file area (`apps/<id>`) | `ctx.storage` |
| `jobs`, `triggers` | background work, trigger firing | `ctx.jobs`, `ctx.triggers` |
| `llm` | model calls, metered to `plugin:<id>.llm` | `ctx.llm` |
| `adapters:<system>.<fn>` | exactly that function | `ctx.adapters.run` |
| `mcp:<server>` | an MCP server | — |
| `network:<host>` | outbound fetch to that host | `ctx.fetch` |
| `fs`, `devices`, `compute` | appliance-only, **also** gated by the deployment's `ROLE_CAPS` | `ctx.fs` etc. |

---

## `appFacade(app, deps, extra)` — absent, not refused

The one sentence that matters: **an app never receives the raw dependency context. It receives a
facade built from the permissions it declared, and anything it did not declare is ABSENT from that
object rather than merely refused.**

```js
const ctx = appFacade(app, deps, { user, tenantId });
ctx.db     // present iff `db:own` AND deps.adapter
ctx.fs     // undefined unless BOTH the app declared `fs` and the deployment offers it
```

Absent rather than throwing is deliberate three ways: a `ctx.db` that exists and throws teaches an app
to try; an undefined one cannot be reached for at all; and a permission audit becomes a property check
rather than a code review.

**Declaring is necessary, never sufficient.** A capability appears only when the app asked for it AND
the host passed the dependency — so an approved listing cannot import filesystem access into a
deployment that has none.

### The install record, not the listing

For a sandboxed plugin the facade is built with `permissions: install.grantedPermissions`. That single
choice is what makes the update prompt a boundary: a v1.1 that adds `fs` has no `ctx.fs` on an
instance that agreed to v1.0, whatever the listing now says.

---

## `mountApps(apps, opts)`

```js
const host = mountApps(FIRST_PARTY_APPS, {
  Router: express.Router,
  deps: { adapter, adapterRegistry, triggers },
  gate: requireAuth,
  meter: (target) => passnode.guard(() => target),
  requestScope: (req) => ({ user: req.user, tenantId: req.user?.tenantId }),
});
```

`requestScope` builds a **per-request** facade, and without it the tenant scoping above is dead code:
the module-level context is bound once at boot and knows no caller, so `tenantId` is null and nothing
is filtered.

### `isInstalled` is the wrong tool for install state

`mountApps` accepts it, and it is only correct for build-or-deployment facts ("is this app in this
edition?"). For INSTALL state it fails three ways, and the third is fatal:

1. it runs at boot, before `backfillInstalls` — so on the upgrade that introduced install gating,
   every router vanishes for an operator who uninstalled nothing;
2. its signature has no tenant, while installs are per-tenant;
3. Express has no unmount, so the decision is stale the moment anyone installs anything.

Install gating happens **per request**, in front of the mounted router — `@leumas/plugin-host`'s
`createInstallGate`.

---

## Related

| | |
|---|---|
| The store, review, install | `shared/services/marketplace/` |
| Sandboxed tier: bundles, proxy, gate | `shared/services/plugin-host/` |
| The iframe protocol | `shared/packages/plugin-bridge/` |
| Guards | `pnpm smoke:apps` · `check:apps` · `smoke:plugin-proxy` · `check:sandbox` |


---
Source: shared/packages/app-kit/README.md
Canonical: https://docs.leumas.tech/p/packages/app-kit
