Skip to content

Custom Policies

Stoma provides a policy SDK at @homegrower-club/stoma/sdk that gives you the same tools the built-in policies use. The recommended way to write custom policies is with definePolicy(), which handles config merging, skip logic, debug logging, and gateway context injection automatically.

The definePolicy() function takes a declarative definition and returns a factory function (config?) => Policy. Your handler receives the Hono context, next, and a PolicyHandlerContext with the merged config, a pre-namespaced debug logger, and the gateway context.

import { definePolicy, Priority } from "@homegrower-club/stoma/sdk";
import { GatewayError } from "@homegrower-club/stoma";
import type { PolicyConfig } from "@homegrower-club/stoma";
interface TenantFilterConfig extends PolicyConfig {
allowedTenants: string[];
}
export const tenantFilter = definePolicy<TenantFilterConfig>({
name: "tenant-filter",
priority: Priority.RATE_LIMIT,
handler: async (c, next, { config, debug }) => {
const tenant = c.req.header("x-tenant-id");
if (!tenant || !config.allowedTenants.includes(tenant)) {
debug("rejected tenant: %s", tenant ?? "none");
throw new GatewayError(403, "forbidden", "Tenant not allowed");
}
debug("allowed tenant: %s", tenant);
await next();
},
});

Using it:

import { createGateway, jwtAuth, rateLimit } from "@homegrower-club/stoma";
import { tenantFilter } from "./policies/tenant-filter";
const gateway = createGateway({
routes: [
{
path: "/api/*",
pipeline: {
policies: [
jwtAuth({ secret: env.JWT_SECRET }),
tenantFilter({ allowedTenants: ["acme", "globex"] }),
rateLimit({ max: 100 }),
],
upstream: { type: "url", target: "https://backend.internal" },
},
},
],
});
  • Config merging — your defaults object is shallow-merged with user config via resolveConfig().
  • Skip logic — the standard skip field from PolicyConfig is automatically checked before your handler runs. You do not need to check config.skip manually.
  • Debug logging — the debug argument is a pre-namespaced logger scoped to stoma:policy:{name}. It is always callable (returns a no-op when debug is disabled or when running outside a gateway pipeline).
  • Gateway context — the gateway argument provides requestId, traceId, spanId, gatewayName, routePath, and a debug factory.
interface PolicyDefinition<TConfig extends PolicyConfig> {
/** Unique policy name (e.g. "my-auth", "custom-cache"). */
name: string;
/** Execution priority. Use Priority constants. Default: Priority.DEFAULT (100). */
priority?: number;
/** Default values for optional config fields. */
defaults?: Partial<TConfig>;
/** The policy handler. */
handler: (
c: Context,
next: Next,
ctx: PolicyHandlerContext<TConfig>,
) => Promise<void> | void;
}
interface PolicyHandlerContext<TConfig> {
/** Fully merged config (defaults + user overrides). */
config: TConfig;
/** Debug logger pre-namespaced to stoma:policy:{name}. Always callable. */
debug: DebugLogger;
/** Gateway context, or undefined when running outside a gateway pipeline. */
gateway: PolicyContext | undefined;
}

gateway includes request metadata such as requestId, traceId, spanId, startTime, gatewayName, and routePath, plus adapter capabilities (when available).


The SDK exports named Priority constants so you do not need magic numbers. All built-in policies use these constants:

ConstantValueTierExamples
Priority.OBSERVABILITY0ObservabilityrequestLog, assignMetrics
Priority.IP_FILTER1Early filtersipFilter
Priority.METRICS1MetricsmetricsReporter
Priority.EARLY5Early transformcors, overrideMethod, latencyInjection
Priority.AUTH10AuthenticationjwtAuth, apiKeyAuth, basicAuth, oauth2, rbac, jws, verifyHttpSignature, requestValidation, jsonValidation
Priority.RATE_LIMIT20Rate limitingrateLimit
Priority.CIRCUIT_BREAKER30Resilience (pre)circuitBreaker
Priority.CACHE40Cachingcache
Priority.REQUEST_TRANSFORM50Request transformrequestTransform, assignAttributes, assignContent, generateJwt, dynamicRouting, httpCallout
Priority.TIMEOUT85Timeouttimeout
Priority.RETRY90Retryretry
Priority.RESPONSE_TRANSFORM92Response transformresponseTransform, trafficShadow, resourceFilter
Priority.PROXY95Proxyproxy, generateHttpSignature
Priority.DEFAULT100DefaultCustom policies (when no priority specified)
Priority.MOCK999Terminalmock
import { Priority } from "@homegrower-club/stoma/sdk";
// Use named constants instead of magic numbers
const myPolicy = definePolicy({
name: "my-policy",
priority: Priority.AUTH,
// ...
});

If you do not specify a priority, your policy receives Priority.DEFAULT (100) unless overridden via GatewayConfig.defaultPolicyPriority.


The SDK also exports three composable helpers that definePolicy uses internally. You can use them directly when building policies with the manual approach (see below).

