Identity layer
Five features that together form the foundation for every
multi-tenant app. Auto-loaded in auto mode (auth: {...}) — you only
need to know about them when you add custom hooks to user/tenant
or want to replace the login UI.
auth-email-password
Status: ✅ Stable
What: Classic email + password login with signed JWTs. Ships
login endpoint, session cookies, logout, password hashing (Argon2id),
reset tokens. Plus React components (emailPasswordClient,
useSession, DefaultTopbarActions) for the UI side.
How it works: On login the server mints a JWT containing
userId + tenantId + roles and sets it as an HttpOnly cookie. Every
following request reads the cookie in the auth middleware, validates
the signature and exposes ctx.user to handlers. Tokens have built-in
refresh logic (sliding window). Multi-tenant: users can be members of
several tenants; the TenantSwitcher only rewrites the tenantId
claim.
When not: SSO-only apps (SAML, OIDC) — those will get their own
bundle. Public-only apps without login → just leave it out and use
anonymousAccess.
Example:
// Server: enable via the auth option (composeFeatures handles the rest)import { runDevApp } from "@kumiko/dev-server";
await runDevApp({ features: [myFeature], auth: { jwtSecret: process.env["JWT_SECRET"]! },});// Client: login form + session hookimport { emailPasswordClient, useSession } from "@kumiko/bundled-features/auth-email-password/web";
function MyApp() { const { user, signOut } = useSession(); if (!user) return <emailPasswordClient.LoginForm />; return <button onClick={signOut}>Logout {user.email}</button>;}sessions
Status: ✅ Stable
What: Server-side session tracking alongside the JWTs. Needed when you want “log out on all devices”, login history, or session limits (max. 5 active sessions per user).
How it works: Every login writes a row into user_sessions with
id, userId, tenantId, createdAt, expiresAt, revokedAt. The auth
middleware reads the session ID from the JWT and checks per request
whether the row is still alive (cache: 60 seconds). Revoke = stamp
revokedAt → the next request comes back with session_expired.
Mass-revoke (all of one user’s) is a single SQL update.
Options: expiryMs, cacheMs on createSessionsFeature(). The
cache TTL is the trade-off between latency (low TTL = more DB hits)
and reaction time on revoke (high TTL = the user is in for at most
TTL seconds after revoke).
Example:
import { createSessionsFeature } from "@kumiko/bundled-features/sessions";
await runDevApp({ features: [ createSessionsFeature({ expiryMs: 30 * 24 * 60 * 60 * 1000, // 30-day sliding window cacheMs: 60_000, // revoke takes effect in ≤ 60s }), myFeature, ],});
// In a handler: end all sessions of a user ("log out everywhere")await ctx.sessions.revokeAllForUser(userId);user
Status: ✅ Stable
What: User entity with email, display name, global roles
(jsonb, parallel to membership roles), sysadmin concept. Ships the
standard CRUD handlers plus dedicated queries (me, byEmail).
How it works: Global users.roles are merged with the membership
roles of the current tenant when minting the JWT at login. That lets
a sysadmin see admin paths everywhere without being a member of
every tenant. Common pattern: a platform tenant with
admin@platform.local as sysadmin; other tenants don’t even know
about that user.
When custom hooks: welcome email on create, automatic tenant
join, edge-case birthday logic — all via r.hook("postSave", ...)
in your own feature that declares requires: ["user"].
Example:
// Welcome email on create — own feature with a user hookexport const welcomeFeature = defineFeature("welcome", (r) => { r.requires(["user"]); r.hook("postSave", "user.create", async (ctx, { id, data }) => { await ctx.delivery.send({ to: id, template: "welcome", data: { email: data.email }, }); });});tenant
Status: ✅ Stable
What: Multi-tenant backbone. Tenant entity with slug + display
name, membership table (user ↔ tenant ↔ roles), tenant-switcher hook
for the UI. One logical tenant per organisation — DB rows carry
tenantId, reads filter automatically.
How it works: The tenantId field is injected into every entity
definition at boot (except the tenant entity itself — tenants.id
is the tenantId). The CrudExecutor writes it on inserts; the
list handler scopes reads automatically. Without a tenant context no
standard read goes through — you have to use anonymousAccess with
an explicit tenant resolver if you want to bypass that.
When not: single-tenant apps (internal tools for one company)
can leave tenant out — the code path with tenantId: "default"
also works without visible multi-tenancy.
Example:
// Public page: tenant resolution by subdomain (acme.app.com → tenant "acme")await runDevApp({ features: [myFeature], anonymousAccess: { tenantResolver: (req) => { const host = req.headers.get("host") ?? ""; const sub = host.split(".")[0]; return sub === "www" ? null : sub; }, },});
// In a handler: use the current tenantconst tenant = await ctx.db.read("tenant", ctx.tenantId);config
Status: ✅ Stable
What: Typed config layers per feature. Platform default → tenant override → user override, all in one table, all type-safe. Encrypted keys (e.g. SMTP password) follow the same pattern; the value is encrypted at rest in the DB.
How it works: You declare config keys in your feature via
r.config({ key: "smtp.host", default: "...", encrypted: false }).
The resolver reads them at request time from config_values, falls
back to defaults. r.readsConfig(["smtp.host", ...]) marks the
dependencies — at boot a check runs that all declared keys exist;
you can’t register an unread key or read an unregistered one.
Encryption: if a key is encrypted: true, you need
CONFIG_ENCRYPTION_KEY (32-byte base64) at server start. Without it
the server aborts at boot with a clear error — no silent skip.
config encrypted vs secrets:
both encrypt values at rest, but for different use cases.
config is for settings the operator maintains (SMTP password,
webhook URL, feature flags) — one row per key, simple encryption,
defaults from the code repo. secrets is for external service
credentials (Stripe API key, OAuth client secret) — envelope
encryption with a per-tenant master key, built-in rotation and a
read audit trail. Rule of thumb: value comes from a settings dialog
→ config. Comes from an external provider dashboard and should be
rotatable → secrets.
Example:
// Plain config key with default — e.g. branding colourexport const brandingFeature = defineFeature("branding", (r) => { r.config({ key: "branding.primary_color", default: "#0066cc" }); r.readsConfig(["branding.primary_color"]);
r.queryHandler({ qn: "branding:theme", handler: async (ctx) => ({ primaryColor: await ctx.config.read("branding.primary_color"), }), });});// Encrypted config key — per-tenant SMTP passwordexport const smtpFeature = defineFeature("smtp", (r) => { r.config({ key: "smtp.host", default: "smtp.example.com" }); r.config({ key: "smtp.username", default: "" }); r.config({ key: "smtp.password", default: "", encrypted: true }); r.readsConfig(["smtp.host", "smtp.username", "smtp.password"]);
r.writeHandler({ qn: "smtp:send", handler: async (ctx, payload) => { const host = await ctx.config.read("smtp.host"); const user = await ctx.config.read("smtp.username"); const pass = await ctx.config.read("smtp.password"); // Tenant-specific if overridden, otherwise platform default await sendEmail({ host, user, pass, ...payload }); }, });});// Operator sets an override for a tenant — e.g. via admin UIawait ctx.config.write({ scope: { tenantId: "acme-corp" }, key: "smtp.host", value: "mail.acme-corp.com",});