--- url: 'https://keryxjs.com/guide/about.md' description: >- Etymology of the name, why Keryx treats MCP as a first-class transport, brand assets, and community links. --- # About Keryx ## The Name **Keryx** (κῆρυξ, pronounced "KEH-rüks") is ancient Greek for "herald" or "messenger" — the person who carried proclamations between gods and mortals, announced the start of games, and ensured safe passage for diplomats. In Athenian democracy, the keryx was essential: no assembly could begin without one. It fits: your [actions](/guide/actions) are the message, and Keryx delivers them across every transport — HTTP, WebSocket, CLI, background tasks, and MCP. Write your controller once, and Keryx heralds it to every client — whether that's a browser, a terminal, a background worker, or an AI agent. Read more on [Britannica](https://www.britannica.com/topic/keryx) or [Wikipedia (Kerykes)](https://en.wikipedia.org/wiki/Kerykes). ## Why MCP? The [Model Context Protocol](https://modelcontextprotocol.io) (MCP) is the open standard for connecting AI agents to tools and data. Anthropic, OpenAI, and Google have all adopted it — it's becoming the universal interface between LLMs and backend services. Most frameworks treat MCP as an afterthought: bolt on a separate MCP server, duplicate your route handlers as tool definitions, manage a second auth layer. Keryx treats MCP as a first-class transport. Every action you write is automatically available as an MCP tool — same validation, same [middleware](/guide/middleware), same auth — with built-in OAuth 2.1 so AI agents authenticate the same way browser clients do. What that means in practice: * **One-line tool registration** — add `mcp = { tool: true }` to an action. No separate definitions. * **OAuth 2.1 + PKCE** — agents authenticate like browser clients. One auth layer. * **Per-session MCP servers** — each agent gets isolated state. * **Typed errors** — agents get structured `ErrorType` values, not generic failures. * **Real-time notifications** — PubSub events forwarded as MCP logging messages. * **Dynamic OAuth forms** — login pages generated from your Zod schemas. Claude Desktop, VS Code Copilot, Cursor, Windsurf, and any other MCP client can discover and call the actions you expose. See the [Building for AI Agents](/guide/agents) guide for a walkthrough. ## Brand Assets ### Images All images are SVGs and can be used at any size. | Asset | Preview | Download | | ------------------ | -------------------------------------------------- | ---------------------------------------------- | | Herald (full logo) | | [hearald.svg](/images/hearald.svg) | | Horn (icon) | | [horn.svg](/images/horn.svg) | | Lion standing | | [lion-standing.svg](/images/lion-standing.svg) | | Lion sitting | | [lion-sitting.svg](/images/lion-sitting.svg) | ### Color Palette ```css /* Brand colors */ --keryx-gold: #b8701e; --keryx-gold-light: #cf8128; --keryx-gold-dark: #9a5e18; --keryx-rust: #bb5533; /* Dark mode variants */ --keryx-gold-dm: #e0a04a; --keryx-gold-light-dm: #eab56e; --keryx-rust-dm: #d4795e; /* Soft / background accent */ --keryx-gold-soft: rgba(207, 129, 40, 0.14); --keryx-gold-soft-dm: rgba(224, 160, 74, 0.14); ``` ## Author Keryx is a project by **Evan Tahler** ([@evantahler](https://twitter.com/evantahler) on Twitter, [@evantahler](https://github.com/evantahler) on GitHub). ## Community Keryx is the spiritual successor to [ActionHero](https://www.actionherojs.com), and we share the same community. Join us on Slack to ask questions, share what you're building, or just say hello. * **Discussions** — [github.com/actionhero/keryx/discussions](https://github.com/actionhero/keryx/discussions) * **Slack** — [actionherojs.slack.com](https://slack.actionherojs.com) * **GitHub** — [github.com/actionhero/keryx](https://github.com/actionhero/keryx) --- --- url: 'https://keryxjs.com/reference/actions.md' description: >- Action class API reference — every property, per-transport behavior (HTTP, WebSocket, CLI, tasks, MCP), type helpers, and raw response passthrough. --- # Action Source: `packages/keryx/classes/Action.ts` The `Action` class is the foundation of Keryx. Every controller — whether it handles HTTP, WebSocket, CLI, or background tasks — is an action. You write the logic once, and the framework handles the transport plumbing. ## Class Definition ```ts abstract class Action { /** Unique identifier, e.g. "user:create" */ name: string; /** Human-readable description — shows up in CLI --help and Swagger */ description?: string; /** Zod schema for input validation */ inputs?: z.ZodType; /** Middleware to run before/after this action */ middleware?: ActionMiddleware[]; /** MCP exposure config — tool, resource, and/or prompt (default: { tool: true }) */ mcp?: McpActionConfig; /** HTTP routing — route can be a string with :params or a RegExp */ web?: { route: RegExp | string; method: HTTP_METHOD; streaming?: boolean; }; /** Per-action timeout in ms (overrides config.actions.timeout; 0 disables) */ timeout?: number; /** * When `false`, tracing plugins skip this action. Unset (the default) means traced. * The built-in `status` action sets `tracing = false`. */ tracing?: boolean; /** Background task config — queue is required, frequency makes it recurring */ task?: { frequency?: number; queue: string; }; /** * The handler. Return data to send to the client. * Return a Response object for raw HTTP passthrough (skips JSON serialization). * Throw TypedError for error responses. * abortSignal is fired when the action timeout elapses. */ abstract run( params: ActionParams, connection?: Connection, abortSignal?: AbortSignal, ): Promise; } ``` Set `tracing = false` to skip distributed tracing for an action. See [Opting Out of Tracing](/guide/actions#opting-out-of-tracing). ## How Actions Work Across Transports This is the core idea. You define an action once — its name, inputs, and `run()` method — and the framework routes it through every transport automatically. The same Zod validation, the same middleware chain, the same `run()` method, the same response shape. The only thing that changes is how the request arrives and how the response is delivered. Here's what a single action looks like from each transport: ### HTTP Add a `web` property to expose an action as an HTTP endpoint. The web server matches incoming requests by route and method, extracts params from the URL path, query string, and request body, validates them against the Zod schema, and calls `run()`. ```ts export class UserCreate implements Action { name = "user:create"; web = { route: "/user", method: HTTP_METHOD.PUT }; inputs = z.object({ name: z.string().min(3), email: z.string().email(), password: secret(z.string().min(8)), }); async run(params: ActionParams) { // ... return { user: serializeUser(user) }; } } ``` ```bash curl -X PUT http://localhost:8080/api/user \ -H "Content-Type: application/json" \ -d '{"name":"Evan","email":"evan@example.com","password":"secret123"}' # → { "user": { "id": 1, "name": "Evan", ... } } ``` Params are loaded in this order (later sources override earlier ones): path params → URL query params → request body. Routes support `:param` path parameters (`/user/:id`) and RegExp patterns. If `run()` returns a `Response` object, the framework passes it through directly — no JSON serialization, no Keryx headers. See [Raw Response Passthrough](/guide/actions#raw-response-passthrough) for details and examples. ### WebSocket WebSocket clients send JSON messages with `messageType: "action"`, the action name, and params. The server finds the matching action, validates params through the same Zod schema, and sends the response back over the socket. ```json // Client sends: { "messageType": "action", "action": "user:create", "messageId": "abc-123", "params": { "name": "Evan", "email": "evan@example.com", "password": "secret123" } } // Server responds: { "messageId": "abc-123", "response": { "user": { "id": 1, "name": "Evan" } } } ``` The `messageId` is echoed back so the client can match responses to requests. WebSocket connections are long-lived — they maintain session state and can subscribe to [channels](/guide/channels) for real-time PubSub. ### CLI Every action is automatically registered as a CLI command via [Commander](https://github.com/tj/commander.js). The Zod schema's field names become `--flags`, descriptions become help text, and required vs optional fields are enforced. ```bash ./keryx.ts "user:create" \ --name Evan \ --email evan@example.com \ --password secret123 \ -q | jq # → { "response": { "user": { "id": 1, "name": "Evan", ... } } } ``` The `-q` flag suppresses server logs so you get clean JSON output. Use `--help` on any action to see its params: ```bash ./keryx.ts "user:create" --help ``` The server boots in `CLI` mode — initializers that don't apply (like the web server) are skipped based on their `runModes` setting. ### Background Tasks Add a `task` property to schedule an action as a background job. The Resque worker calls `run()` with the same params and validation — the action doesn't know or care whether it was triggered by HTTP, a cron schedule, or a fan-out parent. ```ts export class MessagesCleanup implements Action { name = "messages:cleanup"; task = { queue: "default", frequency: 1000 * 60 * 60 }; // hourly inputs = z.object({ age: z.coerce.number().default(1000 * 60 * 60 * 24), }); async run(params: ActionParams) { // same run() — called by the task worker, not by HTTP return { messagesDeleted: deleted.length }; } } ``` See [Tasks](/guide/tasks) for fan-out patterns and queue configuration. ## HTTP\_METHOD ```ts enum HTTP_METHOD { GET = "GET", POST = "POST", PUT = "PUT", DELETE = "DELETE", PATCH = "PATCH", OPTIONS = "OPTIONS", } ``` ## Type Helpers These two types are used throughout the codebase — in actions, tests, ops, and on the frontend: ```ts /** Infers the validated input type from an action's Zod schema */ type ActionParams = A["inputs"] extends z.ZodType ? z.infer : Record; /** Infers the return type of an action's run() method */ type ActionResponse = Awaited> & Partial<{ error?: TypedError }>; ``` `ActionResponse` includes an optional `error` field because the framework catches `TypedError` throws and adds them to the response automatically. The frontend imports `ActionResponse` to get type-safe API responses without any code generation step. ## ActionMiddleware Middleware intercepts action execution. Both methods are optional — you can have auth-only middleware (just `runBefore`) or logging-only middleware (just `runAfter`): ```ts type ActionMiddleware = { runBefore?: ( params: ActionParams, connection: Connection, ) => Promise; runAfter?: ( params: ActionParams, connection: Connection, error?: TypedError, ) => Promise; }; type ActionMiddlewareResponse = { /** Replace the params before the action runs */ updatedParams?: ActionParams; /** Replace the response after the action runs */ updatedResponse?: any; }; ``` Throw from `runBefore` to halt execution — the action's `run()` method won't be called. `runAfter` always executes (even when the action throws) and receives the error as an optional third parameter — useful for cleanup like rolling back a transaction. Return `updatedParams` or `updatedResponse` to modify the data flowing through the pipeline. Middleware runs in the same order regardless of transport. HTTP, WebSocket, CLI, tasks — same middleware chain, same behavior. --- --- url: 'https://keryxjs.com/guide/actions.md' description: >- How to write actions — inputs, validation, web routes, task scheduling, middleware, MCP exposure, tracing opt-out, and type helpers. --- # Actions If there's one idea that defines Keryx, it's this: **actions are the universal controller**. In the original ActionHero, we had actions, tasks, and CLI commands as separate concepts. That always felt like unnecessary duplication — you'd write the same validation logic three times for three different entry points. So in this version, we've collapsed them all into one thing. An action is a class with a `name`, a Zod schema for `inputs`, and a `run()` method that returns data. You add a `web` property to make it an HTTP endpoint. You add a `task` property to make it a background job. You add `mcp = { tool: true }` to make it an MCP tool. CLI support comes for free. Same validation, same error handling, same response shape — everywhere. ## A Simple Example ```ts import { z } from "zod"; import { Action, api, HTTP_METHOD } from "keryx"; export class Status implements Action { name = "status"; description = "Return the status of the server"; inputs = z.object({}); web = { route: "/status", method: HTTP_METHOD.GET }; async run() { return { name: api.process.name, uptime: new Date().getTime() - api.bootTime, }; } } ``` That's a fully functioning HTTP endpoint, CLI command, and WebSocket handler. Hit `GET /api/status` from a browser, run `./keryx.ts status -q | jq` from the terminal, or send `{ action: "status" }` over a WebSocket — same action, same response. The built-in `status` action also sets [`tracing = false`](#opting-out-of-tracing) so load-balancer health checks don't flood Sentry or Jaeger. ## Properties | Property | Type | What it does | | ------------- | ----------------------- | ------------------------------------------------------------------------------ | | `name` | `string` | Unique identifier (e.g., `"user:create"`) | | `description` | `string` | Human-readable description, shows up in CLI `--help` and Swagger | | `inputs` | `z.ZodType` | Zod schema — validation happens automatically | | `web` | `{ route, method, … }` | HTTP routing. Routes are strings with `:param` placeholders or RegExp patterns | | `task` | `{ queue, frequency? }` | Makes this action schedulable as a background job | | `middleware` | `ActionMiddleware[]` | Runs before/after the action (auth, logging, etc.) | | `mcp` | `McpActionConfig` | Controls MCP exposure: tool, resource, and/or prompt (tools are opt-in) | | `timeout` | `number` | Per-action timeout in ms (overrides `config.actions.timeout`; `0` disables) | | `tracing` | `boolean` | Set `false` to [skip distributed tracing](#opting-out-of-tracing) for this action. Unset means traced. | ## Input Validation Inputs use [Zod](https://zod.dev) schemas. If validation fails, the client gets a `406` with the validation errors — you don't need to write any error handling for bad inputs. ```ts inputs = z.object({ name: z.string().min(3).max(256), email: z .string() .email() .transform((val) => val.toLowerCase()), password: secret(z.string().min(8)), }); ``` ### Secret Fields You can mark sensitive fields with the `secret()` wrapper so they're redacted as `[[secret]]` in logs. Don't log passwords — use this: ```ts import { secret } from "keryx"; inputs = z.object({ password: secret(z.string().min(8)), }); ``` ### Type Helpers Two type helpers make your life easier: * `ActionParams` infers the validated input type from an action's Zod schema * `ActionResponse` infers the return type of an action's `run()` method ```ts async run(params: ActionParams) { // params.name, params.email, params.password — all typed } ``` The frontend uses `ActionResponse` to get type-safe API responses without any code generation. ## Web Routes Add a `web` property to expose an action as an HTTP endpoint: ```ts web = { route: "/user/:id", method: HTTP_METHOD.GET }; ``` Routes support `:param` path parameters (like Express) and can also be RegExp patterns. There's no separate `routes.ts` file — the route lives on the action itself, right next to the handler that serves it. Available methods: `GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `OPTIONS`. ## Raw Request Bodies For HTTP connections, `connection.rawRequest` is the underlying `Request` — read headers, the URL, or the method straight off it: ```ts async run(params: ActionParams, connection: Connection) { const signature = connection.rawRequest?.headers.get("x-signature"); } ``` The **body** is a different story. Keryx reads it to build `params`, so by the time `run()` is called it's already consumed. When you need the bytes exactly as they arrived — proxying a request upstream, verifying a webhook signature over the payload, accepting a binary upload — set `web.rawBody` and Keryx won't touch the body at all: ```ts export class ProxyUpstream implements Action { name = "proxy"; web = { route: "/proxy/:target", method: HTTP_METHOD.POST, rawBody: true, }; inputs = z.object({ target: z.string() }); async run(params: ActionParams, connection: Connection) { // The body is untouched — stream it upstream without buffering it here return fetch(`https://${params.target}/v1/messages`, { method: "POST", headers: { authorization: `Bearer ${process.env.UPSTREAM_KEY}` }, body: connection.rawRequest!.body, // @ts-expect-error — Bun supports duplex streaming request bodies duplex: "half", }); } } ``` Three things follow from `rawBody: true`: * **`params` come from path and query params only.** Nothing is merged in from the body, so a body key can't shadow a path param — which matters when that path segment is a routing target or a credential. * **You own the body.** Read it once, with whatever's appropriate: `.text()`, `.json()`, `.arrayBuffer()`, `.formData()`, or `.body` for a `ReadableStream` you never buffer. * **You own the size limit past the headers.** `config.server.web.maxBodySize` is still enforced against `Content-Length`, but Keryx isn't reading the stream, so it can't stop a chunked upload that lies about its size. Enforce that yourself if you accept chunked bodies. Content types are matched on the base media type, so `application/json` and `application/json; charset=utf-8` behave identically — the charset doesn't change whether a body gets parsed into `params`. `rawRequest` only exists for HTTP — it's `undefined` over WebSocket, CLI, background tasks, and MCP, where there's no request to speak of. An action built around raw bytes is an HTTP endpoint first; guard on `connection.rawRequest` if the same action can be reached another way. During an MCP tool, resource, or prompt call, `connection.elicitForm()` and `connection.elicitUrl()` ask the MCP client for more input. They throw `ErrorType.CONNECTION_MCP_ELICITATION` on every other transport. See [Elicitation](/guide/mcp#elicitation). Swagger documents a `rawBody` endpoint's request body as opaque bytes (`*/*`), and any Zod inputs that aren't path params as query params — because that's where they have to come from. ## Raw Response Passthrough Sometimes you need full control over the HTTP response — file downloads, image serving, streaming, redirects. For those cases, return a `Response` object directly from `run()` and the framework passes it through unchanged, skipping JSON serialization entirely. ```ts export class FileDownload implements Action { name = "file:download"; middleware = [SessionMiddleware]; web = { route: "/file/:id/download", method: HTTP_METHOD.GET }; inputs = z.object({ id: z.string() }); async run(params: ActionParams) { const file = await getFileContent(params.id); return new Response(file.buffer, { headers: { "Content-Type": file.mimeType, "Content-Disposition": `attachment; filename="${file.name}"`, }, }); } } ``` Your action still benefits from Keryx's routing, [middleware](/guide/middleware), session handling, and observability — all of that runs before `run()` is called. But the response itself is yours. Keryx's standard headers (CORS, security headers, session cookie) are **not** added to raw responses — you set your own headers on the `Response` you return. This only applies to HTTP. WebSocket, CLI, and background task transports still expect JSON-serializable return values from `run()`. ## Streaming Responses For Server-Sent Events (SSE), LLM streaming, or chunked binary transfers, return a `StreamingResponse` from `run()`. Unlike raw `Response` passthrough, streaming responses still get Keryx's standard headers (CORS, security, session cookie). ```ts import { Action, HTTP_METHOD, StreamingResponse } from "keryx"; export class ChatStream implements Action { name = "chat:stream"; description = "Stream an LLM response via SSE"; web = { route: "/chat/stream", method: HTTP_METHOD.POST, streaming: true }; timeout = 0; // disable timeout for long-running streams async run(params: { prompt: string }) { const sse = StreamingResponse.sse(); (async () => { try { for await (const token of callLLM(params.prompt)) { sse.send(token, { event: "token" }); } sse.send({ done: true }, { event: "done" }); } catch (e) { sse.sendError(String(e)); } finally { sse.close(); } })(); return sse; } } ``` Key points: * **`StreamingResponse.sse()`** — SSE with `Content-Type: text/event-stream`, `Cache-Control: no-cache`. Use `send(data, { event?, id? })` to emit events and `close()` to end the stream. * **`StreamingResponse.stream(readableStream, { contentType? })`** — raw binary/chunked streaming for file downloads or proxied responses. * **`timeout = 0`** — streaming actions should disable the action timeout. * **`web.streaming = true`** — documents the endpoint as `text/event-stream` in Swagger, and tells the web server to treat the response as a stream: no compression, no idle timeout. Only needed when you return a raw `Response` wrapping a stream; a `StreamingResponse` gets that treatment on its own. * **Compression and the idle timeout are skipped** for streaming responses automatically — SSE and chunked binary alike. * **Connection cleanup is deferred** until the stream closes, so sessions and middleware state remain valid during streaming. ### Transport Behavior | Transport | Behavior | | --------- | ----------------------------------------------------------------------------------- | | HTTP | Native SSE / chunked streaming | | WebSocket | Incremental messages with `{ streaming: true, chunk }`, then `{ streaming: false }` | | MCP | Chunks sent as logging messages; accumulated text returned as tool result | See the dedicated [Streaming guide](/guide/streaming) for detailed examples and patterns. ## CLI Commands Every action is automatically available as a CLI command. No extra configuration needed: ```bash ./keryx.ts "user:create" --name evan --email "evan@example.com" --password secret -q | jq ``` The `-q` flag suppresses server logs so you can pipe the JSON output cleanly. Use `--help` on any action to see its parameters. See the [CLI guide](/guide/cli#action-commands) for full details on flags, quiet mode, and error output. ## MCP Tools When the MCP server is enabled, actions can be exposed as [MCP](https://modelcontextprotocol.io) tools that AI agents discover and call through the Model Context Protocol. Tools are **opt-in**: set `mcp = { tool: true }` to publish an action. Actions with no `mcp` config are never exposed, so internal or destructive actions stay private by default. Actions can also be registered as MCP resources or prompts via `mcp.resource` and `mcp.prompt`. See the [MCP guide](/guide/mcp) for full details. ## Task Scheduling Add a `task` property to schedule an action as a recurring background job: ```ts task = { queue: "default", frequency: 1000 * 60 * 60 }; // every hour ``` * `queue` — which Resque queue to use * `frequency` — optional interval in ms for recurring execution See [Tasks](/guide/tasks) for the full story on background processing and the fan-out pattern. ## Opting Out of Tracing Health checks and other high-frequency endpoints will drown a trace backend if you sample them at the same rate as real work. Set `tracing = false` on the action: ```ts export class Status implements Action { name = "status"; tracing = false; web = { route: "/status", method: HTTP_METHOD.GET }; // ... } ``` [`@keryxjs/sentry`](/plugins/sentry) and [`@keryxjs/tracing`](/plugins/tracing) both honor this: no action span, and for HTTP and background tasks the whole request or job transaction is dropped. Nested `connection.act()` into an opted-out action skips that inner span but leaves the outer trace alone. Nested `connection.act()` *from* an opted-out action stays dark too — the outer request already opted out of the whole transaction. Error capture and metrics still run — you still hear about a failing health check, you just don't store a trace for every successful ping. The built-in `status` action sets `tracing = false` already. ## Timeouts Every action execution is wrapped with a timeout (default: 5 minutes). If an action exceeds its timeout, the framework aborts it and returns an HTTP `408` error with type `CONNECTION_ACTION_TIMEOUT`. The global default is set in `config.actions.timeout` (env: `ACTION_TIMEOUT`). You can override it per-action: ```ts export class SlowReport extends Action { name = "report:generate"; timeout = 600_000; // 10 minutes for this action // ... } ``` Set `timeout = 0` to disable the timeout for a specific action. ### AbortSignal When timeouts are enabled, `run()` receives an `AbortSignal` as its third argument. Long-running actions should check the signal or pass it to cancellable APIs: ```ts async run(params: ActionParams, connection?: Connection, abortSignal?: AbortSignal) { const res = await fetch("https://slow-api.example.com/data", { signal: abortSignal, }); // ... } ``` If the action doesn't check the signal, the timeout still works — `Promise.race()` ensures the caller gets the timeout error immediately. ## Error Handling Actions should throw `TypedError` for errors — not generic `Error`. Each error type maps to an HTTP status code: ```ts import { ErrorType, TypedError } from "keryx"; throw new TypedError({ message: "User not found", type: ErrorType.CONNECTION_ACTION_RUN, // → 500 }); ``` Some common mappings: `CONNECTION_ACTION_PARAM_VALIDATION` → 406, `CONNECTION_SESSION_NOT_FOUND` → 401, `CONNECTION_ACTION_NOT_FOUND` → 404, `CONNECTION_RATE_LIMITED` → 429. ## Registration New actions need to be re-exported from `backend/actions/.index.ts`. This is how the frontend gets type information about your API — it imports from that barrel file to power `ActionResponse` on the client side. ## Reference * [`Action` class reference](/reference/actions) — every property, type helper, and option in one place * [Servers](/reference/servers) — how an action is routed over HTTP, WebSocket, CLI, and MCP * [Sentry plugin](/plugins/sentry) — error monitoring and traces in Sentry, including per-action opt-out * [Tracing plugin](/plugins/tracing) — OTLP distributed tracing, including per-action opt-out --- --- url: 'https://keryxjs.com/plugins/admin.md' description: >- Admin dashboard plugin that browses, filters, and edits any table in your database, with read-only and full roles. --- # Admin `@keryxjs/admin` gives you a database admin dashboard in the spirit of RailsAdmin and Django's admin site: browse, filter, and edit any table in your application, without writing a screen per model. Tables come from [`api.db.schema`](/guide/advanced-patterns#schema-introspection), the registry the framework builds from your `schema/` directory. A table added by a migration is browsable the moment it exists. There's no scaffolding step and nothing to regenerate. Everything the dashboard does is an action, so the same surface is reachable from HTTP, `curl`, and — if you opt in — MCP. ## Installation ```bash bun add @keryxjs/admin ``` Requires `keryx` 0.43.0 or newer, the version that introduced `api.db.schema`, and a PostgreSQL database. If the app runs without one (`DATABASE_URL` empty or `"none"`), every admin action fails with an error saying so. ## Configuration `adminPlugin` is a factory with one required option: `resolveRole`, which decides who gets in. There's no default, and that's deliberate — how your app identifies an admin is a property of your schema, and a plugin that guessed would guess wrong. ```ts // config/plugins.ts import { adminPlugin, roleFromUserColumn } from "@keryxjs/admin"; import type { KeryxPlugin } from "keryx"; import type { SessionImpl } from "../actions/session"; import { users } from "../schema/users"; export default { plugins: [ adminPlugin({ resolveRole: roleFromUserColumn({ table: users, sessionKey: (session) => session.userId, role: (user) => user.admin_role === "full" || user.admin_role === "read-only" ? user.admin_role : null, }), columns: { users: { hidden: ["password_hash"], readOnly: ["created_at"] }, }, }), ] as KeryxPlugin[], }; ``` The dashboard is now at `/api/admin`. ### Options | Option | Purpose | |--------|---------| | `resolveRole` | **Required.** Returns `"full"`, `"read-only"`, or `null` to deny | | `tables` | `{ include, exclude }` lists of SQL table names. Default: every table in `schema/` | | `columns` | Per-table `{ hidden, readOnly }` column rules, keyed by SQL table name | | `extraMiddleware` | Middleware appended to every data action, after the role gate | | `writeMiddleware` | Middleware appended to the create, update, and delete actions only | | `serveUi` | Serve the built-in HTML dashboard. Set `false` to keep the JSON APIs and skip the HTML action, so you can ship your own UI on that route | ### Config and environment | Config | Env | Default | Purpose | |--------|-----|---------|---------| | `admin.enabled` | `ADMIN_ENABLED` | `true` | When false, every admin action responds 404 | | `admin.serveUi` | `ADMIN_SERVE_UI` | `true` | When false, the built-in HTML dashboard is not served. JSON actions stay available | | `admin.mcp` | `ADMIN_MCP_ENABLED` | `false` | Exposes all data actions as MCP tools, as one group | | `admin.route` | `ADMIN_ROUTE` | `/admin` | Route prefix for the dashboard and its API | | `admin.defaultLimit` | `ADMIN_DEFAULT_LIMIT` | `25` | Rows per page when the caller doesn't ask | | `admin.maxLimit` | `ADMIN_MAX_LIMIT` | `500` | Ceiling on rows per page | ## Roles There are two, and both are resolved on every request — so revoking someone's access takes effect immediately rather than at their next login. | Role | Can do | |------|--------| | `read-only` | Browse, filter, sort, and read single records | | `full` | Everything above, plus create, update, and delete | `null` denies access entirely (HTTP 401). A `read-only` caller attempting a write gets HTTP 403, enforced server-side — the UI hides the buttons as a courtesy, not as the control. ### Resolving the role yourself `roleFromUserColumn` covers the common case: load the caller's user row, map a column to a role. When roles live somewhere a single row lookup can't reach — a join table, a permissions service — write the callback directly. It receives the live connection, so anything on the request is fair game: ```ts adminPlugin({ resolveRole: async (connection) => { const userId = connection.session?.data.userId; if (!userId) return null; const membership = await api.db.db .select() .from(teamMembers) .where(and(eq(teamMembers.userId, userId), eq(teamMembers.role, "owner"))) .limit(1); return membership.length > 0 ? "full" : null; }, }); ``` ## Filtering Filters are a tree. Leaves compare a single column; branches combine other filters with `and`, `or`, and `not`, nested as deeply as you need: ```ts { and: [ { column: "active", op: "eq", value: true }, { or: [ { column: "email", op: "endsWith", value: "@example.com" }, { column: "tier", op: "in", value: ["pro", "enterprise"] }, ], }, ] } ``` | Operators | Notes | |-----------|-------| | `eq`, `neq` | `value: null` becomes `IS NULL` / `IS NOT NULL` | | `lt`, `lte`, `gt`, `gte` | | | `contains`, `startsWith`, `endsWith` | Case-insensitive, and LIKE wildcards in the value are escaped | | `like`, `ilike` | Pattern passed through, for when you want wildcards | | `in`, `notIn` | Non-empty array | | `between` | `[min, max]` array | | `isNull`, `isNotNull` | No value | Two properties worth knowing about. Column names are resolved against the table's real Drizzle columns before any SQL is built, so an unknown or hidden column fails loudly rather than reaching the database. And values bind as query parameters, never interpolated — which is also why `contains` escapes `%` and `_`: searching for `100%` finds that text instead of matching every row. ## The database validates writes The dashboard does not re-implement your schema's rules in application code. Unique indexes, foreign keys, `NOT NULL`, `CHECK` constraints, and type coercion already live in the database, and that's the only place they can't drift out of sync with reality. So writes are attempted optimistically, and PostgreSQL's `SQLSTATE` codes are translated into readable errors that name the constraint which rejected them: ``` create row in "users": a row with these values already exists (constraint "users_email_idx"). Key (email)=(ada@example.com) already exists. ``` Add a constraint in a migration and the dashboard enforces it immediately, with no plugin change and no config to update. ::: tip Hiding a column that is `NOT NULL` with no default makes the table un-insertable through the dashboard — there's no way to supply the required value. That's usually what you want for something like `password_hash`: browse and edit users here, create them through your signup action. Hiding a **primary key** goes further and makes the whole table read-only. A key the caller can't see is a key it can't send back, so the table reports no primary key at all rather than offering edit and delete actions that could only fail. ::: ## Actions | Action | Route | Access | |--------|-------|--------| | `admin:ui` | `GET /admin` | none — returns no data | | `admin:tables` | `GET /admin/tables` | read | | `admin:table:schema` | `GET /admin/tables/:table/schema` | read | | `admin:table:list` | `POST /admin/tables/:table/list` | read | | `admin:record:show` | `POST /admin/tables/:table/show` | read | | `admin:record:create` | `PUT /admin/tables/:table/record` | write | | `admin:record:update` | `POST /admin/tables/:table/record` | write | | `admin:record:destroy` | `DELETE /admin/tables/:table/record` | write | `list` and `show` are POST because a filter tree and a composite primary key are nested objects, and Keryx only merges JSON bodies for non-GET methods. Query strings can't carry `{ or: [...] }` without inventing an encoding. Records are addressed by a `pk` object keyed by primary key column, which is what makes composite keys work without a positional convention you'd have to guess: ```bash curl -X POST http://localhost:8080/api/admin/tables/memberships/show \ -H 'Content-Type: application/json' \ -d '{"pk": {"user_id": 7, "team_id": 3}}' ``` A table with no primary key is reported as read-only. Without one there's no safe way to address a single row, and an admin tool that issues unbounded `UPDATE`s is a loaded gun. ## MCP `ADMIN_MCP_ENABLED` exposes every admin data action as an MCP tool. It's a single switch for the whole group, because granting an agent generic read and write access to your database is one decision, not eight. It's off by default, and also requires the MCP server itself: ```bash MCP_SERVER_ENABLED=true ADMIN_MCP_ENABLED=true ``` The HTML UI action is never registered as a tool. Everything else about [MCP](/guide/mcp) applies as usual — tool calls go through `connection.act()`, so the role gate and any `extraMiddleware` still run. ## The UI One self-contained HTML file with inline CSS and JavaScript, served by `admin:ui`. No bundler, no build step, no `dist/` directory, and no CDN at runtime — the package ships raw TypeScript plus one `.html` file, so it behaves identically whether installed from npm or linked from a workspace. The client is deliberately thin: it fetches table metadata from `admin:table:schema` and generates the grid and edit form from it, so it renders any schema without knowing anything about yours. Dark mode follows `prefers-color-scheme`. The `/admin` route is unauthenticated because it returns no data. Every byte of content arrives through the JSON actions, which are gated. An anonymous visitor gets a "not authorized" prompt, not a database. ### Bring your own UI The JSON actions are the product; the HTML file is one client of them. If you'd rather build that client yourself — a page in your Vite app, a different layout, extra screens — turn the built-in shell off. Prefer the factory option so the HTML action is never registered and `GET /admin` stays free for your own action: ```ts adminPlugin({ resolveRole, serveUi: false, }); ``` `ADMIN_SERVE_UI=false` does the same at registration. Setting `config.admin.serveUi = false` after boot 404s the shell if it was already registered, but doesn't unbind the route — use the factory option when you want to occupy it yourself. ### Composing with CSRF The dashboard can delete any row, so its writes are worth protecting. If your app uses [`@keryxjs/csrf`](/plugins/csrf), pass `CsrfMiddleware` through `writeMiddleware`: ```ts import { CsrfMiddleware } from "@keryxjs/csrf"; adminPlugin({ resolveRole, writeMiddleware: [CsrfMiddleware], }); ``` Use `writeMiddleware` rather than `extraMiddleware` here. Guarding reads too would push the token into a query string on GET requests, where it lands in access logs and referrer headers — and reads change no state, so there's nothing for CSRF to protect. The dashboard's UI handles its side automatically: it fetches a token from `/csrf-token` on boot and includes it in every write. A 404 there is expected when the CSRF plugin isn't installed, and is ignored. The write actions declare an optional `csrfToken` input for you. That matters more than it looks: Zod objects strip keys the schema doesn't mention, so without it the token would be gone before the middleware ran, and `CsrfMiddleware` would reject every write rather than protecting it. If you write your own middleware that reads a param, make sure the action declares it. Middleware runs after the role gate, so unauthorized callers are turned away before your middleware does any work on their behalf. ## Local development To poke at the dashboard without wiring it into an app, the plugin ships a dev server that seeds a demo schema: ```bash bun dev:admin # http://localhost:8080/api/admin ADMIN_DEV_ROLE=read-only bun dev:admin # see the role-gated UI ``` This grants every visitor an admin role with no authentication whatsoever. It's for looking at the UI, nothing else. ## Security notes * **`resolveRole` is the whole perimeter.** It's required for a reason. Every data action calls it on every request. * **Hide your secrets.** Columns holding password hashes, API keys, or tokens belong in `hidden`. Hidden columns are absent from listings, records, schema output, filters, and writes — a caller can't read them or tell them apart from columns that don't exist. * **Narrow the surface.** `tables.include` is the tightest setting: an allowlist of exactly the tables the dashboard may touch. Anything not in your `schema/` directory is already invisible, which keeps migration bookkeeping tables out of reach. * **Turn it off where it doesn't belong.** `ADMIN_ENABLED=false` makes every admin action respond 404, which is a better answer than 403 for an endpoint you'd rather nobody knew about. `serveUi: false` is the narrower switch: JSON stays, HTML goes. * **Think twice before enabling MCP.** `ADMIN_MCP_ENABLED=true` hands an agent generic read and write access to every exposed table. Combine it with `tables.include` and `read-only` roles. --- --- url: 'https://keryxjs.com/guide/advanced-patterns.md' description: >- Production patterns for database transactions, schema introspection, RBAC, audit logging, and middleware composition in Keryx apps. --- # Advanced Patterns This guide shows how to compose Keryx primitives — [middleware](/guide/middleware), [actions](/guide/actions), and `connection.metadata` — into patterns you'll reach for in most production apps. The examples use a simple "team" domain, but the techniques apply to any multi-tenant or role-based application. ## Middleware Factories The [middleware guide](/guide/middleware) shows static `ActionMiddleware` objects. But what if different actions need different permission levels? Instead of writing separate middleware for each role, write a function that takes a role and *returns* a middleware: ```ts function RequireRole(role: string): ActionMiddleware { return { runBefore: async (params, connection) => { // check the role, throw if insufficient }, }; } ``` Now you can parameterize per-action: `middleware = [SessionMiddleware, RequireRole("admin")]`. The most common use of this pattern is role-based access control. ## Role-Based Access Control ### Roles and Hierarchy Start by defining your roles and a way to compare them: ```ts // roles.ts export const Roles = ["viewer", "editor", "admin"] as const; export type Role = (typeof Roles)[number]; const ROLE_LEVEL: Record = { viewer: 1, editor: 2, admin: 3, }; /** Returns true if the user's role meets or exceeds the required role. */ export function meetsRole(userRole: Role, required: Role): boolean { return ROLE_LEVEL[userRole] >= ROLE_LEVEL[required]; } ``` ### RBAC Middleware Factory The middleware factory looks up the user's membership from the database, checks their role against the requirement, and stores the membership on `connection.metadata` for the action to use: ```ts // rbac-middleware.ts import { api, ErrorType, TypedError, type ActionMiddleware, type Connection, } from "keryx"; import { eq, and } from "drizzle-orm"; import { meetsRole, type Role } from "./roles"; import { teamMembers } from "./schema"; import type { AppConnectionMeta } from "./types"; export function RbacMiddleware(requiredRole: Role): ActionMiddleware { return { runBefore: async ( params: Record, connection: Connection, ) => { const userId = connection.session!.data.userId; const teamId = params.teamId as number; if (!teamId) { throw new TypedError({ message: "teamId is required", type: ErrorType.ACTION_VALIDATION, }); } try { const [membership] = await api.db.db .select() .from(teamMembers) .where( and(eq(teamMembers.userId, userId), eq(teamMembers.teamId, teamId)), ); if (!membership || !meetsRole(membership.role as Role, requiredRole)) { throw new TypedError({ message: "Insufficient permissions", type: ErrorType.CONNECTION_CHANNEL_AUTHORIZATION, }); } // Make the membership available to the action connection.metadata.membership = membership; } catch (e) { if (e instanceof TypedError) throw e; throw new TypedError({ message: "An unexpected error occurred", type: ErrorType.CONNECTION_ACTION_RUN, }); } }, }; } ``` Use it on any action: ```ts export class TeamEdit implements Action { name = "team:edit"; middleware = [SessionMiddleware, RbacMiddleware("admin")]; // ... async run( params: ActionParams, connection: Connection, ) { const membership = connection.metadata.membership!; // set by RbacMiddleware // ... } } ``` ### Multiple Role Domains Some apps have separate role hierarchies — for example, organization-level roles (owner, admin, member) and project-level roles (manager, editor, viewer) with no implicit access between them. In that case, create separate middleware factories for each domain: ```ts middleware = [SessionMiddleware, OrgRbacMiddleware("admin")]; // control plane middleware = [SessionMiddleware, ProjectRbacMiddleware("editor")]; // data plane ``` If an action should be accessible from *either* domain (e.g. a project admin *or* an org admin can manage project members), build a combined middleware that checks both and authorizes if either passes. ### Runtime Introspection You can attach metadata to middleware objects for runtime inspection — useful for building permissions endpoints or auto-generating documentation. One approach is to use a well-known Symbol: ```ts export const RBAC_DESCRIPTOR = Symbol.for("app.rbac"); export function RbacMiddleware(requiredRole: Role): ActionMiddleware { const mw: ActionMiddleware & { [RBAC_DESCRIPTOR]?: { role: Role } } = { runBefore: async (params, connection) => { /* ... */ }, }; mw[RBAC_DESCRIPTOR] = { role: requiredRole }; return mw; } ``` An endpoint can then iterate over an action's `middleware` array, check for the Symbol, and expose the role requirements to the frontend or to AI agents. ## Database Transactions Keryx provides two tools for wrapping database operations in transactions: `TransactionMiddleware` for action-scoped transactions and `withTransaction()` for standalone use. ### TransactionMiddleware Add `TransactionMiddleware` to an action's middleware array. It opens a `BEGIN` in `runBefore`, stores the transaction-scoped Drizzle instance on `connection.metadata.transaction`, and commits or rolls back in `runAfter` based on whether the action succeeded: ```ts import { Action, HTTP_METHOD, TransactionMiddleware, type ActionParams, type Connection, type Transaction, } from "keryx"; import { eq } from "drizzle-orm"; import { z } from "zod"; import { accounts } from "../schema"; export class TransferFunds extends Action { constructor() { super({ name: "transfer:funds", middleware: [SessionMiddleware, TransactionMiddleware], web: { route: "/transfer", method: HTTP_METHOD.POST }, inputs: z.object({ fromId: z.number(), toId: z.number(), amount: z.number().positive(), }), }); } async run(params: ActionParams, connection?: Connection) { const tx = connection!.metadata.transaction as Transaction; // Both updates happen atomically — if either fails, both roll back const [from] = await tx .update(accounts) .set({ balance: sql`balance - ${params.amount}` }) .where(eq(accounts.id, params.fromId)) .returning(); const [to] = await tx .update(accounts) .set({ balance: sql`balance + ${params.amount}` }) .where(eq(accounts.id, params.toId)) .returning(); return { from, to }; } } ``` If the action throws at any point, the transaction is rolled back automatically — no partial writes. ### withTransaction() For one-off transactions outside the action lifecycle (ops functions, scripts, tests), use `withTransaction()`: ```ts import { withTransaction } from "keryx"; import { users, auditLogs } from "../schema"; const user = await withTransaction(async (tx) => { const [created] = await tx .insert(users) .values({ name: "Alice", email: "alice@example.com", password_hash: hash }) .returning(); await tx.insert(auditLogs).values({ action: "user:create", userId: created.id }); return created; }); ``` It acquires a dedicated connection from the pool, runs `BEGIN`, calls your callback, then `COMMIT` on success or `ROLLBACK` on error. `TypedError` is re-thrown directly; other errors are wrapped. ### Passing Transactions to Ops Functions Use the `DbOrTransaction` type so helper functions accept either `api.db.db` (no transaction) or a `Transaction`: ```ts import { api, type DbOrTransaction } from "keryx"; import { eq } from "drizzle-orm"; import { users } from "../schema"; export async function findUserByEmail( email: string, db: DbOrTransaction = api.db.db, ) { const [user] = await db .select() .from(users) .where(eq(users.email, email)) .limit(1); return user; } ``` Now callers can use it with or without a transaction: ```ts // Without transaction — uses api.db.db const user = await findUserByEmail("alice@example.com"); // Inside TransactionMiddleware action const user = await findUserByEmail( "alice@example.com", connection.metadata.transaction as Transaction, ); ``` ### Chaining Actions in a Transaction When an action calls a sub-action via `connection.act()`, the transaction propagates automatically. `TransactionMiddleware` is **re-entrant** — if a transaction already exists on `connection.metadata`, the child action reuses it instead of opening a new one. Only the outermost middleware commits or rolls back. ```ts export class CreateUserWithWelcome extends Action { constructor() { super({ name: "user:create-with-welcome", middleware: [TransactionMiddleware], web: { route: "/user/create-with-welcome", method: HTTP_METHOD.PUT }, inputs: z.object({ name: z.string(), email: z.string() }), }); } async run(params: ActionParams, connection?: Connection) { const tx = connection!.metadata.transaction as Transaction; const [user] = await tx .insert(users) .values({ name: params.name, email: params.email, password_hash: hash }) .returning(); // Sub-action runs inside the same transaction — if it fails, // both the user creation and the welcome message roll back. const { error } = await connection!.act("message:create-welcome", { userId: user.id, }); if (error) throw error; return { user }; } } ``` `connection.metadata` is preserved across nested `act()` calls (it resets only on the outermost call), so the child action sees the parent's `transaction`, `_txClient`, and any other metadata set by earlier middleware. ## Schema Introspection Most of the time you import the table you need and get on with it: ```ts import { users } from "../schema/users"; const rows = await api.db.db.select().from(users); ``` That breaks down the moment you write code that doesn't know its tables ahead of time — an admin dashboard, a CSV exporter, a data-retention sweeper. For those, Keryx loads every Drizzle table exported from your `schema/` directory into `api.db.schema`, keyed by export name: ```ts Object.keys(api.db.schema); // ["messages", "users"] api.db.schema.users === users; // the very same table object ``` Entries are ordinary Drizzle tables, so they go straight into the query builder: ```ts const rows = await api.db.db.select().from(api.db.schema.users).limit(10); ``` Pair the registry with Drizzle's `getTableConfig()` to read a table's real shape — column types, nullability, defaults, unique indexes, and foreign keys: ```ts import { getTableName } from "drizzle-orm"; import { getTableConfig } from "drizzle-orm/pg-core"; for (const [exportName, table] of Object.entries(api.db.schema)) { const { columns, indexes, foreignKeys } = getTableConfig(table); console.log(`${exportName} -> ${getTableName(table)}`); for (const column of columns) { console.log(` ${column.name}: ${column.getSQLType()}`, { nullable: !column.notNull, primaryKey: column.primary, hasDefault: column.hasDefault, }); } console.log(` ${indexes.length} indexes, ${foreignKeys.length} foreign keys`); } ``` Two things worth knowing: * **Keys are export names, not SQL names.** `export const gadgets = pgTable("gadgets_table", ...)` registers under `gadgets`. Reach for `getTableName(table)` when you need the name PostgreSQL knows. * **Non-table exports are ignored.** Schema files can export types, constants, and helpers freely; only Drizzle tables land in the registry. Projects without a `schema/` directory get an empty registry rather than an error, so this stays out of your way until you need it. ## Audit Logging with Base Action Classes When many actions share the same cross-cutting concern — wrapping database writes in a transaction and inserting an audit log — you can extract that into an abstract base class. This pattern uses `TransactionMiddleware` to manage the transaction lifecycle and a `runAfter` hook to insert audit entries. ### The Base Class ```ts // audited-action.ts import { Action, TransactionMiddleware, type ActionMiddleware, type Connection, type Transaction, } from "keryx"; import { z } from "zod"; import { auditLogs } from "./schema"; import type { AppConnectionMeta } from "./types"; /** Middleware that inserts an audit log entry inside the active transaction. */ const AuditLogMiddleware: ActionMiddleware = { runAfter: async ( params: Record, connection: Connection, error, ) => { // Only audit successful operations if (error) return; const tx = connection.metadata.transaction as Transaction; if (!tx) return; await tx.insert(auditLogs).values({ userId: connection.session?.data.userId ?? null, action: connection.metadata.auditAction ?? "unknown", metadata: params, before: connection.metadata.auditBefore ?? null, after: connection.metadata.auditAfter ?? null, }); }, }; export abstract class AuditedAction extends Action { constructor(args: ConstructorParameters[0]) { super({ ...args, // TransactionMiddleware must come before AuditLogMiddleware so // the transaction is open when the audit insert runs middleware: [ ...(args.middleware ?? []), TransactionMiddleware, AuditLogMiddleware, ], }); } } ``` The key detail: `AuditLogMiddleware.runAfter` runs *inside* the same transaction that `TransactionMiddleware` manages. If the audit insert fails, the entire transaction rolls back. ### Using It Subclasses set before/after snapshots on `connection.metadata` and use `connection.metadata.transaction` for all queries: ```ts export class TeamEdit extends AuditedAction { constructor() { super({ name: "team:edit", description: "Edit a team's settings", inputs: z.object({ teamId: z.coerce.number(), name: z.string().min(1).optional(), }), middleware: [SessionMiddleware, RbacMiddleware("admin")], web: { route: "/team", method: HTTP_METHOD.PUT }, }); } async run( params: ActionParams, connection?: Connection, ) { const tx = connection!.metadata.transaction as Transaction; connection!.metadata.auditAction = this.name; // Capture the before state const [before] = await tx .select() .from(teams) .where(eq(teams.id, params.teamId)); connection!.metadata.auditBefore = before; // Make the change const [after] = await tx .update(teams) .set({ name: params.name }) .where(eq(teams.id, params.teamId)) .returning(); connection!.metadata.auditAfter = after; return { team: after }; } } ``` The action only thinks about business logic. The base class composes `TransactionMiddleware` and `AuditLogMiddleware` to handle the transaction wrapper, audit logging, and error handling. ## Putting It Together Here's the full request flow when these patterns are combined: 1. **SessionMiddleware** — verifies the user is authenticated 2. **RbacMiddleware("admin")** — looks up team membership, checks role, stores membership on `connection.metadata` 3. **TransactionMiddleware.runBefore** — opens a database transaction, stores it on `connection.metadata.transaction` 4. **Action.run()** — captures before state, makes the change, captures after state 5. **AuditLogMiddleware.runAfter** — inserts the audit entry in the same transaction 6. **TransactionMiddleware.runAfter** — commits the transaction (data change + audit log atomically) If any step throws, everything after it is skipped — middleware short-circuits the action, and the transaction rolls back both the data change and the audit entry. --- --- url: 'https://keryxjs.com/guide/authentication.md' description: >- Session-based authentication with middleware, login/signup actions, and OAuth for MCP clients. --- # Authentication Keryx uses cookie-based sessions stored in Redis. Authentication is handled through actions and middleware — there's no separate auth plugin or magic. You write a login action, apply `SessionMiddleware` to protected routes, and the framework handles the rest. ## Sessions When a user logs in, a session is created in Redis and a cookie is set on the response. The session stores arbitrary typed data (like `userId`) and has a configurable TTL (default: 24 hours). With `REDIS_URL="memory://"` sessions live in process memory, so every restart (including a `bun dev` reload) logs everyone out. ```ts // Define your session data shape export type SessionImpl = { userId?: number }; ``` The session initializer manages the full lifecycle: ```ts // Create or update session data await connection.updateSession({ userId: user.id }); // Regenerate session ID after login (prevents session fixation) await connection.regenerateSession(); // Access session data const userId = connection.session?.data.userId; // Destroy the session (logout) await api.session.destroy(connection); ``` Sessions are typed via the generic `Connection` parameter, giving you type-safe access to session data throughout your middleware and actions. ## Login Action A login action validates credentials and creates a session: ```ts import { SessionMiddleware } from "../middleware/session"; import type { SessionImpl } from "./session"; export class SessionCreate implements Action { name = "session:create"; description = "Sign in with email and password"; web = { route: "/session", method: HTTP_METHOD.PUT }; mcp = { tool: false, isLoginAction: true }; middleware = [RateLimitMiddleware]; inputs = z.object({ email: z .string() .email() .transform((val) => val.toLowerCase()), password: secret(z.string().min(8)), }); run = async ( params: ActionParams, connection: Connection, ) => { const [user] = await api.db.db .select() .from(users) .where(eq(users.email, params.email)); if (!user || !(await checkPassword(user, params.password))) { throw new TypedError({ message: "Invalid email or password", type: ErrorType.CONNECTION_ACTION_RUN, }); } await connection.updateSession({ userId: user.id }); await connection.regenerateSession(); return { user: serializeUser(user), session: connection.session! }; }; } ``` The `mcp = { isLoginAction: true }` marker tells the OAuth system to use this action during the MCP authorization flow. See [MCP](/guide/mcp) for details. ## SessionMiddleware Apply `SessionMiddleware` to any action that requires authentication: ```ts import { SessionMiddleware } from "../middleware/session"; export class UserEdit implements Action { name = "user:edit"; middleware = [RateLimitMiddleware, SessionMiddleware]; web = { route: "/user", method: HTTP_METHOD.POST }; // ... } ``` If no valid session exists, `SessionMiddleware` throws a `TypedError` with type `CONNECTION_SESSION_NOT_FOUND` (HTTP 401). The action's `run()` method is never called. The middleware checks both that `connection.session` exists and that `connection.session.data.userId` is set — so even if a session cookie is present, the user must have actually logged in. ## Logout Action ```ts export class SessionDestroy implements Action { name = "session:destroy"; web = { route: "/session", method: HTTP_METHOD.DELETE }; middleware = [RateLimitMiddleware, SessionMiddleware]; async run(_params, connection: Connection) { await api.session.destroy(connection); return { success: true }; } } ``` ## OAuth for MCP Clients MCP clients authenticate through OAuth 2.1 with PKCE. The OAuth flow renders a browser-based login/signup page that invokes your login and signup actions. Actions tagged with `mcp.isLoginAction` and `mcp.isSignupAction` must return an `OAuthActionResponse`: ```ts // Must return this shape from login/signup actions used in OAuth type OAuthActionResponse = { user: { id: number } }; ``` The OAuth page dynamically generates form fields from your action's Zod `inputs` schema — if you change the inputs on your login or signup action, the form updates automatically. Fields wrapped in `secret()` render as password inputs, and `.describe()` text is used for labels. Logging an agent out works differently from the cookie logout above. There's no session to destroy — instead, `POST /oauth/revoke` with the client's token (RFC 7009), which invalidates the access and refresh pair together. `POST /oauth/introspect` (RFC 7662) reports whether a token is still live. See the [MCP guide](/guide/mcp#oauth-21-authentication) for the full OAuth flow and endpoint documentation. ## Session Configuration | Key | Env Var | Default | Description | | ---------------- | -------------------------- | ------------- | ----------------------------------------- | | `ttl` | `SESSION_TTL` | `86400` | Session TTL in seconds (1 day) — also sets the MCP OAuth access-token lifetime | | `cookieName` | `SESSION_COOKIE_NAME` | `"__session"` | Cookie name for session tracking | | `cookieHttpOnly` | `SESSION_COOKIE_HTTP_ONLY` | `true` | Prevent JavaScript access | | `cookieSecure` | `SESSION_COOKIE_SECURE` | `false` | HTTPS-only cookies | | `cookieSameSite` | `SESSION_COOKIE_SAME_SITE` | `"Strict"` | CSRF protection (`Strict`, `Lax`, `None`) | `SESSION_TTL` does double duty: it's the cookie session lifetime *and* the expiry of the bearer tokens issued to MCP clients (`expires_in` on the token response). Tightening it for browser hygiene shortens agent tokens too. Refresh tokens are governed separately by `MCP_OAUTH_REFRESH_TTL` — see [Refresh Tokens](/guide/mcp#refresh-tokens). See the [Security guide](/guide/security) for production recommendations on cookie security settings. ## Testing with Sessions The pattern for testing authenticated endpoints: ```ts // 1. Create a user await fetch(url + "/api/user", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Test User", email: "test@example.com", password: "password123", }), }); // 2. Log in to get a session const sessionRes = await fetch(url + "/api/session", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: "test@example.com", password: "password123", }), }); const { session } = await sessionRes.json(); const sessionId = session.id; // 3. Use the session cookie on authenticated requests const res = await fetch(url + "/api/user", { method: "POST", headers: { "Content-Type": "application/json", Cookie: `${config.session.cookieName}=${sessionId}`, }, body: JSON.stringify({ name: "New Name" }), }); ``` See the [Testing guide](/guide/testing) for more patterns. --- --- url: 'https://keryxjs.com/guide/tasks.md' description: >- Background tasks with Resque workers and the fan-out pattern for distributing work across child jobs. --- # Background Tasks One of the things I've always loved about ActionHero is that background tasks are a first-class citizen — not a plugin, not a separate service, just part of the framework. Keryx keeps that tradition, using [node-resque](https://github.com/actionhero/node-resque) for job processing backed by Redis. No Redis server? With `REDIS_URL="memory://"`, the queue, workers, scheduler, and recurring tasks all run against an in-process Redis. The scheduler re-enqueues recurring tasks every time it boots, so they survive a restart; one-off and delayed jobs don't. That's fine for a single-process app — see [Running Without Postgres or Redis](/guide/deployment#running-without-postgres-or-redis) for the full list of trade-offs. The key difference from the original ActionHero: tasks and actions are the same thing now. Any action can be scheduled as a background job by adding a `task` property. Same inputs, same validation, same `run()` method. ## Defining a Task ```ts export class MessagesCleanup implements Action { name = "messages:cleanup"; description = "Cleanup messages older than 24 hours"; task = { queue: "default", frequency: 1000 * 60 * 60 }; // every hour inputs = z.object({ age: z.coerce .number() .int() .min(1000) .default(1000 * 60 * 60 * 24), }); async run(params: ActionParams) { const deleted = await api.db.db .delete(messages) .where(lt(messages.createdAt, new Date(Date.now() - params.age))) .returning(); return { messagesDeleted: deleted.length }; } } ``` * **`queue`** — which Resque queue to put this job on (required) * **`frequency`** — how often to run it, in milliseconds (optional — omit for one-shot tasks) You can also run this same action from the CLI (`./keryx.ts "messages:cleanup" --age 3600000 -q`) or hit it via HTTP if you add a `web` property. It's all the same code. ## Queue Priority Workers drain queues left-to-right. This matters when you want some jobs to take priority: ```ts // In config/tasks.ts queues: ["worker", "scheduler"]; // Jobs on "worker" are processed before "scheduler" ``` Use `["*"]` to process all queues with equal priority. That said, for fan-out patterns (see below), you'll probably want to separate parent tasks from child tasks so the children get processed first. ## Fan-Out Pattern This is one of my favorite features. A parent task can distribute work across many child jobs for parallel processing using `api.actions.fanOut()`. Think "process all users" where you fan out to individual "process one user" jobs. ### Single Action Fan-Out The simple case — bulk-enqueue the same action with different inputs: ```ts export class ProcessAllUsers implements Action { name = "users:processAll"; task = { frequency: 1000 * 60 * 60, queue: "scheduler" }; async run() { const users = await getActiveUsers(); const result = await api.actions.fanOut( "users:processOne", users.map((u) => ({ userId: u.id })), "worker", ); return { fanOut: result }; } } // The child action — nothing special needed here export class ProcessOneUser implements Action { name = "users:processOne"; task = { queue: "worker" }; inputs = z.object({ userId: z.string() }); async run(params) { /* process one user */ } } ``` The child action doesn't know or care that it was spawned by a fan-out. It's just a regular action. ### Multi-Action Fan-Out You can also fan out to different action types in one batch: ```ts const result = await api.actions.fanOut([ { action: "users:processOne", inputs: { userId: "1" } }, { action: "users:processOne", inputs: { userId: "2" } }, { action: "emails:send", inputs: { to: "a@b.com" }, queue: "priority" }, ]); ``` ### Checking Results ```ts const status = await api.actions.fanOutStatus(result.fanOutId); // → { total: 3, completed: 3, failed: 0, results: [...], errors: [...] } ``` Results and metadata are stored in Redis with a configurable TTL (default 10 minutes). The TTL refreshes on each child job completion, so it's relative to the last activity — not the fan-out creation time. ### How Fan-Out Works Internally When you call `fanOut()`, each child job gets a `_fanOutId` injected into its inputs automatically. The child action doesn't need to know about this — the Resque worker checks for `_fanOutId` after each job completes and stores the result (or error) in Redis. This means any existing action works as a fan-out child with zero changes. Redis keys for a fan-out operation: * `fanout:{id}` — hash with metadata (total, completed, failed) * `fanout:{id}:results` — list of successful results * `fanout:{id}:errors` — list of failed results ### Error Handling If a child job fails, it's recorded in the errors list. The fan-out doesn't abort — other children continue processing. Use `fanOutStatus()` to check for failures: ```ts const status = await api.actions.fanOutStatus(result.fanOutId); if (status.failed > 0) { for (const error of status.errors) { logger.error(`Failed for ${JSON.stringify(error.params)}: ${error.error}`); } } ``` Enqueue-time errors (e.g., invalid action name) are returned immediately in the `FanOutResult.errors` array, separate from runtime errors collected via `fanOutStatus()`. ### Options * **`batchSize`** — how many jobs to enqueue per Redis round-trip (default: 100). Jobs are enqueued in batches to avoid flooding Redis. * **`resultTtl`** — how long to keep results in Redis, in seconds (default: 600). The TTL refreshes on each child completion, so it's relative to the last activity — not the fan-out creation time. ### Additional Task APIs Beyond fan-out, the actions initializer exposes the full Resque API for job management: ```ts // Schedule for later await api.actions.enqueueAt(timestamp, "actionName", inputs, queue); await api.actions.enqueueIn(delayMs, "actionName", inputs, queue); // Inspect queues const jobs = await api.actions.queued("default", 0, 100); const delayed = await api.actions.allDelayed(); // Manage failures const failedCount = await api.actions.failedCount(); const failures = await api.actions.failed(0, 10); await api.actions.retryAndRemoveFailed(failedJob); // Worker management const workers = await api.actions.workers(); const working = await api.actions.allWorkingOn(); await api.actions.cleanOldWorkers(3600000); // clean workers older than 1 hour // Recurrent task control await api.actions.stopRecurrentAction("messages:cleanup"); // Full system overview const details = await api.actions.taskDetails(); // → { queues, workers, stats, leader } ``` ## Reference * [`Action` class reference](/reference/actions) — the `task` property, queues, and scheduling options * [Configuration reference](/reference/config) — every `TASK_*` and `RESQUE_*` environment variable --- --- url: 'https://keryxjs.com/guide/agents.md' description: >- Getting started with AI agents — why MCP matters, quick-start walkthrough, Claude Desktop configuration, and what Keryx provides out of the box. --- # Building for AI Agents If you're building a backend that AI agents need to call, you've probably looked at the [Model Context Protocol](https://modelcontextprotocol.io) (MCP). It's the open standard that Claude Desktop, VS Code Copilot, Cursor, Windsurf, and other AI clients use to discover and call external tools. Most frameworks don't support MCP at all. The ones that do require you to build a separate MCP server, duplicate your route handlers as tool definitions, and manage a second auth layer. You end up maintaining two APIs — one for humans, one for agents — with no shared validation or middleware. Keryx treats MCP as a first-class transport. Any [action](/guide/actions) you write becomes an MCP tool by adding one line — `mcp = { tool: true }`. Same Zod validation, same [middleware](/guide/middleware), same [authentication](/guide/authentication). No duplication. ## What You Get * **One-line tool registration** — add `mcp = { tool: true }` to an action and it's a tool. No separate definitions, no schema mapping. * **Private by default** — actions are never exposed to agents unless you opt them in, so internal and destructive actions can't leak through tool discovery. * **OAuth 2.1 + PKCE built-in** — agents authenticate the same way browser clients do. One auth layer, not two. * **Dynamic OAuth forms** — login and signup pages are generated from your Zod schemas. Change a field, the form updates. * **Per-session MCP servers** — each agent connection gets its own isolated `McpServer` instance. No cross-session state leaks. * **Typed errors** — agents get structured `ErrorType` values, not "500 Internal Server Error." They can distinguish validation failures from auth errors from business logic errors. * **Real-time notifications** — PubSub events are forwarded to connected agents as MCP logging messages. Agents don't just call tools — they react to events. * **Elicitation** — a tool can pause and ask the user for more input ([form or URL](/guide/mcp#elicitation)). Clients that omit the capability get a failed tool result, not a protocol error. ## Quick Start ### 1. Create a Keryx Project ```bash bunx keryx new my-agent-api cd my-agent-api cp .env.example .env bun install ``` ### 2. Write an Action Set `mcp = { tool: true }` to publish the action as a tool. Without it, the action still works everywhere else — it's just invisible to agents: ```ts export class WeatherLookup implements Action { name = "weather:lookup"; description = "Look up the current weather for a city"; inputs = z.object({ city: z.string().describe("City name"), units: z.enum(["celsius", "fahrenheit"]).default("celsius"), }); web = { route: "/weather/:city", method: HTTP_METHOD.GET }; mcp = { tool: true }; async run(params: ActionParams) { const weather = await fetchWeather(params.city, params.units); return { temperature: weather.temp, conditions: weather.conditions }; } } ``` This single class gives you an HTTP endpoint *and* an MCP tool called `weather-lookup` with a validated input schema — both from the same code. The actions a fresh `keryx new` project ships with don't set `mcp`, so a brand-new server starts with no tools. That's deliberate — you choose what agents can reach. Add `mcp = { tool: true }` to at least one action before moving on, or `listTools` will come back empty. ### 3. Enable MCP Add one line to your `.env`: ```bash MCP_SERVER_ENABLED=true ``` Start the server: ```bash bun dev ``` ### 4. Connect an AI Agent Add your server to Claude Desktop's MCP configuration (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS): ```json { "mcpServers": { "my-agent-api": { "url": "http://localhost:8080/mcp" } } } ``` The agent can now discover all your actions as tools, authenticate via OAuth, and call them with full type validation. ## Controlling Tool Exposure Tools are opt-in. An action with no `mcp` config is never exposed, so internal maintenance actions, admin-only operations, and anything destructive stay private without you doing anything: ```ts export class InternalCleanup implements Action { name = "internal:cleanup"; // no `mcp` config — invisible to agents // ... } ``` That action still works over HTTP, WebSocket, CLI, and as a background task. It's absent from MCP tool discovery only. To publish an action, opt it in: ```ts export class WeatherLookup implements Action { name = "weather:lookup"; mcp = { tool: true }; // ... } ``` Actions that declare an MCP App (`mcp.ui`) are registered as tools automatically — set `mcp = { ui: …, tool: false }` if you want the UI without the tool. Login and signup actions participate in the OAuth flow without being tools, so they set the flag but not `tool`: ```ts export class SessionCreate implements Action { name = "session:create"; mcp = { isLoginAction: true }; // ... } ``` See the [MCP reference](/guide/mcp#controlling-exposure) for the full `McpActionConfig` type. ## Designing Actions for Agents Not every action should be an MCP tool, and the actions you *do* expose shouldn't just be thin wrappers around your database. Agents work best when tools represent **intentions** — higher-order operations that reflect what a user is trying to accomplish — rather than raw CRUD endpoints that force the agent to orchestrate multi-step workflows on its own. Consider user onboarding. You could expose three tools — `user-create`, `email-send-welcome`, `workspace-create-default` — and hope the agent calls them in the right order with the right parameters. Or you could expose one: ```ts export class UserOnboard implements Action { name = "user:onboard"; description = "Create a new user account, send welcome email, and set up default workspace"; inputs = z.object({ name: z.string().min(3).describe("Display name"), email: z.string().email().describe("Email address (used for login)"), password: secret(z.string().min(8).describe("Password")), company: z.string().optional().describe("Company name for workspace"), }); web = { route: "/user/onboard", method: HTTP_METHOD.PUT }; mcp = { tool: true }; async run(params: ActionParams) { const user = await UserOps.create(params); await EmailOps.sendWelcome(user); await WorkspaceOps.createDefault(user, params.company); return { user: serializeUser(user) }; } } ``` The agent calls `user-onboard` once and three things happen. No multi-step orchestration, no missed steps, no half-created state if the agent loses context midway. This doesn't mean you can't have fine-grained actions — `user:create` is still useful as an HTTP endpoint or background task. Leave the low-level actions un-opted-in and set `mcp = { tool: true }` only on the higher-order workflow. You keep full flexibility for your HTTP clients while giving agents the right level of abstraction. A few principles that hold up in practice: * **Name tools by intent, not by verb + resource.** `user-onboard` is clearer to an agent than `user-create`. The description matters too — agents read it to decide which tool to call. * **Bundle related side effects.** If operation A always requires operations B and C, make one tool that does all three. The agent doesn't know your business rules — your server does. * **Use `.describe()` on every Zod field.** These descriptions become the parameter documentation agents see. "Email address (used for login)" is more useful than just `z.string().email()`. * **Don't expose internal plumbing.** Admin actions, cleanup jobs, and migration tasks aren't useful to agents. Leaving them without an `mcp` config is enough — be sparing about what you opt in. * **Consider markdown responses for agent-facing tools.** Actions that return human-readable data (status, reports, search results) can set `mcp = { responseFormat: MCP_RESPONSE_FORMAT.MARKDOWN }` so agents receive formatted markdown instead of raw JSON — saving tokens and avoiding re-formatting. See the [MCP response format guide](/guide/mcp#response-format) for details. For a deeper dive on tool design patterns for AI agents — including composition, batching, and context injection — see the [Arcade tool design patterns guide](https://www.arcade.dev/patterns). ## The Agent Auth Experience When an agent connects to your MCP server, it goes through an OAuth 2.1 flow with PKCE: 1. Agent calls `/mcp` and gets a `401` with a metadata URL 2. Agent discovers your OAuth endpoints automatically 3. Agent registers as a client via `POST /oauth/register` 4. Agent opens a browser window for the user to log in 5. User authenticates on a form **generated from your Zod schemas** 6. Agent receives an access token and uses it for all subsequent tool calls The key detail: the login and signup forms are built from the `inputs` schemas of your `isLoginAction` and `isSignupAction` actions. Fields wrapped in `secret()` render as password inputs, fields with "email" in the name get `type="email"`, and validation constraints like `min(8)` become HTML `minlength` attributes. Change your action's schema, and the OAuth page updates to match. Once authenticated, protected actions (those using `SessionMiddleware` or similar) work in MCP context the same way they work over HTTP. The agent's session is loaded automatically. See the [MCP reference](/guide/mcp#oauth-21-authentication) for the full OAuth flow and endpoint details. ## Testing Agent Interactions Test your MCP tools using the `@modelcontextprotocol/sdk` client: ```ts import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const transport = new StreamableHTTPClientTransport( new URL("http://localhost:8080/mcp"), { requestInit: { headers: { Authorization: `Bearer ${accessToken}` }, }, }, ); const client = new Client({ name: "test-client", version: "1.0.0" }); await client.connect(transport); // List available tools const tools = await client.listTools(); // Call a tool const result = await client.callTool({ name: "weather-lookup", arguments: { city: "Portland", units: "fahrenheit" }, }); ``` For authenticated tests, see the [MCP reference testing section](/guide/mcp#testing) for the full OAuth token flow. ## Real-Time Notifications Agents aren't limited to request-response tool calls. When messages are broadcast through the [PubSub system](/guide/channels) (e.g., chat messages via Redis PubSub), they're forwarded to MCP clients as MCP logging messages — but only to sessions whose authenticated user is authorized to subscribe to that channel. Notifications honor the same channel [authorization](/guide/channels) as WebSocket subscribers, so an agent never receives broadcasts for channels its user couldn't join. This enables reactive agent behavior — an agent can monitor a channel and respond when new data arrives, rather than polling. ## What Agents See When an agent calls `listTools`, it gets a clean list of your actions: * **Tool names** — action names with `:` replaced by `-` (e.g., `user:create` → `user-create`) * **Descriptions** — from your action's `description` property * **Input schemas** — JSON Schema generated from your Zod `inputs`, with field descriptions from `.describe()` * **Typed errors** — when a tool call fails, the agent receives a structured error with an `ErrorType` (e.g., `CONNECTION_ACTION_PARAM_VALIDATION`, `CONNECTION_ACTION_RUN`) instead of a generic failure message The Zod-to-JSON-Schema conversion handles edge cases automatically. Types that can't be represented in JSON Schema (like `z.date()`) fall back to `z.string()`, so tool registration never fails. ## Next Steps * [MCP reference](/guide/mcp) — full technical details on the MCP server, elicitation, OAuth endpoints, templates, and configuration * [Authentication guide](/guide/authentication) — how sessions and middleware work across all transports * [Actions guide](/guide/actions) — the core concept behind every MCP tool --- --- url: 'https://keryxjs.com/guide/caching.md' description: >- Use the built-in Redis connection for caching — cache-aside, helpers, and invalidation patterns. --- # Caching Keryx already boots a Redis connection for you. `api.redis.redis` is a fully configured [ioredis](https://github.com/redis/ioredis) client, available the moment your initializers finish starting. You don't need a separate caching library. Redis *is* your caching layer. ## How Redis is Initialized The `redis` initializer creates two ioredis connections at startup: | Connection | Access | Purpose | |---|---|---| | `api.redis.redis` | General commands | Caching, ad-hoc queries, anything your app needs | | `api.redis.subscription` | PubSub subscriber | Reserved for [channels](/guide/channels) — don't use this for caching | Both connect to the URL in `config.redis.connectionString` (defaults to `redis://localhost:6379/0`, overridable via `REDIS_URL`). The initializer verifies connectivity at boot — if Redis is unreachable, the server won't start. Set `REDIS_URL="memory://"` and both clients become in-process [ioredis-mock](https://github.com/stipsan/ioredis-mock) instances instead. Caching still works, but the cache belongs to one process and is empty after every restart — see [Running Without Postgres or Redis](/guide/deployment#running-without-postgres-or-redis). Because `api.redis.redis` is a standard ioredis instance, every Redis command is available: `GET`, `SET`, `DEL`, `HSET`, `LPUSH`, `EXPIRE`, `SETNX`, pipelines, Lua scripts, streams — the full Redis API. No wrapper to learn. ## Cache-Aside Pattern The most common approach: check Redis before hitting the database, populate the cache on a miss. ```ts import { api } from "keryx"; import { eq } from "drizzle-orm"; import { users } from "../schema"; const CACHE_TTL = 300; // 5 minutes export async function getUserById(id: number) { const cacheKey = `cache:user:${id}`; // Try cache first const cached = await api.redis.redis.get(cacheKey); if (cached) return JSON.parse(cached); // Miss — query the database const [user] = await api.db.db .select() .from(users) .where(eq(users.id, id)); if (user) { await api.redis.redis.set(cacheKey, JSON.stringify(user), "EX", CACHE_TTL); } return user ?? null; } ``` Invalidate when the data changes: ```ts export async function updateUser(id: number, data: Partial) { const [updated] = await api.db.db .update(users) .set(data) .where(eq(users.id, id)) .returning(); await api.redis.redis.del(`cache:user:${id}`); return updated; } ``` ## Cache Helper Wrap the cache-aside pattern into a reusable helper so your actions stay concise: ```ts import { api } from "keryx"; export async function cached( key: string, ttl: number, fn: () => Promise, ): Promise { const hit = await api.redis.redis.get(key); if (hit) return JSON.parse(hit) as T; const result = await fn(); await api.redis.redis.set(key, JSON.stringify(result), "EX", ttl); return result; } ``` Use it in read-heavy actions: ```ts export class UserShow implements Action { name = "user:show"; web = { route: "/user/:userId", method: HTTP_METHOD.GET }; inputs = z.object({ userId: z.coerce.number() }); async run(params: ActionParams) { const user = await cached( `cache:user:${params.userId}`, 60, () => UserOps.findById(params.userId), ); return { user }; } } ``` The helper checks Redis first, calls your function on a miss, and stores the result with a TTL — all in one call. Because the caching happens inside `run()`, the action controls exactly what gets cached and how the key is built. ## Invalidation ### By Key When you know exactly which cache entry changed: ```ts await api.redis.redis.del(`cache:user:${userId}`); ``` ### By Pattern Need to bust all cached responses for a resource? Use a key prefix convention and `scanStream`: ```ts export async function invalidateUserCache(userId: number) { const stream = api.redis.redis.scanStream({ match: `cache:*:*user*:${userId}*`, count: 100, }); for await (const keys of stream) { if (keys.length > 0) await api.redis.redis.del(...keys); } } ``` ### Version-Based For high-throughput invalidation, embed a version counter in your cache keys and bump it instead of scanning. A single `INCR` replaces an unbounded `SCAN`: ```ts const version = await api.redis.redis.get(`version:user:${id}`) ?? "0"; const cacheKey = `cache:user:${id}:v${version}`; // To invalidate: bump the version. Old keys expire naturally via TTL. await api.redis.redis.incr(`version:user:${id}`); ``` ## Beyond GET/SET Because you have the full ioredis client, you're not limited to string caching: | Technique | Redis command | Use case | |---|---|---| | Atomic set-if-absent | `SETNX` / `SET ... NX` | Distributed locks, deduplication | | Hash fields | `HSET` / `HGET` / `HGETALL` | Cache objects without serializing the whole thing | | Sorted sets | `ZADD` / `ZRANGE` | Leaderboards, rate-limit sliding windows | | Pipelines | `pipeline().get().set().exec()` | Batch multiple commands in one round trip | | Lua scripts | `eval()` / `evalsha()` | Atomic read-modify-write operations | | Expiry | `EXPIRE` / `PEXPIRE` | TTL on any key type, not just strings | ## Why Not a Cache Abstraction? You might expect a `cache.get()` / `cache.set()` wrapper. We intentionally skip that layer — ioredis already has a clean API, and a thin wrapper just hides the Redis features you'll eventually need. If you know Redis, you already know the Keryx caching API. --- --- url: 'https://keryxjs.com/changelog.md' description: 'Release history for Keryx, pulled from GitHub releases.' --- # Changelog All releases are published to [GitHub](https://github.com/actionhero/keryx/releases) and [npm](https://www.npmjs.com/package/keryx). --- --- url: 'https://keryxjs.com/guide/channels.md' description: >- Channels define PubSub topics for real-time WebSocket messaging with middleware-based authorization. --- # Channels Every project I've worked on eventually needs real-time messaging — chat, live dashboards, notifications, presence indicators. Channels are how Keryx handles this. They define PubSub topics that WebSocket clients can subscribe to, with middleware for controlling who gets access. Under the hood, channels use Redis PubSub, so messages are distributed across multiple server instances automatically. You don't need to think about sticky sessions or shared state. ## Defining a Channel ```ts import { Channel } from "keryx"; export class MessagesChannel extends Channel { constructor() { super({ name: "messages", description: "Public message stream", }); } } ``` That's a basic channel. Any WebSocket client can subscribe to `"messages"` and receive broadcasts. ## Pattern Matching Channel names can be exact strings or RegExp patterns: ```ts // Exact match — only "messages" name: "messages"; // Pattern match — "room:123", "room:abc", etc. name: /^room:.*$/; ``` This is useful when you have per-resource channels — chat rooms, user-specific feeds, document collaboration sessions. The `matches(channelName)` method handles the routing. ## Middleware Channel middleware controls who can subscribe and handles cleanup on unsubscribe: ```ts import type { ChannelMiddleware } from "keryx"; const AuthMiddleware: ChannelMiddleware = { runBefore: async (channel, connection) => { if (!connection.session) { throw new TypedError({ message: "Must be logged in to subscribe", type: ErrorType.CONNECTION_SESSION_NOT_FOUND, }); } }, runAfter: async (channel, connection) => { // cleanup on unsubscribe — presence tracking, etc. }, }; ``` * **`runBefore`** runs before a connection subscribes. Throw a `TypedError` to deny the subscription. * **`runAfter`** runs after a connection unsubscribes. Useful for cleanup or presence tracking. ## Custom Authorization For more complex authorization logic, you can override the `authorize()` method directly on the channel class: ```ts export class RoomChannel extends Channel { constructor() { super({ name: /^room:.*$/ }); } async authorize(channelName: string, connection: Connection) { const roomId = channelName.split(":")[1]; // check if the user has access to this room... } } ``` This runs after middleware, so you can combine both approaches — middleware for common checks (is the user logged in?) and `authorize()` for channel-specific logic (does this user belong to this room?). ## Broadcasting Use `api.pubsub.broadcast()` to send messages to all subscribers: ```ts await api.pubsub.broadcast( "messages", // channel name { message: serializedMessage }, // payload `user:${userId}`, // sender identifier ); ``` Messages go through Redis PubSub, so they work across server instances. If you're running three backend processes behind a load balancer, a broadcast from one reaches subscribers on all three. ## Presence Tracking Channels have built-in presence tracking — you can see who's currently subscribed to a channel. When a connection subscribes, it's registered with a presence key. When the last connection for a key leaves, a leave event is broadcast. ### How It Works By default, the presence key is the connection ID. Override `presenceKey()` on your channel to use something more meaningful, like a user ID from the session: ```ts export class RoomChannel extends Channel { constructor() { super({ name: /^room:.*$/ }); } async presenceKey(connection: Connection): Promise { return `user:${connection.session?.userId}`; } } ``` ### Presence API ```ts // Get all presence keys for a channel (across all server instances) const members = await api.channels.members("room:123"); // → ["user:1", "user:42", "user:7"] ``` Presence data is stored in Redis, so `members()` returns a global view across all instances in a multi-server deployment. With the in-process Redis (`REDIS_URL="memory://"`), channels and presence work the same way — but there is no cross-instance communication. Only run a single node in this mode. Presence events are broadcast automatically via PubSub when a key joins or leaves: ```json { "event": "join", "presenceKey": "user:42" } { "event": "leave", "presenceKey": "user:42" } ``` A single presence key can have multiple connections (e.g., a user with multiple browser tabs). The `join` event fires when the first connection for that key subscribes, and the `leave` event fires when the last connection for that key unsubscribes. ### Clearing Presence For test cleanup: ```ts await api.channels.clearPresence(); ``` ## WebSocket Security Channels and WebSocket connections have several built-in protections. See the [Security guide](/guide/security) for the full picture. ### Channel Name Validation Channel names must match `/^[a-zA-Z0-9:._-]{1,200}$/` — alphanumeric characters plus `:`, `.`, `_`, `-`, with a max length of 200. Invalid names are rejected before any subscription logic runs. ### Undefined Channels If a client tries to subscribe to a channel name that doesn't match any registered channel, the subscription is denied with a `CONNECTION_CHANNEL_AUTHORIZATION` error. You must define a channel (exact or pattern) for every topic clients can subscribe to. ### Origin Validation Before upgrading an HTTP connection to WebSocket, the server checks the `Origin` header against `config.server.web.allowedOrigins`. Requests from unrecognized origins are rejected, preventing Cross-Site WebSocket Hijacking (CSWSH). ### Connection Limits Each WebSocket connection is subject to: * **Message size** — messages larger than `websocket.maxPayloadSize` (default 64 KB) are rejected * **Message rate** — clients sending more than `websocket.maxMessagesPerSecond` (default 20/s) are disconnected * **Subscription count** — each connection can subscribe to at most `websocket.maxSubscriptions` (default 100) channels All of these are configurable via environment variables. See [Configuration](/guide/config) for details. ## WebSocket Client Examples ### Connecting and Running Actions ```js const ws = new WebSocket("ws://localhost:8080"); ws.onopen = () => { // Run an action over WebSocket ws.send( JSON.stringify({ messageType: "action", action: "status", messageId: "req-1", // echoed back in response for correlation }), ); }; ws.onmessage = (event) => { const data = JSON.parse(event.data); console.log(data); // → { messageId: "req-1", name: "server", uptime: 12345, ... } }; ``` ### Subscribing to Channels ```js // Subscribe ws.send( JSON.stringify({ messageType: "subscribe", channel: "messages", }), ); // Listen for broadcasts ws.onmessage = (event) => { const data = JSON.parse(event.data); if (data.message?.channel) { console.log(`Broadcast on ${data.message.channel}:`, data.message.message); } }; // Unsubscribe ws.send( JSON.stringify({ messageType: "unsubscribe", channel: "messages", }), ); ``` ### Message Types Reference | messageType | Fields | Description | | --------------- | ------------------------------------------ | -------------------------- | | `"action"` | `action`, `params`, `messageId` (optional) | Execute an action | | `"subscribe"` | `channel` | Subscribe to a channel | | `"unsubscribe"` | `channel` | Unsubscribe from a channel | ## Reference * [`Channel` class reference](/reference/classes#channel) — full API, including `ChannelMiddleware` * [`Connection`](/reference/classes#connection) — what a subscriber actually is --- --- url: 'https://keryxjs.com/guide/cli.md' description: >- CLI commands for scaffolding projects, generating components, running your Keryx server, and invoking actions from the terminal. --- # CLI Keryx includes a CLI (`keryx`) for common project tasks. When installed locally, run it with `bunx keryx`. When scaffolding a brand-new project, use `bunx keryx@latest`. The CLI has two kinds of commands: **framework commands** that ship with Keryx, and **action commands** that are automatically generated from the [actions](/guide/actions) you write. ## Framework Commands These are built-in commands for managing your project. ### `keryx new` Scaffold a new Keryx project. See [Getting Started](/guide/) for the full walkthrough. ```bash bunx keryx new my-app ``` Options: * `-y` / `--yes` — skip prompts, use defaults * `--no-interactive` — skip prompts, use defaults (same effect as `-y`) * `--no-db` — run without PostgreSQL: no Drizzle files or dependencies, and an empty `DATABASE_URL` so the app boots without a database * `--no-redis` — run without a Redis server: `REDIS_URL="memory://"` uses an in-process Redis (single process, not persisted) * `--no-example` — skip the example action `bunx keryx new my-app --no-db --no-redis` gives you a project that runs with nothing but Bun installed — background tasks and scheduled jobs included. * `--force` — scaffold into an existing directory; files that already exist are left untouched (merge-skip, never overwrite) `keryx new` also scaffolds OAuth template files into `templates/` (login/signup page, shared CSS, and the lion SVG). These are customizable — see the [MCP guide](/guide/mcp#oauth-templates) for details. ### `keryx generate` Generate a new component file with boilerplate. Aliased as `keryx g`. ```bash bunx keryx generate ``` #### Supported Types | Type | Directory | Example | | ------------- | --------------- | ------------------------------- | | `action` | `actions/` | `keryx g action user:delete` | | `initializer` | `initializers/` | `keryx g initializer cache` | | `middleware` | `middleware/` | `keryx g middleware auth` | | `channel` | `channels/` | `keryx g channel notifications` | | `ops` | `ops/` | `keryx g ops UserOps` | | `plugin` | `plugins/` | `keryx g plugin analytics` | #### Naming Conventions Colon-separated names create nested directories and map to routes for actions: ```bash keryx g action user:delete # Creates: actions/user/delete.ts # Route: /api/user/delete # Class: UserDelete ``` Simple names stay flat: ```bash keryx g initializer cache # Creates: initializers/cache.ts # Class: Cache ``` #### Options * `--dry-run` — preview what would be generated without writing files * `--force` — overwrite existing files * `--no-test` — skip generating the companion test file By default, each generated component also creates a matching test file in `__tests__/`. #### Example Output ```bash $ bunx keryx g action user:delete Generated: actions/user/delete.ts __tests__/actions/user/delete.test.ts ``` The generated action file: ```ts import { z } from "zod"; import { Action, type ActionParams, HTTP_METHOD } from "keryx"; export class UserDelete implements Action { name = "user:delete"; description = "TODO: describe this action"; inputs = z.object({}); web = { route: "/user/delete", method: HTTP_METHOD.GET }; async run(params: ActionParams) { // TODO: implement return {}; } } ``` ### `keryx upgrade` Update framework-owned files (config, built-in actions, OAuth templates) to match the installed version of Keryx. ```bash bunx keryx upgrade ``` Options: * `--dry-run` — show what would change without writing * `--force` — overwrite all framework files without confirmation * `-y` / `--yes` — overwrite all framework files without confirmation (same effect as `--force`) ### `keryx build` Pre-generate swagger response schemas so the server can skip the expensive and slow ts-morph analysis at startup. This is recommended for production deploys. ```bash bunx keryx build ``` The command writes `.cache/swagger-schemas.json`. When the server starts and finds a valid cache, it skips ts-morph entirely. **Recommended Dockerfile pattern:** ```dockerfile COPY . . RUN bun keryx.ts build CMD ["bun", "keryx.ts", "start"] ``` If you skip this step, the server generates the schemas on the fly at startup instead. That analysis can peak over 1 GB of RSS on a large app, which is enough to get the process OOM-killed on a small container before it finishes booting. The server logs a warning naming this remedy *before* it starts generating, so the message survives a kill. If ts-morph fails without crashing the process, the server starts with empty swagger response schemas. See [Deployment](/guide/deployment#build-step) for the full picture. ### `keryx start` Start the server. ```bash bunx keryx start ``` ### `keryx actions` List all discovered actions with their routes and descriptions. ```bash bunx keryx actions ``` ## Action Commands Every action you write is automatically registered as a CLI command — no extra configuration needed. Keryx discovers your actions and adds them to the CLI, so you can invoke any action directly from the terminal: ```bash bunx keryx [--input value] ``` When you run an action this way, Keryx starts the server in CLI mode (initializers run, but the web server doesn't bind a port), executes the action, prints the JSON result, and exits. ### Inputs as Flags Action inputs map directly to CLI flags. Required inputs in your Zod schema become required CLI options; optional inputs become optional flags: ```bash bunx keryx user:create --name evan --email "evan@example.com" --password secret ``` Run `--help` on any action to see its available parameters: ```bash bunx keryx user:create --help ``` ### Quiet Mode By default, Keryx logs server startup output alongside the action response. Use `-q` / `--quiet` to suppress everything except the action's JSON result — handy for piping into `jq` or other tools: ```bash bunx keryx status -q | jq ``` You can also set `CLI_QUIET=true` as an environment variable to make quiet mode the default. ### Error Output When an action throws a `TypedError`, the CLI includes the error's `message`, `type`, `key`, and `value` in the JSON output. Stack traces are included by default; set `CLI_INCLUDE_STACK_IN_ERRORS=false` to omit them. --- --- url: 'https://keryxjs.com/guide/from-actionhero.md' description: >- Migration guide from ActionHero — unified action/task controllers, route-on-action, Drizzle ORM replacing Sequelize, MCP as a transport, and removed features. --- # Coming from ActionHero Keryx is the spiritual successor to [ActionHero](https://www.actionherojs.com). It keeps the core ideas — transport-agnostic controllers, built-in background tasks, real-time channels — but rewrites everything on Bun with first-class MCP support, Zod validation, and Drizzle ORM. If you've used ActionHero before, here's what changed and why. ## Unified Controllers The biggest structural change: actions, tasks, and CLI commands are the same thing. In ActionHero you had separate `Action` and `Task` classes. In Keryx, there's just `Action`. Add a `task` property to make it a background job, add a `web` property for HTTP, or do both. Same `run()` method, same inputs, same middleware. See the [Actions guide](/guide/actions) for details. ## Separate Applications The frontend and backend are separate Bun applications. No `ah-next-plugin` — the Vite + React app is its own project in `frontend/`. Deploy them independently: frontend on Vercel, backend on a VPS, whatever works. They share types but not a process. ## Routes on Actions Actions define their own routes — strings with `:params` or RegExp patterns — directly on the action class. No `routes.ts` file. This keeps routing close to the handler and eliminates a common source of drift. ```ts web = { route: "/user/:id", method: HTTP_METHOD.GET }; ``` ## MCP as a Transport Every action is automatically available as an [MCP tool](/guide/mcp) for AI agents. OAuth 2.1 with PKCE handles authentication. This didn't exist in ActionHero. ## Real-Time Channels PubSub via Redis with middleware-based authorization for WebSocket clients. Channels support pattern matching (RegExp names) and [presence tracking](/guide/channels#presence-tracking). See [Channels](/guide/channels). ## Drizzle ORM First-class database support with [Drizzle ORM](https://orm.drizzle.team), auto-migrations, and type-safe schemas. Replaces the old `ah-sequelize-plugin`. ## Environment Config Config is static at boot, loaded from TypeScript files with per-`NODE_ENV` overrides via environment variables. The `loadFromEnvIfSet()` helper checks `${ENV_VAR}_${NODE_ENV}` first, then `${ENV_VAR}`, then falls back to the default. See [Configuration](/guide/config). ## Simplified Logger No Winston. STDOUT and STDERR only, with optional colors and timestamps. The `Logger` class supports levels from `trace` to `fatal`. ## Middleware Applied to actions as an array of `ActionMiddleware` objects with `runBefore` and `runAfter` hooks. Can throw to halt execution, or modify params/responses. See [Middleware](/guide/middleware). ## Testing No mock server. Tests make real HTTP requests with `fetch` — Bun includes it natively. Each test file boots the full server on a random port. See [Testing](/guide/testing). ## Sessions Cookie-based sessions stored in Redis are a first-class part of the framework. `SessionMiddleware` handles authentication. See [Authentication](/guide/authentication). ## Removed Features * **No Pidfiles** — Process management is left to your deployment tooling (systemd, Docker, etc.) * **No Cache Layer** — The old ActionHero cache has been removed. Use Redis directly — it's already part of the stack. * **No `ah-*-plugin` pattern** — Functionality that was previously in plugins (Sequelize, Next.js) is built in or handled as a separate application (the example frontend is now Vite + React). --- --- url: 'https://keryxjs.com/guide/config.md' description: >- Modular configuration with per-environment overrides via environment variables. --- # Configuration Config in Keryx is statically defined at boot — there's no dynamic config reloading. That said, every config value supports per-environment overrides via environment variables, so you can set things differently in test, development, and production without touching code. ## Structure Config is split into modules: ``` backend/config/ ├── index.ts # Aggregates everything into one `config` object ├── actions.ts # Action timeout, fan-out batch size and TTL ├── channels.ts # Presence TTL and heartbeat interval ├── database.ts # Database connection string (empty = no database), auto-migrate flag ├── logger.ts # Log level, timestamps, colors, output format (text/JSON) ├── observability.ts # OpenTelemetry metrics toggle, route, service name ├── process.ts # Process name, shutdown timeout ├── rateLimit.ts # Rate limiting windows and thresholds ├── redis.ts # Redis connection string (memory:// = in-process Redis) ├── session.ts # Session TTL, cookie security flags ├── tasks.ts # Task queue settings └── server/ ├── cli.ts # CLI error display, quiet mode ├── web.ts # Web server port, CORS, security headers, WS limits └── mcp.ts # MCP server toggle, route, OAuth TTLs, markdown depth limit ``` Everything rolls up into a single `config` object: ```ts import { config } from "../config"; config.database.connectionString; // Postgres URL ("" = no database) config.server.web.port; // 8080 config.logger.level; // "info" ``` ## Custom Config You can add your own config modules alongside the built-in ones. Create a new file in your `config/` directory, then aggregate it in `config/index.ts`: ```ts // config/audit.ts import { loadFromEnvIfSet } from "keryx"; export const configAudit = { retentionDays: await loadFromEnvIfSet("AUDIT_RETENTION_DAYS", 30), enabled: await loadFromEnvIfSet("AUDIT_ENABLED", true), }; ``` ```ts // config/index.ts import { configAudit } from "./audit"; export default { audit: configAudit, }; ``` At boot, Keryx deep-merges your config into the framework's `config` object, so `config.audit.retentionDays` works at runtime. To get full type safety, augment the `KeryxConfig` interface: ```ts // config/audit.ts (add at the bottom) declare module "keryx" { interface KeryxConfig { audit: typeof configAudit; } } ``` Now `config.audit.retentionDays` is fully typed everywhere you import `config` from `"keryx"` — no casts needed. ## Environment Overrides The `loadFromEnvIfSet()` helper is where the magic happens: ```ts import { loadFromEnvIfSet } from "../util/config"; export const configDatabase = { connectionString: await loadFromEnvIfSet("DATABASE_URL", ""), autoMigrate: await loadFromEnvIfSet("DATABASE_AUTO_MIGRATE", true), }; ``` The resolution order is: 1. `DATABASE_URL_TEST` (env var with `NODE_ENV` suffix — checked first) 2. `DATABASE_URL` (plain env var) 3. `""` (the default value) This means you can set `DATABASE_URL_TEST=postgres://localhost/bun-test` and it'll automatically be used when `NODE_ENV=test`, without any conditional logic in your config files. The helper is also type-aware — it parses `"true"`/`"false"` strings into booleans and numeric strings into numbers. So `DATABASE_AUTO_MIGRATE=false` does what you'd expect. One gotcha: an *empty* env var counts as unset for the `NODE_ENV` override. `DATABASE_URL_TEST=""` falls through to `DATABASE_URL`. To turn something off for one environment only, use an explicit value — that's why the database accepts `"none"` as well as `""`. ## Reference ### Actions | Key | Env Var | Default | Description | | ----------------- | --------------------------- | ---------------- | ----------------------------------------------- | | `timeout` | `ACTION_TIMEOUT` | `300000` (5 min) | Global action execution timeout in ms (0 = off) | | `fanOutBatchSize` | `ACTION_FAN_OUT_BATCH_SIZE` | `100` | Max jobs enqueued per Redis round-trip | | `fanOutResultTtl` | `ACTION_FAN_OUT_RESULT_TTL` | `600` (10 min) | TTL in seconds for fan-out result keys in Redis | ### Database | Key | Env Var | Default | | ------------------ | ----------------------- | ------- | | `connectionString` | `DATABASE_URL` | `""` | | `autoMigrate` | `DATABASE_AUTO_MIGRATE` | `true` | An empty `DATABASE_URL` (the default) or `"none"` runs without a database. Nothing connects, no migrations run, `api.db.enabled` is `false`, and `/status` reports the database check as `null` instead of failing. `withTransaction()`, `TransactionMiddleware`, and `zIdOrModel()` throw a clear `TypedError` if you call them anyway. Set a `postgres://` URL and the database comes back — no code changes. #### Advanced: Pool Tuning The database connection pool defaults are suitable for development and most production workloads. If you need to tune pool behavior — for example, to match your database's `max_connections` limit or to reduce idle resource usage — you can override these settings via environment variables. | Key | Env Var | Default | Description | | ------------------------------ | ------------------------------- | ------- | --------------------------------------------------------------------- | | `pool.max` | `DATABASE_POOL_MAX` | `10` | Maximum number of connections in the pool | | `pool.min` | `DATABASE_POOL_MIN` | `0` | Minimum number of idle connections to maintain | | `pool.idleTimeoutMillis` | `DATABASE_POOL_IDLE_TIMEOUT` | `10000` | How long (ms) a connection can sit idle before being closed | | `pool.connectionTimeoutMillis` | `DATABASE_POOL_CONNECT_TIMEOUT` | `0` | Max time (ms) to wait for a connection from the pool (0 = no timeout) | | `pool.allowExitOnIdle` | `DATABASE_POOL_EXIT_ON_IDLE` | `false` | Allow the Node.js process to exit while idle connections remain open | All defaults match `pg.Pool`'s built-in defaults, so existing deployments are unaffected. A common production override: ```bash DATABASE_POOL_MAX=25 DATABASE_POOL_MIN=5 DATABASE_POOL_IDLE_TIMEOUT=30000 DATABASE_POOL_CONNECT_TIMEOUT=5000 ``` ### Logger | Key | Env Var | Default | Description | | ------------------- | ------------------------ | -------- | ------------------------------------------------------------------------------------- | | `level` | `LOG_LEVEL` | `"info"` | Minimum log level (`trace`, `debug`, `info`, `warn`, `error`, `fatal`) | | `includeTimestamps` | `LOG_INCLUDE_TIMESTAMPS` | `true` | Prepend ISO-8601 timestamp to each log line | | `colorize` | `LOG_COLORIZE` | `true` | Apply ANSI color codes (text format only) | | `format` | `LOG_FORMAT` | `"text"` | Output format: `"text"` for human-readable, `"json"` for structured NDJSON | | `maxParamLength` | `LOG_MAX_PARAM_LENGTH` | `100` | Max length of individual param values in action logs before truncation (0 = no limit) | In JSON mode, each log line is a single JSON object with `timestamp`, `level`, `message`, and `pid` fields. Action and task logs include additional structured fields like `action`, `duration`, `status`, `method`, `url`, `correlationId`, `queue`, and `jobClass` — making them easy to parse with log aggregation systems (ELK, Datadog, CloudWatch, Loki, etc.). ```bash # Enable JSON logging in production LOG_FORMAT=json bun run start ``` Example JSON output: ```json { "timestamp": "2025-01-15T10:30:00.000Z", "level": "info", "message": "action: status", "pid": 12345, "action": "status", "connectionType": "web", "status": "OK", "duration": 12, "method": "GET", "url": "/api/status" } ``` ### Redis | Key | Env Var | Default | Description | | ------------------ | ----------- | ---------------------------- | --------------------------------------------------------- | | `connectionString` | `REDIS_URL` | `"redis://localhost:6379/0"` | Redis server URL, or `memory://` for the in-process Redis | `REDIS_URL="memory://"` swaps the Redis server for [ioredis-mock](https://github.com/stipsan/ioredis-mock) running inside your process. Tasks, the scheduler, PubSub, presence, sessions, OAuth, rate limiting, and fan-out all keep working, and `api.redis.inMemory` is `true`. The catch: that state belongs to one process and is gone when it exits. See [Running Without Postgres or Redis](/guide/deployment#running-without-postgres-or-redis). ### Session | Key | Env Var | Default | Description | | ---------------- | -------------------------- | -------------------------- | ----------------------------------------- | | `ttl` | `SESSION_TTL` | `86400` (1 day in seconds) | Session lifetime | | `cookieName` | `SESSION_COOKIE_NAME` | `"__session"` | Cookie name | | `cookieHttpOnly` | `SESSION_COOKIE_HTTP_ONLY` | `true` | Prevent JavaScript access | | `cookieSecure` | `SESSION_COOKIE_SECURE` | `false` | HTTPS-only cookies | | `cookieSameSite` | `SESSION_COOKIE_SAME_SITE` | `"Strict"` | CSRF protection (`Strict`, `Lax`, `None`) | ### Process | Key | Env Var | Default | | ----------------- | -------------------------- | ------------- | | `name` | `PROCESS_NAME` | `"server"` | | `shutdownTimeout` | `PROCESS_SHUTDOWN_TIMEOUT` | `30000` (30s) | ### Web Server | Key | Env Var | Default | | ---------------------- | ------------------------------------ | ------------------------------------------------ | | `enabled` | `WEB_SERVER_ENABLED` | `true` | | `port` | `WEB_SERVER_PORT` | `8080` | | `host` | `WEB_SERVER_HOST` | `"localhost"` | | `applicationUrl` | `APPLICATION_URL` | `"http://localhost:8080"` | | `apiRoute` | `WEB_SERVER_API_ROUTE` | `"/api"` | | `allowedOrigins` | `WEB_SERVER_ALLOWED_ORIGINS` | `"*"` | | `allowedMethods` | `WEB_SERVER_ALLOWED_METHODS` | `"HEAD, GET, POST, PUT, PATCH, DELETE, OPTIONS"` | | `allowedHeaders` | `WEB_SERVER_ALLOWED_HEADERS` | `"Content-Type"` | | `includeStackInErrors` | `WEB_SERVER_INCLUDE_STACK_IN_ERRORS` | `true` (dev) / `false` (prod) | | `theme` | `WEB_SERVER_THEME` | `""` | `theme` is an optional path (relative to your app's root, or absolute) to a stylesheet that Keryx compiles once at boot and inlines into every framework-rendered HTML surface — the [OAuth authorization page](./mcp.md#oauth-templates) and [MCP App shells](./mcp-apps.md). It accepts a `.css` file (bundled through `bun build`, so `@import`s resolve) or a `.ts`/`.js` entrypoint that default-exports a CSS string (compute the CSS from your design tokens). `.scss`/`.sass` are not compiled natively — precompile them to `.css` first. The value is cached at boot, so restart to pick up changes. #### Static Files | Key | Env Var | Default | Description | | -------------------------- | --------------------------------- | ------------------------ | ----------------------------------- | | `staticFiles.enabled` | `WEB_SERVER_STATIC_ENABLED` | `true` | Enable static file serving | | `staticFiles.directory` | `WEB_SERVER_STATIC_DIRECTORY` | `"assets"` | Directory to serve files from | | `staticFiles.route` | `WEB_SERVER_STATIC_ROUTE` | `"/"` | URL route prefix for static files | | `staticFiles.cacheControl` | `WEB_SERVER_STATIC_CACHE_CONTROL` | `"public, max-age=3600"` | Cache-Control header value | | `staticFiles.etag` | `WEB_SERVER_STATIC_ETAG` | `true` | Enable ETag/304 conditional caching | #### WebSocket | Key | Env Var | Default | Description | | -------------------------------- | ---------------------------- | --------------- | ---------------------------------------- | | `websocket.maxPayloadSize` | `WS_MAX_PAYLOAD_SIZE` | `65536` (64 KB) | Max message size in bytes | | `websocket.maxMessagesPerSecond` | `WS_MAX_MESSAGES_PER_SECOND` | `20` | Per-connection rate limit | | `websocket.maxSubscriptions` | `WS_MAX_SUBSCRIPTIONS` | `100` | Max channel subscriptions per connection | | `websocket.drainTimeout` | `WS_DRAIN_TIMEOUT` | `5000` (5 s) | Graceful shutdown drain period | #### Compression HTTP responses are automatically gzip-compressed when the client sends an `Accept-Encoding: gzip` header. Responses below the threshold or with incompressible content types (images, video, etc.) are served uncompressed. | Key | Env Var | Default | Description | | ----------------------- | --------------------------- | ---------- | ------------------------------------------ | | `compression.enabled` | `WEB_COMPRESSION_ENABLED` | `true` | Enable HTTP response compression | | `compression.threshold` | `WEB_COMPRESSION_THRESHOLD` | `1024` | Minimum response size in bytes to compress | | `compression.encodings` | — | `["gzip"]` | Supported encodings | #### Correlation IDs | Key | Env Var | Default | Description | | -------------------------- | -------------------------------- | ---------------- | ------------------------------------------------------------- | | `correlationId.header` | `WEB_CORRELATION_ID_HEADER` | `"X-Request-Id"` | Header name to read/echo (empty string to disable) | | `correlationId.trustProxy` | `WEB_CORRELATION_ID_TRUST_PROXY` | `false` | Read and echo the incoming correlation ID header from proxies | See the [Security guide](/guide/security#correlation-ids) for details. #### Security Headers All HTTP responses include these headers. Each is configurable: | Header | Env Var | Default | | --------------------------- | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Content-Security-Policy` | `WEB_SECURITY_CSP` | `default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; font-src 'self' https://cdn.jsdelivr.net data:; img-src 'self' data: blob:; connect-src 'self'; worker-src blob:` | | `X-Content-Type-Options` | `WEB_SECURITY_CONTENT_TYPE_OPTIONS` | `nosniff` | | `X-Frame-Options` | `WEB_SECURITY_FRAME_OPTIONS` | `DENY` | | `Strict-Transport-Security` | `WEB_SECURITY_HSTS` | `max-age=31536000; includeSubDomains` | | `Referrer-Policy` | `WEB_SECURITY_REFERRER_POLICY` | `strict-origin-when-cross-origin` | ### Channels | Key | Env Var | Default | Description | | --------------------------- | ----------------------------- | ------- | ------------------------------------------------- | | `presenceTTL` | `PRESENCE_TTL` | `90` | Presence key TTL in seconds | | `presenceHeartbeatInterval` | `PRESENCE_HEARTBEAT_INTERVAL` | `30` | Heartbeat interval in seconds to refresh presence | ### Observability | Key | Env Var | Default | Description | | -------------- | ---------------------- | ------------ | ----------------------------------------------- | | `enabled` | `OTEL_METRICS_ENABLED` | `false` | Enable OpenTelemetry metrics and /metrics route | | `metricsRoute` | `OTEL_METRICS_ROUTE` | `"/metrics"` | URL path for Prometheus scrape endpoint | | `serviceName` | `OTEL_SERVICE_NAME` | `""` | Service name in metric labels | ### Tasks | Key | Env Var | Default | | -------------------- | ----------------- | ------------------------- | | `enabled` | `TASKS_ENABLED` | `true` | | `queues` | — | `["*"]` | | `timeout` | `TASK_TIMEOUT` | `5000` | | `taskProcessors` | `TASK_PROCESSORS` | `1` (`0` in test) | | `checkTimeout` | — | `500` | | `maxEventLoopDelay` | — | `5` | | `stuckWorkerTimeout` | — | `3600000` (1 hour) | | `retryStuckJobs` | — | `false` | ### Rate Limiting See the [Security guide](/guide/security) for details on how rate limiting works. | Key | Env Var | Default | | ----------------------- | ------------------------------------- | ------------------------- | | `enabled` | `RATE_LIMIT_ENABLED` | `true` (disabled in test) | | `windowMs` | `RATE_LIMIT_WINDOW_MS` | `60000` (1 min) | | `unauthenticatedLimit` | `RATE_LIMIT_UNAUTH_LIMIT` | `20` | | `authenticatedLimit` | `RATE_LIMIT_AUTH_LIMIT` | `200` | | `keyPrefix` | `RATE_LIMIT_KEY_PREFIX` | `"ratelimit"` | | `oauthRegisterLimit` | `RATE_LIMIT_OAUTH_REGISTER_LIMIT` | `5` | | `oauthRegisterWindowMs` | `RATE_LIMIT_OAUTH_REGISTER_WINDOW_MS` | `3600000` (1 hour) | | `mcpAnonymousInitLimit` | `RATE_LIMIT_MCP_ANON_INIT_LIMIT` | `10` | | `mcpAnonymousInitWindowMs` | `RATE_LIMIT_MCP_ANON_INIT_WINDOW_MS` | `60000` (1 min) | ### CLI | Key | Env Var | Default | | ---------------------- | ----------------------------- | ------- | | `includeStackInErrors` | `CLI_INCLUDE_STACK_IN_ERRORS` | `true` | | `quiet` | `CLI_QUIET` | `false` | ### MCP Server | Key | Env Var | Default | | -------------------- | -------------------------- | ------------------- | | `enabled` | `MCP_SERVER_ENABLED` | `false` | | `route` | `MCP_SERVER_ROUTE` | `"/mcp"` | | `authMode` | `MCP_AUTH_MODE` | `"required"` | | `anonymousSessionTtl` | `MCP_ANONYMOUS_SESSION_TTL` | `3600` | | `allowedOrigins` | `MCP_ALLOWED_ORIGINS` | Claude/ChatGPT/VS Code Web | | `instructions` | `MCP_SERVER_INSTRUCTIONS` | package description | | `oauthClientTtl` | `MCP_OAUTH_CLIENT_TTL` | `2592000` | | `oauthCodeTtl` | `MCP_OAUTH_CODE_TTL` | `300` | | `oauthTrustProxy` | `MCP_OAUTH_TRUST_PROXY` | `false` | | `oauthCimdEnabled` | `MCP_OAUTH_CIMD_ENABLED` | `true` | | `oauthCimdCacheTtl` | `MCP_OAUTH_CIMD_CACHE_TTL` | `3600` | | `oauthCimdFetchTimeoutMs` | `MCP_OAUTH_CIMD_FETCH_TIMEOUT_MS` | `5000` | | `oauthCimdMaxBytes` | `MCP_OAUTH_CIMD_MAX_BYTES` | `65536` | | `oauthCimdAllowPrivateHosts` | `MCP_OAUTH_CIMD_ALLOW_PRIVATE_HOSTS` | `false` | | `markdownDepthLimit` | `MCP_MARKDOWN_DEPTH_LIMIT` | `5` | `oauthTrustProxy` controls whether `X-Forwarded-Proto` / `X-Forwarded-Host` headers are honored when deriving the external origin used in OAuth metadata and MCP `WWW-Authenticate` URLs. Only enable behind a trusted reverse proxy that strips client-supplied forwarded headers. See the [Security guide](/guide/security#reverse-proxy-forwarded-headers) for details. The `oauthCimd*` keys govern [Client ID Metadata Documents](/guide/mcp#client-id-metadata-documents). Leave `oauthCimdAllowPrivateHosts` off in production — it disables the SSRF guard that keeps the authorization server from fetching private addresses. ## Reference * [Full configuration reference](/reference/config) — every config key, its environment variable, and its default, generated from source --- --- url: 'https://keryxjs.com/reference/config.md' description: >- Auto-generated configuration reference — every config key, its environment variable, and default value. --- # Configuration Every config key in the backend, auto-generated from source. Each key can be overridden via environment variable — the system checks for `ENV_VAR_NODEENV` first (e.g., `DATABASE_URL_TEST` when `NODE_ENV=test`), then `ENV_VAR`, then falls back to the default. ## {{ section.section }} --- --- url: 'https://keryxjs.com/reference/classes.md' description: >- API singleton, Connection, Channel, Server, TypedError, and Logger class definitions. --- # Core Classes The remaining framework classes — the API singleton, connections, channels, servers, errors, and logging. ## API Source: `packages/keryx/classes/API.ts` The global singleton that manages the full server lifecycle. Stored on `globalThis` so it's accessible everywhere. Initializers attach their namespaces to it during boot. ```ts class API { rootDir: string; initialized: boolean; started: boolean; stopped: boolean; bootTime: number; logger: Logger; runMode: RUN_MODE; initializers: Initializer[]; /** Run all initializers in topological order (dependencies first) */ async initialize(): Promise; /** Start all initializers in topological order (dependencies first) */ async start(runMode?: RUN_MODE): Promise; /** Stop all initializers in reverse topological order (dependents first) */ async stop(): Promise; /** Stop then start */ async restart(): Promise; // Initializer namespaces are added dynamically: // api.db, api.redis, api.actions, api.session, etc. [key: string]: any; } ``` The lifecycle is `initialize() → start() → [running] → stop()`. Calling `start()` automatically calls `initialize()` first if it hasn't been called yet. ## Connection Source: `packages/keryx/classes/Connection.ts` Represents a client connection — HTTP request, WebSocket, or CLI invocation. The connection handles action execution, session management, and channel subscriptions. ```ts class Connection< T extends Record = Record, TMeta extends Record = Record, > { /** Connection type: "web", "websocket", "cli" */ type: string; /** Client identifier (IP, socket ID, etc.) */ identifier: string; /** Unique connection ID (UUID) */ id: string; /** Session ID for Redis lookup (defaults to `id`) */ sessionId: string; /** Session data, typed with your session shape */ session?: SessionData; /** Channels this connection is subscribed to */ subscriptions: Set; /** The underlying transport object (Bun Request, WebSocket, etc.) */ rawConnection?: any; /** * The underlying `Request` for HTTP connections. Headers are always readable; * the body only when the action declares `web.rawBody`. */ rawRequest?: Request; /** Request correlation ID for distributed tracing */ correlationId?: string; /** App-defined request-scoped metadata. Reset on each act() call. */ metadata: Partial; /** Execute an action with the given params */ async act( actionName: string | undefined, params: Record, method?: string, url?: string, ): Promise<{ response: Object; error?: TypedError }>; /** Update session data (merges with existing) */ async updateSession(data: Partial): Promise; /** Regenerate the session ID (prevents session fixation). Call after login. */ async regenerateSession(): Promise | null>; /** Subscribe to a PubSub channel */ subscribe(channel: string): void; /** Unsubscribe from a PubSub channel */ unsubscribe(channel: string): void; /** Broadcast a message to a subscribed channel */ async broadcast(channel: string, message: string): Promise; /** * Ask the MCP client for structured, non-sensitive input. Throws * `CONNECTION_MCP_ELICITATION` unless `connection.type` is MCP and the * client advertised form elicitation. */ async elicitForm(options: { message: string; schema: TSchema; }): Promise>; /** * Ask the MCP client for consent to open an out-of-band URL. `accept` is * consent, not completion. Throws unless the connection is MCP and the * client advertised URL elicitation. */ async elicitUrl(options: { message: string; url: string | URL; elicitationId?: string; }): Promise; /** Notify the MCP client that an accepted URL elicitation finished. */ async completeElicitation(elicitationId: string): Promise; /** Remove this connection from the connection pool */ destroy(): void; } ``` The generic `T` parameter types your session data. For example, `Connection<{ userId: number }>` gives you typed access to `connection.session.data.userId`. The generic `TMeta` parameter types request-scoped metadata that middleware and actions share within a single `act()` call. It's reset to `{}` at the start of each invocation, so long-lived connections (like WebSockets) don't leak state between actions. See the [Middleware guide](/guide/middleware#passing-data-between-middleware-and-actions) for usage examples. ## Channel Source: `packages/keryx/classes/Channel.ts` Defines a PubSub topic for WebSocket real-time messaging. Channels support exact-match names or RegExp patterns. ```ts abstract class Channel { /** String for exact match, RegExp for pattern matching */ name: string | RegExp; description?: string; /** Middleware for subscribe/unsubscribe lifecycle */ middleware: ChannelMiddleware[]; /** Check if this channel definition matches a requested channel name */ matches(channelName: string): boolean; /** Override for custom authorization logic. Throw TypedError to deny. */ async authorize(channelName: string, connection: Connection): Promise; /** Returns the presence identifier for a connection. Override to use e.g. user ID. Defaults to connection.id. */ async presenceKey(connection: Connection): Promise; } ``` ### ChannelMiddleware ```ts type ChannelMiddleware = { /** Runs before subscribe — throw TypedError to deny */ runBefore?: (channel: string, connection: Connection) => Promise; /** Runs after unsubscribe — cleanup, presence tracking, etc. */ runAfter?: (channel: string, connection: Connection) => Promise; }; ``` ## Server Source: `packages/keryx/classes/Server.ts` Base class for transport servers. The framework ships with a web server (`Bun.serve` for HTTP + WebSocket), a CLI entry point, and an MCP server for AI agents. You could add others (gRPC, raw TCP, etc.) by extending the `Server` base class. ```ts abstract class Server { name: string; /** The underlying server object (e.g., Bun.Server) */ server?: T; constructor(name: string); abstract initialize(): Promise; abstract start(): Promise; abstract stop(): Promise; } ``` ## TypedError Source: `packages/keryx/classes/TypedError.ts` All action errors should use `TypedError` instead of generic `Error`. Each error type maps to an HTTP status code, so the framework knows what status to return to the client. ```ts class TypedError extends Error { type: ErrorType; key?: string; // which param caused the error value?: any; // what value was invalid constructor(args: { message: string; type: ErrorType; cause?: unknown; // underlying error being wrapped (ES2022 Error.cause) key?: string; value?: any; }); } ``` When wrapping a caught error, pass it via `cause` so the native ES2022 `Error.cause` chain is preserved. `console.error(err)` will walk the chain and print the inner error's stack under "Caused by:", which is invaluable when debugging initializer boot failures. ```ts try { await redis.connect(); } catch (e) { throw new TypedError({ message: `Failed to connect to Redis`, type: ErrorType.SERVER_INITIALIZATION, cause: e, }); } ``` References: * [MDN — `Error.prototype.cause`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause) * [MDN — `Error()` constructor](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/Error) (see the `options.cause` parameter) * [TC39 — `proposal-error-cause`](https://github.com/tc39/proposal-error-cause) (stage 4, shipped in ES2022) * [Node.js — `util.inspect`](https://nodejs.org/api/util.html#utilinspectobject-options) walks `.cause` by default ### ErrorType → HTTP Status Mapping | ErrorType | Status | When | | ------------------------------------ | ------ | ------------------------------------------ | | `SERVER_INITIALIZATION` | 500 | Initializer failed to boot | | `SERVER_START` | 500 | Initializer failed to start | | `SERVER_STOP` | 500 | Initializer failed to stop | | `CONFIG_ERROR` | 500 | Invalid configuration | | `INITIALIZER_VALIDATION` | 500 | Initializer definition is invalid | | `ACTION_VALIDATION` | 500 | Action class definition is invalid | | `TASK_VALIDATION` | 500 | Task definition is invalid | | `SERVER_VALIDATION` | 500 | Server definition is invalid | | `CONNECTION_SESSION_NOT_FOUND` | 401 | No session / not authenticated | | `CONNECTION_SERVER_ERROR` | 500 | Internal server error | | `CONNECTION_ACTION_NOT_FOUND` | 404 | Unknown action name | | `CONNECTION_ACTION_PARAM_REQUIRED` | 406 | Missing required input | | `CONNECTION_ACTION_PARAM_DEFAULT` | 406 | Default value failed to apply | | `CONNECTION_ACTION_PARAM_VALIDATION` | 406 | Input failed Zod validation | | `CONNECTION_ACTION_PARAM_FORMATTING` | 406 | Input formatting error | | `CONNECTION_ACTION_RUN` | 500 | Action threw during `run()` | | `CONNECTION_TYPE_NOT_FOUND` | 406 | Unknown connection type | | `CONNECTION_NOT_SUBSCRIBED` | 406 | Tried to broadcast to unsubscribed channel | | `CONNECTION_CHANNEL_AUTHORIZATION` | 403 | Channel subscription denied | | `CONNECTION_CHANNEL_VALIDATION` | 400 | Invalid channel name | | `CONNECTION_ACTION_TIMEOUT` | 408 | Action exceeded its timeout | | `CONNECTION_RATE_LIMITED` | 429 | Client exceeded rate limit | | `CONNECTION_MCP_ELICITATION` | 406 | Elicitation off MCP, or mode unsupported | | `CONNECTION_TASK_DEFINITION` | 500 | Task definition error | ## Logger Source: `packages/keryx/classes/Logger.ts` Simple logger that writes to stdout. No Winston, no Pino — just STDOUT and STDERR with optional colors and timestamps. ```ts class Logger { level: LogLevel; colorize: boolean; includeTimestamps: boolean; trace(message: string, data?: any): void; debug(message: string, data?: any): void; info(message: string, data?: any): void; warn(message: string, data?: any): void; error(message: string, data?: any): void; fatal(message: string, data?: any): void; } enum LogLevel { trace = "trace", debug = "debug", info = "info", warn = "warn", error = "error", fatal = "fatal", } ``` --- --- url: 'https://keryxjs.com/plugins/csrf.md' description: >- CSRF protection middleware that issues per-session tokens and validates them on state-changing requests. --- # CSRF `@keryxjs/csrf` adds opt-in CSRF protection to a Keryx app. It exposes a `GET /csrf-token` endpoint that issues a per-session token, and a `CsrfMiddleware` you attach to any state-changing action you want to protect. Tokens are stored in Redis, keyed by session ID, and remain stable for the configured TTL. This plugin is the recommended response to [issue #447](https://github.com/actionhero/keryx/issues/447). The framework's default `SameSite=Strict` cookie blocks form-based CSRF for most apps, but breaks down once you relax `SameSite` to `Lax` / `None` (e.g. for OAuth flows) or accept `application/x-www-form-urlencoded` payloads. This plugin closes that gap. ## Installation ```bash bun add @keryxjs/csrf ``` ## Configuration `csrfPlugin` is a factory — call it with the middleware that should guard `/csrf-token`. Without a session check there, anonymous callers can mint tokens against the framework's auto-created anonymous session, which defeats the protection. Pass whatever session middleware your app uses: ```ts // config/plugins.ts import { csrfPlugin } from "@keryxjs/csrf"; import { SessionMiddleware } from "../middleware/session"; export default { plugins: [csrfPlugin({ tokenActionMiddleware: [SessionMiddleware] })], }; ``` Other config knobs (all optional): ```ts // config/csrf.ts export default { csrf: { tokenTtl: 3600, // seconds; refreshed on each /csrf-token fetch paramName: "csrfToken", // params field the token is read from redisKeyPrefix: "csrf:token", // Redis key prefix }, }; ``` ## Protecting an Action Attach `CsrfMiddleware` to any action that mutates state. **Declare `csrfToken` in the action's input schema** — Zod's `.object()` strips unknown keys before middleware runs, so without this field the token will be silently dropped and every request will fail validation. ```ts import { CsrfMiddleware } from "@keryxjs/csrf"; import { type Action, HTTP_METHOD } from "keryx"; import { z } from "zod"; import { SessionMiddleware } from "../middleware/session"; export class MessageCreate implements Action { name = "message:create"; middleware = [SessionMiddleware, CsrfMiddleware]; web = { route: "/message", method: HTTP_METHOD.PUT }; inputs = z.object({ body: z.string().min(1), csrfToken: z.string().optional(), }); async run(params: { body: string }) { /* ... */ } } ``` `CsrfMiddleware` is opt-in per action — only the actions you list it on are checked. There is no global enforcement and no method-based exemption (don't add it to GET actions). ## Fetching a Token (SPA) Call `GET /api/csrf-token` once on app bootstrap (or whenever you suspect the session has rotated). The token must be sent as a body field (or query string parameter) on state-changing requests — there is no header support. Cast the response through `ActionResponse` so the `token` field is typed at the call site (see [Typed Clients](/guide/typed-clients) for the full pattern): ```ts import type { ActionResponse } from "keryx"; import type { CsrfTokenAction } from "@keryxjs/csrf"; async function getCsrfToken(): Promise { const res = await fetch("/api/csrf-token", { credentials: "include" }); const body = (await res.json()) as ActionResponse; return body.token; } const csrfToken = await getCsrfToken(); await fetch("/api/message", { method: "PUT", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ body: "hello", csrfToken }), }); ``` The same token is returned on subsequent calls until it expires or the session is regenerated. ## Sending the Token from an HTML Form ```html
``` The middleware reads the token from the action's params (folded together from JSON body, form-encoded body, and query string). If you prefer a query parameter for `GET`-style state changes, append `?csrfToken=...` to the URL. ## What's Validated `CsrfMiddleware.runBefore`: 1. Skips non-web transports (`task`, `cli`, `mcp`, `websocket`) — those have no cross-site request concept. 2. Requires that a session has been established. Anonymous callers get `403`. 3. Reads the candidate token from `params[config.csrf.paramName]` (default `csrfToken`). 4. Compares it constant-time against the value stored at `csrf:token:${sessionId}` in Redis. 5. Throws `403` on miss / mismatch / expiry. ## Token Lifecycle * **Issuance** — `GET /csrf-token` looks up the token currently bound to the caller's session. If none exists, a fresh 32-byte URL-safe random token is generated. In both cases, the TTL is refreshed. * **Stability** — the same token is returned for the same session until it expires. This keeps long-lived tabs and cached forms working. * **Rotation** — call `connection.regenerateSession()` (e.g. on login) to invalidate the previous token. The new session has no token until the next `/csrf-token` fetch. ## Limitations * Tokens are bound to the session, so the caller must have a session before fetching one. The framework auto-creates an anonymous session on first request. * This plugin does not validate the `Origin` header. If you want that as a defense-in-depth measure, layer it in front of the middleware (or open an issue for a follow-up). --- --- url: 'https://keryxjs.com/guide/deployment.md' description: 'Deploying Keryx — Docker, production builds, reverse proxies, and scaling.' --- # Deployment Keryx is a backend API server. The included Vite + React frontend is a demo app — in production you'll bring your own client. This guide focuses on deploying the backend. ## Build Step Run `keryx build` at image build time. It is the one deploy step Keryx really wants you to have: ```bash cd backend && bun keryx.ts build ``` The command analyzes every action's return type with [ts-morph](https://ts-morph.com) and writes the result to `.cache/swagger-schemas.json`. At startup the server looks for that file, and — if its hash still matches your action sources — loads the schemas and moves on. **Without it, the server does the same analysis on every boot.** ts-morph loads the whole TypeScript program into memory, so peak RSS during that window scales with your action count: an app with ~100 actions peaks near 1.5 GB, roughly 18× its ~80 MB steady state, for about three seconds. On a 512 MB container the kernel kills the process before it finishes, and because the kill lands mid-analysis you get no application log at all — just the host's OOM notice. Container filesystems don't survive a restart, so a fresh container never has the cache and every deploy pays it. Keryx now warns before it starts generating, so if you do hit this the log names the cause: ``` warning: no swagger schema cache found at .cache/swagger-schemas.json; generating response schemas via ts-morph. This takes seconds and can peak over 1 GB of RSS, which will OOM a memory-capped host. Run `keryx build` at build time to pre-generate them and skip this step. ``` `keryx build` needs no database and no Redis, which is what makes it safe to run inside an image build: ```dockerfile COPY . . RUN cd backend && bun keryx.ts build CMD ["bun", "keryx.ts", "start"] ``` If you can't add a build step, the fallbacks are to give the runtime host enough headroom to absorb the spike (~1.5 GB for a large app), or to bake the cache in some other way — a persistent volume mounted at `.cache/` also works, since the server rewrites the file whenever it does regenerate. See [`keryx build`](/guide/cli#keryx-build) for the full command reference. ## Production Start ```bash cd backend # set NODE_ENV=production in .env, then start NODE_ENV=production bun start ``` ## Docker There's a `docker-compose.yaml` to run the backend with PostgreSQL and Redis: ```bash docker compose up ``` You probably won't use this exact setup in production, but it shows how the pieces fit together and gives you a working reference for your own deployment config. The backend `Dockerfile` runs `bun keryx.ts build` before its `CMD` — keep that line in whatever image you build. ## Environment Variables Set production config through environment variables. The config system (see [Configuration](/guide/config)) handles the rest: ```bash NODE_ENV=production DATABASE_URL=postgres://user:pass@host:5432/dbname REDIS_URL=redis://host:6379/0 APPLICATION_URL=https://api.example.com WEB_SERVER_PORT=8080 ``` ## Production Security Keryx ships with secure defaults, but a few settings need adjustment for production. See the [Security guide](/guide/security) for full details. ```bash # Cookies — require HTTPS transport SESSION_COOKIE_SECURE=true # CORS — restrict to your domain (wildcard blocks credentials) WEB_SERVER_ALLOWED_ORIGINS=https://yourapp.com # Rate limiting — enabled by default, tune thresholds as needed RATE_LIMIT_UNAUTH_LIMIT=20 RATE_LIMIT_AUTH_LIMIT=200 # Error stack traces — auto-disabled when NODE_ENV=production NODE_ENV=production # Security headers — defaults are production-ready # Customize CSP if your backend serves HTML with external resources: # WEB_SECURITY_CSP="default-src 'self'; script-src 'self' https://cdn.example.com" # WebSocket limits — adjust for your expected traffic # WS_MAX_PAYLOAD_SIZE=65536 # WS_MAX_MESSAGES_PER_SECOND=20 ``` ## Database Migrations Migrations auto-apply on server start when `DATABASE_AUTO_MIGRATE=true` (the default). If you'd rather run them explicitly before deploying: ```bash cd backend && bun run migrations ``` This generates migration files from schema changes into `./drizzle/`. They'll be applied automatically the next time the server starts — or you can set `DATABASE_AUTO_MIGRATE=false` and handle it yourself. ## Reverse Proxy In production, you'll typically put the backend behind a reverse proxy (nginx, Caddy, etc.) for TLS termination, compression, and load balancing. Here's a minimal nginx config: ```nginx upstream keryx_backend { server 127.0.0.1:8080; } server { listen 443 ssl; server_name api.example.com; ssl_certificate /etc/ssl/certs/api.example.com.pem; ssl_certificate_key /etc/ssl/private/api.example.com.key; location / { proxy_pass http://keryx_backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # WebSocket support proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; } } ``` Make sure to include the WebSocket upgrade headers — without them, WebSocket connections and MCP Streamable HTTP transport will fail. ## Scaling Keryx backends can run as multiple instances behind a load balancer. Redis handles the shared state (which is why this requires a real Redis server, not `memory://`): * **Sessions** are stored in Redis, so any instance can serve any request * **PubSub** broadcasts go through Redis, so channel messages reach subscribers on all instances * **Background tasks** use Resque with Redis, so workers on any instance can process jobs * **Presence tracking** uses Redis, so `api.channels.members()` returns the global view across all instances For horizontal scaling, the main consideration is that each instance runs its own Resque workers. Configure `TASK_PROCESSORS` per instance to control how many workers each one runs. Use `["*"]` for queues unless you need dedicated worker instances for specific queues. ## Running Without Postgres or Redis Not every app needs a database and a Redis cluster. An internal MCP tool server, a webhook relay, a cron-style worker — these can run on Bun alone: ```bash DATABASE_URL="" # or "none" — no Postgres REDIS_URL="memory://" # in-process Redis ``` `bunx keryx new my-app --no-db --no-redis` scaffolds exactly this. **What keeps working:** every transport (HTTP, WebSocket, CLI, background tasks, and MCP), the task scheduler and recurring tasks, `fanOut()`, PubSub and channel presence, sessions, OAuth, and rate limiting. Under the hood, `memory://` runs [ioredis-mock](https://github.com/stipsan/ioredis-mock) — a Redis implementation inside your process — so all of the Redis-backed code paths are the same ones you'd run in production. **What you give up:** * **The database.** No Drizzle models, no migrations. `api.db.enabled` is `false`, and database helpers throw if you call them. The [admin plugin](/plugins/admin) needs Postgres and refuses to serve without it. * **Durability.** Everything in Redis — sessions, OAuth tokens, queued and delayed jobs, fan-out results — lives in process memory and is gone on restart. That includes every `bun dev` hot reload, so expect to be logged out when you save a file. Recurring tasks survive because the scheduler re-enqueues them at boot; one-off jobs queued before a restart do not. * **More than one process.** The in-memory Redis is not shared, so you can't scale horizontally, split web and worker processes, or run more than one instance behind a load balancer. A `keryx` CLI action command runs in its own process with its own empty store — it can't enqueue work for your server or see its sessions. * **Redis instrumentation.** The [tracing](/plugins/tracing) and [Sentry](/plugins/sentry) plugins don't emit Redis spans in memory mode. When you outgrow it, set real URLs. `DATABASE_URL=postgres://…` and `REDIS_URL=redis://…` are the whole migration — your actions, tasks, and channels don't change. ## Process Management In production, use a process manager to keep the backend running: * **Docker** — use `restart: unless-stopped` in docker-compose * **systemd** — create a service unit for the backend process * **PM2** — `pm2 start "bun start" --name keryx-backend` Keryx handles `SIGINT` and `SIGTERM` for graceful shutdown — it stops accepting new connections, finishes in-flight requests, and disconnects from Redis and Postgres (when configured) before exiting. ### WebSocket Drain During shutdown, Keryx sends a close frame with code `1001` (Going Away) and reason `"Server shutting down"` to all connected WebSocket clients before terminating connections. This gives clients an opportunity to reconnect gracefully: ```javascript ws.onclose = (event) => { if (event.code === 1001) { // Server shutting down — reconnect after a delay setTimeout(() => reconnect(), 5000); } }; ``` The drain period is configurable via `WS_DRAIN_TIMEOUT` (default: 5000 ms). After the timeout, any remaining connections are forcibly closed. ## Monitoring & Observability Keryx includes built-in OpenTelemetry instrumentation. Enable it and point Prometheus at your instances: ```bash OTEL_METRICS_ENABLED=true bun run start ``` Each Keryx process serves a `/metrics` endpoint in Prometheus exposition format. Add a scrape target to your `prometheus.yml`: ```yaml scrape_configs: - job_name: "keryx" scrape_interval: 15s metrics_path: "/metrics" static_configs: - targets: ["localhost:8080"] ``` Because each process serves its own endpoint, **every node must be scraped individually** — metrics are not aggregated across instances. Use service discovery or list each target explicitly. For production log aggregation, switch to structured JSON logging: ```bash LOG_FORMAT=json bun run start ``` This outputs NDJSON with correlated fields (timestamp, level, correlation ID) that can be ingested by Elasticsearch, Datadog, Loki, or any structured log system. See the [Observability guide](/guide/observability) for the full list of available metrics and custom exporter setup, the [Tracing plugin](/plugins/tracing) for OTLP distributed tracing, or the [Sentry plugin](/plugins/sentry) for error monitoring and traces in Sentry. --- --- url: 'https://keryxjs.com/guide/style-guide.md' description: >- Editorial style guide for Keryx documentation — voice, tone, capitalization, and formatting conventions. --- # Docs Style Guide This page is for anyone writing or editing Keryx documentation. Follow these conventions to keep the docs consistent. ## Naming & Capitalization * **Keryx** — Always capitalized, used as a bare proper noun (like "Rails" or "Bun"), never "the Keryx" or "the Keryx framework" in prose * **action / initializer / channel / middleware** — Lowercase in prose when referring to the concept or an instance ("write an action," "each initializer runs in order"). Capitalize only when referring to the class name directly, and use backticks: "the `Action` class," "extends `Initializer`" * **MCP** — "an MCP tool" (not "a MCP" — the M is pronounced "em"). Spell out "Model Context Protocol" on first mention per page, then use "MCP" thereafter * **WebSocket** — Capital W, capital S, one word. Never "Websocket," "websocket," or "web socket" * **Bun, Zod, Drizzle, Redis, PostgreSQL** — Always capitalized as proper nouns * **OAuth** — Capital O, capital A. "OAuth 2.1" with a space before the version ## Transport Lists When listing all transports, use this canonical order: **HTTP, WebSocket, CLI, background tasks, and MCP**. Always include the Oxford comma. ## Code Terms in Prose Use backticks for: * Class names (`Action`, `Initializer`) * Type names (`ActionParams`, `ActionResponse`) * Property names (`web`, `inputs`, `task`) * Method names (`run()`, `initialize()`) * Config paths (`config.server.web.port`) * File names (`keryx.ts`) Don't use backticks for: * General concepts (action, initializer, middleware) * Transport names (HTTP, WebSocket) * Tool names (Bun, Zod, Redis) ## Voice & Tone **The voice:** An opinionated practitioner. You've built enough projects to know what works and what doesn't, and you're not shy about saying so. The tone is confident and prescriptive — earned through experience, not arrogance. Think: a senior engineer who's shipped real systems explaining things at a whiteboard. Knowledgeable but never lecturing. **Earned snark is welcome.** If there's a common anti-pattern or a "don't do this" moment, say it plainly. The reader respects directness because the opinion is backed by real scars. But snark should punch at bad ideas, never at the reader. Good examples: * "You don't need a separate routes file. The route lives on the action — where you'll actually look for it six months from now." * "Most frameworks make you write the same handler five times for five transports. That's not DRY, that's a staffing plan." * "Middleware is powerful. It's also the first place people over-engineer. A simple `if` check in your action is fine — save middleware for cross-cutting concerns." Bad examples (avoid): * "Obviously, you should..." (condescending) * "As any experienced developer knows..." (gatekeeping) * "Simply do X" (nothing is simple if you're reading the docs) **Perspective:** Strictly "you" (the reader). Avoid "we" — the focus is on what you're building, not on what the framework authors decided over lunch. The exception is the [About](/guide/about) page, which can use "we" when referring to the project and community. ## Grammar * **Tense:** Present tense for describing what things do ("Keryx routes the request," not "Keryx will route the request") * **Voice:** Active voice preferred ("the framework loads initializers" not "initializers are loaded by the framework") * **Oxford comma:** Always ("HTTP, WebSocket, CLI, background tasks, and MCP") * **Abbreviations:** Use `e.g.,` (with comma) in parenthetical examples. Spell out "for example" in running prose * **Contractions:** Use freely — "it's," "you'll," "doesn't." The docs should read like conversation, not a textbook * **"Simply" / "just" / "easy":** Avoid. If you need the docs, it's not simple yet. Describe what to do without editorializing the difficulty * **Links:** External links use absolute URLs. Internal links use relative paths (`/guide/actions`). Link text should be descriptive (not "click here") ## Page Structure * Every guide page should have a YAML frontmatter `description` field * First mention of another Keryx concept should link to its guide page * Code examples should be practical and runnable, not toy examples * Lead with the "what" and "why," then show the code. Don't dump a code block without context --- --- url: 'https://keryxjs.com/guide/comparisons.md' description: >- How Keryx compares to Hono, Elysia, NestJS, FastAPI, and Django across transports, type safety, and MCP support. --- # Framework Comparisons Every framework makes trade-offs. Here's where Keryx sits relative to the tools you're probably evaluating. ## Feature Matrix | Feature | Keryx | Hono | Elysia | NestJS | FastAPI | Django | | ------------------- | ----- | ------- | ------ | -------- | ------- | -------- | | HTTP | yes | yes | yes | yes | yes | yes | | WebSocket | yes | adapter | yes | yes | yes | channels | | CLI commands | yes | — | — | limited | — | yes | | Background tasks | yes | — | — | Bull | Celery | Celery | | MCP tools | yes | — | — | — | — | — | | Unified controller | yes | — | — | — | — | — | | Type-safe responses | yes | yes | yes | partial | yes | — | | OAuth 2.1 built-in | yes | — | — | Passport | — | allauth | ## vs Hono Hono is excellent for edge HTTP. It runs everywhere — Cloudflare Workers, Deno, Bun, Node — and its middleware composition is elegant. If you need multi-runtime edge deployment and nothing else, use Hono. Where Keryx diverges: Hono is an HTTP router. If you need WebSocket handling, CLI commands, background tasks, and Model Context Protocol (MCP) tools from the same codebase, you're stitching together separate libraries. Keryx gives you one [action](/guide/actions) class that serves all five transports with shared validation and middleware. Hono also doesn't have a built-in task runner or database layer. Keryx includes Resque-based [background tasks](/guide/tasks) with [fan-out](/guide/tasks#fan-out-pattern), Drizzle ORM with auto-migrations, and Redis PubSub [channels](/guide/channels) out of the box. ## vs Elysia Both Keryx and Elysia are Bun-native and type-safe. Elysia focuses on HTTP performance and ergonomics — its Eden Treaty for end-to-end type safety is clever, and it benchmarks well for pure HTTP workloads. Keryx takes a different bet: instead of optimizing one transport, it unifies all of them. Your `ActionResponse` types flow to the frontend the same way Eden does, but your action also works as a WebSocket handler, CLI command, background task, and MCP tool without any extra code. If you're building a pure HTTP API and performance is the top priority, Elysia is a strong choice. If you need your API to also be an MCP server that AI agents can discover and call — or you want built-in task scheduling, real-time channels, and CLI generation — that's Keryx. ## vs NestJS NestJS is enterprise-grade with a huge ecosystem. If you want Angular-style architecture for your backend — decorators, dependency injection, separate modules per transport — NestJS has the most mature story in TypeScript. Keryx is opinionated in a different direction. Instead of separate controllers, gateways, and processors for HTTP, WebSocket, and tasks, Keryx uses one action class for all transports. There's no DI container — the `api` singleton manages lifecycle, and initializers wire up services with explicit priority ordering. NestJS also has no MCP story. If you need your API to double as an MCP server for AI agents, you're on your own. Keryx treats MCP as a first-class transport with built-in OAuth 2.1 authentication. The trade-off: NestJS has more community packages and a larger hiring pool. Keryx has less ceremony and fewer files per feature. ## vs FastAPI FastAPI set the bar for type-safe API frameworks. Pydantic validation, automatic OpenAPI docs, async by default — it proved that a modern web framework should validate inputs at the boundary and generate documentation for free. Keryx brings that same developer experience to TypeScript. Zod schemas validate inputs, generate OpenAPI docs, power CLI `--help` text, and define MCP tool parameters — all from one definition. If you liked FastAPI's approach but work in TypeScript, Keryx is the closest equivalent. The difference: FastAPI is HTTP-only by default. WebSocket support exists but it's separate from your route handlers. Background tasks need Celery. CLI tools need Typer. MCP needs a separate server. Keryx ships all of these as built-in transports on a single action class. ## vs Django Django is batteries-included for web applications — admin panel, ORM, template engine, auth system, the works. It's the right choice for server-rendered apps where you want a proven, stable foundation. Keryx is batteries-included for APIs and MCP. No template engine, no admin panel — instead you get transport-agnostic controllers, real-time WebSocket channels, background task scheduling, and an MCP server that lets AI agents call your API through the Model Context Protocol. Different tools for different jobs. Django for server-rendered web apps with decades of stability. Keryx for API-first backends that need AI agent integration alongside traditional HTTP and WebSocket clients. ## vs MCP SDK (Bare) If you're building for AI agents, you might consider using the `@modelcontextprotocol/sdk` directly. It's well-built — Keryx uses it internally. But the SDK gives you the protocol, not the framework. With the bare SDK, you write tool handlers, manage sessions, build an auth layer, validate inputs, and wire up a transport — all by hand. With Keryx, you write an action with a Zod schema and get an MCP tool with OAuth 2.1, typed errors, input validation, middleware, and per-session isolation out of the box. Your action also works as an HTTP endpoint, WebSocket handler, CLI command, and background task — no extra code. The SDK is the right choice if you need a standalone MCP server with no HTTP API alongside it. If your tools need a web API, a database, background tasks, or real-time channels too, that's Keryx. ## Agent Readiness If you're evaluating frameworks for AI agent integration, here's what matters: Does it expose tools via MCP? Does auth work for non-browser clients? Can you control which endpoints agents see? Does each agent get isolated session state? Keryx is the only framework in this comparison where MCP, OAuth, tool control, and per-session isolation work out of the box — without additional libraries or configuration. See the [Building for AI Agents](/guide/agents) guide for a walkthrough. ## The Honest Answer If you're building an HTTP-only API, any of these frameworks will serve you well. Keryx shines when you need your API to also be a WebSocket server, a CLI tool, a background task runner, and an MCP server — without writing the same logic five times. --- --- url: 'https://keryxjs.com/guide.md' description: >- Get up and running with Keryx — prerequisites, installation, and your first dev server. --- # Getting Started Keryx is the fullstack TypeScript framework for MCP and APIs. It's the spiritual successor to [ActionHero](https://www.actionherojs.com), rebuilt from scratch on [Bun](https://bun.sh) with Zod for validation, Drizzle for the ORM, and a Vite + React frontend. The core ideas are the same — transport-agnostic actions, built-in background tasks, strong typing between frontend and backend — but the tooling and scope are different. Write your controller logic once, and it works as an HTTP endpoint, WebSocket handler, CLI command, background task, and MCP tool — all at the same time. Enable `MCP_SERVER_ENABLED=true` and AI agents can discover and call those same actions as MCP tools. The name **Keryx** (κῆρυξ) comes from ancient Greek, meaning "herald" or "messenger" — the person who carried proclamations between gods and mortals. It fits: your actions are the message, and Keryx delivers them across every transport. ## Prerequisites You'll need these running locally: * [Bun](https://bun.sh) (latest) * [PostgreSQL](https://www.postgresql.org/) * [Redis](https://redis.io/) ## Installation (macOS) ```bash # install bun curl -fsSL https://bun.sh/install | bash # install postgres and redis brew install postgresql redis brew services start postgresql brew services start redis ``` ## Create a New Project ```bash bunx keryx new my-app cd my-app cp .env.example .env createdb my-app createdb my-app-test bun install ``` The generated `.env` points `DATABASE_URL` at a database named after your project, and `DATABASE_URL_TEST` at `-test` — so create both before starting the server. `keryx new` prints these same steps when it finishes. The `keryx new` command will prompt you for a project name and optional features (database, Redis server, example action). You can also skip prompts with `--no-interactive`: ```bash bunx keryx new my-app --no-interactive ``` ### Without Postgres or Redis Postgres and Redis are what Keryx is built for, and what you should run in production. For a quick prototype or a single-process tool, you can skip them: ```bash bunx keryx new my-app --no-db --no-redis ``` `--no-db` leaves `DATABASE_URL` empty, so the app runs without a database. `--no-redis` sets `REDIS_URL="memory://"`, so Redis runs inside your process — background tasks, scheduled jobs, PubSub, and sessions keep working, but everything lives in one process and disappears on restart. See [Running Without Postgres or Redis](/guide/deployment#running-without-postgres-or-redis) for the trade-offs. Moving to the real thing later is just setting `DATABASE_URL` and `REDIS_URL`. ## Run the Dev Server ```bash bun dev ``` That's it. The backend will start with hot reload — edit a file, save it, and see the change immediately. ## Project Structure A new Keryx project looks like this: ``` my-app/ ├── actions/ # Transport-agnostic controllers ├── channels/ # WebSocket PubSub channels ├── drizzle/ # Generated migration SQL (not created with --no-db) ├── initializers/ # Lifecycle components (DB, Redis, etc.) ├── middleware/ # Action and channel middleware ├── schema/ # Drizzle ORM table definitions (not created with --no-db) ├── index.ts # Sets api.rootDir, re-exports framework types ├── keryx.ts # CLI entry point ├── .env.example # Environment variable template └── package.json ``` Actions, initializers, and channels are auto-discovered from their directories — just drop in a `.ts` file and the framework picks it up. ## Generate Components Use the CLI generator to scaffold new components with boilerplate: ```bash bunx keryx generate action user:create bunx keryx g initializer cache # "g" is a shorthand alias ``` This creates the component file and a matching test file. See the [CLI guide](/guide/cli) for all generator types and options. ## What's Next * [Actions](/guide/actions) — the core concept. Everything is an action. * [Initializers](/guide/initializers) — how the server boots up and connects to services * [Tasks](/guide/tasks) — background jobs and the fan-out pattern * [Configuration](/guide/config) — environment-based config with per-env overrides * [Building for AI Agents](/guide/agents) — expose your actions as MCP tools for Claude, Cursor, and other AI clients * [Caching](/guide/caching) — Redis-backed caching helpers and invalidation patterns * [Testing](/guide/testing) — real HTTP requests against a real server, no mocks * [Deployment](/guide/deployment) — running Keryx in production * [Coming from ActionHero](/guide/from-actionhero) — what changed, and how concepts map across --- --- url: 'https://keryxjs.com/reference/initializers.md' description: Initializer class definition and the module augmentation pattern. --- # Initializer Source: `packages/keryx/classes/Initializer.ts` Initializers are the lifecycle components that boot up your server. They run in dependency order — derived from each initializer's `dependsOn` field via a topological sort — during `initialize → start → stop`, and each one attaches its namespace to the global `api` singleton. ## Class Definition ```ts abstract class Initializer { /** The name of the initializer — also used as the api namespace key */ name: string; /** * Names of other initializers whose initialize() and start() must complete * before this one runs. Also determines stop order (dependents stop first). * Unknown names and cycles cause a startup error. * Default: [] */ dependsOn: string[]; /** Which run modes this initializer activates in */ runModes: RUN_MODE[]; constructor(name: string); /** Set up namespace object and return it. Attaches to api[name]. */ async initialize?(): Promise; /** Connect to external services. All dependencies are initialized and started by this point. */ async start?(): Promise; /** Clean up — close connections, flush buffers. */ async stop?(): Promise; } ``` ## RUN\_MODE Initializers can be scoped to specific run modes. By default, they run in both: ```ts enum RUN_MODE { CLI = "cli", SERVER = "server", } ``` ## Module Augmentation Pattern This is how each initializer makes `api.myNamespace` fully typed. You declare the type on the `API` interface, and TypeScript knows what's there: ```ts const namespace = "db"; declare module "../classes/API" { export interface API { [namespace]: Awaited>; } } ``` The return type of `initialize()` becomes `api[namespace]` — autocomplete, type checking, the works. ## Dependency Reference Each framework initializer declares what it depends on: | Initializer | `dependsOn` | | --------------- | -------------------------------------------------------- | | `connections` | `[]` | | `signals` | `[]` | | `process` | `[]` | | `db` | `[]` | | `redis` | `[]` | | `hooks` | `[]` | | `actions` | `["hooks"]` | | `observability` | `["hooks", "actions", "connections"]` | | `swagger` | `["actions"]` | | `session` | `["redis"]` | | `oauth` | `["redis", "actions"]` | | `pubsub` | `["redis", "connections"]` | | `channels` | `["redis", "pubsub"]` | | `servers` | `["actions", "hooks"]` | | `mcp` | `["hooks", "actions", "oauth", "connections", "pubsub"]` | | `resque` | `["redis", "db", "actions", "process", "hooks"]` | The resolved graph is rendered to the logs at startup. See the [initializers guide](../guide/initializers.md) for sample output. --- --- url: 'https://keryxjs.com/guide/initializers.md' description: >- Initializers are lifecycle components that set up services and attach them to the global API singleton. --- # Initializers Initializers are the backbone of the server's boot process. They're lifecycle components that set up services — connecting to databases, starting Redis, registering actions, configuring the task queue — in a controlled, dependency-ordered sequence. If you've worked with the original ActionHero, initializers will feel familiar. The big difference here is the TypeScript integration: each initializer uses module augmentation to extend the `API` interface with its namespace, so `api.db`, `api.redis`, `api.actions` are all fully typed throughout the codebase. ## Lifecycle The server goes through three phases: ``` initialize() → start() → [running] → stop() ``` * **`initialize()`** — set up your namespace object and return it. This is where you define the shape of what gets attached to `api`. * **`start()`** — connect to external services (databases, Redis, etc.). By this point, all initializers have been loaded, so you can reference other namespaces. * **`stop()`** — clean up. Close connections, flush buffers, shut down gracefully. ## Dependency Ordering Each initializer declares the names of other initializers it depends on via `dependsOn: string[]`. At boot the framework performs a topological sort so every dependency's `initialize()` and `start()` run before the dependent's. The `stop()` phase runs in the reverse order so dependents shut down before their dependencies. ```ts export class Session extends Initializer { constructor() { super("session"); this.dependsOn = ["redis"]; // session needs api.redis before it can initialize } } ``` Unknown dependency names or cycles cause a startup failure with a clear, actionable error message. | Initializer | `dependsOn` | What it does | | --------------- | -------------------------------------------------------- | -------------------------------------------------- | | `connections` | `[]` | Connection pool management | | `signals` | `[]` | SIGINT/SIGTERM graceful shutdown handlers | | `process` | `[]` | Process metadata (name, boot time) | | `db` | `[]` | Drizzle ORM + connection pool (skipped when `DATABASE_URL` is empty) | | `redis` | `[]` | Redis client connections (in-process with `memory://`) | | `hooks` | `[]` | Central registry for framework lifecycle hooks | | `actions` | `["hooks"]` | Discovers and registers all actions | | `observability` | `["hooks", "actions", "connections"]` | OpenTelemetry metrics + Prometheus scrape endpoint | | `swagger` | `["actions"]` | Parses source code for OpenAPI schemas | | `session` | `["redis"]` | Redis-backed session management | | `oauth` | `["redis", "actions"]` | OAuth 2.1 provider for MCP auth | | `pubsub` | `["redis", "connections"]` | Redis PubSub for real-time messaging | | `channels` | `["redis", "pubsub"]` | Discovers and registers PubSub channels | | `servers` | `["actions", "hooks"]` | Auto-discovers and loads transport servers | | `mcp` | `["hooks", "actions", "oauth", "connections", "pubsub"]` | MCP server — exposes actions as tools | | `resque` | `["redis", "db", "actions", "process", "hooks"]` | Background task queue | When the server starts, it renders the resolved graph to the logs so the order is visible at a glance: ``` --- 🔗 Initializer dependency graph --- 01 connections 02 signals 03 process 04 db 05 redis 06 hooks 07 actions ← hooks 08 observability ← hooks, actions, connections 09 swagger ← actions 10 session ← redis 11 oauth ← redis, actions 12 pubsub ← redis, connections 13 channels ← redis, pubsub 14 servers ← actions, hooks 15 mcp ← hooks, actions, oauth, connections, pubsub 16 resque ← redis, db, actions, process, hooks ``` ## The Module Augmentation Pattern This is the part that makes the type system work. Each initializer extends the `API` interface so TypeScript knows what's available on the `api` singleton: ```ts import { Initializer } from "../classes/Initializer"; import { api, logger } from "../api"; const namespace = "db"; // This is the magic — tells TypeScript that api.db exists and what type it is declare module "../classes/API" { export interface API { [namespace]: Awaited>; } } export class DB extends Initializer { constructor() { super(namespace); // no dependencies — Postgres connection stands alone } async initialize() { const dbContainer = {} as { db: ReturnType; pool: Pool; }; return Object.assign( { generateMigrations: this.generateMigrations, clearDatabase: this.clearDatabase, }, dbContainer, ); } async start() { api.db.pool = new Pool({ connectionString: config.database.connectionString, }); api.db.db = drizzle(api.db.pool); // migrations run here if configured... } async stop() { await api.db.pool.end(); } } ``` The return value of `initialize()` becomes `api.db` — and that type flows everywhere. You get autocomplete in your actions, your tests, your ops layer… everywhere. ## The `api` Singleton The `api` object lives on `globalThis` and accumulates namespaces as initializers run: ```ts api.db; // Drizzle ORM + Postgres pool api.redis; // Redis client api.actions; // Action registry + fan-out api.session; // Session manager api.pubsub; // Redis PubSub api.swagger; // OpenAPI schema cache api.oauth; // OAuth 2.1 provider api.mcp; // MCP server api.resque; // Background task queue ``` Every namespace is typed via module augmentation, so you never have to cast or guess at the shape of `api.db` or `api.redis`. ## Auto-Discovery Initializers are auto-discovered. Drop a `.ts` file in `initializers/`, export a class that extends `Initializer`, and it'll get picked up on boot. Files prefixed with `.` are skipped — useful for temporarily disabling an initializer without deleting it. ## Run Modes The server can boot in two modes: * **`RUN_MODE.SERVER`** (default) — starts all transports (web server, task workers, etc.) * **`RUN_MODE.CLI`** — skips transport-specific setup; used when running actions from the command line Each initializer declares which run modes it supports via `runModes`. Most initializers run in both modes, but transport-specific ones (like the web server) only run in `SERVER` mode. This means `./keryx.ts "status" -q` can execute the action without binding to a port. ## Swagger / OpenAPI Schema Generation The `swagger` initializer (depends on `actions`) generates JSON Schema definitions for action response types using TypeScript AST parsing via [ts-morph](https://github.com/dsherret/ts-morph). It scans all action source files, finds the `run()` method return type, and converts it to JSON Schema. Schemas are cached in `/.cache/swagger-schemas.json` and regenerated when action source files change (detected via content hashing). These schemas are used by the web server to serve a Swagger/OpenAPI-compatible API description. ## Process Lifecycle The `api` singleton manages the full lifecycle: ```ts await api.start(); // initialize + start all initializers await api.stop(); // stop all initializers in reverse dependency order await api.restart(); // stop + start (with flap prevention) ``` `api.restart()` includes flap prevention — if a restart is already in progress, the second call is a no-op. This prevents cascading restart loops. Signal handlers are registered by the `signals` initializer: * **SIGINT** (Ctrl+C) — triggers graceful shutdown via `api.stop()` * **SIGTERM** — same graceful shutdown The shutdown process walks the dependency graph in reverse, so dependents stop before the initializers they depend on — channels and the MCP server stop before the web server, which stops before the database pool and Redis are closed. ## Reference * [`Initializer` class reference](/reference/initializers) — full lifecycle API and the dependency-graph fields * [API, Connection, Channel & more](/reference/classes) — the objects an initializer attaches to `api` --- --- url: 'https://keryxjs.com/guide/mcp-apps.md' description: >- MCP Apps — render an action's MCP tool result as an interactive HTML UI in the host. Point mcp.ui.client at a browser entrypoint; Keryx bundles it and delivers structured data via UIResponse. --- # MCP Apps [MCP Apps](https://modelcontextprotocol.io/extensions/apps/overview) is an extension to the Model Context Protocol that lets a tool return an **interactive HTML UI** instead of just text. The host (Claude, Claude Desktop, VS Code Copilot, and others) renders that UI inline in the conversation, inside a sandboxed iframe. The UI can call back into your server's tools and receive fresh data — a dashboard, form, chart, or viewer that lives right next to the chat. In Keryx this is a natural extension of the action model. Just as an action already exposes an MCP tool, resource, or prompt, it can declare a UI. You point `mcp.ui.client` at a browser entrypoint and return a [`UIResponse`](#uiresponse) from `run()` — Keryx bundles the client and wires up the rest. ::: tip Prerequisite MCP Apps build on the MCP server. Enable it first (see [MCP Server](./mcp.md)), then add a UI to any tool action. Elicitation (`connection.elicitForm` / `elicitUrl`) is a separate server-to-client request — it is not how MCP Apps collect input. ::: ## How it works An MCP App combines two MCP primitives that Keryx registers for you: 1. **A tool** whose description points at a UI via `_meta.ui.resourceUri`. 2. **A `ui://` resource** that serves the app's self-contained HTML. At startup Keryx bundles your `mcp.ui.client` entrypoint (with Bun's bundler) and inlines it into a self-contained HTML document. When the model calls the tool, the host fetches the `ui://` resource, renders the HTML in a sandboxed iframe, and pushes the tool's **`structuredContent`** to the app for rendering. The app can then call any tool on your server to fetch more data — all over a secure `postMessage` channel. ``` Action (mcp.ui.client + returns UIResponse) ├─▶ tool "status-app" _meta.ui.resourceUri = "ui://status-app" └─▶ resource "ui://status-app" → text/html;profile=mcp-app (bundled at boot) tool call ─▶ UIResponse ─▶ { content: [text], structuredContent: {…} } │ │ model context app rendering ``` Keryx advertises the `io.modelcontextprotocol/ui` extension capability during `initialize` (when any action declares `mcp.ui`) so compliant hosts negotiate UI support. ## Declaring a UI An MCP App is two files: the **action** (server) and the **client** (browser). ### 1. The action Add a `ui` block to an action's `mcp` config and point `client` at a browser entrypoint. Return a [`UIResponse`](#uiresponse) from `run()`. ```ts import { Action, api, UIResponse } from "keryx"; import { z } from "zod"; export class StatusDashboardApp implements Action { name = "status:app"; description = "Show live server status as an interactive dashboard."; inputs = z.object({}); mcp = { ui: { client: new URL("../mcpApp/status.ts", import.meta.url), // Keryx bundles this prefersBorder: true, }, }; async run() { return new UIResponse( { name: api.process.name, pid: api.process.pid, uptime: new Date().getTime() - api.bootTime, }, { text: `Server ${api.process.name} is running.` }, ); } } ``` That's it — the tool `status-app` is now linked to a `ui://status-app` resource whose HTML embeds your bundled client, and calling it delivers the structured data to the app. ::: info No build step, no CDN `client` is bundled at boot into one self-contained HTML document, so the sandboxed iframe needs no network access and the default deny-by-default CSP just works. You don't run a bundler, add a build script, or maintain a placeholder HTML file. (Bundling happens in a short-lived child process during startup.) ::: ### 2. The client Install the browser client package: ```bash bun add @keryxjs/mcp-app ``` Write your entrypoint against [`mountMcpApp`](#mountmcpapp). It connects to the host, renders the tool's structured data, and keeps it fresh: ```ts // mcpApp/status.ts import type { ActionResponse } from "keryx"; import { mountMcpApp } from "@keryxjs/mcp-app"; import type { Status } from "../actions/status"; // Reuse the action's response type instead of redefining it — the UI binds to // exactly what the tool returns. See Typed Clients. type StatusData = ActionResponse; mountMcpApp({ name: "Server Status", render: (data, root) => { root.innerHTML = `

