@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...
@leumas/variables
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.
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).