Policy Cookbook
Copy-paste recipes for common gateway scenarios. Each recipe is a self-contained policy with usage example. Click “Open in Editor” to try it live.
API Version Gate
Section titled “API Version Gate”Require a specific API version header. Requests without the correct version receive a structured 400 error.
import { definePolicy, Priority, GatewayError } from "@homegrower-club/stoma";import type { PolicyConfig } from "@homegrower-club/stoma";
interface ApiVersionConfig extends PolicyConfig { required: string; header?: string;}
export const apiVersionGate = definePolicy<ApiVersionConfig>({ name: "api-version-gate", priority: Priority.AUTH, defaults: { header: "x-api-version" }, handler: async (c, next, { config }) => { const version = c.req.header(config.header!); if (version !== config.required) { throw new GatewayError(400, "invalid_version", `API version ${config.required} required, got ${version ?? "none"}`); } await next(); },});Usage:
apiVersionGate({ required: "2024" })Maintenance Mode
Section titled “Maintenance Mode”Return 503 with a Retry-After header when enabled. Requests to the route are short-circuited before reaching the upstream.
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;}
export const maintenanceMode = definePolicy<MaintenanceConfig>({ name: "maintenance-mode", priority: Priority.EARLY, defaults: { enabled: false, message: "Service under maintenance", retryAfter: 300 }, handler: async (c, next, { config }) => { if (config.enabled) { throw new GatewayError(503, "maintenance", config.message!, { "retry-after": String(config.retryAfter), }); } await next(); },});Usage:
maintenanceMode({ enabled: true })Request ID Passthrough
Section titled “Request ID Passthrough”Forward an incoming x-request-id header or generate a new UUID. The same value is set on both the request (for downstream policies and the upstream) and the response (for client correlation).
import { definePolicy, Priority } from "@homegrower-club/stoma";
export const requestIdPassthrough = definePolicy({ name: "request-id-passthrough", priority: Priority.EARLY, handler: async (c, next, { debug }) => { const id = c.req.header("x-request-id") ?? crypto.randomUUID(); c.req.raw.headers.set("x-request-id", id); await next(); c.res.headers.set("x-request-id", id); debug("request-id: %s", id); },});Usage:
requestIdPassthrough()JSON Response Envelope
Section titled “JSON Response Envelope”Wrap all JSON responses in a { data, meta } structure. Non-JSON responses pass through untouched.
import { definePolicy, Priority } from "@homegrower-club/stoma";
export const jsonEnvelope = definePolicy({ name: "json-envelope", priority: Priority.RESPONSE_TRANSFORM, handler: async (c, next) => { await next(); const ct = c.res.headers.get("content-type") ?? ""; if (!ct.includes("application/json")) return; const original = await c.res.json(); c.res = new Response(JSON.stringify({ data: original, meta: { timestamp: new Date().toISOString() }, }), { status: c.res.status, headers: c.res.headers }); },});Usage:
jsonEnvelope()IP Allowlist from Header
Section titled “IP Allowlist from Header”Allow only requests whose x-forwarded-for IP is in a configured allowlist. All other requests receive a 403.
import { definePolicy, Priority, GatewayError } from "@homegrower-club/stoma";import type { PolicyConfig } from "@homegrower-club/stoma";
interface IpAllowlistConfig extends PolicyConfig { allowed: string[];}
export const ipAllowlist = definePolicy<IpAllowlistConfig>({ name: "ip-allowlist", priority: Priority.IP_FILTER, handler: async (c, next, { config, debug }) => { const ip = c.req.header("x-forwarded-for")?.split(",")[0].trim(); if (!ip || !config.allowed.includes(ip)) { debug("blocked IP: %s", ip ?? "unknown"); throw new GatewayError(403, "ip_blocked", "IP not in allowlist"); } debug("allowed IP: %s", ip); await next(); },});Usage:
ipAllowlist({ allowed: ["10.0.0.1", "10.0.0.2"] })Webhook HMAC Verification
Section titled “Webhook HMAC Verification”Verify webhook payloads using HMAC-SHA256. The policy reads the raw request body, computes the expected signature, and compares it to the value in the signature header.
import { definePolicy, Priority, GatewayError } from "@homegrower-club/stoma";import type { PolicyConfig } from "@homegrower-club/stoma";
interface HmacConfig extends PolicyConfig { secret: string; signatureHeader?: string;}
export const hmacVerify = definePolicy<HmacConfig>({ name: "hmac-verify", priority: Priority.AUTH, defaults: { signatureHeader: "x-signature-256" }, handler: async (c, next, { config, debug }) => { const signature = c.req.header(config.signatureHeader!); if (!signature) { throw new GatewayError(401, "missing_signature", "Signature header required"); } const body = await c.req.text(); const key = await crypto.subtle.importKey( "raw", new TextEncoder().encode(config.secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"], ); const mac = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(body)); const expected = "sha256=" + Array.from(new Uint8Array(mac)) .map(b => b.toString(16).padStart(2, "0")).join(""); if (signature !== expected) { debug("HMAC mismatch"); throw new GatewayError(401, "invalid_signature", "Signature verification failed"); } debug("HMAC verified"); await next(); },});Usage:
hmacVerify({ secret: env.WEBHOOK_SECRET })Request Body Size Limiter
Section titled “Request Body Size Limiter”Reject requests whose Content-Length exceeds a configured byte limit. Returns 413 Payload Too Large.
import { definePolicy, Priority, GatewayError } from "@homegrower-club/stoma";import type { PolicyConfig } from "@homegrower-club/stoma";
interface SizeLimitConfig extends PolicyConfig { maxBytes?: number;}
export const bodySizeLimit = definePolicy<SizeLimitConfig>({ name: "body-size-limit", priority: Priority.EARLY, defaults: { maxBytes: 1_048_576 }, // 1MB handler: async (c, next, { config, debug }) => { const contentLength = c.req.header("content-length"); if (contentLength && parseInt(contentLength, 10) > config.maxBytes!) { debug("rejected: body too large (%s bytes)", contentLength); throw new GatewayError(413, "payload_too_large", `Request body exceeds ${config.maxBytes} bytes`); } await next(); },});Usage:
bodySizeLimit({ maxBytes: 512_000 })Response Caching by Header
Section titled “Response Caching by Header”Generate a cache key based on the request path and a specific header value. Useful for multi-tenant APIs where each tenant gets isolated cache entries.
import { definePolicy, Priority } from "@homegrower-club/stoma";import type { PolicyConfig } from "@homegrower-club/stoma";
interface HeaderCacheConfig extends PolicyConfig { headerName: string; ttlSeconds?: number;}
export const headerCache = definePolicy<HeaderCacheConfig>({ name: "header-cache", priority: Priority.CACHE, defaults: { ttlSeconds: 60 }, handler: async (c, next, { config, debug }) => { const cacheKey = `${c.req.path}:${c.req.header(config.headerName) ?? "default"}`; debug("cache key: %s", cacheKey); await next(); c.res.headers.set("x-cache-key", cacheKey); c.res.headers.set("cache-control", `max-age=${config.ttlSeconds}`); },});Usage:
headerCache({ headerName: "x-tenant-id", ttlSeconds: 120 })Feature Flag Gate
Section titled “Feature Flag Gate”Block access to a route when a feature flag is disabled. Returns 404 so the endpoint appears nonexistent to clients without the feature.
import { definePolicy, Priority, GatewayError } from "@homegrower-club/stoma";import type { PolicyConfig } from "@homegrower-club/stoma";
interface FeatureFlagConfig extends PolicyConfig { flag: string; flags: Record<string, boolean>;}
export const featureFlag = definePolicy<FeatureFlagConfig>({ name: "feature-flag", priority: Priority.AUTH, handler: async (c, next, { config, debug }) => { if (!config.flags[config.flag]) { debug("feature %s is disabled", config.flag); throw new GatewayError(404, "feature_disabled", `Feature "${config.flag}" is not available`); } debug("feature %s is enabled", config.flag); await next(); },});Usage:
featureFlag({ flag: "v2-api", flags: { "v2-api": true, "beta": false } })Latency Budget Logger
Section titled “Latency Budget Logger”Measure total request processing time and flag responses that exceed a time budget. Adds x-latency-ms to every response and x-latency-budget-exceeded: true when the budget is blown.
import { definePolicy, Priority } from "@homegrower-club/stoma";import type { PolicyConfig } from "@homegrower-club/stoma";
interface LatencyBudgetConfig extends PolicyConfig { budgetMs?: number;}
export const latencyBudget = definePolicy<LatencyBudgetConfig>({ name: "latency-budget", priority: Priority.OBSERVABILITY, defaults: { budgetMs: 500 }, handler: async (c, next, { config, debug }) => { const start = Date.now(); await next(); const elapsed = Date.now() - start; c.res.headers.set("x-latency-ms", String(elapsed)); if (elapsed > config.budgetMs!) { debug("BUDGET EXCEEDED: %dms > %dms budget", elapsed, config.budgetMs); c.res.headers.set("x-latency-budget-exceeded", "true"); } },});Usage:
latencyBudget({ budgetMs: 200 })Quick Reference
Section titled “Quick Reference”| Recipe | Priority | Status code | Key technique |
|---|---|---|---|
| API Version Gate | AUTH (10) | 400 | Validate header before next() |
| Maintenance Mode | EARLY (5) | 503 | Short-circuit with custom headers |
| Request ID Passthrough | EARLY (5) | — | Set header before and after next() |
| JSON Response Envelope | RESPONSE_TRANSFORM (92) | — | Read and replace c.res after next() |
| IP Allowlist | IP_FILTER (1) | 403 | Extract IP from x-forwarded-for |
| Webhook HMAC | AUTH (10) | 401 | crypto.subtle HMAC-SHA256 verification |
| Body Size Limiter | EARLY (5) | 413 | Check content-length before next() |
| Header Cache | CACHE (40) | — | Key cache by path + header value |
| Feature Flag Gate | AUTH (10) | 404 | Conditional short-circuit by flag |
| Latency Budget | OBSERVABILITY (0) | — | Measure elapsed time around next() |