Skip to content

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.


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" })
Open in Editor

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 })
Open in Editor

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()
Open in Editor

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()
Open in Editor

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"] })
Open in Editor

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 })
Open in Editor

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 })
Open in Editor

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 })
Open in Editor

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 } })
Open in Editor

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 })
Open in Editor
RecipePriorityStatus codeKey technique
API Version GateAUTH (10)400Validate header before next()
Maintenance ModeEARLY (5)503Short-circuit with custom headers
Request ID PassthroughEARLY (5)Set header before and after next()
JSON Response EnvelopeRESPONSE_TRANSFORM (92)Read and replace c.res after next()
IP AllowlistIP_FILTER (1)403Extract IP from x-forwarded-for
Webhook HMACAUTH (10)401crypto.subtle HMAC-SHA256 verification
Body Size LimiterEARLY (5)413Check content-length before next()
Header CacheCACHE (40)Key cache by path + header value
Feature Flag GateAUTH (10)404Conditional short-circuit by flag
Latency BudgetOBSERVABILITY (0)Measure elapsed time around next()