Common Policy Patterns
This page covers six recurring patterns you will encounter when writing custom
policies with Stoma’s definePolicy() SDK. Each pattern includes a standalone
example and an editor link so you can run it immediately.
Request Validation
Section titled “Request Validation”Reject requests early when a required header or value is missing. This is useful for API version gates, content-type enforcement, or any precondition that must hold before further processing.
The policy below requires an x-api-version: 2024 header on every request.
Requests without it receive a structured 400 error.
import { definePolicy, Priority, GatewayError } from "@homegrower-club/stoma";import type { PolicyConfig } from "@homegrower-club/stoma";
interface ApiVersionConfig extends PolicyConfig { requiredVersion?: string; headerName?: string;}
const apiVersionGate = definePolicy<ApiVersionConfig>({ name: "api-version-gate", priority: Priority.AUTH, defaults: { requiredVersion: "2024", headerName: "x-api-version" }, handler: async (c, next, { config, debug }) => { const version = c.req.header(config.headerName!); if (version !== config.requiredVersion) { debug("rejected version: %s (required: %s)", version ?? "none", config.requiredVersion); throw new GatewayError(400, "invalid_version", `API version ${config.requiredVersion} required`); } debug("accepted version: %s", version); await next(); },});How it works: The handler reads the header before calling next(). If the
value does not match, it throws a GatewayError which short-circuits the
pipeline and returns a structured JSON error response. The defaults object
means callers can instantiate the policy with apiVersionGate() and get
sensible behavior out of the box, or override with
apiVersionGate({ requiredVersion: "2025" }).
Header Injection
Section titled “Header Injection”Add or propagate headers across the request/response boundary. This pattern
uses the await next() boundary to operate on both the request (before next)
and the response (after next).
The policy below ensures every request carries an x-correlation-id. If the
client did not send one, a UUID is generated. The same value is echoed back on
the response so clients can correlate requests with logs.
import { definePolicy, Priority } from "@homegrower-club/stoma";
const correlationId = definePolicy({ name: "correlation-id", priority: Priority.EARLY, handler: async (c, next, { debug }) => { const id = c.req.header("x-correlation-id") ?? crypto.randomUUID(); debug("correlation-id: %s", id);
// Make available to downstream policies and upstream c.req.raw.headers.set("x-correlation-id", id);
await next();
// Echo back on response c.res.headers.set("x-correlation-id", id); },});How it works: Everything before await next() runs during the request
phase — downstream policies and the upstream handler can read the injected
header. Everything after await next() runs during the response phase, after
the upstream has produced a response. This pre/post split is the standard middleware pattern and works identically in Stoma policies.
Response Modification
Section titled “Response Modification”Transform or wrap the upstream response after it has been produced. This runs
in the response phase (after await next()) and replaces c.res with a new
Response object.
The policy below wraps every JSON response in a { data, meta } envelope,
injecting the gateway request ID into the metadata.
import { definePolicy, Priority, getGatewayContext } from "@homegrower-club/stoma";
const jsonEnvelope = definePolicy({ name: "json-envelope", priority: Priority.RESPONSE_TRANSFORM, handler: async (c, next, { debug }) => { await next();
const contentType = c.res.headers.get("content-type") ?? ""; if (!contentType.includes("application/json")) return;
const original = await c.res.json(); const ctx = getGatewayContext(c); const wrapped = { data: original, meta: { requestId: ctx?.requestId }, }; debug("wrapped response with envelope"); c.res = new Response(JSON.stringify(wrapped), { status: c.res.status, headers: c.res.headers, }); },});How it works: After next() completes, the policy reads the original JSON
body, wraps it, and assigns a new Response to c.res. The content-type
check ensures non-JSON responses (HTML error pages, binary data) pass through
untouched. getGatewayContext(c) provides the request ID from the gateway
context injector — it returns undefined when running outside a gateway, so
the optional chain handles that gracefully.
Conditional Short-Circuit
Section titled “Conditional Short-Circuit”Return a response immediately without calling next(), preventing the rest of
the pipeline and the upstream from executing. This is the standard way to
implement maintenance modes, feature flags, or emergency kill switches.
The policy below returns a 503 with a Retry-After header when maintenance
mode is enabled.
import { definePolicy, Priority, GatewayError } from "@homegrower-club/stoma";import type { PolicyConfig } from "@homegrower-club/stoma";
interface MaintenanceConfig extends PolicyConfig { enabled?: boolean; message?: string; retryAfter?: number;}
const maintenanceMode = definePolicy<MaintenanceConfig>({ name: "maintenance-mode", priority: Priority.EARLY, defaults: { enabled: false, message: "Service under maintenance", retryAfter: 300 }, handler: async (c, next, { config, debug }) => { if (config.enabled) { debug("maintenance mode active, returning 503"); throw new GatewayError(503, "maintenance", config.message!, { "retry-after": String(config.retryAfter), }); } await next(); },});How it works: When config.enabled is true, the handler throws a
GatewayError with a 503 status and custom headers. The gateway’s error
handler converts this into a structured JSON response with the Retry-After
header included. Because next() is never called, no downstream policies or
the upstream handler execute. When maintenance mode is off, the policy calls
next() and becomes transparent.
Custom Authentication
Section titled “Custom Authentication”Validate credentials and forward identity claims to the upstream via headers. This pattern combines early rejection (short-circuit on failure) with header injection (forwarding claims on success).
The policy below validates bearer tokens against a static lookup table and
sets x-user-id and x-user-role headers for the upstream to consume.
import { definePolicy, Priority, GatewayError } from "@homegrower-club/stoma";import type { PolicyConfig } from "@homegrower-club/stoma";
interface TokenAuthConfig extends PolicyConfig { tokens: Record<string, { userId: string; role: string }>;}
const tokenAuth = definePolicy<TokenAuthConfig>({ name: "token-auth", priority: Priority.AUTH, handler: async (c, next, { config, debug }) => { const token = c.req.header("authorization")?.replace("Bearer ", ""); if (!token) { throw new GatewayError(401, "unauthorized", "Missing authorization header"); }
const claims = config.tokens[token]; if (!claims) { debug("rejected unknown token"); throw new GatewayError(401, "unauthorized", "Invalid token"); }
// Forward claims as headers to upstream c.req.raw.headers.set("x-user-id", claims.userId); c.req.raw.headers.set("x-user-role", claims.role); debug("authenticated user %s with role %s", claims.userId, claims.role); await next(); },});How it works: The handler extracts the bearer token, looks it up in the
configured map, and either rejects with a 401 or injects identity headers and
calls next(). Using Priority.AUTH (10) ensures this runs before rate
limiting and caching, so unauthenticated requests are rejected as early as
possible. In a real system, you would replace the static token map with a
database lookup, JWT verification, or an external identity provider call.
Wrapping Hono Middleware
Section titled “Wrapping Hono Middleware”Any existing Hono middleware can become a Stoma policy by wrapping it in an
object with name and priority. This lets you reuse Hono’s middleware ecosystem without rewriting anything.
import type { Policy } from "@homegrower-club/stoma";
function poweredByPolicy(): Policy { return { name: "powered-by", priority: 93, handler: async (c, next) => { await next(); c.res.headers.set("x-powered-by", "Stoma"); }, };}How it works: A Policy is just { name, priority, handler } where
handler is a standard Hono MiddlewareHandler. You can wrap any existing
middleware — whether from hono/compress, a third-party package, or your own
codebase — by returning it as the handler field. The name is used for
deduplication when merging global and route-level policies, and priority
controls where in the pipeline it executes.
Summary
Section titled “Summary”| Pattern | Priority | Key technique |
|---|---|---|
| Request validation | AUTH (10) | Check before next(), throw GatewayError on failure |
| Header injection | EARLY (5) | Set headers before and after next() |
| Response modification | RESPONSE_TRANSFORM (92) | Read and replace c.res after next() |
| Conditional short-circuit | EARLY (5) | Throw GatewayError without calling next() |
| Custom authentication | AUTH (10) | Validate credentials, inject identity headers |
| Wrapping Hono middleware | Varies | Return { name, priority, handler } object |
These patterns compose naturally. A real gateway route might combine a correlation ID policy, token authentication, request validation, and a response envelope — each running at its own priority level in the pipeline. See the Recipes section for complete gateway configurations that combine multiple patterns.