Skip to content

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 hook
import { 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 hook
export 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 tenant
const 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 colour
export 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 password
export 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 UI
await ctx.config.write({
scope: { tenantId: "acme-corp" },
key: "smtp.host",
value: "mail.acme-corp.com",
});

See also