Shallow-merge default config values with user-provided config:

import { resolveConfig } from "@homegrower-club/stoma/sdk";
const config = resolveConfig<MyConfig>(
{ timeout: 5000, retries: 3 }, // defaults
userConfig, // user overrides (may be undefined)
);

Get a debug logger pre-namespaced to stoma:policy:{name}. Returns a no-op logger when there is no gateway context:

import { policyDebug } from "@homegrower-club/stoma/sdk";
// Inside a policy handler:
const debug = policyDebug(c, "my-policy");
debug("processing request %s", c.req.url);

Wrap a middleware handler with PolicyConfig.skip logic. If skipFn is undefined, returns the original handler unchanged (zero overhead):

import { withSkip } from "@homegrower-club/stoma/sdk";
const handler: MiddlewareHandler = async (c, next) => {
// policy logic
await next();
};
return {
name: "my-policy",
priority: 50,
handler: withSkip(config?.skip, handler),
};

The SDK provides createPolicyTestHarness() to eliminate the repeated boilerplate of setting up a test Hono app. It creates a minimal app with the policy under test, GatewayError handling, gateway context injection, and a configurable upstream.

import { createPolicyTestHarness } from "@homegrower-club/stoma/sdk";
import { tenantFilter } from "./tenant-filter";
const { request } = createPolicyTestHarness(
tenantFilter({ allowedTenants: ["acme"] }),
);
it("should allow valid tenants", async () => {
const res = await request("/test", {
headers: { "x-tenant-id": "acme" },
});
expect(res.status).toBe(200);
});
it("should reject unknown tenants", async () => {
const res = await request("/test", {
headers: { "x-tenant-id": "evil-corp" },
});
expect(res.status).toBe(403);
});
interface PolicyTestHarnessOptions {
/** Custom upstream handler. Default: returns { ok: true } with status 200. */
upstream?: MiddlewareHandler;
/** Route path pattern for the test app. Default: "/*". */
path?: string;
/** Gateway name injected into context. Default: "test-gateway". */
gatewayName?: string;
}

Custom upstream for testing pass-through behavior:

const { request } = createPolicyTestHarness(myPolicy(), {
upstream: async (c) => {
return c.json({ user: "test-user", role: "admin" });
},
});

You can also create policies by returning a Policy object directly. This is the lower-level approach — you handle config merging, skip logic, and debug logging yourself.

Every policy has three fields:

interface Policy {
/** Unique name -- used for deduplication when merging global and route policies. */
name: string;
/** A standard Hono MiddlewareHandler. */
handler: MiddlewareHandler;
/** Execution priority -- lower numbers run first. Default: 100. */
priority?: number;
}
import type { Policy, PolicyConfig } from "@homegrower-club/stoma";
import { GatewayError } from "@homegrower-club/stoma";
import { resolveConfig, policyDebug, withSkip, Priority } from "@homegrower-club/stoma/sdk";
interface TenantFilterConfig extends PolicyConfig {
allowedTenants: string[];
}
export function tenantFilter(config: TenantFilterConfig): Policy {
if (config.allowedTenants.length === 0) {
throw new Error("tenantFilter requires at least one allowed tenant");
}
const handler: import("hono").MiddlewareHandler = async (c, next) => {
const debug = policyDebug(c, "tenant-filter");
const tenant = c.req.header("x-tenant-id");
if (!tenant || !config.allowedTenants.includes(tenant)) {
debug("rejected tenant: %s", tenant ?? "none");
throw new GatewayError(403, "forbidden", "Tenant not allowed");
}
debug("allowed tenant: %s", tenant);
await next();
};
return {
name: "tenant-filter",
priority: Priority.RATE_LIMIT,
handler: withSkip(config.skip, handler),
};
}

The gateway injects a PolicyContext on every request. Access it via getGatewayContext(c) to read the request ID, timing data, or create a namespaced debug logger:

import { getGatewayContext } from "@homegrower-club/stoma";
// Inside a policy handler:
const ctx = getGatewayContext(c);
const debug = ctx?.debug("stoma:policy:my-policy");
debug?.("processing request", ctx?.requestId);

The context includes requestId, startTime, gatewayName, routePath, traceId, spanId, and a debug factory. See the Policy System page for the full interface.


A policy short-circuits the pipeline by returning a Response without calling next(). When this happens, no further policies or the upstream handler execute. Use GatewayError for structured JSON error responses:

import { GatewayError } from "@homegrower-club/stoma";
// Inside a policy handler:
throw new GatewayError(403, "forbidden", "Access denied");

You can also return a response directly:

// Inside a policy handler:
return c.json({ status: "blocked" }, 403);

Any existing Hono middleware can be wrapped as a policy by adding name and priority:

import { compress } from "hono/compress";
import type { Policy } from "@homegrower-club/stoma";
function compression(): Policy {
return {
name: "compression",
priority: 93,
handler: compress(),
};
}