Audit & security
Three features for compliance, secret management and protection against runaway workloads.
audit
Status: ✅ Stable
What: Every write through the pipeline (create/update/delete) is
recorded in audit_log — who, when, what, with before/after diff.
Comes out of the box; your handlers don’t need to know about it.
How it works: Registered as a postSave system hook (priority
1002). Receives the SaveContext from the lifecycle pipeline
({ id, data, changes, previous, isNew, user }) and writes one row
per entity mutation. Read it later via r.queryHandler — or build
yourself an audit dashboard via the AuditQueries constant.
Performance: async append, in the same transaction as the write. No extra round-trip, but the audit write can slow a boot down if the table is huge and indices are missing — use a partition-by-month setup for high-volume tenants.
Example:
import { createAuditFeature, AuditQueries } from "@kumiko/bundled-features/audit";
await runDevApp({ features: [createAuditFeature(), myFeature],});
// Custom query: last 10 audit events for the current tenantr.queryHandler({ qn: "admin:audit:recent", handler: async (ctx) => { return ctx.dispatcher.query(AuditQueries.list, { tenantId: ctx.tenantId, limit: 10, orderBy: { createdAt: "desc" }, }); },});secrets
Status: ✅ Stable
What: Per-tenant encrypted values — API keys for external services, OAuth client secrets, webhook tokens. Encrypted with a per-tenant key, which is itself encrypted with your master key (envelope encryption).
How it works: r.secret({ key: "stripe.api_key" }) declares a
secret slot. The operator writes a value through the admin UI or
directly into tenant_secrets (encrypted). In handler code:
const stripe = await ctx.secrets.read("stripe.api_key"). Every
read emits a tenant.secret.read event — you see in the audit
trail which code accessed which secret slot when.
Rotation: built in. rotateJob re-encrypts all of a tenant’s
secrets against a new master key — as a background job, no downtime.
Turns a per-tenant master-key compromise into a routine ops task,
not a disaster.
secrets vs config encrypted:
config is for settings the operator maintains (SMTP password from a
settings dialog), secrets is for external service credentials with
rotation and audit requirements (Stripe API key, OAuth client
secret). When a compliance auditor asks “who accessed the Stripe key
when” — that’s secrets territory.
Recipe: samples/recipes/encrypted-tenant-config
shows the end-to-end: schema, write, read, rotation.
Example:
import { createSecretsFeature } from "@kumiko/bundled-features/secrets";
await runDevApp({ features: [createSecretsFeature(), myFeature],});
// Declare a secret slot + read itexport const stripeFeature = defineFeature("stripe", (r) => { r.secret({ key: "stripe.api_key" });
r.writeHandler({ qn: "stripe:charge", handler: async (ctx, { amount }) => { const apiKey = await ctx.secrets.read("stripe.api_key"); const stripe = new Stripe(apiKey); return stripe.charges.create({ amount, currency: "eur" }); }, });});rate-limiting
Status: ✅ Stable
What: Per-tenant + per-user throttle on write paths. Protects against bot attacks, runaway scripts, broken integrations hammering an endpoint.
How it works: Sliding window in Redis. On every handler invocation
a pre-hook checks whether the counter is below the limit; if not →
rate_limited error with retry-after. Defaults: 60 writes/minute
per user, 600 per tenant — overridable per handler via
r.writeHandler({ ..., rateLimit: { perUserPerMinute: 5 } }).
When custom limits: the login endpoint should be tighter (5/min/IP); public read endpoints often need no rate limiting at all. Implementation runs as a Lua script in Redis, atomic.
Example:
import { createRateLimitingFeature } from "@kumiko/bundled-features/rate-limiting";
await runDevApp({ features: [createRateLimitingFeature(), myFeature],});
// Tighter limit per handler (login: 5/min/user)r.writeHandler({ qn: "auth:login", rateLimit: { perUserPerMinute: 5 }, handler: async (ctx, payload) => { /* ... */ },});