Skip to content

Audit & security

Three features for compliance, secret management and protection against runaway workloads.

Audit log: every write recorded with who, when and what

Status: ✅ Stable

What: Every write through the pipeline (create/update/delete) is recorded for compliance review, who, when, what, with before/after context. Comes out of the box; your handlers don’t need to know about it.

How it works: Registered as a postSave system hook. Receives the SaveContext from the lifecycle pipeline and appends an audit event. Operators browse via the built-in audit screens, or call AuditQueries.list / AuditQueries.details from another handler.

Example:

import { createAuditFeature, AuditQueries } from "@cosmicdrift/kumiko-bundled-features/audit";
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
import { z } from "zod";
await runDevApp({
features: [createAuditFeature(), myFeature],
});
// Custom query: recent audit rows for the current tenant
defineFeature("ops", (r) => {
r.queryHandler(
"recent-audit",
z.object({}),
async (_query, ctx) =>
ctx.dispatch.query(AuditQueries.list, {
limit: 10,
}),
{ access: { roles: ["Admin"] } },
);
});

Status: ✅ Stable

What: Per-tenant encrypted values, API keys for external services, OAuth client secrets, webhook tokens. Envelope-encrypted at rest under a platform master key (KUMIKO_SECRETS_MASTER_KEY_V1, with versioned successors for rotation).

How it works: r.secret(name, opts) (or the object form r.secret({ name, … })) declares a typed secret handle. Operators set values via secrets:write:set. In handlers:

const branded = await ctx.services.secrets.get(tenantId, handle, auditCtx?) : then branded.reveal() for the plaintext. Prefer requireSecretsContext(ctx, handlerName) so every .get carries the audit user + handler automatically. Reads append a tenantSecretRead event.

Secrets screen in the tenant admin: one row per declared key with its hint, a Not set badge and an input field

The secretsEdit screen type lists the declared keys for a tenant admin. Each row shows whether a value is set; saved values are never shown again.

Writes: secrets:write:set and secrets:write:delete only accept keys that a feature declared with r.secret, or keys under a declared r.secretNamespace. Any other key fails with a not-found error, so a tenant admin cannot store arbitrary rows next to the feature’s own keys. Two options narrow writes further:

  • writeRoles lists the roles that may set or delete the key. The caller needs the handler access and one of these roles. An empty list is a boot error. Without writeRoles, the handler access alone decides.
  • valueSchema is a Zod string schema that every new value must pass. Both secrets:write:set and ctx.services.secrets.set check it. A failed check returns a generic validation error on the value field that contains neither the value nor the schema’s issues. Values that are already stored are not checked again.

r.secretNamespace takes the same two options for every key in the namespace. To catch a rejected value from ctx.services.secrets.set in your own code, use isInvalidSecretValueError(err) from @cosmicdrift/kumiko-bundled-features/secrets. It matches on the error code and i18n key, so it also works across a duplicated framework copy, where instanceof would fail.

Rotation: a built-in rotate job re-encrypts envelopes after a KEK version bump (KUMIKO_SECRETS_MASTER_KEY_V2, … + KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION).

secrets vs config: config is for settings operators maintain (SMTP host from a settings dialog); secrets is for external credentials with rotation and read-audit requirements (Stripe API key). When a compliance auditor asks “who accessed the Stripe key when”, that’s secrets.

Recipe: samples/recipes/secrets-demo

Example:

import { createSecretsFeature, requireSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secrets";
import { defineFeature, defineWriteHandler, type SecretKeyHandle } from "@cosmicdrift/kumiko-framework/engine";
import { z } from "zod";
await runDevApp({
features: [createSecretsFeature(), createBillingFeature()],
});
let STRIPE_API_KEY: SecretKeyHandle;
const chargeWrite = defineWriteHandler({
name: "charge",
schema: z.object({ amount: z.number().positive() }),
access: { roles: ["TenantAdmin"] },
handler: async (event, ctx) => {
const secrets = requireSecretsContext(ctx, "billing:write:charge");
const branded = await secrets.get(event.user.tenantId, STRIPE_API_KEY);
const apiKey = branded!.reveal();
// use apiKey in-memory only, never return it to the client
return { isSuccess: true, data: { charged: event.payload.amount } };
},
});
export function createBillingFeature() {
return defineFeature("billing", (r) => {
STRIPE_API_KEY = r.secret("stripe.apiKey", {
label: { en: "Stripe API Key" },
scope: "tenant",
writeRoles: ["TenantAdmin"],
valueSchema: z.string().startsWith("sk_"),
});
r.writeHandler(chargeWrite);
});
}

Status: ✅ Stable

What: Per-handler throttle (L3) wired by the dispatcher when a handler declares rateLimit. Optional ops introspection via rate-limiting:query:status. Protects login, expensive search, and other hot paths.

How it works: Sliding window (typically Redis-backed when configured on the server). Declare limits as { per: "user" | "tenant" | "ip" | "user+handler" | "tenant+handler" | "ip+handler", limit, windowSeconds } on the handler options. The rate-limiting feature itself is optional if you only need the L3 option and no status query.

Recipe: samples/recipes/rate-limiting

Example:

import { createRateLimitingFeature } from "@cosmicdrift/kumiko-bundled-features/rate-limiting";
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
import { z } from "zod";
await runDevApp({
features: [createRateLimitingFeature(), createRateLimitShowcaseFeature()],
});
export function createRateLimitShowcaseFeature() {
return defineFeature("rl-showcase", (r) => {
r.queryHandler(
"expensive-search",
z.object({ q: z.string().min(1) }),
async ({ payload }) => ({ q: payload.q, hits: 0 }),
{
access: { roles: ["Admin", "User"] },
rateLimit: { per: "user", limit: 3, windowSeconds: 60 },
},
);
});
}