${data.name}

PID ${data.pid}

`; }, refreshTool: { name: "status" }, // optional: powers refresh() + self-hydrate }); ``` `root` defaults to the `#root` element in the shell Keryx generates, so a minimal app needs no HTML of its own. When you want custom markup or CSS, provide your own shell — see [Custom HTML shell](#custom-html-shell). ::: tip Type the UI off your action `import type` a server action and use [`ActionResponse
`](./typed-clients.md) for the render payload — the `import type` is erased at bundle time, so no server code reaches the browser, and your UI stays in sync with the tool's return type. This is the same zero-tooling pattern the frontend uses. ::: ### TypeScript Browser code needs DOM types, which must not leak into your Bun server program (they clash with Bun's `WebSocket`). Keep browser files in their own directory with a 2-line tsconfig that extends the base shipped by `@keryxjs/mcp-app`: ```json // mcpApp/tsconfig.json { "extends": "@keryxjs/mcp-app/tsconfig.mcp-app.json", "include": ["**/*.ts"] } ``` Then exclude that directory from your server tsconfig (`"exclude": ["mcpApp"]`) and typecheck it separately (`tsc -p mcpApp`). ## `mountMcpApp` `mountMcpApp(options)` wraps the [`@modelcontextprotocol/ext-apps`](https://github.com/modelcontextprotocol/ext-apps) `App` and the connect/hydrate lifecycle. It registers the tool-result handler **before** connecting (so the host's initial data push is never missed) and **self-hydrates** by calling the refresh tool if that push never arrives — the workaround some hosts (e.g. Cursor) require. ```ts const { app, refresh } = await mountMcpApp({ name: "Server Status", // reported to the host (default "MCP App") version: "1.0.0", render: (data, root) => { /* paint the DOM */ }, onError: (err) => { /* surface failures (default: swallowed) */ }, root: "#root", // element or selector passed to render (default "#root") refreshTool: { name: "status" }, // tool for refresh()/self-hydrate }); ``` It returns `{ app, refresh }`: call `refresh()` (e.g. from a button) to re-fetch and re-render; `app` is the underlying `App` for advanced use. Because every Keryx action is already a tool, your app can call any of them — JSON object tool results include `structuredContent` automatically, so apps bind without `JSON.parse`. If you omit `refreshTool`, `mountMcpApp` falls back to the app's own tool when the host advertises it. ## `UIResponse` Return a `UIResponse` from `run()` to hand the host two payloads at once: * **`structuredContent`** — the object your app UI renders. Delivered to the app, **not** added to the model's context. * **`text`** — a text summary added to the model's context. Defaults to `JSON.stringify(structuredContent)`. ```ts new UIResponse(structuredContent, { text: "optional model-facing summary" }); // or: UIResponse.from(structuredContent, { text: "…" }) ``` Over non-MCP transports (HTTP, WebSocket, CLI) a `UIResponse` serializes to its `structuredContent` via `toJSON()`, so the same action still returns useful JSON everywhere. A `GET` to the action's web route returns the structured object directly. `UIResponse` is generic over the shape of `structuredContent`. When you build one from an object literal, the field types are inferred and flow through to `run()`'s return type — and from there into the generated OpenAPI/MCP response schema, so the app UI has a fully-typed payload to bind against rather than an opaque object. ## `McpUiConfig` options | Property | Type | Default | Description | | --------------- | ----------------- | ------------------- | --------------------------------------------------------------------------- | | `client` | `string \| URL` | — | Browser entrypoint Keryx bundles at boot. Provide `client`, `html`, or both.| | `html` | `string` | *(default shell)* | Verbatim HTML, or (with `client`) the shell the bundle is inlined into. | | `resourceUri` | `string` | `ui://` | The `ui://` resource URI the tool links to. | | `csp` | `object` | — | External origins the app may reach (see [CSP](#csp-and-permissions)). | | `permissions` | `object` | — | Extra iframe capabilities: `camera`, `microphone`, `geolocation`, `clipboardWrite`. | | `prefersBorder` | `boolean` | — | Hint the host to render a border/frame around the app. | | `domain` | `string` | — | Logical grouping/isolation hint for the host. | At least one of `client` or `html` must be set. ## Custom HTML shell For custom markup or CSS, pass an HTML shell as `html` alongside `client`. Keryx inlines the bundled client into an empty `` (or a `/* MCP_APP_CLIENT */` placeholder comment, else before ``): ```ts mcp = { ui: { client: new URL("../mcpApp/status.ts", import.meta.url), html: await Bun.file(new URL("./status-app.html", import.meta.url)).text(), }, }; ``` Your render function can then target elements in that shell directly instead of the default `#root`. ## Shared theme App shells render in a **sandboxed iframe**, so they cannot `` to a stylesheet on your origin — shared branding must be **inlined source**. Set [`config.server.web.theme`](./config.md#web-server) to a `.css` file (or a `.ts`/`.js` entrypoint that default-exports a CSS string) and Keryx inlines that CSS into every shell at boot — the same theme it applies to the [OAuth authorization page](./mcp.md#shared-theme), so both Keryx-rendered surfaces stay consistent. The default shell already inlines Keryx's shared `--keryx-*` [design tokens](./mcp.md#shared-theme), so your render code can reference the palette (`color: var(--keryx-color-primary)`) and inherits the shared font by default — even before you configure a theme. Your configured theme is inlined **after** the tokens, so it overrides them. The default shell picks the theme up automatically. In a custom `html` shell, mark where the theme should land with a `/* MCP_APP_THEME */` comment (mirroring `/* MCP_APP_CLIENT */`); if you omit it, Keryx inserts a ` ``` ## Bring your own HTML (escape hatch) You don't have to use `client` at all. Set only `html` to a fully self-contained string and Keryx serves it verbatim — you inline your own scripts and styles (e.g. a UI you bundle with [Vite](https://vitejs.dev/) and [`vite-plugin-singlefile`](https://github.com/richVL/vite-plugin-singlefile)): ```ts mcp = { ui: { html: await Bun.file(builtHtmlPath).text() } }; ``` ## CSP and permissions Apps render under a deny-by-default Content-Security-Policy. Keep the HTML self-contained (the `client` path does this for you) and no CSP tuning is needed. If your UI loads scripts, styles, or data from external origins, declare them: ```ts mcp = { ui: { client: new URL("../mcpApp/status.ts", import.meta.url), csp: { resourceDomains: ["https://esm.sh"], // load scripts/styles from connectDomains: ["https://api.example.com"], // fetch/XHR/WebSocket to }, permissions: { clipboardWrite: {} }, }, }; ``` | CSP field | Controls | | ----------------- | ----------------------------------------------- | | `resourceDomains` | Scripts, styles, images, fonts the app may load | | `connectDomains` | Origins the app may `fetch`/XHR/WebSocket to | | `frameDomains` | Origins the app may embed in nested frames | | `baseUriDomains` | Origins allowed in the app's `` | ## Testing To see an app render you need a host that supports MCP Apps. Two easy options: * **basic-host** — the [ext-apps](https://github.com/modelcontextprotocol/ext-apps) repo ships a local test host. From `examples/basic-host`, point it at your server: ```bash SERVERS='["http://localhost:8080/mcp"]' npm start ``` * **Claude** — expose your local server with a tunnel (e.g. `npx cloudflared tunnel --url http://localhost:8080`) and add it as a [custom connector](https://support.anthropic.com/en/articles/11175166). For automated tests, assert the wiring over a normal MCP session: the tool's `_meta.ui.resourceUri` appears in `tools/list`, the `ui://` resource reads back HTML with the `text/html;profile=mcp-app` MIME type (and contains your bundled client), and a `tools/call` returns `structuredContent`. See `example/backend/__tests__/initializers/mcp.test.ts`. ::: warning Rendering is up to the host Whether an app actually renders is controlled entirely by the host. MCP Apps is new, and support varies — some clients gate it behind a server-side flag or have version-specific regressions (e.g. a host may negotiate the `io.modelcontextprotocol/ui` capability and list the `ui://` resource, yet never call `resources/read`, falling back to raw text). If a compliant host like the ext-apps basic-host renders your app but another client doesn't, the gap is on the client side, not in your server. ::: ## See also * [MCP Server](./mcp.md) — enabling MCP, tools, resources, prompts, OAuth * [`@keryxjs/mcp-app`](https://www.npmjs.com/package/@keryxjs/mcp-app) — the browser client (`mountMcpApp`) * [MCP Apps specification](https://modelcontextprotocol.io/extensions/apps/overview) * [ext-apps examples](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples) — starter templates for React, Vue, Svelte, and vanilla JS --- --- url: 'https://keryxjs.com/guide/mcp.md' description: >- MCP server configuration — how actions become tools, controlling tool exposure, McpActionConfig options, elicitation, response formats, resources, and prompts. --- # MCP Server The [Model Context Protocol](https://modelcontextprotocol.io) (MCP) is an open standard for connecting AI agents to external tools and data sources. In Keryx, MCP is a natural extension of the transport-agnostic action model — just like an action can serve HTTP, WebSocket, CLI, and background tasks, it can also be exposed as an MCP tool for AI agents. ## Protocol Versions Keryx builds on the official [`@modelcontextprotocol/sdk`](https://www.npmjs.com/package/@modelcontextprotocol/sdk) and negotiates the wire protocol at `initialize`. The supported revisions, newest first: `2025-11-25` · `2025-06-18` · `2025-03-26` · `2024-11-05` · `2024-10-07` A client asking for something newer than `2025-11-25` is answered with `2025-11-25`; a client that sends no version is treated as `2025-03-26`. After `initialize`, every request must carry the negotiated version in an `MCP-Protocol-Version` header — a mismatch is rejected with `400`. The header is CORS-allowlisted so browser-based clients can send it. Some sections below cite MCP `2026-07-28` (the CIMD, `iss`, and `application_type` requirements). Those are **authorization** rules backported from a later specification revision; they don't change the wire protocol version list above. ## Enabling the MCP Server The MCP server is disabled by default. Enable it with an environment variable: ```bash MCP_SERVER_ENABLED=true ``` Or set it directly in `backend/config/server/mcp.ts`: ```ts export const configServerMcp = { enabled: true, route: "/mcp", // ... }; ``` Once enabled, the server listens at `http://localhost:8080/mcp` (or your configured `applicationUrl` + `route`). ## How Actions Become Tools MCP tools are **opt-in**. An action is exposed as a tool only when it sets `mcp = { tool: true }` (or declares an [MCP App](./mcp-apps.md) via `mcp.ui`). Actions with no `mcp` config — including internal, destructive, or maintenance actions — are never reachable over MCP, so nothing is published to AI clients by accident. ```ts export class Status implements Action { name = "status"; description = "Server health and runtime information"; mcp = { tool: true }; // opt in to expose as an MCP tool // ... } ``` For each opted-in action: 1. **Name** — The action name is converted to a valid MCP tool name by replacing `:` with `-` (e.g., `user:create` → `user-create`) 2. **Description** — The action's `description` property becomes the tool description 3. **Input schema** — The action's Zod `inputs` schema is converted to JSON Schema for tool parameter definitions ```ts // This action... export class UserView implements Action { name = "user:view"; description = "View a user's profile"; inputs = z.object({ userId: z.string() }); // ... } // ...becomes MCP tool "user-view" with: // - description: "View a user's profile" // - inputSchema: { type: "object", properties: { userId: { type: "string" } } } ``` ## Controlling Exposure Tools are opt-in, so actions stay private until you explicitly publish them. To expose an action as a tool: ```ts export class PublicAction implements Action { name = "reports:list"; mcp = { tool: true }; // ... } ``` An internal action omits `mcp` (or sets `mcp = { tool: false }`) and is never registered: ```ts export class InternalAction implements Action { name = "internal:cleanup"; // no mcp config → not an MCP tool } ``` The full `mcp` property is of type `McpActionConfig`: | Property | Type | Default | Description | | ---------------- | --------------------- | ------- | ---------------------------------------------------------------------------------- | | `tool` | `boolean` | `false` | Opt in to expose this action as an MCP tool (tools are opt-in) | | `public` | `boolean` | `false` | Let anonymous clients use this action when `MCP_AUTH_MODE=optional` (see [Public (Anonymous) Access](#public-anonymous-access)) | | `isLoginAction` | `boolean` | — | Tag as the login action for the OAuth flow | | `isSignupAction` | `boolean` | — | Tag as the signup action for the OAuth flow | | `resource` | `object` | — | Expose this action as an MCP resource (see [Resources](#resources) below) | | `prompt` | `object` | — | Expose this action as an MCP prompt (see [Prompts](#prompts) below) | | `ui` | `McpUiConfig` | — | Render the tool's result as an interactive UI (see [MCP Apps](./mcp-apps.md)) | | `responseFormat` | `MCP_RESPONSE_FORMAT` | `JSON` | Response format for MCP tool calls (see [Response Format](#response-format) below) | The `isLoginAction` and `isSignupAction` markers tell the OAuth system which actions to invoke when users authenticate through the MCP authorization page. These actions must return `OAuthActionResponse` (`{ user: { id: number } }`). ## Elicitation A tool can pause mid-call and ask the user for more information through the MCP client. That is **elicitation**, not a second HTTP form and not an [MCP App](./mcp-apps.md). Keryx exposes it on the current [`Connection`](/reference/classes#connection): `elicitForm`, `elicitUrl`, and `completeElicitation`. Those methods throw a `TypedError` with `ErrorType.CONNECTION_MCP_ELICITATION` on any transport that is not MCP. Do not request passwords, API keys, access tokens, or payment details with form mode. Use URL mode for secrets so the values never cross the MCP client. ### Client capabilities Keryx checks the client's `initialize` capabilities **before** sending `elicitation/create`. There is no automatic fallback to extra tool inputs. | Client declared | `elicitForm` | `elicitUrl` | | ------------------------------------ | ------------------- | ----------- | | no `elicitation` | throws | throws | | `elicitation: {}` | allowed (form only) | throws | | `elicitation: { form: {}, url: {} }` | allowed | allowed | A missing capability becomes a normal failed tool result (`isError: true`) with a structured `{ error, type }` payload. Catch it in the action if you want a softer message. The in-flight action timeout still applies. Elicitation is aborted when that timeout fires. ### Form mode This is an MCP-only tool. There is no web route. ```ts export class ConfirmDisplayName implements Action { name = "user:confirm-display-name"; description = "Ask the user for a display name, then return it"; inputs = z.object({}); mcp = { tool: true }; async run(_params: ActionParams, connection: Connection) { const elicited = await connection.elicitForm({ message: "What display name should we show other people?", schema: z.object({ displayName: z.string().min(1).max(40).describe("Public display name"), }), }); if (elicited.action !== "accept") { return { saved: false, reason: elicited.action }; // "decline" | "cancel" } return { saved: true, displayName: elicited.content.displayName }; } } ``` `schema` must be a flat Zod object of primitives (strings, numbers, booleans, string enums). Nested objects are rejected up front. ### URL mode `elicitUrl` waits only for **consent to open the URL**. `accept` does not mean the other page finished. Your app detects completion (Redis, a database row, a webhook), then calls `completeElicitation` so the client can close its prompt. ```ts const ELICIT_KEY = (id: string) => `mcp:elicit:${id}`; export class LinkApiKey implements Action { name = "secrets:link-api-key"; inputs = z.object({}); mcp = { tool: true }; async run( _params: ActionParams, connection: Connection, abortSignal?: AbortSignal, ) { const elicitationId = crypto.randomUUID(); const url = new URL( `${config.server.web.apiRoute}/secrets/api-key`, config.server.web.applicationUrl, ); url.searchParams.set("elicitationId", elicitationId); const elicited = await connection.elicitUrl({ message: "Open this page to store an API key.", url, elicitationId, }); if (elicited.action !== "accept") { return { linked: false, reason: elicited.action }; } while (!abortSignal?.aborted) { const raw = await api.redis.redis.get(ELICIT_KEY(elicitationId)); if (raw) { await connection.completeElicitation(elicitationId); return { linked: true }; } await Bun.sleep(50); } throw new TypedError({ message: "Timed out waiting for API key page", type: ErrorType.CONNECTION_ACTION_TIMEOUT, }); } } ``` The destination page is a **separate** HTTP action, not a web route on the tool. It writes the Redis key when the user submits. If that HTTP request lands on another process, Redis still bridges the wait. Keryx does not ship a generic waiter — completion is app-specific. ### URL required, then retry When the current tool call cannot stay open, throw `McpUrlElicitationRequiredError` with one or more URL elicitations. Keryx maps that to MCP JSON-RPC `-32042`. The client completes the URL flow and retries the original request. ## Response Format By default, MCP tool responses are JSON-serialized. You can configure actions to return human-readable markdown instead, which is more token-efficient for LLM consumers that don't need structured data. Set `mcp.responseFormat` on an action to change its format: ```ts import { Action, MCP_RESPONSE_FORMAT } from "keryx"; export class StatusAction implements Action { name = "status"; mcp = { tool: true, responseFormat: MCP_RESPONSE_FORMAT.MARKDOWN }; // ... } ``` ### Markdown rendering rules The automatic markdown serializer converts action return values based on their shape: | Data shape | Rendered as | | ------------------------ | ----------------------- | | Flat object | Bulleted key-value list | | Nested object | Headings + recursion | | Array of uniform objects | Markdown table | | Array of primitives | Bulleted list | | Beyond depth limit | JSON code block | The depth limit controls how many levels of nesting are rendered as markdown before falling back to a JSON code block. Configure it with `MCP_MARKDOWN_DEPTH_LIMIT` (default: `5`). ::: tip Error responses always use JSON regardless of the requested format, so agents can reliably parse error details programmatically. ::: ## Resources MCP resources are URI-addressed, read-only data that AI clients can fetch for context. An action becomes an MCP resource by setting `mcp.resource`: ```ts export class StatusResource implements Action { name = "status:resource"; description = "Server status as an MCP resource"; mcp = { tool: false, // don't also expose as a tool resource: { uri: "keryx://status", mimeType: "application/json" }, }; async run() { return { text: JSON.stringify({ ok: true, uptime: new Date().getTime() - api.bootTime, }), mimeType: "application/json", }; } } ``` The action's `run()` must return either `{ text: string; mimeType?: string }` or `{ blob: string; mimeType?: string }` (base64-encoded binary). ### Resource Templates Use `uriTemplate` instead of `uri` to expose a parameterized resource. Variables in the template (e.g., `{userId}`) are passed as action params: ```ts export class UserResource implements Action { name = "user:resource"; description = "Fetch a user by ID as an MCP resource"; inputs = z.object({ userId: z.string() }); mcp = { tool: false, resource: { uriTemplate: "keryx://users/{userId}", mimeType: "application/json", }, }; async run(params: ActionParams) { const user = await fetchUser(params.userId); return { text: JSON.stringify(user), mimeType: "application/json" }; } } ``` An action can be registered as both a tool and a resource by also setting `tool: true`. ## Prompts MCP prompts are named templates that AI clients surface to users (e.g., as slash commands). An action becomes an MCP prompt by setting `mcp.prompt`. The action's `inputs` schema becomes the prompt's argument schema, and `run()` must return `{ description?: string; messages: PromptMessage[] }`: ```ts export class GreetingPrompt implements Action { name = "greeting:prompt"; description = "A greeting prompt"; inputs = z.object({ name: z.string().optional() }); mcp = { tool: false, prompt: { title: "Greeting" }, }; async run(params: ActionParams) { return { description: "A personalized greeting", messages: [ { role: "user" as const, content: { type: "text" as const, text: `Hello, ${params.name ?? "world"}!`, }, }, ], }; } } ``` ## Server Instructions The MCP server includes an `instructions` string that AI clients display to help users understand what the server provides. By default this is the package description from `package.json`, but you can override it: ```bash MCP_SERVER_INSTRUCTIONS="This server provides access to the Acme API..." ``` ## Schema Sanitization The MCP SDK's internal JSON Schema converter (`zod/v4-mini`'s `toJSONSchema`) doesn't support all Zod types (e.g., `z.date()`). The MCP initializer tests each field individually and replaces incompatible fields with `z.string()` as a fallback, so your tools always register successfully even if some input types need coercion. ## OAuth 2.1 Authentication MCP clients authenticate using OAuth 2.1 with PKCE (Proof Key for Code Exchange). The flow is: 1. MCP client connects to `/mcp` and receives a `401` response 2. Client fetches `/.well-known/oauth-protected-resource` to discover the authorization server 3. Client fetches `/.well-known/oauth-authorization-server` for endpoints 4. Client identifies itself — either with a [Client ID Metadata Document](#client-id-metadata-documents) URL (preferred) or by registering dynamically via `POST /oauth/register` 5. Client opens a browser to `/oauth/authorize` with PKCE challenge 6. User logs in or signs up on the authorization page 7. Server issues an authorization code and redirects back 8. Client exchanges the code for an access token and a refresh token at `POST /oauth/token` 9. Client includes `Authorization: Bearer ` on subsequent MCP requests 10. When the access token expires, the client exchanges its refresh token for a new pair ### OAuth Endpoints | Endpoint | Method | Description | | ----------------------------------------- | ------ | ---------------------------------------------- | | `/.well-known/oauth-protected-resource` | GET | Resource metadata (RFC 9728) | | `/.well-known/oauth-authorization-server` | GET | Authorization server metadata (RFC 8414) | | `/oauth/register` | POST | Dynamic client registration | | `/oauth/authorize` | GET | Authorization page (login/signup form) | | `/oauth/authorize` | POST | Process login/signup form submission | | `/oauth/token` | POST | Exchange authorization code or refresh token | | `/oauth/introspect` | POST | Token introspection (RFC 7662) | | `/oauth/revoke` | POST | Token revocation (RFC 7009) | All six are advertised in the authorization server metadata document, so a conforming client discovers them without hardcoding paths. None require client authentication — `token_endpoint_auth_methods_supported` is `["none"]`, since public clients using PKCE hold no secret. ### Refresh Tokens `POST /oauth/token` accepts `grant_type=refresh_token` as well as `grant_type=authorization_code`; both are advertised in `grant_types_supported`. Refresh tokens rotate on every use: the server deletes the old access/refresh pair **before** writing the new one, so a replayed refresh token fails closed rather than minting a second live session. Access tokens and refresh tokens have separate lifetimes: | Token | TTL source | Environment variable | Default | | ------- | -------------------------------- | ----------------------- | ------- | | Access | `config.session.ttl` | `SESSION_TTL` | 1 day | | Refresh | `config.server.mcp.oauthRefreshTtl` | `MCP_OAUTH_REFRESH_TTL` | 30 days | Note that the access-token lifetime is the **session** TTL, not an `MCP_OAUTH_*` key. Shortening `SESSION_TTL` to tighten browser cookie expiry also shortens every agent's bearer token — see [Authentication](/guide/authentication#session-configuration). To log an agent out, `POST /oauth/revoke` with the token. Revoking either half of a pair cascades to both. ### Client ID Metadata Documents MCP 2026-07-28 ([SEP-991](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration#client-id-metadata-documents)) deprecates Dynamic Client Registration in favor of **Client ID Metadata Documents (CIMD)**: a client uses an HTTPS URL as its `client_id`, and the authorization server fetches its metadata from that URL. There is no registration round trip, and the resulting client ID is portable across authorization servers. Keryx supports this out of the box — `/.well-known/oauth-authorization-server` advertises `client_id_metadata_document_supported: true`. To use it, host a document like this at an HTTPS URL with a path component: ```json { "client_id": "https://app.example.com/oauth/client.json", "client_name": "Example MCP Client", "redirect_uris": ["http://localhost:3000/callback"], "grant_types": ["authorization_code"], "response_types": ["code"], "token_endpoint_auth_method": "none" } ``` Then pass that URL as `client_id` on the authorization and token requests. `POST /oauth/register` still works for clients that predate CIMD. A document is accepted only when all of the following hold: * The `client_id` URL uses `https:`, has a path component, and carries no fragment. * The URL resolves to a publicly routable address. Loopback, private, link-local (including cloud instance-metadata endpoints such as `169.254.169.254`), CGNAT, and multicast targets are refused, and redirects are not followed — this is the SSRF guard required by the [CIMD security considerations](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/security-considerations#client-id-metadata-document-security). * The response is HTTP 200, served as JSON, and no larger than `MCP_OAUTH_CIMD_MAX_BYTES`. * The document's `client_id` matches the URL it was fetched from exactly, and it declares a `client_name` and at least one `redirect_uris` entry. Redirect URIs are validated by the same rules as `/oauth/register`. * The `redirect_uri` on the authorization request matches one in the document exactly — except for the port on `http:` loopback URIs, which is ignored per [RFC 8252 §7.3](https://datatracker.ietf.org/doc/html/rfc8252#section-7.3). A CLI client can therefore declare `http://localhost/callback` and authorize with `http://localhost:49152/callback`. Successful lookups are cached in Redis honoring the origin's `Cache-Control` (`no-store` / `no-cache` disables it), capped at `MCP_OAUTH_CIMD_CACHE_TTL`. Rejected documents are never cached. The authorization page names the requesting client and always shows the host the authorization will be redirected to, with an extra warning for loopback callbacks — a metadata document cannot prove that a `localhost` callback belongs to the app it names. | Config | Env Var | Default | Description | | ---------------------------- | ------------------------------------- | ------- | --------------------------------------------------------------------------------- | | `oauthCimdEnabled` | `MCP_OAUTH_CIMD_ENABLED` | `true` | Accept URL-formatted client IDs, and advertise support | | `oauthCimdCacheTtl` | `MCP_OAUTH_CIMD_CACHE_TTL` | `3600` | Ceiling on how long a document is cached (seconds) | | `oauthCimdFetchTimeoutMs` | `MCP_OAUTH_CIMD_FETCH_TIMEOUT_MS` | `5000` | Fetch timeout (milliseconds) | | `oauthCimdMaxBytes` | `MCP_OAUTH_CIMD_MAX_BYTES` | `65536` | Maximum document size | | `oauthCimdAllowPrivateHosts` | `MCP_OAUTH_CIMD_ALLOW_PRIVATE_HOSTS` | `false` | Allow documents on loopback/private hosts and over `http:`. Development only — this disables the SSRF guard | ### Security The OAuth implementation includes several hardening measures: * **Redirect URI validation** — URIs registered via `/oauth/register` must not contain fragments or userinfo. HTTPS is accepted for any host (remote web callbacks); plain `http` is accepted only for loopback addresses (`localhost`, `127.0.0.1`, `[::1]`); and private-use / custom URI schemes (e.g. `vscode://`, `cursor://`) are accepted for native apps per [RFC 8252 §7.1](https://datatracker.ietf.org/doc/html/rfc8252#section-7.1). The `javascript:`, `data:`, `vbscript:`, and `file:` schemes are always rejected. On the authorization request, the `redirect_uri` must match a registered URI exactly, except that the port of an `http:` loopback URI is ignored per [RFC 8252 §7.3](https://datatracker.ietf.org/doc/html/rfc8252#section-7.3), since a native app binds an ephemeral port at request time. When exchanging an authorization code, the `redirect_uri` must equal the one carried on the authorization request, in full. * **Issuer identification (`iss`)** — the authorization response redirect includes an `iss` parameter identifying this authorization server, and `/.well-known/oauth-authorization-server` advertises `authorization_response_iss_parameter_supported: true`. Clients validate `iss` to defend against mix-up attacks ([RFC 9207](https://datatracker.ietf.org/doc/html/rfc9207), MCP 2026-07-28 / SEP-2468). * **Client ID Metadata Document fetching** — resolving a URL-formatted `client_id` is guarded against SSRF: the host must be publicly routable, redirects are not followed, and the body is size- and time-capped. See [Client ID Metadata Documents](#client-id-metadata-documents) above and the [Security guide](/guide/security#client-id-metadata-documents). * **Client `application_type`** — `POST /oauth/register` accepts an `application_type` of `"web"` (the default) or `"native"`, stored on the client record so native/CLI clients are not misclassified as web clients (MCP 2026-07-28 / SEP-837). Any other value is rejected with `invalid_client_metadata`. * **Registration rate limiting** — `POST /oauth/register` has a separate, stricter rate limit (default: 5 requests per hour per IP) to prevent abuse. See `RATE_LIMIT_OAUTH_REGISTER_LIMIT` and `RATE_LIMIT_OAUTH_REGISTER_WINDOW_MS` in [Configuration](/guide/config). * **CORS** — OAuth and MCP endpoints respect the `allowedOrigins` configuration. When `allowedOrigins` is `"*"`, credentials headers are not sent, per the browser spec. Set a specific origin in production for credentialed requests to work. * **Browser MCP clients** — The MCP endpoint admits browser requests whose `Origin` is in `MCP_ALLOWED_ORIGINS` (comma-separated), in addition to `APPLICATION_URL` and `WEB_SERVER_ALLOWED_ORIGINS`. It defaults to the popular browser-based connectors (`https://claude.ai`, `https://claude.com`, `https://chatgpt.com`, `https://vscode.dev`, `https://github.dev`), so web connectors work out of the box even when `WEB_SERVER_ALLOWED_ORIGINS` is locked down. Requests with no `Origin` (CLI and other non-browser clients) always pass — the OAuth bearer token is the security boundary. The origin gate and the `Access-Control-Allow-Origin` response share one allowlist, so they never disagree. ## Public (Anonymous) Access Authorization is optional in the MCP specification, so a server can also be public. Set `MCP_AUTH_MODE=optional` to let clients without an OAuth token connect, and mark the actions they may use with `mcp.public`: ```ts export class Status implements Action { name = "status"; mcp = { tool: true, public: true }; // ... } ``` The two modes: | `MCP_AUTH_MODE` | Request without a token | Request with a valid token | Request with an invalid or expired token | | ---------------------- | ------------------------------------------------ | -------------------------- | ---------------------------------------- | | `required` (default) | `401` challenge | Full access | `401` challenge | | `optional` | Anonymous session, limited to `mcp.public` actions | Full access | `401` challenge | A bad token is never downgraded to anonymous access. The spec requires an invalid or expired token to get a `401`, so the client refreshes or re-authorizes. ### What anonymous sessions can do * **See and use only what's public.** `tools/list`, `resources/list`, `resources/templates/list`, and `prompts/list` return only entries whose action sets `mcp.public`; an authenticated client sees everything. A `tools/call`, `prompts/get`, or `resources/read` aimed at an action without `mcp.public` is refused. * **Run as a guest.** The action runs on a connection with no user in its session, exactly like an anonymous HTTP request, so `SessionMiddleware` and similar checks still reject it. `mcp.public` only opens the MCP gate; the action's own middleware still decides what a guest may do. * **Receive only public broadcasts.** PubSub notifications reach an anonymous session only for channels whose [authorization](/guide/channels) admits a subscriber with no user. `mcp.public` has no effect on HTTP, WebSocket, CLI, or background task access, and no effect at all while `MCP_AUTH_MODE` is `required`. ### Signing in mid-session (step-up) Protected tools don't appear in an anonymous session's lists, but a client can still call one by name, for example a tool it remembers from an earlier signed-in session. When an anonymous client calls a protected tool, Keryx answers the HTTP request with the same `401` and `WWW-Authenticate: Bearer resource_metadata="…", scope="mcp"` challenge a tokenless client gets in `required` mode. MCP clients must handle a `401` on any request: they run the [OAuth flow](#oauth-2-1-authentication) and retry with the new token. A batch is challenged if any message in it targets a protected action. The retried request carries the token on the **same** `mcp-session-id`, and that upgrades the session: the shared registry record is rebound to the token's OAuth client and switches to the authenticated TTL. The session's hidden tools, resources, and prompts are enabled at the same moment, and the client is sent `notifications/tools/list_changed` (and the resource and prompt equivalents) so it can refresh its lists. From then on the usual ownership rules apply: a different client's token gets `403`, and dropping the token gets `401` (a session never downgrades back to anonymous). Clients differ in how well they handle a `401` that arrives mid-session. Some hosted connectors only decide whether to sign in when they first connect, and stay anonymous afterwards. Those clients report the `401` as a failed tool call, and the user has to reconnect with sign-in. (Independently of the HTTP challenge, every tool, resource, and prompt handler checks access itself, so a protected action never runs for an anonymous session.) If most of your tools need a user, `required` mode gives the more predictable experience: since anonymous sessions can't list protected tools, users who need them should connect with sign-in from the start. ### Abuse protection Anonymous sessions cost memory and a Redis record, so they're more tightly limited than authenticated ones: * **Shorter idle TTL.** Anonymous sessions expire after `MCP_ANONYMOUS_SESSION_TTL` (default 1 hour) instead of `MCP_SESSION_TTL` (24 hours). * **Per-IP rate limits.** When rate limiting is enabled, every anonymous MCP request counts against `RATE_LIMIT_UNAUTH_LIMIT`. Opening a new anonymous session also counts against `RATE_LIMIT_MCP_ANON_INIT_LIMIT` (default 10 per minute). Requests over a limit get `429` with a `Retry-After` header. Put `RateLimitMiddleware` on public actions that are expensive to run, just as you would for a public HTTP endpoint. ## Session Management Each MCP connection, authenticated or anonymous, creates its own `McpServer` instance. Sessions are tracked via the `mcp-session-id` header — the MCP SDK generates a UUID per session at `initialize` and the client includes it on every subsequent request. ### Cluster-wide sessions (multi-node) Sessions are recorded in a **shared Redis registry** (`mcp:session:`) — in-process when `REDIS_URL="memory://"`, which limits you to a single node — so any node in a cluster can serve any session — the same way keryx already scales WebSocket clients using the shared session store and Redis PubSub. When you run more than one keryx process behind a load balancer: * **Source of truth is Redis, not local memory.** Every request validates the `mcp-session-id` against the registry. The live transport/`McpServer` objects stay node-local (like a WebSocket socket — they can't move), but they're a cache, not the authority. * **Lazy adoption.** If a request lands on a node that doesn't hold the transport (the load balancer routed it elsewhere), that node re-materializes the session locally from the shared record and serves the request. No sticky-session configuration is required. * **Idle TTL.** The registry entry has a TTL (`MCP_SESSION_TTL`, default 24h) that is refreshed on every request, so idle sessions are reclaimed automatically. * **`DELETE` is cluster-wide.** An HTTP `DELETE` to the MCP route removes the shared record, so every node then returns `404` for that session id. * **The standalone `GET` SSE stream** is held by whichever node the client's `GET` lands on; because PubSub notifications already fan out across the cluster, notifications reach that node regardless of where the session was created. ### Error semantics (per the Streamable HTTP spec) * **Unknown or expired `mcp-session-id` → `404 Not Found`** (JSON, no SSE stream is opened). This is the client's cue to re-`initialize` and recover automatically — for example after the session's TTL lapses or a `DELETE`. * **A non-`initialize` `POST` with no `mcp-session-id` → `400 Bad Request`.** Only `initialize` may open a new session. * **A session id owned by a different OAuth client → `403 Forbidden`.** * **An anonymous request (`MCP_AUTH_MODE=optional`) that targets a protected tool, prompt, or resource, or that uses a session owned by an authenticated client → `401 Unauthorized`** with a `WWW-Authenticate` challenge. See [Public (Anonymous) Access](#public-anonymous-access). * **An anonymous request over the per-IP rate limit → `429 Too Many Requests`** with `Retry-After`. * **A `POST` body that isn't JSON → `400 Bad Request`** with a JSON-RPC error response: `-32700 Parse error`, `id: null`. * **A `POST` body that is JSON but not a valid JSON-RPC message → `400 Bad Request`** with `-32600 Invalid Request`, `id: null`. JSON-RPC 2.0 reserves `-32700` for input that can't be parsed at all, so Keryx validates the envelope itself rather than letting the SDK transport report both cases as a parse error. * **A non-`initialize` request whose `MCP-Protocol-Version` header doesn't match the negotiated version → `400 Bad Request`.** See [Protocol Versions](#protocol-versions). ### Lifecycle hooks in a cluster * `onConnect` fires **once**, on the node that runs the real `initialize` handshake. Adopting a session on another node does not re-fire it. * `onDisconnect` fires **once cluster-wide**, on the node whose explicit client `DELETE` removes the shared record. A node shutting down does **not** fire it — the session may still be live and adoptable elsewhere. * A session that lapses via `MCP_SESSION_TTL` rather than a `DELETE` **never fires `onDisconnect`**. There is no reaper watching for expiry; the record simply ages out of Redis and subsequent requests get a `404`. Don't rely on `onDisconnect` for cleanup that must always run — clients that vanish without a `DELETE` are the common case. * `onMessage` fires per inbound request, on whichever node handles it. > **Tip:** sticky sessions (session affinity) are still a useful optimization — they keep a client on the node that already holds its transport and avoid re-adoption — but they are not required for correctness. ## OAuth Templates The authorization page (login/signup form) is rendered from [Mustache](https://mustache.github.io/) templates in your project's `templates/` directory: | File | Purpose | | ---------------------- | ------------------------------------ | | `oauth-authorize.html` | Login/signup form with tab switching | | `oauth-common.css` | Shared styles for the page | | `lion.svg` | Decorative SVG included in the page | These files are scaffolded into your project by `keryx new` and kept in sync by `keryx upgrade`. ### Dynamic Form Fields Form fields on the login and signup tabs are **generated automatically** from the Zod `inputs` schema of your `isLoginAction` and `isSignupAction` actions. If you add, remove, or rename fields in those actions, the OAuth page updates to match — no template edits required. The framework uses your schema to determine: * **Field names** — from the keys of your `z.object({})` shape * **Labels** — from `.describe()` on each field, or the capitalized field name as a fallback * **Input types** — fields wrapped in `secret()` render as `type="password"`, fields with "email" in the name render as `type="email"`, everything else is `type="text"` * **Validation** — `minlength` and `maxlength` attributes are set from Zod `.min()` / `.max()` constraints ### Mustache Variables The `oauth-authorize.html` template receives these variables: | Variable | Type | Description | | -------------- | --------- | --------------------------------------------------- | | `signinFields` | `Array` | Form field objects for the login action | | `signupFields` | `Array` | Form field objects for the signup action | | `hasSignin` | `boolean` | Whether a login action is configured | | `hasSignup` | `boolean` | Whether a signup action is configured | | `errorHtml` | `string` | Pre-rendered error message HTML (empty if no error) | | `hiddenFields` | `string` | Pre-rendered hidden inputs for OAuth state | Each field object in `signinFields` / `signupFields` has: `name`, `label`, `type`, `required`, and optional `minlength` / `maxlength`. ### Customization To customize the look and feel, edit the template files in your project's `templates/` directory. The Mustache loops and hidden OAuth fields must be preserved for the flow to work — but you can change all styling, layout, and field rendering. ### Shared Theme For branding, prefer a shared **theme** over cloning the template files. Set [`config.server.web.theme`](./config.md#web-server) to a `.css` file (or a `.ts`/`.js` entrypoint that default-exports a CSS string), and Keryx inlines it into both the OAuth page **and** your [MCP App shells](./mcp-apps.md) — one source of truth for palette and fonts. Keryx ships a **default theme**: a shared set of `--keryx-*` design tokens that both the OAuth page and MCP App shells inline as their baseline, so the two surfaces already agree before you configure anything. The OAuth page styles itself entirely from these tokens, so a theme only needs to redeclare the subset it wants — your theme is inlined **after** the default tokens, so its `:root` declarations win by cascade order: ```css /* your theme.css */ :root { --keryx-color-primary: #6b46c1; --keryx-color-primary-hover: #553c9a; --keryx-color-accent: #ec4899; --keryx-font-family: "Inter", system-ui, sans-serif; } ``` | Variable | Controls | | ----------------------------- | ---------------------------------------------------- | | `--keryx-font-family` | Page font stack | | `--keryx-color-primary` | Headings, buttons, focus rings, active tab, gradient | | `--keryx-color-primary-hover` | Button hover, gradient midpoint | | `--keryx-color-accent` | Gradient endpoint | | `--keryx-bg` | Page background (a gradient of the colors above) | | `--keryx-surface` | Card background | | `--keryx-color-text` | Form label text | | `--keryx-color-text-muted` | Secondary/muted text | | `--keryx-color-border` | Input borders | | `--keryx-radius` | Card corner radius | ## PubSub Notifications When messages are broadcast through the PubSub system (e.g., chat messages sent via Redis PubSub), they are forwarded to connected MCP clients as MCP logging messages. This allows AI agents to receive real-time notifications about events happening in your application. Delivery is authorized per channel, not broadcast to everyone. Each MCP session is checked against the same channel [authorization](/guide/channels) that governs WebSocket subscribers, so an agent only receives broadcasts for channels its authenticated user could join. The check fails closed: a session with no captured auth receives nothing. ## Configuration Reference | Key | Env Var | Default | Description | | -------------------- | -------------------------- | ------------------- | ---------------------------------------- | | `enabled` | `MCP_SERVER_ENABLED` | `false` | Enable the MCP server | | `route` | `MCP_SERVER_ROUTE` | `"/mcp"` | URL path for the MCP endpoint | | `allowedOrigins` | `MCP_ALLOWED_ORIGINS` | Claude/ChatGPT/VS Code Web | Browser origins allowed to reach the MCP endpoint | | `instructions` | `MCP_SERVER_INSTRUCTIONS` | package description | Instructions shown to MCP clients | | `oauthClientTtl` | `MCP_OAUTH_CLIENT_TTL` | `2592000` | OAuth client registration TTL (seconds) | | `oauthCodeTtl` | `MCP_OAUTH_CODE_TTL` | `300` | Authorization code TTL (seconds) | | `oauthCimdEnabled` | `MCP_OAUTH_CIMD_ENABLED` | `true` | Accept [Client ID Metadata Documents](#client-id-metadata-documents) | | `authMode` | `MCP_AUTH_MODE` | `"required"` | `"optional"` admits anonymous clients for `mcp.public` actions | | `sessionTtl` | `MCP_SESSION_TTL` | `86400` | Shared MCP session registry TTL, refreshed on activity (seconds) | | `anonymousSessionTtl` | `MCP_ANONYMOUS_SESSION_TTL` | `3600` | Idle TTL for anonymous sessions (seconds) | | `markdownDepthLimit` | `MCP_MARKDOWN_DEPTH_LIMIT` | `5` | Max nesting depth for markdown rendering | ## Testing You can test MCP actions using the `@modelcontextprotocol/sdk` client: ```ts import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const transport = new StreamableHTTPClientTransport( new URL("http://localhost:8080/mcp"), { requestInit: { headers: { Authorization: `Bearer ${accessToken}`, }, }, }, ); const client = new Client({ name: "test-client", version: "1.0.0" }); await client.connect(transport); const tools = await client.listTools(); const result = await client.callTool({ name: "status", arguments: {}, }); ``` The `accessToken` above has to come from somewhere. In tests, drive the same OAuth flow a real client would — see `example/backend/__tests__/initializers/mcp.test.ts` for a worked end-to-end example that registers a client, completes the authorization code exchange, and calls a tool with the resulting bearer token. ## Debugging The MCP endpoint always requires authentication — there is no anonymous mode — so the first response to an unauthenticated request is a `401`. That's expected, not a misconfiguration. The `WWW-Authenticate` header on that response tells you where discovery starts: ``` WWW-Authenticate: Bearer resource_metadata="https://your-host/.well-known/oauth-protected-resource/mcp", scope="mcp" ``` A few things worth checking when a client won't connect: * **`listTools` returns nothing.** Tools are opt-in. Confirm at least one action sets `mcp = { tool: true }` — see [Controlling Exposure](#controlling-exposure). A fresh `keryx new` app has no tools until you add one. * **`400` on every request after `initialize`.** The client isn't echoing the negotiated `MCP-Protocol-Version` header. See [Protocol Versions](#protocol-versions). * **`404` mid-session.** The session id expired or was never created on this node; the client should re-`initialize`. See [Error semantics](#error-semantics-per-the-streamable-http-spec). * **`403` mid-session.** The session id belongs to a different OAuth client. * **CIMD fails against `localhost`.** The SSRF guard refuses private and loopback addresses by default. Set `MCP_OAUTH_CIMD_ALLOW_PRIVATE_HOSTS=true` for local development only — never in production. The [MCP Inspector](https://github.com/modelcontextprotocol/inspector) is the fastest way to exercise the server by hand. It walks the OAuth flow interactively and shows the raw JSON-RPC traffic, which makes protocol-level problems much easier to see than reading server logs: ```bash bunx @modelcontextprotocol/inspector ``` Point it at `http://localhost:8080/mcp` with transport type "Streamable HTTP". --- --- url: 'https://keryxjs.com/guide/middleware.md' description: >- Middleware intercepts action execution for authentication, authorization, logging, and response modification. --- # Middleware Middleware lets you run logic before and after an action executes — authentication checks, parameter normalization, response enrichment, logging, that sort of thing. If you've used Express middleware, the concept is similar, but scoped to individual actions rather than applied globally. ## The Basics Here's the session middleware we use for authenticated endpoints. It's about as simple as middleware gets: ```ts import { ErrorType, TypedError, type ActionMiddleware } from "keryx"; export const SessionMiddleware: ActionMiddleware = { runBefore: async (_params, connection) => { if (!connection.session || !connection.session.data.userId) { throw new TypedError({ message: "Session not found", type: ErrorType.CONNECTION_SESSION_NOT_FOUND, }); } }, }; ``` If `runBefore` throws, the action's `run()` method is skipped entirely and the error goes back to the client. That's the primary pattern for auth — check the session, throw if it's missing. ## Interface ```ts type ActionMiddleware = { runBefore?: ( params: ActionParams, connection: Connection, ) => Promise; runAfter?: ( params: ActionParams, connection: Connection, error?: TypedError, ) => Promise; }; ``` Both methods are optional. You can have middleware that only runs before (auth), only runs after (logging), or both. `runAfter` always executes (even when the action throws) and receives the error as an optional third parameter — useful for cleanup like rolling back a transaction. Middleware can also modify params and responses by returning an `ActionMiddlewareResponse`: ```ts type ActionMiddlewareResponse = { updatedParams?: ActionParams; updatedResponse?: any; }; ``` ## Applying Middleware Add middleware to an action via the `middleware` array: ```ts export class UserEdit implements Action { name = "user:edit"; middleware = [SessionMiddleware]; // ... } ``` Middleware runs in array order. If you have `[AuthMiddleware, RateLimitMiddleware]`, auth runs first — if it throws, rate limiting never executes. ## Common Patterns ### Authentication This is the most common use case. Check that a session exists and has the data you expect: ```ts export const SessionMiddleware: ActionMiddleware = { runBefore: async (_params, connection) => { if (!connection.session?.data.userId) { throw new TypedError({ message: "Session not found", type: ErrorType.CONNECTION_SESSION_NOT_FOUND, }); } }, }; ``` ### Param Normalization You can modify params before the action sees them — useful for things like lowercasing emails: ```ts export const NormalizeMiddleware: ActionMiddleware = { runBefore: async (params) => { return { updatedParams: { ...params, email: params.email?.toLowerCase(), }, }; }, }; ``` That said, you can also handle this in the Zod schema with `.transform()` — so use whichever approach makes more sense for your case. ### Rate Limiting The built-in `RateLimitMiddleware` uses a Redis-backed sliding window to limit request rates per client. It identifies users by user ID (authenticated) or IP address (unauthenticated): ```ts import { RateLimitMiddleware } from "keryx"; export class ApiEndpoint implements Action { name = "api:endpoint"; middleware = [SessionMiddleware, RateLimitMiddleware]; // ... } ``` When a client exceeds the limit, the middleware throws a `CONNECTION_RATE_LIMITED` error (HTTP 429). Rate limit info is attached to the connection and included in response headers automatically. See the [Security guide](/guide/security) for configuration options and custom limit overrides. ### Database Transactions The built-in `TransactionMiddleware` wraps the entire action lifecycle in a database transaction. It opens a transaction in `runBefore`, stores it on `connection.metadata.transaction`, and commits or rolls back in `runAfter` based on whether the action succeeded: ```ts import { TransactionMiddleware, type Transaction } from "keryx"; export class TransferFunds extends Action { constructor() { super({ name: "transfer:funds", middleware: [SessionMiddleware, TransactionMiddleware], web: { route: "/transfer", method: HTTP_METHOD.POST }, inputs: z.object({ fromId: z.number(), toId: z.number(), amount: z.number() }), }); } async run(params: ActionParams, connection?: Connection) { const tx = connection!.metadata.transaction as Transaction; // Both updates happen atomically — if either fails, both roll back await tx.update(accounts).set({ ... }).where(eq(accounts.id, params.fromId)); await tx.update(accounts).set({ ... }).where(eq(accounts.id, params.toId)); return { success: true }; } } ``` For one-off transactions outside the middleware lifecycle, use the `withTransaction()` utility. See the [Advanced Patterns guide](/guide/advanced-patterns) for more details. ### Passing Data Between Middleware and Actions Use `connection.metadata` to pass request-scoped data from middleware to actions (or between `runBefore` and `runAfter`). Metadata is reset to `{}` at the start of each `act()` call, so long-lived connections like WebSockets won't leak state between requests. First, declare your metadata shape: ```ts // types.ts import type { Membership, Project } from "./models"; export type AppConnectionMeta = { membership?: Membership; project?: Project; auditBefore?: Record; auditAfter?: Record; }; ``` Then use it in middleware and actions with the second generic on `Connection`: ```ts import type { Connection } from "keryx"; import type { AppConnectionMeta } from "../types"; export const RbacMiddleware: ActionMiddleware = { runBefore: async ( _params, connection: Connection, ) => { const membership = await resolveMembership(connection.session!.data.userId); connection.metadata.membership = membership; // type-safe write }, }; ``` ```ts export class OrgView extends Action { middleware = [SessionMiddleware, RbacMiddleware]; async run( params: ActionParams, connection: Connection, ) { const membership = connection.metadata.membership; // type-safe read // ... } } ``` This replaces the old pattern of casting through `unknown` to attach properties to the connection. ### Response Enrichment `runAfter` can add data to the response. This runs after the action's `run()` method completes: ```ts export const TimingMiddleware: ActionMiddleware = { runBefore: async (_params, connection) => { connection.metadata._startTime = Date.now(); }, runAfter: async (_params, connection) => { return { updatedResponse: { requestDuration: Date.now() - (connection.metadata._startTime as number), }, }; }, }; ``` ## Reference * [`ActionMiddleware` and `ChannelMiddleware`](/reference/classes) — the full middleware type surface * [`TypedError` and `ErrorType`](/reference/classes#typederror) — what to throw from a middleware that rejects --- --- url: 'https://keryxjs.com/guide/observability.md' description: >- OpenTelemetry-based metrics for HTTP requests, WebSocket connections, action executions, and background tasks — with a built-in Prometheus scrape endpoint. --- # Observability Keryx includes built-in OpenTelemetry instrumentation that provides metrics for HTTP requests, WebSocket connections, action executions, and background tasks. Disabled by default — enable it and scrape `/metrics` for Prometheus. ## Quick Start 1. Enable metrics via environment variable: ```bash OTEL_METRICS_ENABLED=true bun run start ``` 2. Scrape metrics at `GET /metrics` (Prometheus exposition format). ## Configuration | Config Key | Env Var | Default | Description | | --------------------- | ----------------------------- | ------------ | ------------------------------------------------------------------------------------------ | | `enabled` | `OTEL_METRICS_ENABLED` | `false` | Master toggle for all instrumentation | | `metricsRoute` | `OTEL_METRICS_ROUTE` | `"/metrics"` | Path for the Prometheus scrape endpoint | | `serviceName` | `OTEL_SERVICE_NAME` | *(app name)* | Service name in metric labels. Defaults to the `name` field from your app's `package.json` | | `metricsAuthUsername` | `OTEL_METRICS_AUTH_USERNAME` | `""` | When set together with `metricsAuthPassword`, requires HTTP Basic auth on `/metrics` | | `metricsAuthPassword` | `OTEL_METRICS_AUTH_PASSWORD` | `""` | Password for the metrics endpoint when basic auth is enabled | ## Available Metrics ### HTTP | Metric | Type | Attributes | Description | | ----------------------------- | -------------- | --------------------- | ---------------------------- | | `keryx.http.requests` | Counter | method, route, status | Total HTTP requests received | | `keryx.http.request.duration` | Histogram (ms) | method, route, status | HTTP request duration | ### WebSocket | Metric | Type | Attributes | Description | | ---------------------- | ------------- | ---------- | -------------------------------------- | | `keryx.ws.connections` | UpDownCounter | — | Currently active WebSocket connections | | `keryx.ws.messages` | Counter | — | Total WebSocket messages received | ### MCP | Metric | Type | Attributes | Description | | --------------------- | ------------- | ---------- | ---------------------------------------------------- | | `keryx.mcp.sessions` | UpDownCounter | — | Currently active MCP sessions | | `keryx.mcp.messages` | Counter | — | Total MCP requests received (POST/GET/DELETE) | ### Actions | Metric | Type | Attributes | Description | | ------------------------- | -------------- | -------------- | ------------------------- | | `keryx.action.executions` | Counter | action, status | Total action executions | | `keryx.action.duration` | Histogram (ms) | action | Action execution duration | Action metrics are recorded for all transports (HTTP, WebSocket, CLI, background tasks, and MCP). ### Background Tasks | Metric | Type | Attributes | Description | | --------------------- | -------------- | --------------------- | ------------------------------- | | `keryx.task.enqueued` | Counter | action, queue | Total tasks enqueued | | `keryx.task.executed` | Counter | action, queue, status | Total tasks executed by workers | | `keryx.task.duration` | Histogram (ms) | action | Task execution duration | ### System | Metric | Type | Description | | -------------------------- | ---------------- | ------------------------------ | | `keryx.system.connections` | Observable Gauge | Current total connection count | ## Prometheus Integration The `/metrics` endpoint serves metrics in [Prometheus exposition format](https://prometheus.io/docs/instrumenting/exposition_formats/). Because each Keryx process serves its own `/metrics` endpoint, **every node in your cluster must be scraped individually** — metrics are not aggregated across instances. Use Prometheus service discovery or list each target explicitly. Add a scrape target to your `prometheus.yml`: ```yaml scrape_configs: - job_name: "keryx" scrape_interval: 15s metrics_path: "/metrics" static_configs: - targets: ["localhost:8080"] ``` The metrics endpoint is served on the existing web server — no additional ports or servers needed. It's intercepted before action routing, so it won't conflict with your API routes. Keryx validates at startup that no action route overlaps with the metrics path. ### Authentication By default `/metrics` is unauthenticated. This is suitable for private networks (e.g. Kubernetes clusters) where network segmentation provides the access control. For publicly-exposed servers, enable HTTP Basic auth by setting both `OTEL_METRICS_AUTH_USERNAME` and `OTEL_METRICS_AUTH_PASSWORD`. When either is empty, the endpoint stays open. ```bash OTEL_METRICS_ENABLED=true \ OTEL_METRICS_AUTH_USERNAME=prometheus \ OTEL_METRICS_AUTH_PASSWORD=secret \ bun run start ``` Configure Prometheus to send the credentials on each scrape: ```yaml scrape_configs: - job_name: "keryx" scrape_interval: 15s metrics_path: "/metrics" basic_auth: username: "prometheus" password_file: "/etc/prometheus/keryx_password" static_configs: - targets: ["keryx.example.com"] ``` Requests without an `Authorization: Basic ...` header (or with wrong credentials) get a `401 Unauthorized` with `WWW-Authenticate: Basic realm="Metrics"`. ## Custom Exporters The built-in `/metrics` endpoint covers the Prometheus pull model. For push-based exporters (OTLP, Datadog, etc.), configure your own `MeterProvider` before calling `api.start()`: ```ts import { metrics } from "@opentelemetry/api"; import { MeterProvider } from "@opentelemetry/sdk-metrics"; import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http"; import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics"; const exporter = new OTLPMetricExporter({ url: "https://your-collector:4318/v1/metrics", }); const provider = new MeterProvider({ readers: [ new PeriodicExportingMetricReader({ exporter, exportIntervalMillis: 30_000, }), ], }); metrics.setGlobalMeterProvider(provider); ``` Keryx's instruments use the global OTel API, so they'll automatically report to whatever `MeterProvider` is registered. ## Programmatic Access You can collect metrics programmatically via `api.observability.collectMetrics()`: ```ts import { api } from "keryx"; const prometheusText = await api.observability.collectMetrics(); ``` ## Custom Metrics The built-in meter instruments are private to the observability initializer — framework emission is wired through `api.hooks.*`, and no direct `api.observability.*.add/record` surface is exposed. To record your own metrics, create a `Meter` off the global OTel `MeterProvider` that Keryx installs at `start()`: ```ts import { metrics } from "@opentelemetry/api"; const meter = metrics.getMeter("my-app"); const signupsTotal = meter.createCounter("my_app.signups", { description: "Total user signups", }); signupsTotal.add(1, { plan: "pro" }); ``` Your custom metrics will be exported alongside Keryx's built-in metrics on the same `/metrics` endpoint. ## Cardinality & Memory All built-in metric attributes have bounded cardinality — they use action names, HTTP methods, status codes, and queue names, all of which are known at startup. This means the number of unique time series stays proportional to your action count and memory usage remains constant regardless of traffic volume. If you record custom metrics via your own `Meter`, avoid using unbounded values (user IDs, request paths, timestamps, etc.) as attributes. Unbounded cardinality causes the OTel SDK to allocate a new time series per unique combination, which can lead to unbounded memory growth. ## Distributed Tracing For traces, use [`@keryxjs/tracing`](/plugins/tracing) (OTLP) or [`@keryxjs/sentry`](/plugins/sentry). Both honor `tracing = false` on an action so health checks like `status` stay out of the trace backend — see [Opting Out of Tracing](/guide/actions#opting-out-of-tracing). Don't run both plugins at once; they fight over the process-wide tracing context. --- --- url: 'https://keryxjs.com/guide/plugins.md' description: >- Plugins let third-party packages contribute actions, initializers, channels, servers, and config to a Keryx application. --- # Plugins ::: tip Looking for first-party plugins? See the [Plugins catalog](/plugins/) for ready-to-use plugins maintained by the Keryx team. ::: Plugins package reusable functionality — initializers, actions, channels, servers, and config defaults — into npm modules that any Keryx app can install and register. If you've built an initializer or a set of actions that would be useful across projects, a plugin is how you distribute it. ## First-Party Plugins | Package | Description | |---------|-------------| | [`@keryxjs/tracing`](/plugins/tracing) | OpenTelemetry distributed tracing (OTLP) for HTTP, actions, tasks, Redis, and Drizzle | | [`@keryxjs/sentry`](/plugins/sentry) | Sentry error monitoring and tracing for HTTP, WebSocket, MCP, actions, tasks, Redis, and Postgres | | [`@keryxjs/resque-admin`](/plugins/resque-admin) | Web dashboard and API for monitoring Redis, queues, workers, failed jobs, and locks | ## Using a Plugin Install the plugin package, then add it to your config: ```ts // config/plugins.ts import { resqueAdminPlugin } from "@keryxjs/resque-admin"; export default { plugins: [resqueAdminPlugin], }; ``` That's it. The framework loads plugins during initialization — their initializers, actions, channels, and servers are discovered automatically. ## The KeryxPlugin Interface A plugin is an object that satisfies the `KeryxPlugin` interface: ```ts import type { KeryxPlugin } from "keryx"; export const myPlugin: KeryxPlugin = { name: "my-plugin", version: "1.0.0", // Class constructors (optional) — framework instantiates them initializers: [MyInitializer], actions: [MyAction, AnotherAction], channels: [MyChannel], servers: [MyServer], // Config defaults (optional) — merged before user config configDefaults: { myPlugin: { enabled: true, maxRetries: 3, }, }, // Custom generator types (optional) generators: [ { type: "resolver", directory: "resolvers", templatePath: path.join(import.meta.dir, "templates/resolver.ts.mustache"), }, ], }; ``` All fields except `name` and `version` are optional. Provide only what your plugin needs. ## What Plugins Can Provide ### Initializers Plugin initializers work exactly like framework or user initializers — they extend the `Initializer` class, declare their dependencies via `dependsOn`, and can attach namespaces to the `api` singleton via module augmentation: ```ts import { Initializer } from "keryx"; declare module "keryx" { export interface API { cache: Awaited>; } } export class CacheInitializer extends Initializer { constructor() { super("cache"); this.dependsOn = ["redis"]; // names of other initializers this one needs } async initialize() { const store = new Map(); return { get: (k: string) => store.get(k), set: (k: string, v: unknown) => store.set(k, v) }; } } ``` Users of the plugin need to import it (or the plugin package) so the module augmentation is visible to TypeScript: ```ts import "@keryxjs/cache"; // side-effect import for type augmentation ``` ### Actions Plugin actions extend `Action` and are registered alongside the app's own actions. They show up in HTTP routing, the CLI, MCP, and Swagger automatically: ```ts import { Action, HTTP_METHOD, type ActionParams } from "keryx"; export class HealthCheck extends Action { constructor() { super({ name: "plugin:health", description: "Extended health check from plugin", web: { route: "/health", method: HTTP_METHOD.GET }, }); } async run(_params: ActionParams) { return { healthy: true }; } } ``` ### Channels Plugin channels extend `Channel` and are registered alongside user channels: ```ts import { Channel } from "keryx"; export class PluginNotifications extends Channel { constructor() { super({ name: /^plugin:notify:.*$/, description: "Plugin notification channel" }); } } ``` ### Servers Plugin servers extend `Server` and participate in the standard initialize → start → stop lifecycle. ### Config Defaults Plugin config defaults are applied using `deepMergeDefaults` — they only fill in values that aren't already set. User config always takes precedence. Use module augmentation to make plugin config type-safe: ```ts declare module "keryx" { interface KeryxConfig { myPlugin: { enabled: boolean; maxRetries: number }; } } ``` ### Middleware Middleware isn't registered through the plugin manifest — actions import and reference it directly. Just export your middleware from the plugin package: ```ts // In your plugin package export const MyPluginMiddleware: ActionMiddleware = { runBefore: async (params, connection) => { /* ... */ }, }; ``` Users apply it to their actions: ```ts import { MyPluginMiddleware } from "keryx-plugin-foo"; export class MyAction extends Action { constructor() { super({ name: "my-action", middleware: [MyPluginMiddleware], }); } } ``` ### Lifecycle Hooks Plugins can observe or wrap framework-wide lifecycle events across five namespaces: HTTP requests, WebSocket connections, MCP sessions, actions, and background tasks. All hooks are registered via the `api.hooks` namespace from code (not the plugin manifest), typically inside a plugin initializer's `initialize()`. Use lifecycle hooks when action middleware (`runBefore` / `runAfter`) isn't enough — for example, to wrap an entire HTTP request in a tracing span, inject trace headers into every enqueued job, or restore distributed trace context before a worker runs an action. ```ts import { api, Initializer } from "keryx"; class MyTracer extends Initializer { constructor() { super("myTracer"); this.dependsOn = ["hooks"]; // ensure api.hooks is ready } async initialize() { api.hooks.web.beforeRequest((req, ctx) => { /* ... */ }); api.hooks.web.afterRequest((req, res, ctx, outcome) => { /* ... */ }); api.hooks.ws.onConnect((connection) => { /* ... */ }); api.hooks.ws.onMessage((connection, message) => { /* ... */ }); api.hooks.ws.onDisconnect((connection) => { /* ... */ }); api.hooks.mcp.onConnect((sessionId) => { /* ... */ }); api.hooks.mcp.onMessage((sessionId) => { /* ... */ }); api.hooks.mcp.onDisconnect((sessionId) => { /* ... */ }); api.hooks.actions.onEnqueue((name, inputs, queue) => { /* ... */ }); api.hooks.actions.beforeAct((name, params, connection, ctx) => { /* ... */ }); api.hooks.actions.afterAct((name, params, connection, ctx, outcome) => { /* ... */ }); api.hooks.resque.beforeJob((name, params, ctx) => { /* ... */ }); api.hooks.resque.afterJob((name, params, ctx, outcome) => { /* ... */ }); } } ``` **HTTP request hooks** — `api.hooks.web.beforeRequest` fires at the start of every HTTP request before routing (covers static files, OAuth, MCP, metrics, and actions); `api.hooks.web.afterRequest` fires after the `Response` is built, before compression. WebSocket upgrades do not fire these hooks. A shared `RequestContext` passes from `beforeRequest` to `afterRequest` so state can be threaded through `ctx.metadata`; `afterRequest` additionally receives a `RequestOutcome` with `{ method, status, actionName?, durationMs }` describing the resolved routing decision: ```ts api.hooks.web.beforeRequest((req, ctx) => { ctx.metadata.startedAt = Date.now(); }); api.hooks.web.afterRequest((_req, _res, _ctx, outcome) => { // outcome.actionName is undefined for static/oauth/mcp/metrics/404 paths recordSpan(outcome.actionName ?? "unknown", outcome.durationMs, outcome.status); }); ``` A `beforeRequest` hook that returns a `Response` **short-circuits** the request: later `beforeRequest` hooks and all routing are skipped, and that response goes to the client. `afterRequest` hooks still fire (with `outcome.actionName` unset), and the response is still compressed. Use this for concerns that have to answer before routing — health checks, a maintenance mode, blocklists: ```ts api.hooks.web.beforeRequest((req) => { if (maintenanceMode) { return new Response("Down for maintenance", { status: 503 }); } // Return nothing to let the request continue }); ``` Reading the request body in a hook consumes it — the action or handler downstream then sees an empty body. If an action needs the bytes, have it declare [`web.rawBody`](/guide/actions#raw-request-bodies) and read `connection.rawRequest` instead. **WebSocket hooks** — `api.hooks.ws.onConnect` fires when a WebSocket is accepted (after the `Connection` is constructed); `onMessage` fires for each inbound message before parsing; `onDisconnect` fires when the socket closes, before channel presence cleanup. All three receive the persistent per-session `Connection` instance: ```ts api.hooks.ws.onConnect((connection) => { logger.info(`ws connected: ${connection.id}`); }); api.hooks.ws.onMessage((connection, _message) => { recordActivity(connection.id); }); ``` **MCP session hooks** — `api.hooks.mcp.onConnect` fires when an MCP session finishes initializing; `onMessage` fires before each inbound MCP request is dispatched to the transport (`sessionId` is `undefined` on the very first POST that creates a new session); `onDisconnect` fires when the transport closes. Unlike WebSocket, MCP has no persistent `Connection` per session — a fresh transient one is created per tool call — so hooks receive the stable `sessionId` string instead: ```ts api.hooks.mcp.onConnect((sessionId) => { logger.info(`mcp session opened: ${sessionId}`); }); ``` **Task enqueue hook** — `api.hooks.actions.onEnqueue` fires on every `api.actions.enqueue`, `enqueueAt`, `enqueueIn`, and each per-job call inside `fanOut`. Return a replacement `TaskInputs` object to mutate the payload before it hits Redis; return `void` to leave it unchanged: ```ts api.hooks.actions.onEnqueue((actionName, inputs, queue) => { return { ...inputs, _traceparent: currentTraceparent() }; }); ``` **Action lifecycle hooks (cross-transport)** — `api.hooks.actions.beforeAct` and `api.hooks.actions.afterAct` fire inside `Connection.act()` for every action invocation, regardless of transport (web, websocket, task, cli, mcp, …). The `Connection` is passed so handlers can branch on `connection.type`. Unlike `hooks.web.beforeRequest` (HTTP-only) and `hooks.resque.beforeJob` (task-only), these fire across all transports with one registration. They fire after params are validated and don't run if the action isn't found or validation fails. ```ts api.hooks.actions.beforeAct((name, params, connection, ctx) => { ctx.metadata.span = startSpan(`action:${name}`, { transport: connection.type, }); }); api.hooks.actions.afterAct((_name, _params, _connection, ctx, outcome) => { const span = ctx.metadata.span as Span; if (outcome.success) span.setStatus({ code: "OK" }); else span.recordException(outcome.error as Error); span.end(); }); ``` **Task execution hooks** — `api.hooks.resque.beforeJob` and `api.hooks.resque.afterJob` fire inside the job wrapper, bracketing the action run. They have access to the decoded action name and params (unlike the underlying `worker.on("job")` event, which only sees raw `job.args`). `afterJob` receives a unified `JobOutcome` that discriminates success from failure via `outcome.success`, so both paths reach a single handler: ```ts api.hooks.resque.beforeJob((name, params, ctx) => { ctx.metadata.span = startSpan(name, params); }); api.hooks.resque.afterJob((_name, _params, ctx, outcome) => { const span = ctx.metadata.span as Span; if (outcome.success) span.setStatus({ code: "OK" }); else span.recordException(outcome.error as Error); span.end(); }); ``` All hook types (`RequestContext`, `RequestOutcome`, `BeforeRequestHook`, `AfterRequestHook`, `OnConnectHook`, `OnMessageHook`, `OnDisconnectHook`, `OnMcpConnectHook`, `OnMcpMessageHook`, `OnMcpDisconnectHook`, `OnEnqueueHook`, `ActContext`, `ActOutcome`, `BeforeActHook`, `AfterActHook`, `JobContext`, `JobOutcome`, `BeforeJobHook`, `AfterJobHook`) are exported from `"keryx"`. Hooks run sequentially in registration order; thrown errors propagate (a throw in `beforeRequest` aborts the request, a throw in `beforeAct` fails the action, a throw in `beforeJob` fails the job). ### Custom Generators Plugins can register custom types for the `keryx generate` CLI command. Provide a Mustache template and an output directory: ```ts { generators: [{ type: "resolver", // `keryx generate resolver myThing` directory: "resolvers", // output: resolvers/myThing.ts templatePath: path.join(import.meta.dir, "templates/resolver.ts.mustache"), testTemplatePath: path.join(import.meta.dir, "templates/resolver.test.ts.mustache"), }] } ``` The template receives `{{ name }}` and `{{ className }}` as variables. ## Loading Order Understanding the loading order helps you declare `dependsOn` correctly: 1. **User config loaded** — from the app's `config/` directory (including `config.plugins`) 2. **Plugin config defaults applied** — via `deepMergeDefaults` (never overwrites user-set values) 3. **Initializer discovery** — framework → plugins → user (registration order within plugins) 4. **Initializer execution** — all initializers are topologically sorted by `dependsOn`, regardless of source 5. **Action discovery** — plugin actions → user actions 6. **Channel discovery** — plugin channels → user channels 7. **Server discovery** — framework servers → plugin servers → user servers `dependsOn` names can refer to any initializer in the graph — framework, plugin, or user. Unknown names and cycles cause a startup error. Independent initializers keep their discovery order, so when two plugins are mutually independent the order they appear in `config.plugins` wins. ## Naming Convention | Scope | Convention | Example | |-------|-----------|---------| | First-party | `@keryxjs/` | `@keryxjs/resque-admin` | | Third-party | `keryx-plugin-` | `keryx-plugin-graphql` | These are conventions, not enforced by the framework. The `name` field in the plugin manifest is what matters for uniqueness. ## Building a Plugin Package A minimal plugin package: ``` keryx-plugin-hello/ package.json index.ts actions/ hello.ts ``` **`package.json`:** ```json { "name": "keryx-plugin-hello", "version": "1.0.0", "type": "module", "module": "index.ts", "peerDependencies": { "keryx": ">=0.20.0" } } ``` **`index.ts`:** ```ts import type { KeryxPlugin } from "keryx"; import { HelloAction } from "./actions/hello"; export const helloPlugin: KeryxPlugin = { name: "hello", version: "1.0.0", actions: [HelloAction], }; ``` **`actions/hello.ts`:** ```ts import { Action, HTTP_METHOD, type ActionParams } from "keryx"; export class HelloAction extends Action { constructor() { super({ name: "hello", description: "Says hello", web: { route: "/hello", method: HTTP_METHOD.GET }, }); } async run(_params: ActionParams) { return { message: "Hello from plugin!" }; } } ``` Use `keryx` as a peer dependency so the app controls the framework version. --- --- url: 'https://keryxjs.com/plugins.md' description: >- First-party plugins maintained by the Keryx team that add opt-in functionality to your application. --- # Plugins The Keryx team maintains a set of first-party plugins under the `@keryxjs` npm scope. Each plugin is a standalone package you install alongside `keryx` — they're opt-in and independent. ## Available Plugins | Plugin | Package | Description | |--------|---------|-------------| | [Tracing](/plugins/tracing) | `@keryxjs/tracing` | OpenTelemetry distributed tracing (OTLP) for HTTP, actions, tasks, Redis, and Drizzle | | [Sentry](/plugins/sentry) | `@keryxjs/sentry` | Sentry error monitoring and tracing for HTTP, WebSocket, MCP, actions, tasks, Redis, and Postgres | | [Resque Admin](/plugins/resque-admin) | `@keryxjs/resque-admin` | Web dashboard and API for monitoring Redis, queues, workers, failed jobs, and locks | | [CSRF](/plugins/csrf) | `@keryxjs/csrf` | Per-session CSRF tokens and middleware to protect state-changing endpoints | | [Admin](/plugins/admin) | `@keryxjs/admin` | Dashboard and API to browse, filter, and edit any table in your database, with read-only and full roles | ## Using a Plugin Install the package, register it in your config, and you're done: ```ts // config/plugins.ts import { resqueAdminPlugin } from "@keryxjs/resque-admin"; export default { plugins: [resqueAdminPlugin], }; ``` The framework loads plugins during initialization — their actions, initializers, and config defaults are discovered automatically. ## Building Your Own Want to create a plugin? See the [Plugins guide](/guide/plugins) for the `KeryxPlugin` interface, what plugins can provide, and how to build and publish one. --- --- url: 'https://keryxjs.com/plugins/resque-admin.md' description: >- A web dashboard and API for monitoring Redis, Resque queues, workers, failed jobs, and locks in your Keryx application. --- # Resque Admin `@keryxjs/resque-admin` provides a password-protected web dashboard and a set of API endpoints for monitoring your Keryx application's background task system. Think of it as a modern [resque-web](https://github.com/resque/resque-web) for Keryx. ![Resque Admin overview tab showing stats, queues, a 5-minute queue-length chart, and workers](/images/resque-admin-overview.png) ## Installation ```bash bun add @keryxjs/resque-admin ``` ## Configuration Register the plugin and set a password: ```ts // config/plugins.ts import { resqueAdminPlugin } from "@keryxjs/resque-admin"; export default { plugins: [resqueAdminPlugin], }; ``` ```ts // config/resqueAdmin.ts export default { resqueAdmin: { password: process.env.RESQUE_ADMIN_PASSWORD || "", }, }; ``` The password is required. If no password is set, all admin endpoints return a 500 error until one is configured. | Config | Env | Default | Purpose | |--------|-----|---------|---------| | `resqueAdmin.password` | | `""` | Shared password for every JSON endpoint | | `resqueAdmin.serveUi` | `RESQUE_ADMIN_SERVE_UI` | `true` | When false, the built-in HTML dashboard is not served. JSON endpoints stay available | ## Web Dashboard Visit `/api/resque-admin` in your browser to access the dashboard. You'll be prompted for the admin password, which is stored in `sessionStorage` for the duration of your browser session. The dashboard includes: * **Overview** — queue lengths, worker status, processed/failed stats, leader info (auto-refreshes every 5 seconds) * **Failed** — browse failed jobs with retry and remove actions * **Queues** — inspect jobs in any queue, delete queues * **Delayed** — view all scheduled future jobs by timestamp * **Locks** — list and delete resque locks * **Redis Info** — parsed output of the Redis `INFO` command organized by section * **Enqueue** — manually enqueue any action as a background task ### Bring your own UI The JSON endpoints are usable on their own. If you want to build the dashboard yourself, skip the built-in HTML so `GET /resque-admin` stays free: ```ts import { createResqueAdminPlugin } from "@keryxjs/resque-admin"; export default { plugins: [createResqueAdminPlugin({ serveUi: false })], }; ``` `RESQUE_ADMIN_SERVE_UI=false` does the same for the default `resqueAdminPlugin` export. Setting `config.resqueAdmin.serveUi = false` after boot 404s the shell if it was already registered, but doesn't unbind the route — use the factory when you want to occupy it yourself. ## API Endpoints All endpoints except the UI require the `password` parameter. For GET requests, pass it as a query parameter. For POST requests, include it in the JSON body. | Endpoint | Method | Description | |----------|--------|-------------| | `/resque-admin` | GET | Web dashboard UI (no password required) | | `/resque-admin/overview` | GET | Queue lengths, workers, stats, leader, failed count | | `/resque-admin/failed` | GET | Failed jobs with pagination (`start`, `stop`) | | `/resque-admin/retry-failed` | POST | Retry a failed job (`failedJob` as JSON string) | | `/resque-admin/remove-failed` | POST | Remove a failed job (`failedJob` as JSON string) | | `/resque-admin/queue/:queue` | GET | Jobs in a specific queue with pagination | | `/resque-admin/del-queue` | POST | Delete a queue and all its jobs (`queue`) | | `/resque-admin/locks` | GET | All resque locks | | `/resque-admin/del-lock` | POST | Delete a lock (`lock`) | | `/resque-admin/delayed` | GET | All delayed jobs by timestamp | | `/resque-admin/redis-info` | GET | Parsed Redis INFO output | | `/resque-admin/enqueue` | POST | Enqueue an action (`actionName`, `inputs`, `queue`) | ## Security * The password is sent as a query parameter (GET) or in the request body (POST). Use HTTPS in production. * All admin actions are excluded from MCP tool exposure (`mcp: { tool: false }`). * The password field is marked with `secret()` so it won't appear in logs or Swagger documentation. * Consider placing these endpoints behind a VPN or reverse proxy with additional authentication in production environments. The password check is exported as `ResqueAdminPasswordMiddleware`, so you can reuse it on your own actions: ```ts import { ResqueAdminPasswordMiddleware } from "@keryxjs/resque-admin"; export class QueueDrain implements Action { name = "queue:drain"; middleware = [ResqueAdminPasswordMiddleware]; // ... } ``` It compares against `config.resqueAdmin.password` in constant time, rejecting with a `401` on mismatch and a `500` when no password is configured. --- --- url: 'https://keryxjs.com/guide/security.md' description: >- Built-in security features — rate limiting, security headers, cookie hardening, CORS, WebSocket protections, and OAuth validation. --- # Security Keryx ships with security defaults that are sensible for development and tightenable for production. Most features are configured via environment variables — no code changes needed to go from development to a hardened production deployment. ## Rate Limiting Rate limiting uses a sliding window algorithm backed by Redis. It's implemented as action middleware, so you can apply it to specific actions or leave it off entirely. ### Setup Add `RateLimitMiddleware` to any action: ```ts import { RateLimitMiddleware } from "keryx"; export class UserCreate implements Action { name = "user:create"; middleware = [RateLimitMiddleware]; // ... } ``` The middleware identifies clients by user ID (if authenticated) or IP address (if not), and applies different limits to each: | Config Key | Env Var | Default | Description | | ---------------------- | ------------------------- | ------------- | ------------------------------------ | | `enabled` | `RATE_LIMIT_ENABLED` | `true` | Master toggle (disabled in test) | | `windowMs` | `RATE_LIMIT_WINDOW_MS` | `60000` | Sliding window size (ms) | | `unauthenticatedLimit` | `RATE_LIMIT_UNAUTH_LIMIT` | `20` | Max requests per window (no session) | | `authenticatedLimit` | `RATE_LIMIT_AUTH_LIMIT` | `200` | Max requests per window (logged in) | | `keyPrefix` | `RATE_LIMIT_KEY_PREFIX` | `"ratelimit"` | Redis key prefix | When a client exceeds the limit, the action returns a `429` with a message indicating how many seconds until the window resets. Rate limit info is also included in response headers (`X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`). ### Custom Limits The `checkRateLimit()` function is exported for use outside of action middleware — for example, the OAuth registration endpoint uses it with a stricter limit: ```ts import { checkRateLimit } from "keryx"; const info = await checkRateLimit(`oauth-register:${ip}`, false, { limit: config.rateLimit.oauthRegisterLimit, // default: 5 windowMs: config.rateLimit.oauthRegisterWindowMs, // default: 1 hour }); ``` ## Security Headers Every HTTP response includes security headers by default. Each is configurable via environment variable: | Header | Env Var | Default | | --------------------------- | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Content-Security-Policy` | `WEB_SECURITY_CSP` | `default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; font-src 'self' https://cdn.jsdelivr.net data:; img-src 'self' data: blob:; connect-src 'self'; worker-src blob:` | | `X-Content-Type-Options` | `WEB_SECURITY_CONTENT_TYPE_OPTIONS` | `nosniff` | | `X-Frame-Options` | `WEB_SECURITY_FRAME_OPTIONS` | `DENY` | | `Strict-Transport-Security` | `WEB_SECURITY_HSTS` | `max-age=31536000; includeSubDomains` | | `Referrer-Policy` | `WEB_SECURITY_REFERRER_POLICY` | `strict-origin-when-cross-origin` | These defaults are production-ready. The CSP allows loading scripts, styles, and fonts from `cdn.jsdelivr.net` (used by the built-in Swagger UI and OAuth pages). Tighten or adjust via `WEB_SECURITY_CSP` for your needs. ## Cookie Security Session cookies are configured with security flags: | Config Key | Env Var | Default | Description | | ---------------- | -------------------------- | ---------- | -------------------------------------------- | | `cookieHttpOnly` | `SESSION_COOKIE_HTTP_ONLY` | `true` | Prevents JavaScript access to the cookie | | `cookieSecure` | `SESSION_COOKIE_SECURE` | `false` | Only send cookie over HTTPS | | `cookieSameSite` | `SESSION_COOKIE_SAME_SITE` | `"Strict"` | CSRF protection (`Strict`, `Lax`, or `None`) | For production, set `SESSION_COOKIE_SECURE=true` so cookies are only transmitted over HTTPS. The `SameSite=Strict` default prevents CSRF attacks by ensuring cookies aren't sent on cross-origin requests. ## CORS Cross-origin request handling is configured on the web server: | Config Key | Env Var | Default | | ---------------- | ---------------------------- | ------------------------------------------------ | | `allowedOrigins` | `WEB_SERVER_ALLOWED_ORIGINS` | `"*"` | | `allowedMethods` | `WEB_SERVER_ALLOWED_METHODS` | `"HEAD, GET, POST, PUT, PATCH, DELETE, OPTIONS"` | | `allowedHeaders` | `WEB_SERVER_ALLOWED_HEADERS` | `"Content-Type"` | **Important:** When `allowedOrigins` is `"*"` (the default), the server will not send `Access-Control-Allow-Credentials: true` — this follows the browser spec that forbids wildcard origins with credentials. For production, set `WEB_SERVER_ALLOWED_ORIGINS` to your specific domain(s) so that credentialed requests (cookies, auth headers) work correctly. ## Request Body Size Limits HTTP request bodies are limited to prevent memory exhaustion from oversized payloads. The limit is enforced in two layers: 1. **Pre-flight check** — if the request includes a `Content-Length` header larger than the limit, the server rejects it immediately with `413 Payload Too Large` before reading any body data 2. **Read-time check** — when parsing the body (JSON, form data, or multipart), the raw byte size is verified before parsing begins, catching chunked requests that omit `Content-Length` | Config Key | Env Var | Default | Description | | ------------- | ------------------ | -------------------- | ------------------------------------- | | `maxBodySize` | `WEB_MAX_BODY_SIZE` | `10485760` (10 MB) | Max request body size in bytes | Set `WEB_MAX_BODY_SIZE=0` to disable the limit entirely (not recommended for production). The WebSocket transport has its own payload limit via `websocket.maxPayloadSize` (default 64 KB) — see [WebSocket Protections](#websocket-protections) below. ## WebSocket Protections WebSocket connections have several layers of protection: ### Origin Validation Before upgrading an HTTP connection to WebSocket, the server validates the `Origin` header against `config.server.web.allowedOrigins`. If the origin doesn't match, the upgrade is rejected. This prevents Cross-Site WebSocket Hijacking (CSWSH) attacks. ### Message Limits | Config Key | Env Var | Default | Description | | -------------------------------- | ---------------------------- | ------- | ---------------------------------- | | `websocket.maxPayloadSize` | `WS_MAX_PAYLOAD_SIZE` | `65536` | Max message size in bytes (64 KB) | | `websocket.maxMessagesPerSecond` | `WS_MAX_MESSAGES_PER_SECOND` | `20` | Per-connection rate limit | | `websocket.maxSubscriptions` | `WS_MAX_SUBSCRIPTIONS` | `100` | Max channel subscriptions per conn | Messages exceeding the payload size are rejected. Clients sending more than the per-second limit are disconnected. These protect against resource exhaustion from misbehaving or malicious clients. ### Channel Validation * **Channel names** must match the pattern `/^[a-zA-Z0-9:._-]{1,200}$/` — alphanumeric characters plus `:`, `.`, `_`, `-`, max 200 characters * **Undefined channels** are rejected — if no registered channel matches the requested name, the subscription is denied with a `CONNECTION_CHANNEL_AUTHORIZATION` error ## OAuth Security The MCP server's OAuth 2.1 implementation includes several hardening measures: ### Redirect URI Validation When clients register via `/oauth/register`, redirect URIs are validated: * Must be a valid URL * Must not contain a fragment (`#`) * Must not contain userinfo (username/password in the URL) * Must use HTTPS for non-localhost URIs When authorizing or exchanging authorization codes, the redirect URI must match a registered URI using exact string comparison (per RFC 6749 §3.1.2.3), with one exception: for `http:` loopback URIs (`localhost`, `127.0.0.1`, `[::1]`) the port is ignored, as [RFC 8252 §7.3](https://datatracker.ietf.org/doc/html/rfc8252#section-7.3) requires — a CLI or native client takes an ephemeral port from the OS when it starts its callback listener, so it cannot register the port ahead of time. Scheme, host, path, query, and fragment stay byte-exact, and `localhost` still does not match `127.0.0.1`. ### Client ID Metadata Documents A client may identify itself with an HTTPS URL as its `client_id` instead of registering ([MCP 2026-07-28 / SEP-991](/guide/mcp#client-id-metadata-documents)). Because that makes the authorization server fetch a URL an untrusted caller chose, the fetch is constrained: * **SSRF guard** — the host must resolve to a publicly routable address. Loopback, private (RFC 1918), link-local (including `169.254.169.254`, the cloud instance-metadata endpoint), CGNAT, benchmarking, documentation, multicast, and reserved ranges are refused, for IPv4 and IPv6 alike (including IPv4-mapped, NAT64, and Teredo forms). Set `MCP_OAUTH_CIMD_ALLOW_PRIVATE_HOSTS=true` **only** in local development. The check runs before the request, so a hostname whose DNS records change between the lookup and the fetch could still be re-pointed; egress-filter the process if you need a hard guarantee. * **No redirects** — a `3xx` is rejected rather than followed, so a redirect cannot hop past the host check to a private address. * **Bounded reads** — the response must be HTTP 200, served as JSON, within `MCP_OAUTH_CIMD_FETCH_TIMEOUT_MS`, and no larger than `MCP_OAUTH_CIMD_MAX_BYTES` (streamed and aborted once the cap is passed, whether or not `Content-Length` is honest). * **Self-consistency** — the document's `client_id` must exactly equal the URL it was served from, so a document cannot claim an identity it is not hosted at. * **Exact redirect URI match** — the request's `redirect_uri` must appear verbatim in the document, apart from the loopback port exception above, and each `redirect_uris` entry is validated by the rules above. That exception is what lets a CLI client declare `http://localhost/callback` in its document and authorize on whatever port it ends up binding. * **Consent transparency** — the authorization page names the client and always displays the host the authorization is redirected to, with an added warning for loopback callbacks, which a metadata document cannot attest to. Only successful lookups are cached; a rejected document is refetched on the next attempt rather than remembered. Set `MCP_OAUTH_CIMD_ENABLED=false` to turn the mechanism off entirely — the authorization server then stops advertising `client_id_metadata_document_supported` and clients fall back to `POST /oauth/register`. ### Registration Rate Limiting OAuth client registration (`POST /oauth/register`) has a separate, stricter rate limit to prevent abuse: | Config Key | Env Var | Default | Description | | ----------------------- | ------------------------------------- | --------- | ---------------------------- | | `oauthRegisterLimit` | `RATE_LIMIT_OAUTH_REGISTER_LIMIT` | `5` | Max registrations per window | | `oauthRegisterWindowMs` | `RATE_LIMIT_OAUTH_REGISTER_WINDOW_MS` | `3600000` | Window size (1 hour) | ### Anonymous MCP Sessions With `MCP_AUTH_MODE=optional`, clients without a token can open MCP sessions and call actions marked `mcp.public`. Every anonymous MCP request counts against the unauthenticated per-IP limit, and opening a session also counts against a stricter one. Anonymous sessions also expire sooner (`MCP_ANONYMOUS_SESSION_TTL`, default 1 hour). See [Public (Anonymous) Access](/guide/mcp#public-anonymous-access). | Config Key | Env Var | Default | Description | | -------------------------- | ------------------------------------ | ------- | ---------------------------------------- | | `mcpAnonymousInitLimit` | `RATE_LIMIT_MCP_ANON_INIT_LIMIT` | `10` | Max new anonymous sessions per window | | `mcpAnonymousInitWindowMs` | `RATE_LIMIT_MCP_ANON_INIT_WINDOW_MS` | `60000` | Window size (1 minute) | ## Error Stack Traces By default, error responses include stack traces in development but omit them in production: | Config Key | Env Var | Default | | ---------- | ------------------------------------ | ---------------------------- | | Web server | `WEB_SERVER_INCLUDE_STACK_IN_ERRORS` | `true` (dev), `false` (prod) | | CLI | `CLI_INCLUDE_STACK_IN_ERRORS` | `true` | The web server default is based on `NODE_ENV` — when `NODE_ENV=production`, stack traces are automatically hidden from HTTP responses to avoid leaking internal implementation details. If the server boots with `includeStackInErrors=true` and binds to a non-localhost address, a warning is logged at startup. Stack traces in error responses leak deployment paths, package structure, and file/line numbers. To suppress them on a non-production host that is publicly reachable, set `WEB_SERVER_INCLUDE_STACK_IN_ERRORS=false` (or `NODE_ENV=production`). ## Reverse Proxy & Forwarded Headers The server can derive its external-facing origin (used in OAuth metadata, MCP `WWW-Authenticate` URLs, and protected-resource metadata) from `X-Forwarded-Proto` and `X-Forwarded-Host` headers. Because any client can spoof these headers when the server is reachable directly, trusting them unconditionally would let an attacker poison OAuth discovery responses and redirect MCP clients to attacker-controlled hosts. By default, `oauthTrustProxy` is `false` and forwarded headers are ignored — origin resolution falls back to `applicationUrl` (when set) or the request URL. Enable it only when the server is behind a reverse proxy that strips client-supplied `X-Forwarded-*` headers and sets them itself. | Key | Env Var | Default | Description | | ----------------- | ----------------------- | ------- | ------------------------------------------------------------ | | `oauthTrustProxy` | `MCP_OAUTH_TRUST_PROXY` | `false` | Honor `X-Forwarded-Proto` / `X-Forwarded-Host` (and `Host`). | For most production deployments, set `APPLICATION_URL` to your canonical external URL — it takes precedence over any forwarded headers and is the safest configuration regardless of `oauthTrustProxy`. ## Correlation IDs When a reverse proxy or load balancer sets a correlation ID header (e.g. `X-Request-Id`), the server can propagate it through the stack for distributed tracing. Enable this by setting `trustProxy` to `true` — the server will read the configured header from the incoming request and echo it back in the response. If the header is not present on a request, no correlation ID is set. | Config Key | Env Var | Default | Description | | ------------ | -------------------------------- | ---------------- | ------------------------------------------------------------- | | `header` | `WEB_CORRELATION_ID_HEADER` | `"X-Request-Id"` | Header name to read/echo (empty string to disable) | | `trustProxy` | `WEB_CORRELATION_ID_TRUST_PROXY` | `false` | Read and echo the incoming correlation ID header from proxies | Correlation IDs appear in action log lines as `[cor:]`: ``` [ACTION:WEB:OK] status (3ms) [GET] 127.0.0.1(http://localhost:8080/api/status) [cor:a1b2c3d4-...] {} ``` For fan-out tasks, you can propagate the parent's correlation ID to child jobs via `correlationId` in the fan-out options: ```ts const result = await api.actions.fanOut("child:action", inputsArray, "worker", { correlationId: connection.correlationId, }); ``` ## Static File Path Traversal Static file serving validates requested paths to prevent directory traversal attacks. Requests containing `..` segments that would escape the configured static files directory are rejected with a `403`. ## Production Checklist When deploying to production, review these environment variables: ```bash # Cookie security — require HTTPS SESSION_COOKIE_SECURE=true # External origin — set to your canonical URL (used for OAuth/MCP metadata) APPLICATION_URL=https://yourapp.com # Trust proxy — only enable behind a reverse proxy that strips client X-Forwarded-* headers # MCP_OAUTH_TRUST_PROXY=true # CORS — restrict to your domain WEB_SERVER_ALLOWED_ORIGINS=https://yourapp.com # Request body size — tune for your payload needs (default 10 MB) WEB_MAX_BODY_SIZE=10485760 # Rate limiting — tune for your traffic RATE_LIMIT_ENABLED=true RATE_LIMIT_UNAUTH_LIMIT=20 RATE_LIMIT_AUTH_LIMIT=200 # Error responses — hide internals NODE_ENV=production # (stack traces auto-disabled when NODE_ENV=production) # Security headers — defaults are good, customize CSP if needed WEB_SECURITY_CSP="default-src 'self'; script-src 'self'" ``` --- --- url: 'https://keryxjs.com/plugins/sentry.md' description: >- Sentry error monitoring and distributed tracing for Keryx — captures action exceptions and emits spans for HTTP, WebSocket, MCP, actions, tasks, Redis, and Postgres. --- # Sentry `@keryxjs/sentry` sends errors and traces from your Keryx app to [Sentry](https://sentry.io). It captures action exceptions and emits spans for HTTP, WebSocket, and MCP transports, action executions, background task enqueue/execute, Redis commands, and Postgres queries. This plugin is independent of [`@keryxjs/tracing`](/plugins/tracing). Pick one: Sentry if you want issues and traces in Sentry, the OpenTelemetry plugin if you want OTLP (Jaeger, Tempo, Honeycomb, Datadog). Running both fights over the process-wide tracing context. It can optionally send Sentry [logs and metrics](#logs-and-metrics) too — off by default. For Prometheus-style metrics scraped from your own process, see the built-in [Observability](/guide/observability) feature that ships with the core framework. ## Quick Start Install the package: ```bash bun add @keryxjs/sentry ``` Then register it in your plugins config: ```ts // config/plugins.ts import { sentryPlugin } from "@keryxjs/sentry"; export default { plugins: [sentryPlugin], }; ``` Set a DSN and start the app. The plugin turns itself on when `SENTRY_DSN` is present: ```bash SENTRY_DSN=https://@o.ingest.sentry.io/ bun run start ``` To keep a DSN in the environment but stay dark (local, CI): ```bash SENTRY_ENABLED=false bun run start ``` ## Configuration The plugin adds its own `config.sentry.*` namespace. All keys except the `beforeSend*` callbacks can be set via env vars at startup. | Config Key | Env Var | Default | Description | | ---------------------------- | ------------------------------ | -------------------- | --------------------------------------------------------------------------- | | `sentry.enabled` | `SENTRY_ENABLED` | `true` when DSN set | Master toggle | | `sentry.dsn` | `SENTRY_DSN` | `""` | Sentry DSN. Required to send events | | `sentry.environment` | `SENTRY_ENVIRONMENT` | `""` | Environment tag (`production`, `staging`, …) | | `sentry.release` | `SENTRY_RELEASE` | `""` | Release identifier attached to events and transactions | | `sentry.tracesSampleRate` | `SENTRY_TRACES_SAMPLE_RATE` | `1.0` | Trace sampling ratio (0–1). Lower this in production | | `sentry.sendDefaultPii` | `SENTRY_SEND_DEFAULT_PII` | `false` | Forward IP / user PII the SDK would otherwise drop | | `sentry.debug` | `SENTRY_DEBUG` | `false` | Verbose Sentry SDK logging | | `sentry.captureClientErrors` | `SENTRY_CAPTURE_CLIENT_ERRORS` | `false` | Also capture 4xx `TypedError` failures (validation, not-found, auth) | | `sentry.enableLogs` | `SENTRY_ENABLE_LOGS` | `false` | Forward the app's real logs to Sentry (`Sentry.logger`) | | `sentry.enableMetrics` | `SENTRY_ENABLE_METRICS` | `false` | Send metrics to Sentry (`Sentry.metrics`), including a per-action counter | | `sentry.shutdownTimeoutMs` | `SENTRY_SHUTDOWN_TIMEOUT_MS` | `2000` | Timeout for flushing events on shutdown | | `observability.serviceName` | `OTEL_SERVICE_NAME` | *(app name)* | Service name set on the Sentry client (shared with core metrics) | `beforeSend`, `beforeSendSpan`, `beforeSendLog`, `beforeSendMetric`, and `transport` are config-only (not env vars). Use them to filter PII, drop noisy logs/metrics, or to swap the transport in tests. ## Opting Out of Tracing Set `tracing = false` on an action to keep it out of Sentry traces. See [Opting Out of Tracing](/guide/actions#opting-out-of-tracing) on the actions guide for the full behavior — no action span, no Redis / Postgres spans during that action, and for HTTP and background tasks the whole transaction is dropped. Error capture and the per-action count metric still run. ```ts export class Status implements Action { name = "status"; tracing = false; web = { route: "/status", method: HTTP_METHOD.GET }; async run() { return { ok: true }; } } ``` The built-in `status` action already opts out, so load-balancer health checks don't fill your Sentry project with `GET status` transactions. ## Logs and Metrics Sentry [logs](https://docs.sentry.io/product/explore/logs/) and [metrics](https://docs.sentry.io/product/explore/metrics/) are opt-in. Both are off by default so existing installs keep sending only errors and traces. Turn them on with `sentry.enableLogs=true` / `sentry.enableMetrics=true` (or the matching env vars). **Metrics** — a counter named `keryx.action.count` is incremented once per action that runs, with `keryx.action`, `keryx.connection.type`, and `keryx.action.success` attributes. Group by `keryx.action` in Sentry to see how often each action runs. This fires for every action across HTTP, WebSocket, CLI, background tasks, and MCP. **Logs** — the plugin forwards your application's **real logs** to Sentry. It wraps the framework `logger`, so every line your app already writes to stdout (`logger.info(...)`, `logger.error(...)`, and the framework's own logs) is mirrored to `Sentry.logger` at the matching level. The structured `data` you pass is carried through as Sentry log attributes: ```ts import { logger } from "keryx"; // Reaches stdout and Sentry, with the object as attributes: logger.info("charged customer", { userId: user.id, amount }); ``` The logger's level and `quiet` settings are respected — a log that is filtered from stdout is never sent to Sentry either. No synthetic logs are invented; if your app logs nothing, Sentry gets nothing. Flipping the toggles also makes the SDK's log and metric APIs live, so you can emit metrics (and additional logs) directly: ```ts import * as Sentry from "@sentry/bun"; Sentry.metrics.count("orders.created", 1, { attributes: { plan: user.plan } }); ``` ## What Gets Instrumented | Span name | Op | Attributes | Notes | | ---------------------------- | ------------- | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | | `` / `GET user:create` | `http.server` | `http.request.method`, `http.response.status_code`, `http.route`, `url.full` | Root HTTP span. Renamed to ` ` once the action resolves. Not created when the action sets `tracing = false`. | | `ws.connect` | `ws.server` | `keryx.connection.type`, `keryx.connection.id` | Opened on accept, ended on disconnect | | `ws.message ` | `ws.server` | `keryx.ws.message_type` | Child of the connection span. Action messages stay open until `afterAct` | | `mcp.connect` | `mcp.server` | `keryx.mcp.session_id` | Opened when the MCP session is initialized, ended on teardown | | `mcp.message` | `mcp.server` | `keryx.mcp.session_id` | Child of the session span. Ended when the action finishes or the session closes | | `task:` | `queue.process` | `keryx.action`, `keryx.connection.type`, `messaging.destination.name` | Root transaction for a Resque job. Continues the enqueuer's trace when headers are present; otherwise starts a new trace | | `action:` | `keryx.action` | `keryx.action`, `keryx.connection.type`, `keryx.action.duration_ms` | Fires for every traced action across HTTP, WebSocket, CLI, background tasks, and MCP. Actions with `tracing = false` are skipped. | | `redis.` | `db` | `db.system="redis"`, `db.operation.name`, `db.query.text` | `db.query.text` is ` …` — keys only, values are never captured | | `pg.` | `db` | `db.system="postgresql"`, `db.operation.name`, `db.query.text` | Wraps the node-postgres `Pool` Drizzle uses. SQL text up to 1000 chars; bind values are not | Spans nest naturally: the transport span (HTTP request, WebSocket message, MCP message, or background task) is the parent of the action span, which is the parent of any Redis / Postgres spans emitted during the action. Nested `connection.act()` calls stay in that same trace — the inner action is a child span, not a new transaction. ## Error Capture Failed actions become Sentry issues when the error maps to HTTP 5xx (or is not a `TypedError`). Validation errors, 404s, and auth failures stay out of the issue stream unless you set `sentry.captureClientErrors=true`. Each captured exception is tagged with `keryx.action` and `keryx.connection.type` so you can filter by action name and transport. ## Distributed Context Propagation The plugin uses Sentry's native trace headers: * **Incoming HTTP**: reads `sentry-trace` / `baggage` and links the request span to the caller's trace. * **Outgoing tasks**: injects `_sentryTrace` / `_sentryBaggage` into background task params, so a worker picking up a job continues the originating trace. * **Task execution**: extracts those fields, starts a root `queue.process` transaction (`task:`), and strips the headers so they never reach the action's validated params. Scheduled jobs with no incoming headers still get their own root transaction. If that action then calls another action via `connection.act()`, the inner span stays on the same task trace — it does not open a second `task:` transaction. ## Programmatic Access The plugin exposes `api.sentry` for manual capture from action code: ```ts import { api } from "keryx"; api.sentry.setUser({ id: String(user.id), email: user.email }); api.sentry.setTag("plan", user.plan); try { await charge(user); } catch (e) { api.sentry.captureException(e); throw e; } ``` `setUser` and `setTag` are scoped to the current action's request context, not the process. The identity you set is buffered per-request and applied to any `captureException` / `captureMessage` (and the automatic 5xx capture) that fires while handling that request, so it never bleeds onto a concurrent or later request's events. When the plugin is disabled (no DSN, or `SENTRY_ENABLED=false`), every method is a no-op — `captureException()` returns `undefined` and `flush()` resolves `true`. Leave the calls in place; they cost nothing until you turn Sentry on. For custom spans, import the SDK directly: ```ts import * as Sentry from "@sentry/bun"; await Sentry.startSpan({ name: "charge.card", op: "task" }, async () => { await charge(user); }); ``` Child spans started this way pick up the active action as parent when they run inside `action.run()`. ## How It Works The plugin is fully hook-based — it does **not** modify core Keryx code. It registers: * `api.hooks.web.beforeRequest` / `afterRequest` — create and finalize the HTTP span * `api.hooks.ws.onConnect` / `onMessage` / `onDisconnect` — WebSocket connection and message spans * `api.hooks.mcp.onConnect` / `onMessage` / `onDisconnect` — MCP session and message spans * `api.hooks.actions.beforeAct` / `afterAct` — create and finalize the action span (skipped when the action sets `tracing = false`); capture 5xx exceptions; emit the per-action count metric when metrics are enabled * `api.hooks.actions.onEnqueue` — inject Sentry trace headers into task params * `api.hooks.resque.beforeJob` / `afterJob` — create and finalize the root `queue.process` transaction (continuing the enqueuer's trace when headers are present) When logs are enabled, the plugin also wraps the framework `logger.log` so the application's real logs are mirrored to `Sentry.logger` (restored on shutdown). The plugin also wraps `api.redis.redis.sendCommand` and the node-postgres `Pool` on `api.db.pool` after those initializers run (each is skipped when unavailable — the in-process `memory://` Redis has no `sendCommand`, and a disabled database has no pool). Only the checked-out client's `query` is wrapped (never `Pool.query`, which internally delegates to it), so each SQL statement is a single `pg.*` span. Drizzle goes through that pool, so `api.db.db.execute(...)` and query builders show up as `pg.*` spans. Sentry's built-in `BunServer` integration is filtered out so you don't get a second, nameless HTTP transaction alongside the Keryx one. The package also exports the underlying `SentryPlugin` initializer class alongside the `sentryPlugin` manifest. Registering the manifest is the supported path; reach for the class only when you need to subclass it or control its position in the initializer graph yourself. --- --- url: 'https://keryxjs.com/reference/servers.md' description: >- Server class and the built-in transports — HTTP, WebSocket, CLI, and MCP via Bun.serve. --- # Servers Source: `packages/keryx/classes/Server.ts`, `packages/keryx/servers/web.ts`, `packages/keryx/initializers/mcp.ts` Servers are the transport layer — they accept incoming connections and route them to actions. The framework ships with a web server (HTTP + WebSocket via `Bun.serve`), a CLI entry point, and an MCP server for AI agents. You could add others (gRPC, raw TCP, etc.) by extending the `Server` base class. ## Server Base Class ```ts abstract class Server { name: string; /** The underlying server object (e.g., Bun.Server) */ server?: T; abstract initialize(): Promise; abstract start(): Promise; abstract stop(): Promise; } ``` Servers are auto-discovered from the `servers/` directory, just like actions and initializers. ## WebServer The built-in web server uses `Bun.serve` to handle HTTP requests and WebSocket connections on the same port. It's configured via `config.server.web`. ### HTTP Request Flow When an HTTP request comes in, the server: 1. Checks for a WebSocket upgrade — if the client is requesting a WebSocket connection, it upgrades transparently 2. Runs `beforeRequest` hooks — a hook that returns a `Response` answers the request here, skipping every step below it 3. Tries to serve a static file (if `staticFiles.enabled` is `true` and the path matches) 4. Matches the request path and method against registered action routes 5. Extracts params from path segments (`:param`), query string, and request body 6. Creates a `Connection`, calls `connection.act()` with the action name and params 7. Returns the JSON response with appropriate headers and status codes Param loading order matters — later sources override earlier ones: 1. **Path params** (e.g., `/user/:id` → `{ id: "123" }`) 2. **Query params** (e.g., `?limit=10`) 3. **Body params** (JSON or FormData) Body parsing keys off the base media type, so `application/json` and `application/json; charset=utf-8` are treated the same. Actions that declare [`web.rawBody`](/guide/actions#raw-request-bodies) skip step 5's body read entirely — they get path and query params only, and read the untouched body themselves via `connection.rawRequest`. ### WebSocket Message Flow WebSocket connections are long-lived. After the initial HTTP upgrade, the client sends JSON messages with a `messageType` field: | messageType | What it does | | --------------- | ------------------------------------------------------------- | | `"action"` | Execute an action — same validation and middleware as HTTP | | `"subscribe"` | Subscribe to a PubSub channel (with middleware authorization) | | `"unsubscribe"` | Unsubscribe from a channel | Action messages include `action`, `params`, and an optional `messageId` that's echoed back in the response so the client can correlate requests. ### Static Files The web server can serve static files from a configured directory (default: `assets/`). This is useful for serving the frontend build output or other static assets alongside the API. ### HTTP Compression The web server compresses responses using gzip when the client supports it (via `Accept-Encoding`). Compression is enabled by default for responses larger than 1024 bytes. Streaming responses are never compressed: anything built from a `StreamingResponse`, any `text/event-stream` response, and any response from an action declaring `web.streaming`. They also get Bun's idle timeout disabled, so a stream that pauses between chunks isn't cut. A response with no `Content-Length` has to be measured before the threshold can be applied. The server reads at most `threshold` bytes to decide, then either sends those bytes as-is or stream-compresses them plus the remainder — a large or slow body is never held in memory in full. Configure via `config.server.web.compression`: | Key | Env Var | Default | What it does | | ----------- | --------------------------- | --------------- | ----------------------------------------- | | `enabled` | `WEB_COMPRESSION_ENABLED` | `true` | Enable/disable HTTP compression | | `threshold` | `WEB_COMPRESSION_THRESHOLD` | `1024` | Minimum response size (bytes) to compress | | `encodings` | — | `["gzip"]` | Supported encodings (preference order) | ### Static File Caching When static file serving is enabled, the server supports ETag-based caching and `Cache-Control` headers. If a client sends `If-None-Match` with a matching ETag, the server returns `304 Not Modified` instead of the full file. | Key | Env Var | Default | What it does | | -------------------------- | --------------------------------- | ------------------------ | ------------------------------------- | | `staticFiles.cacheControl` | `WEB_SERVER_STATIC_CACHE_CONTROL` | `"public, max-age=3600"` | Cache-Control header for static files | | `staticFiles.etag` | `WEB_SERVER_STATIC_ETAG` | `true` | Enable ETag generation and 304s | ### Correlation IDs Every request is assigned a correlation ID (UUID) for distributed tracing. The ID is available on `connection.correlationId` and returned in the `X-Request-Id` response header. If a reverse proxy or upstream service sends an `X-Request-Id` header, the server can trust and propagate it instead of generating a new one. | Key | Env Var | Default | What it does | | -------------------------- | -------------------------------- | ---------------- | ------------------------------------- | | `correlationId.header` | `WEB_CORRELATION_ID_HEADER` | `"X-Request-Id"` | Header name for correlation IDs | | `correlationId.trustProxy` | `WEB_CORRELATION_ID_TRUST_PROXY` | `false` | Trust incoming correlation ID headers | ### WebSocket Configuration | Key | Env Var | Default | What it does | | -------------------------------- | ---------------------------- | ------- | ----------------------------------------------- | | `websocket.maxPayloadSize` | `WS_MAX_PAYLOAD_SIZE` | `65536` | Max message size in bytes | | `websocket.maxMessagesPerSecond` | `WS_MAX_MESSAGES_PER_SECOND` | `20` | Rate limit per connection | | `websocket.maxSubscriptions` | `WS_MAX_SUBSCRIPTIONS` | `100` | Max channel subscriptions per connection | | `websocket.drainTimeout` | `WS_DRAIN_TIMEOUT` | `5000` | Ms to wait for pending messages during shutdown | ### Configuration All web server settings are in `config.server.web`: | Key | Default | What it does | | ----------------------- | ------------- | ----------------------------- | | `enabled` | `true` | Enable/disable the web server | | `port` | `8080` | Listen port | | `host` | `"localhost"` | Bind address | | `apiRoute` | `"/api"` | URL prefix for action routes | | `allowedOrigins` | `"*"` | CORS allowed origins | | `maxBodySize` | `10485760` | Max HTTP request body (bytes) | | `staticFiles.enabled` | `true` | Serve static files | | `staticFiles.directory` | `"assets"` | Directory for static files | ## CLI "Server" The CLI isn't technically a server — it's a separate entry point (`keryx.ts`) that uses [Commander](https://github.com/tj/commander.js) to register every action as a CLI command. But it goes through the same `Connection → act()` pipeline as HTTP and WebSocket. The server boots in `RUN_MODE.CLI`, which tells initializers to skip transport-specific setup (like binding to a port). After the action executes, the process exits with the appropriate exit code. ```bash # List all available actions ./keryx.ts actions # Run an action ./keryx.ts "user:create" --name Evan --email evan@example.com --password secret -q | jq # Start the full server ./keryx.ts start ``` ## MCP Server Source: `packages/keryx/initializers/mcp.ts`, `packages/keryx/initializers/oauth.ts` The [MCP (Model Context Protocol)](https://modelcontextprotocol.io) server exposes actions as tools for AI agents. Unlike the web server and CLI, MCP is implemented as an initializer rather than a `Server` subclass — but it follows the same pattern of accepting requests and routing them through `Connection → act()`. When enabled (`MCP_SERVER_ENABLED=true`), the MCP initializer: 1. Registers every action (where `mcp.tool !== false`) as an MCP tool 2. Registers actions with `mcp.resource` as MCP resources (static URI or URI template) 3. Registers actions with `mcp.prompt` as MCP prompts 4. Converts action names from `:` to `-` format (e.g., `user:create` → `user-create`) 5. Converts Zod input schemas to JSON Schema for tool/prompt parameter definitions 6. Handles Streamable HTTP transport at the configured route (default `/mcp`) Each authenticated client gets its own `McpServer` instance, tracked by the `mcp-session-id` header. ### Authentication MCP uses OAuth 2.1 with PKCE for authentication. The OAuth initializer (`packages/keryx/initializers/oauth.ts`) provides the required endpoints: | Endpoint | Method | Purpose | | ----------------------------------------- | ------ | --------------------------------- | | `/.well-known/oauth-protected-resource` | GET | Resource metadata (RFC 9728) | | `/.well-known/oauth-authorization-server` | GET | Authorization server metadata | | `/oauth/register` | POST | Dynamic client registration | | `/oauth/authorize` | GET | Authorization page (login/signup) | | `/oauth/authorize` | POST | Process login/signup | | `/oauth/token` | POST | Exchange code for access token | Clients may also skip registration entirely and use an HTTPS URL as their `client_id`; see [Client ID Metadata Documents](/guide/mcp#client-id-metadata-documents). The authorization page is rendered from Mustache templates in `packages/keryx/templates/`. Actions tagged with `mcp.isLoginAction` or `mcp.isSignupAction` handle the actual authentication during the OAuth flow. ### Configuration | Key | Env Var | Default | | ---------------- | ------------------------- | ------------------- | | `enabled` | `MCP_SERVER_ENABLED` | `false` | | `route` | `MCP_SERVER_ROUTE` | `"/mcp"` | | `instructions` | `MCP_SERVER_INSTRUCTIONS` | package description | | `oauthClientTtl` | `MCP_OAUTH_CLIENT_TTL` | `2592000` | | `oauthCodeTtl` | `MCP_OAUTH_CODE_TTL` | `300` | | `oauthCimdEnabled` | `MCP_OAUTH_CIMD_ENABLED` | `true` | ### Request Flow 1. MCP client sends a POST to `/mcp` with `Authorization: Bearer ` 2. The initializer verifies the token against Redis (`oauth:token:{token}`) 3. A new `Connection` is created with type `"mcp"` and the authenticated user's session 4. Action params are extracted from the MCP tool call arguments 5. `connection.act()` executes the action through the standard middleware pipeline.\* 6. The result is returned as an MCP tool response \* During `run()`, the action may send a nested `elicitation/create` request if the client advertised that mode. A `McpUrlElicitationRequiredError` is mapped to JSON-RPC `-32042` so the client can finish a URL flow and retry. See [Elicitation](/guide/mcp#elicitation). See the [MCP guide](/guide/mcp) for full usage details. --- --- url: 'https://keryxjs.com/guide/streaming.md' description: >- Return streaming responses from actions — Server-Sent Events for real-time data and chunked binary for file downloads over HTTP. --- # Streaming In the AI agent era, streaming is table stakes. LLM responses arrive token by token, progress updates trickle in over seconds, and long-running operations need to show they're alive. Keryx supports streaming natively — return a `StreamingResponse` from your action's `run()` method and the framework handles the rest. ## Server-Sent Events (SSE) SSE is the right tool for most streaming use cases: LLM token streaming, real-time progress, live data feeds. It's simpler than WebSocket (unidirectional, auto-reconnect built into browsers) and works everywhere HTTP works. ```ts import { Action, HTTP_METHOD, StreamingResponse } from "keryx"; import { z } from "zod"; export class ChatStream implements Action { name = "chat:stream"; description = "Stream an LLM response via SSE"; inputs = z.object({ prompt: z.string() }); web = { route: "/chat/stream", method: HTTP_METHOD.POST, streaming: true }; timeout = 0; async run(params: { prompt: string }) { const sse = StreamingResponse.sse(); (async () => { try { for await (const token of callLLM(params.prompt)) { sse.send(token, { event: "token" }); } sse.send({ done: true }, { event: "done" }); } catch (e) { sse.sendError(String(e)); } finally { sse.close(); } })(); return sse; } } ``` A few things to notice: * **`StreamingResponse.sse()`** creates the stream and returns immediately. The async IIFE runs in the background, calling `send()` as data becomes available. * **`timeout = 0`** disables the action timeout. Streaming actions can run for minutes or hours — you don't want the framework killing them after 5 minutes. * **`web.streaming = true`** tells Swagger to document this endpoint as `text/event-stream` instead of `application/json`. * **`sse.close()`** is mandatory. Always close the stream when you're done, even in error paths. The `finally` block is your friend. `StreamingResponse.sse()` returns an `SSEResponse` — a subclass of `StreamingResponse` that adds the `send()` method and the `text/event-stream` headers. Both classes are exported, so you can annotate a helper's return type: ```ts import { SSEResponse } from "keryx"; function openTokenStream(): SSEResponse { return StreamingResponse.sse(); } ``` `new SSEResponse(extraHeaders?)` works too and does the same thing — `StreamingResponse.sse()` is a thin alias kept for symmetry with `StreamingResponse.stream()`. ### The `send()` Method ```ts sse.send(data, { event?, id? }) ``` * **`data`** — A string or an object. Objects are JSON-serialized. Multiline strings are split into multiple `data:` lines per the SSE spec. * **`event`** — Optional event type name. Clients can listen for specific events with `EventSource.addEventListener("token", ...)`. * **`id`** — Optional event ID. Clients use this for reconnection — the browser sends the last ID in the `Last-Event-ID` header on reconnect. ### Error Handling If something goes wrong mid-stream, don't just close — tell the client what happened: ```ts sse.sendError("upstream timeout"); // This sends an event with type "error", then closes the stream ``` Clients should listen for the `error` event type to handle these gracefully. ## Binary / Chunked Streaming For file downloads, proxied responses, or any binary data that doesn't fit the SSE format: ```ts async run() { const fileStream = Bun.file("large-export.csv").stream(); return StreamingResponse.stream(fileStream, { contentType: "text/csv", headers: { "Content-Disposition": 'attachment; filename="export.csv"', }, }); } ``` This wraps any `ReadableStream` with the right headers and delivers it as a chunked HTTP response. Chunks go out as your stream produces them — nothing is buffered, so memory stays flat no matter how large the download is. ## What You Get for Free Unlike raw `Response` passthrough, `StreamingResponse` gives you Keryx's standard headers: * **CORS** — `Access-Control-Allow-Origin` and friends, based on your config * **Security headers** — CSP, HSTS, X-Content-Type-Options * **Session cookie** — set automatically so the client maintains its session * **Rate limit headers** — if rate limiting middleware ran before the stream started * **Correlation ID** — propagated from the incoming request Compression and the idle timeout are automatically skipped for every `StreamingResponse` — SSE and chunked alike. Compressing a live stream would buffer chunks until the compressor had enough bytes to be worth a gzip block, which defeats the point, and Bun's idle timeout would cut a stream that goes quiet while an upstream is still computing. ## Transport Behavior The same action works across all transports, but streaming behaves differently in each: | Transport | Behavior | | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | **HTTP** | Native SSE or chunked streaming. The connection stays open until `close()`. | | **WebSocket** | Each chunk becomes an incremental message: `{ messageId, streaming: true, chunk }`. A final `{ messageId, streaming: false }` signals completion. | | **MCP** | Chunks are forwarded as logging messages for real-time visibility. The full accumulated text is returned as the tool result. | | **CLI / Tasks** | The stream is consumed and the result is collected — no incremental output. | ## Connection Lifecycle Normally, Keryx destroys the connection immediately after `run()` returns. For streaming responses, cleanup is deferred until the stream closes. This means: * Your session remains valid during streaming * Middleware state is preserved * The connection appears in `api.connections` for the duration of the stream One gotcha: `runAfter` middleware executes right after `run()` returns the `StreamingResponse` object, not after the stream closes. If middleware tries to replace the response via `updatedResponse`, it's ignored (with a warning log). Middleware that reads connection state or records metrics still works fine. ## Middleware Compatibility * **`runBefore`** — works normally. Auth checks, rate limiting, param mutation — all happen before streaming starts. * **`runAfter`** — runs after `run()` returns the `StreamingResponse`, before the stream is consumed. Cannot replace the response. Useful for logging or recording that a stream was initiated. ## Tips * **Always set `timeout = 0`** on streaming actions. The default 5-minute timeout will abort your stream. * **Always call `close()`** in a `finally` block. Unclosed streams leak connections. * **Set `mcp.tool = false`** if the streaming action doesn't make sense as an MCP tool (most LLM streaming endpoints don't). * **Use event types** to structure your SSE stream. Clients can subscribe to specific events rather than parsing every message. --- --- url: 'https://keryxjs.com/guide/testing.md' description: 'Testing with Bun''s built-in test runner — real HTTP requests, no mocking.' --- # Testing We don't mock the server. That's a deliberate choice — if you're testing an API, you should be making real HTTP requests against a real running server. Now that Bun includes `fetch` out of the box, this is trivially easy. ## Test Structure Each test file boots and stops the full server in `beforeAll`/`afterAll`. Tests use dynamic port binding (`WEB_SERVER_PORT=0`) so each file gets a random available port — no conflicts when running multiple test files. Use the `useTestServer()` helper to register these hooks in one line: ```ts import { useTestServer } from "keryx/testing"; const getUrl = useTestServer(); test("status endpoint returns server info", async () => { const res = await fetch(getUrl() + "/api/status"); const body = (await res.json()) as ActionResponse; expect(res.status).toBe(200); expect(body.name).toBe("server"); expect(body.uptime).toBeGreaterThan(0); }); ``` Yes, this means each test file starts the entire server — database connections, Redis, the works. It's slower than unit testing with mocks, but you're testing what actually happens when a client hits your API. I'll take that tradeoff every time. ## Test Helpers The `keryx/testing` subpath exports helpers that cover the common test lifecycle: * **`useTestServer(opts?)`** — Registers `beforeAll` / `afterAll` hooks that call `api.start()` and `api.stop()`. Returns a getter that resolves the server URL (the URL isn't known until the port is bound, so it's a function, not a string). Options: * `clearDatabase` (default `false`) — truncate all tables in `beforeAll` (requires the `db` initializer; a no-op when the database is disabled). * `clearRedis` (default `false`) — `FLUSHDB` on the current Redis database in `beforeAll` (requires the `redis` initializer). Opt in for tests that exercise pub/sub so messages from prior tests don't leak in. ```ts const getUrl = useTestServer({ clearDatabase: true, clearRedis: true }); ``` Need additional setup like inserting a seed user? Bun supports multiple `beforeAll` blocks per file — add another one after `useTestServer()` that runs once `api.start()` has completed. * **`serverUrl()`** — Returns the actual URL the web server bound to (with resolved port). Call after `api.start()`. `useTestServer()` wraps this internally; reach for it directly only when you need manual lifecycle control. * **`HOOK_TIMEOUT`** — A generous timeout (60s) for `beforeAll`/`afterAll` hooks, since they connect to Redis, Postgres, run migrations, etc. Pass as the second argument to `beforeAll`/`afterAll` when writing your own lifecycle hooks. * **`buildWebSocket(opts?)`**, **`createUser`**, **`createSession`**, **`subscribeToChannel`**, **`waitForBroadcastMessages`** — Higher-level helpers for WebSocket tests. See [Testing WebSocket Connections](#testing-websocket-connections) below. * **`waitFor(condition, { interval, timeout })`** — Polls a condition function until it returns `true`, or throws after a timeout. Use this instead of fixed `Bun.sleep()` calls when waiting for async side effects like background tasks: ```ts await waitFor( async () => { const result = await db.query( "SELECT count(*) FROM jobs WHERE status = 'done'", ); return result.count > 0; }, { interval: 100, timeout: 5000 }, ); ``` ## Running Tests ```bash # all backend tests cd example/backend && bun test # a single file cd example/backend && bun test __tests__/actions/user.test.ts # full CI — lint + test both frontend and backend bun run ci ``` Tests run non-concurrently to avoid port conflicts. Each test file gets the server to itself. ## Making Requests Just use `fetch`. Here's a typical test for creating a user: ```ts test("create a user", async () => { const res = await fetch(getUrl() + "/api/user", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Test User", email: "test@example.com", password: "password123", }), }); const body = await res.json(); expect(res.status).toBe(200); expect(body.user.name).toBe("Test User"); }); ``` Nothing special — it's the same `fetch` you'd use in a browser or a Bun script. ## Database Setup Tests typically clear the database before running to ensure a clean slate — pass `clearDatabase: true` to `useTestServer()`: ```ts const getUrl = useTestServer({ clearDatabase: true }); ``` `clearDatabase()` truncates all tables with `RESTART IDENTITY CASCADE`. It refuses to run when `NODE_ENV=production`, so you can't accidentally nuke your production data. You'll need a separate test database: ```bash createdb keryx-test ``` Set `DATABASE_URL_TEST` in your environment (or `backend/.env`) to point at it. ### Tests Without Postgres or Redis If your app doesn't use the database, skip the test database entirely: set `DATABASE_URL_TEST="none"` and `REDIS_URL_TEST="memory://"`. The full server still boots for every test file — real HTTP requests, real tasks — but against an in-process Redis, with no services to install in CI. (Use `"none"` rather than an empty string; an empty `DATABASE_URL_TEST` falls through to `DATABASE_URL`.) ## Testing Authenticated Endpoints Most endpoints require a session. The pattern is: create a user, log in, then pass the session cookie on subsequent requests: ```ts import { config } from "keryx"; test("authenticated request", async () => { // Create a user await fetch(getUrl() + "/api/user", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Test User", email: "test@example.com", password: "password123", }), }); // Log in const sessionRes = await fetch(getUrl() + "/api/session", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: "test@example.com", password: "password123", }), }); const sessionBody = (await sessionRes.json()) as ActionResponse; const sessionId = sessionBody.session.id; // Make an authenticated request const res = await fetch(getUrl() + "/api/user", { method: "POST", headers: { "Content-Type": "application/json", Cookie: `${config.session.cookieName}=${sessionId}`, }, body: JSON.stringify({ name: "New Name" }), }); expect(res.status).toBe(200); }); ``` The session ID comes from the login response, and you pass it as a `Cookie` header. This is the same cookie the browser would send automatically. ## Testing WebSocket Connections WebSocket tests connect to the same server and send JSON messages. The lowest-level pattern looks like: ```ts test("websocket action", async () => { const wsUrl = getUrl().replace("http", "ws"); const ws = new WebSocket(wsUrl); await new Promise((resolve) => { ws.onopen = () => resolve(); }); const responsePromise = new Promise((resolve) => { ws.onmessage = (event) => resolve(JSON.parse(event.data)); }); ws.send( JSON.stringify({ messageType: "action", action: "status", messageId: "test-1", }), ); const response = await responsePromise; expect(response.messageId).toBe("test-1"); expect(response.name).toBe("server"); ws.close(); }); ``` For common flows — opening a socket, creating a user, logging in, subscribing to a channel, and collecting broadcasts — reach for the helpers exported from `keryx/testing`: ```ts import { buildWebSocket, createSession, createUser, subscribeToChannel, waitForBroadcastMessages, } from "keryx/testing"; test("broadcast reaches subscribers", async () => { const { socket, messages } = await buildWebSocket(); await createUser(socket, messages, "Marco", "marco@example.com", "abc12345"); await createSession(socket, messages, "marco@example.com", "abc12345"); await subscribeToChannel(socket, messages, "messages"); // ...trigger a broadcast... const broadcasts = await waitForBroadcastMessages(messages, 1); expect(broadcasts[0].message.body).toBe("hello"); socket.close(); }); ``` `buildWebSocket()` resolves once the socket's `open` event fires and exposes a live `messages` array that every subsequent handler populates. The action helpers assume the socket is fresh (they read from fixed indices in `messages`); `subscribeToChannel` matches the subscribe confirmation by content so it's resilient to presence broadcasts arriving out of order. ## Testing Background Tasks Use `waitFor()` to poll for side effects from background tasks: ```ts test("cleanup task removes old messages", async () => { // Insert test data... // Enqueue the task await api.actions.enqueue("messages:cleanup", { age: 1000 }); // Wait for the side effect await waitFor( async () => { const remaining = await api.db.db.select().from(messages); return remaining.length === 0; }, { interval: 100, timeout: 5000 }, ); }); ``` ## Testing MCP elicitation Advertise the capability on the SDK client and handle `elicitation/create`: ```ts import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"; const client = new Client( { name: "test", version: "1.0.0" }, { capabilities: { elicitation: { form: {}, url: {} } } }, ); client.setRequestHandler(ElicitRequestSchema, () => ({ action: "accept", content: { displayName: "Ada" }, })); ``` A client that omits `elicitation` should see a failed tool result, not a JSON-RPC protocol error. See [Elicitation](/guide/mcp#elicitation). ## Gotcha: Stale Processes If you're changing code but your tests are still seeing old behavior… you probably have a stale server process running from a previous dev session. This has bitten me more than once: ```bash ps aux | grep "bun keryx" | grep -v grep kill -9 ``` Check for old processes whenever code changes aren't being reflected. It'll save you hours of debugging. ## Reference * [Testing helpers](/reference/utilities#testing-helpers) — `useTestServer()`, `serverUrl()`, `waitFor()`, and `HOOK_TIMEOUT` signatures --- --- url: 'https://keryxjs.com/plugins/tracing.md' description: >- OpenTelemetry distributed tracing for Keryx — emits OTLP spans for HTTP requests, actions, background tasks, Redis commands, and Drizzle DB queries. --- # Tracing `@keryxjs/tracing` adds OpenTelemetry distributed tracing to your Keryx app. It exports OTLP spans for HTTP requests, action executions, background task enqueue/execute, Redis commands, and Drizzle DB queries — and propagates W3C trace context across service and process boundaries. For metrics, see the built-in [Observability](/guide/observability) feature — that ships with the core framework. This plugin is tracing-only. For error monitoring and traces in Sentry, see [`@keryxjs/sentry`](/plugins/sentry) instead — don't run both. ## Quick Start Install the package: ```bash bun add @keryxjs/tracing ``` Then register it in your plugins config: ```ts // config/plugins.ts import { tracingPlugin } from "@keryxjs/tracing"; export default { plugins: [tracingPlugin], }; ``` Enable tracing and point at an OTLP HTTP receiver: ```bash OTEL_TRACING_ENABLED=true OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 bun run start ``` ## Local Testing with Jaeger The repo ships a one-container OTLP receiver + UI for local development. Jaeger's `all-in-one` image accepts OTLP/HTTP on port `4318` (matching the plugin's default endpoint) and serves its web UI on port `16686` — no OTel Collector needed. Run all commands below from the repo root. Start Jaeger: ```bash docker compose -f packages/plugins/tracing/docker-compose.tracing.yaml up -d ``` Run the example backend with tracing enabled: ```bash OTEL_TRACING_ENABLED=true bun dev ``` Open the UI at `http://localhost:16686`, pick `keryx-example-backend` (or whatever `OTEL_SERVICE_NAME` resolves to) in the service dropdown, and click **Find Traces**. A request to a traced action (not `status` — that opts out) will show an HTTP span parenting an `action:*` span; actions that touch Redis or Postgres will show `redis.*` and `drizzle.*` children. ![Jaeger trace for PUT session:create: the HTTP span parents action:session:create, which in turn parents redis.get/set/incr/expire and drizzle.select child spans. The detail pane shows redis.set with db.query.text "set session:1aa34179-…" — the key is captured, the value is not.](/images/tracing-jaeger-session-create.png) Tear it down when you're done: ```bash docker compose -f packages/plugins/tracing/docker-compose.tracing.yaml down ``` Jaeger `all-in-one` is memory-backed — traces are lost on container restart. This is intentional for dev; don't use it for production trace storage. ## Configuration The plugin adds its own `config.tracing.*` namespace. All keys can be set via env vars at startup. | Config Key | Env Var | Default | Description | | ------------------------------- | -------------------------------- | ------------------------- | ------------------------------------------------------- | | `tracing.enabled` | `OTEL_TRACING_ENABLED` | `false` | Master toggle for tracing | | `tracing.otlpEndpoint` | `OTEL_EXPORTER_OTLP_ENDPOINT` | `"http://localhost:4318"` | OTLP HTTP receiver (Jaeger, Tempo, Honeycomb, Datadog…) | | `tracing.sampleRate` | `OTEL_TRACING_SAMPLE_RATE` | `1.0` | Head-based sampling ratio (0–1) | | `tracing.spanQueueSize` | `OTEL_SPAN_QUEUE_SIZE` | `2048` | Max spans buffered before dropping | | `tracing.spanBatchSize` | `OTEL_SPAN_BATCH_SIZE` | `512` | Max spans per export batch | | `tracing.spanExportDelayMs` | `OTEL_SPAN_EXPORT_DELAY_MS` | `5000` | Delay between scheduled batch exports | | `tracing.spanShutdownTimeoutMs` | `OTEL_SPAN_SHUTDOWN_TIMEOUT_MS` | `5000` | Timeout for flushing pending spans on shutdown | | `observability.serviceName` | `OTEL_SERVICE_NAME` | *(app name)* | Service name set on all spans (shared with core metrics) | ## Opting Out of Tracing Set `tracing = false` on an action to keep it out of OTLP export. See [Opting Out of Tracing](/guide/actions#opting-out-of-tracing) on the actions guide for the full behavior — no action span, no Redis spans during that action, and for HTTP the whole request transaction is dropped. The built-in `status` action already opts out so health-check pings don't flood Jaeger. ```ts export class Status implements Action { name = "status"; tracing = false; web = { route: "/status", method: HTTP_METHOD.GET }; async run() { return { ok: true }; } } ``` ## What Gets Instrumented | Span name | Kind | Attributes | Notes | | ------------------------- | -------- | --------------------------------------------------------------- | ------------------------------------------------------------- | | `` / `GET user:create` | SERVER | `http.request.method`, `http.response.status_code`, `http.route`, `url.full` | Renamed to ` ` once the action resolves. Not created when the action sets `tracing = false`. | | `action:` | INTERNAL | `keryx.action`, `keryx.connection.type`, `keryx.action.duration_ms` | Fires for every traced action across all transports (HTTP, WS, task, CLI, MCP). Actions with `tracing = false` are skipped. | | `redis.` | CLIENT | `db.system.name="redis"`, `db.operation.name`, `db.query.text` | `db.query.text` is ` …` — keys only, values are never captured (so AUTH passwords, SET values, etc. stay out of traces) | | `drizzle.*` | CLIENT | `db.system="postgresql"`, `db.statement` (up to 1000 chars) | Provided by `@kubiks/otel-drizzle` | Spans nest naturally: the HTTP span is the parent of the action span, which is the parent of any Redis / Drizzle spans emitted during the action. ## Distributed Context Propagation The plugin uses the [W3C Trace Context](https://www.w3.org/TR/trace-context/) standard: * **Incoming HTTP**: reads `traceparent` / `tracestate` headers and links the request span to the caller's trace. * **Outgoing tasks**: injects `traceparent` / `tracestate` into background task params, so a worker picking up a job continues the originating trace. * **Task execution**: extracts the propagated context before running the action, so task spans are children of the enqueuer's trace. ## Programmatic Access The plugin exposes `api.tracing` for manual instrumentation from action code: ```ts import { api } from "keryx"; import { SpanKind, SpanStatusCode } from "@opentelemetry/api"; const span = api.tracing.tracer.startSpan("my.custom.work", { kind: SpanKind.INTERNAL, }); try { // … span.setStatus({ code: SpanStatusCode.OK }); } catch (e) { span.recordException(e as Error); span.setStatus({ code: SpanStatusCode.ERROR }); throw e; } finally { span.end(); } ``` When tracing is disabled, `api.tracing.tracer` returns a no-op tracer — `startSpan()` is cheap and calling span methods does nothing, so you can leave instrumentation in place with no runtime cost. ## How It Works The plugin is fully hook-based — it does **not** modify core Keryx code. It registers: * `api.hooks.web.beforeRequest` / `afterRequest` — create and finalize the HTTP span * `api.hooks.actions.beforeAct` / `afterAct` — create and finalize the action span (skipped when the action sets `tracing = false`) * `api.hooks.actions.onEnqueue` — inject trace context into task params * `api.hooks.resque.beforeJob` — extract trace context when a worker picks up a task The plugin also wraps `api.redis.redis.sendCommand` and attaches `@kubiks/otel-drizzle` to `api.db.db` after those initializers run. With the in-process Redis (`REDIS_URL="memory://"`) there's no `sendCommand` to wrap, so no `redis.*` spans are emitted; without a database there are no `drizzle.*` spans either. Because everything runs through hooks and the OTel global APIs, you can mix-and-match — register your own span processors, swap the exporter, or install custom propagators — and Keryx's instrumentation will continue to work. The package also exports the underlying `TracingPlugin` initializer class alongside the `tracingPlugin` manifest. Registering the manifest is the supported path; reach for the class only when you need to subclass it or control its position in the initializer graph yourself. --- --- url: 'https://keryxjs.com/guide/typed-clients.md' description: >- Type-safe API clients for Keryx — from zero-tooling type imports to generated OpenAPI clients. --- # Typed Clients Keryx gives you type-safe API responses out of the box. Every [action](/guide/actions) defines its inputs as a Zod schema and its outputs as the return type of `run()`. The framework exposes `ActionParams` and `ActionResponse` to infer both sides at compile time — and the built-in [Swagger action](/reference/actions) generates a complete OpenAPI 3.0.0 spec from that same metadata. This page covers three approaches to consuming your API from a TypeScript frontend, from simplest to most powerful. ## Direct Type Imports (Zero Tooling) If your frontend lives in the same monorepo as your backend, you can import action classes directly and use `ActionResponse` to type your fetch calls. This is what the example app does — no codegen, no build step, no extra dependencies. Set up a path alias so the frontend can reach backend types: ```json // frontend/tsconfig.json { "compilerOptions": { "paths": { "@backend/*": ["../backend/*"] } } } ``` Then import the action types and use them with a generic fetch wrapper: ```ts import type { SessionCreate } from "@backend/actions/session"; import type { UserView } from "@backend/actions/user"; import type { ActionResponse } from "keryx"; const API_URL = "http://localhost:8080"; async function apiFetch( path: string, options: RequestInit = {}, ): Promise { const res = await fetch(`${API_URL}/api${path}`, { credentials: "include", ...options, }); const payload = await res.json(); if (payload.error) throw new Error(payload.error.message); return payload as T; } // Fully typed — hover over `user` and you'll see the exact shape const { user } = await apiFetch>(`/user/${userId}`); const { user, session } = await apiFetch>( "/session", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password }), }, ); ``` You still construct URLs and pick HTTP methods manually, but the response types are inferred from your action classes. Change a field in the action's `run()` return type and the frontend lights up with type errors immediately. **When this works well:** Monorepo setups where the frontend and backend share a TypeScript project. Small-to-medium APIs where manually typing a few paths isn't a burden. **When it doesn't:** Separate repos, non-TypeScript frontends, or large APIs where you want route construction handled for you. ## openapi-typescript + openapi-fetch (Recommended) For a fully type-safe client that also handles routes, methods, and parameters, use [openapi-typescript](https://openapi-ts.dev/) to generate types from your Swagger spec, then [openapi-fetch](https://openapi-ts.dev/openapi-fetch/) to make typed requests. Install both: ```bash bun add -d openapi-typescript bun add openapi-fetch ``` Generate types from your running server's Swagger endpoint: ```bash bunx openapi-typescript http://localhost:8080/api/swagger -o src/api-types.d.ts ``` Create a typed client: ```ts import createClient from "openapi-fetch"; import type { paths } from "./api-types"; const client = createClient({ baseUrl: "http://localhost:8080/api" }); // Fully typed — route, method, params, and response const { data, error } = await client.PUT("/user", { body: { name: "Evan", email: "evan@example.com", password: "hunter2hunter2" }, }); // data is typed as { user: { id: number, name: string, email: string, ... } } // Path parameters are type-checked too const { data: userData } = await client.GET("/user/{user}", { params: { path: { user: 42 } }, }); // Query parameters on GET actions const { data: messages } = await client.GET("/messages/list", { params: { query: { limit: 10, offset: 0 } }, }); ``` Keryx's Swagger action uses `z.toJSONSchema()` with `io: "input"` to generate request schemas (the pre-transform Zod input, which is what clients actually send) and [ts-morph](https://ts-morph.com/) to generate response schemas from your action's return types. The generated OpenAPI spec includes path parameters, query parameters, request bodies, and response shapes — `openapi-typescript` picks all of this up. ::: tip Automate type generation Add a script to your `package.json` so types stay fresh: ```json { "scripts": { "api:types": "openapi-typescript http://localhost:8080/api/swagger -o src/api-types.d.ts" } } ``` Run it after schema changes, or wire it into your dev server startup. ::: **When this works well:** Any frontend (same repo or not), any framework (React, Vue, Svelte, vanilla). The generated types are standalone `.d.ts` files with no runtime dependency on your backend code. ## orval (Generated React Query / SWR Hooks) If your frontend uses React Query or SWR, [orval](https://orval.dev/) generates typed hooks directly from the OpenAPI spec: ```bash bun add -d orval ``` ```ts // orval.config.ts export default { api: { input: "http://localhost:8080/api/swagger", output: { target: "src/api/generated.ts", client: "react-query", }, }, }; ``` This generates hooks like `useUserView()`, `useSessionCreate()`, etc., with typed params and responses. See the [orval docs](https://orval.dev/overview) for configuration options. ## Choosing an Approach | Approach | Codegen? | Route safety? | Best for | | ---------------------------------- | ------------- | ------------- | ----------------------- | | Direct type imports | None | Manual | Monorepo, small API | | openapi-typescript + openapi-fetch | Types only | Yes | Most projects | | orval | Types + hooks | Yes | React Query / SWR users | All three approaches benefit from Keryx's Swagger output — the more complete your action `inputs` schemas and `run()` return types are, the better the generated types will be. --- --- url: 'https://keryxjs.com/reference/utilities.md' description: >- Zod helper utilities — secret() field redaction, zBooleanFromString(), paginationInputs(), paginate() for Drizzle queries, and the zIdOrModel() factory for ID-or-object resolution. --- # Utilities Keryx provides several Zod helper utilities in `packages/keryx/util/zodMixins.ts` for common patterns. App-specific helpers like `zUserIdOrModel()` and `zMessageIdOrModel()` live in `example/backend/util/zodMixins.ts`. ## `secret(schema)` Marks a Zod schema as secret so the field is redacted as `[[secret]]` in request logs. Uses Zod v4's native `.meta()` API. ```ts import { secret } from "keryx"; inputs = z.object({ email: z.string().email(), password: secret(z.string().min(8)), }); ``` When a request comes in with `password: "hunter2"`, the logs will show `password: [[secret]]`. ## `isSecret(schema)` Check if a Zod schema has been marked as secret: ```ts import { isSecret } from "keryx"; if (isSecret(schema)) { // redact this field in output } ``` ## `zBooleanFromString()` Creates a Zod schema that accepts both boolean and string values, transforming `"true"` and `"false"` strings into actual booleans. Useful for HTML form data where booleans arrive as strings. ```ts import { zBooleanFromString } from "keryx"; inputs = z.object({ active: zBooleanFromString(), }); // Accepts: true, false, "true", "false" // Returns: boolean ``` ## `paginationInputs()` Creates a Zod schema for pagination inputs with sensible defaults. Returns `{ page, limit }` where `page` is 1-indexed. Accepts an optional configuration object for custom defaults and bounds. ```ts import { paginationInputs } from "keryx"; // Use defaults: page=1, limit=25, maxLimit=100 inputs = paginationInputs(); // Custom defaults inputs = paginationInputs({ defaultLimit: 10, maxLimit: 50 }); ``` Both `page` and `limit` use `z.coerce.number()` so they work with query string parameters out of the box. | Option | Default | Description | |--------|---------|-------------| | `defaultLimit` | `25` | Default items per page when `limit` is not provided | | `maxLimit` | `100` | Maximum allowed value for `limit` | ## `paginate()` Applies pagination to a Drizzle select query and returns a standardized envelope. Runs the data query and a count query in parallel via `Promise.all`. ```ts import { paginate, type PaginatedResult } from "keryx"; ``` ```ts function paginate( query, // Drizzle select query (before .limit()/.offset()) countQuery, // Promise resolving to [{ count: number }] params, // { page, limit } from paginationInputs() ): Promise> ``` The response envelope: ```ts interface PaginatedResult { data: T[]; pagination: { page: number; // Current page (1-indexed) limit: number; // Items per page total: number; // Total matching records pages: number; // Total pages (ceil(total / limit)) }; } ``` ### Full example ```ts import { type Action, type ActionParams, api, HTTP_METHOD, paginate, paginationInputs, } from "keryx"; import { count, desc, eq } from "drizzle-orm"; import { messages } from "../schema/messages"; import { users } from "../schema/users"; export class MessagesList implements Action { name = "messages:list"; description = "List messages with pagination."; web = { route: "/messages/list", method: HTTP_METHOD.GET }; inputs = paginationInputs({ defaultLimit: 10 }); async run(params: ActionParams) { const result = await paginate( api.db.db .select({ id: messages.id, body: messages.body, user_name: users.name }) .from(messages) .orderBy(desc(messages.id)) .leftJoin(users, eq(users.id, messages.user_id)), api.db.db.select({ count: count() }).from(messages), params, ); return { messages: result.data, pagination: result.pagination }; } } ``` `GET /api/messages/list?page=2&limit=5` returns: ```json { "messages": [ ... ], "pagination": { "page": 2, "limit": 5, "total": 23, "pages": 5 } } ``` The count query is a separate argument so you have full control — use different JOINs, add WHERE clauses, or skip joins that don't affect the total count. ## `zIdOrModel()` Factory A generic factory that creates a Zod schema accepting either a numeric ID or a full model object. If an ID is provided, it resolves to the full model via a database lookup using an async Zod transform. ```ts function zIdOrModel( table: TTable, // Drizzle table definition (must have `id` column) modelSchema: z.ZodType, // Zod schema for the model isModel: (val: unknown) => val is TModel, // Type guard function entityName: string, // For error messages ); ``` Throws a `TypedError` if the ID doesn't match any record. ### `zUserIdOrModel()` Pre-built helper for the users table: ```ts import { zUserIdOrModel } from "../util/zodMixins"; inputs = z.object({ user: zUserIdOrModel(), }); // Accepts: 1, 42, or a full User object // Returns: User (resolved from DB if ID was provided) ``` ### `zMessageIdOrModel()` Pre-built helper for the messages table: ```ts import { zMessageIdOrModel } from "../util/zodMixins"; inputs = z.object({ message: zMessageIdOrModel(), }); // Accepts: 1, 42, or a full Message object // Returns: Message (resolved from DB if ID was provided) ``` ### Creating Your Own To create a resolver for a custom table, use the `zIdOrModel` factory directly: ```ts import { zIdOrModel } from "keryx"; import { createSchemaFactory } from "drizzle-zod"; import { z } from "zod"; import { projects, type Project } from "../schema/projects"; const { createSelectSchema } = createSchemaFactory({ zodInstance: z }); const zProjectSchema = createSelectSchema(projects); function isProject(val: unknown): val is Project { return zProjectSchema.safeParse(val).success; } export function zProjectIdOrModel() { return zIdOrModel( projects, zProjectSchema as z.ZodType, isProject, "Project", ); } ``` ## Auto-Generated Drizzle Schemas The Zod schemas for database models are auto-generated from Drizzle table definitions using `drizzle-zod`: ```ts import { createSchemaFactory } from "drizzle-zod"; const { createSelectSchema } = createSchemaFactory({ zodInstance: z }); export const zUserSchema = createSelectSchema(users); export const zMessageSchema = createSelectSchema(messages); ``` These stay in sync with the database schema automatically — when you add a column to the Drizzle table, the Zod schema updates too. ## Loading & Comparison Helpers ### `globLoader(searchDir)` Recursively loads every `.ts` file under `searchDir`, instantiates each exported class, and returns the instances. This is what discovers your actions, initializers, channels, and middleware — it's the mechanism behind "drop a file in the directory and it works." ```ts import { globLoader } from "keryx"; import { Action } from "keryx"; const actions = await globLoader("./actions"); ``` Throws a `TypedError` with `ErrorType.SERVER_INITIALIZATION` if any class fails to instantiate. ### `safeCompare(a, b)` Constant-time string comparison. Both values are SHA-256 hashed before `timingSafeEqual`, so unequal lengths can't leak through a length oracle. Use it anywhere you compare a secret supplied by a caller — API keys, tokens, admin passwords: ```ts import { safeCompare } from "keryx"; if (!safeCompare(params.apiKey, config.myPlugin.apiKey)) { throw new TypedError({ message: "invalid key", type: ErrorType.CONNECTION_ACTION_RUN, }); } ``` A plain `===` on a secret is a timing side-channel. Reach for this instead. ## CLI & Generator Helpers These back the `keryx` CLI. You need them only when building your own CLI entry point or a [plugin generator](/guide/plugins). | Export | Signature | Purpose | | ------------------ | -------------------------------- | -------------------------------------------------------------------- | | `buildProgram()` | `(opts) => Promise` | Builds the Commander program with every framework and action command | | `getValidTypes()` | `() => string[]` | All valid `keryx generate` type names, including plugin-registered ones | | `PluginGenerator` | type | Shape a plugin implements to add its own `keryx generate ` | ## Swagger Schema Cache The web server pre-generates OpenAPI schemas so the first request doesn't pay for it. `keryx build` calls these; you'd only call them directly when building a custom deploy pipeline. | Export | Purpose | | -------------------------- | --------------------------------------------------------------- | | `generateSwaggerSchemas()` | Build OpenAPI schemas for every registered action | | `computeActionsHash()` | Fingerprint the current action set, used to invalidate the cache | | `writeSchemasCache()` | Persist generated schemas to disk | | `loadCachedSchemas()` | Read schemas back, returning `undefined` on a hash mismatch | | `JSONSchema` | Type of a generated schema object | ## Constants & Enums | Export | Type | Value / Purpose | | ---------------------- | ----------------------------- | ---------------------------------------------------------------------- | | `CHANNEL_NAME_PATTERN` | `RegExp` | `/^[a-zA-Z0-9:._-]{1,200}$/` — validates [channel](/guide/channels) names | | `MCP_APP_MIME_TYPE` | `string` | `text/html;profile=mcp-app`, the MIME type for [MCP App](/guide/mcp-apps) resources | | `MCP_RESPONSE_FORMAT` | enum | `JSON` or `MARKDOWN` — an action's [MCP response format](/guide/mcp#response-format) | | `HTTP_METHOD` | enum | HTTP verbs for an action's `web.method` | | `LogFormat` | enum | `text` or `json` — see [Logger](/reference/classes#logger) | | `LogLevel` | enum | Logger verbosity threshold | | `ExitCode` | enum | `success = 0`, `error = 1` — used by the CLI runner and signal handlers | | `ErrorStatusCodes` | `Record` | Maps each `ErrorType` to its HTTP status — see [TypedError](/reference/classes#typederror) | | `CONNECTION_TYPE` | enum | Transport that opened a [Connection](/reference/classes#connection) | ## Testing Helpers Exported from the `keryx/testing` subpath, not the package root: ```ts import { useTestServer, serverUrl, waitFor, HOOK_TIMEOUT } from "keryx/testing"; ``` | Export | Signature | Purpose | | ------------------ | ------------------------------------------------------ | -------------------------------------------------------------- | | `useTestServer()` | `(opts?) => void` | Registers `beforeAll`/`afterAll` to boot and stop the server | | `serverUrl()` | `() => string` | The URL the server actually bound to, with the resolved port | | `waitFor()` | `(condition, { interval, timeout }) => Promise` | Polls until `condition` returns `true`, instead of a fixed sleep | | `HOOK_TIMEOUT` | `60_000` | Timeout to pass to your own `beforeAll`/`afterAll` hooks | See the [Testing guide](/guide/testing) for how these fit together.