# @leumas/variables

Leumas Variables — one value, read everywhere. A tenant/user-scoped key-value store whose secrets are sealed at rest with @leumas/connectors' aes-256-gcm, expanded server-side as {{var.KEY}} in...


**One value, set in one place, read everywhere.** Inside Leumas it is `{{var.KEY}}`; outside it is
`/api/variables` with an `lk_` API key, opt-in per row.

```js
import { createVariableResolver, createVariablesRouter } from '@leumas/variables';

const variables = createVariableResolver({ connector });          // 5s cached snapshot
app.use('/api/variables', createVariablesRouter({ connector, gate, adminGate, isAdmin,
  onChange: () => variables.invalidate() }));

const rules = createRuleEngine({ triggers, actions, variables }); // expands authored step props
```

## [critical] The one rule the whole feature rests on

**Expand authored text, never received text.** A rule fires with `{ ...payload, ...step.props }`.
`step.props` is what an operator typed into Studio; `payload` is whatever the trigger carried — and on
the webhook path that is a body a stranger chose. Expanding the *merged* object would let an inbound
request containing the literal text `{{var.stripe.secret}}` resolve it, and the next action step — a
mailer, an outbound call, a log line — would carry the plaintext straight back out. The attacker never
reads the collection; they ask this instance to read it for them.

So every call site expands `step.props` **only**. `pnpm smoke:variables` fires a real rule with that
exact string in both halves and asserts one expanded and the other did not.

The syntax is safe to add: `@leumas/actions`' interpolator matches `/\{\{(\w+)\}\}/`, and `\w`
excludes the dot, so `{{var.NAME}}` passes through it untouched.

## Scopes, secrets, and the outside

**Two scopes, one collection.** A `user` row beats the `instance` row for its owner. An `instance` row
is policy for everybody, which is verbatim the `PLATFORM_COLLECTIONS` criterion — so writing one is
admin-only and this router is the legitimate path. **The owner is stamped from the session and never
read off the body**; it used to be, and a member could create a user-scoped row owned by a victim,
which the resolver then preferred over the admin's instance value for that person.

**Secrets are sealed with `@leumas/connectors`**, not with crypto written here — `sealSecret` /
`openSecret`, aes-256-gcm, keyed on `LEUMAS_SECRET_KEY`. It **no-ops when no key is configured**, by
design, which is exactly why `sealingEnabled()` is surfaced in the Studio tab: an operator who ticks
"secret" on a deployment with no key would otherwise believe a token is encrypted while it sits in the
collection in the clear. A secret's value is **omitted** from every list — omitted, not masked, because
a row of dots still tells a reader how long it is — and can never be published externally.

**The external lane is route-local, and must stay that way.** `attachApiKeyUser()` is mounted inside
this router only. `@leumas/auth` forbids a global mount — it would make every authenticated route in
the API reachable with a long-lived static bearer that has none of a session's protections — and
`ops/infra/scripts/check-mcp.mjs` asserts it. Note `hasScope` returns true for any *session* user, so
it constrains only key-borne identities; the row's own `external` flag and the owner check are what do
the work. An API-key identity is `isAdmin: false` by construction, so **no key can ever write an
instance variable** — a property of the identity, not a policy someone has to remember.

**A miss stays verbatim.** `{{var.typo}}` renders as `{{var.typo}}` rather than blanking, so the
mistake is visible where it was made instead of three systems downstream.

## Guards

`pnpm smoke:variables` · `pnpm check:mcp` (proves the API-key mount is still route-local).


---
Source: shared/engines/variables/README.md
Canonical: https://docs.leumas.tech/p/engines/variables
