@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...
@leumas/app-kit
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()andmountApps(), 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.
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)
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:
- it runs at boot, before
backfillInstalls— so on the upgrade that introduced install gating,
every router vanishes for an operator who uninstalled nothing;
- its signature has no tenant, while installs are per-tenant;
- 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 |
What it exports
3 exports: 0 components, 0 hooks, 3 helpers. Import from @leumas/app-kit.
appFacadedefineLeumasAppmountApps