@api-policy/hono
v2.0.0
Published
Hono.js adapter for API Policy. Auth middleware, permissions, routing.
Maintainers
Readme
@api-policy/hono
Hono adapter for @api-policy/server. Drop-in auth middleware, route factory, and permission enforcement for Hono applications.
npm install @api-policy/hono @api-policy/server hono joseValidation schemas use Standard Schema — bring your own library (Zod v4, Valibot, ArkType), or use the built-in slugId with zero dependencies.
Setup
import { Hono } from 'hono'
import {
createPolicyPlugin,
defineRoute,
definePublicRoute,
createRouteGroup,
combineRoutes,
requestContextMiddleware,
BOUNDARY,
PERM,
} from '@api-policy/hono'
import { ok } from 'ts-micro-result'
// 1. Create plugin (wires up auth + permission engines)
const policy = createPolicyPlugin({
auth: {
jwt: { secret: process.env.JWT_SECRET! },
},
})
// 2. Define routes
const listPosts = defineRoute(policy, {
method: 'GET',
path: '/',
auth: true,
permission: { resource: 'post', action: PERM.READ, boundary: BOUNDARY.TENANT },
handler: async ({ user, query }) => {
const posts = await db.posts.list(user!.tenantId)
return ok(posts)
},
})
const health = definePublicRoute(policy, {
method: 'GET',
path: '/health',
handler: async () => ok({ status: 'ok' }),
})
// 3. Group and mount
const app = new Hono()
app.use('*', requestContextMiddleware)
app.route('/api/v1', createRouteGroup('/posts', [listPosts]))
app.route('/api/v1', health)createPolicyPlugin
Creates the auth and permission engines used by all routes.
import { createPolicyPlugin } from '@api-policy/hono'
import { createGatewayJwtAuthMethod, createDefaultJwtVerify } from '@api-policy/server'
const policy = createPolicyPlugin({
auth: {
// Default methods when route has auth: true
defaultMethods: ['jwt', 'apiKey'],
// JWT — symmetric (HS256) or asymmetric (RS256, ES256, EdDSA)
jwt: { secret: process.env.JWT_SECRET! },
// jwt: { publicKey: process.env.JWT_PUBLIC_KEY!, algorithm: 'EdDSA' },
// jwt: { publicKey: '...', verify: myCustomVerifyFn }, // custom verify
// API Key
apiKey: {
lookup: async (key) => {
const record = await db.apiKeys.findOne({ key })
return record ? { ownerId: record.userId, perms: record.perms, tenantId: record.tenantId } : null
},
},
// Custom auth methods (e.g. Gateway JWT for BFF → backend)
customMethods: [myCustomAuthMethod],
},
// Custom error → HTTP status codes (merged with system defaults)
errorHttpMap: {
PRODUCT_NOT_FOUND: 404,
PRODUCT_SLUG_EXISTS: 409,
},
})
// policy.auth → AuthPolicyEngine
// policy.permission → PermissionEngine
// policy.errorHttpMap → merged error→HTTP status mapGateway JWT mode (BFF → backend)
If your service sits behind a BFF (e.g. Cloudflare Worker) that verifies the client JWT and forwards requests with a signed Gateway JWT:
import { createGatewayJwtAuthMethod, createDefaultJwtVerify } from '@api-policy/server'
const jwtConfig = { secret: process.env.JWT_SECRET! }
const policy = createPolicyPlugin({
auth: {
defaultMethods: ['gatewayJwt'],
customMethods: [
createGatewayJwtAuthMethod({
gateway: {
publicKeyHex: process.env.BFF_PUBLIC_KEY_HEX, // omit for local dev
expectedIssuer: 'bff-worker',
expectedAudience: 'my-backend',
},
accessJwt: {
config: jwtConfig,
verifyFn: createDefaultJwtVerify(jwtConfig),
},
}),
],
},
})defineRoute
Builds a Hono middleware handler with the full auth pipeline attached.
import { defineRoute, BOUNDARY, PERM } from '@api-policy/hono'
import { z } from 'zod' // or valibot, arktype — any Standard Schema library
const createPost = defineRoute(policy, {
method: 'POST',
path: '/',
auth: true, // use default methods
// auth: 'jwt' // specific method
// auth: ['jwt', 'apiKey'] // try list in order
permission: {
resource: 'post',
action: PERM.WRITE,
boundary: BOUNDARY.TENANT,
roles: ['editor', 'admin'], // optional role gate (OR logic by default)
},
input: {
body: z.object({ title: z.string(), content: z.string() }),
query: z.object({ draft: z.coerce.boolean().default(false) }),
},
handler: async ({ user, body, query, tenantContext, traceId }) => {
const post = await db.posts.create({ ...body, authorId: user!.id })
return ok(post)
},
})Handler context (PolicyContext):
| Field | Type | Description |
|-------|------|-------------|
| user | UserContext \| undefined | Authenticated user (always present on auth routes) |
| body | TBody | Validated request body |
| query | TQuery | Validated query params |
| params | TParams | Validated path params |
| tenantContext | TenantContext \| undefined | Set by createTenantResolverMiddleware |
| traceId | string | Request trace ID |
Middleware pipeline (in order):
1. Auth → verify credentials, set c.var.authResult
2. Permission → roles gate → owner bypass → bitmask → boundary
3. Validation → validate body/query/params (Standard Schema — Zod, Valibot, ArkType, or built-in)
4. Handler → business logic, returns Result<T>Route metadata is attached to the returned handler:
createPost.routeMeta // { method: 'POST', path: '/', authRequired: true }definePublicRoute
Shorthand for routes with no auth (auth: false is forced, no permission field):
const health = definePublicRoute(policy, {
method: 'GET',
path: '/health',
handler: async () => ok({ status: 'ok' }),
})Route grouping
import { createRouteGroup, combineRoutes } from '@api-policy/hono'
// Group routes under a base path
const postRoutes = createRouteGroup('/posts', [listPosts, createPost, getPost, deletePost])
const productRoutes = createRouteGroup('/products', [listProducts, createProduct])
// Merge groups and mount
const app = new Hono()
app.route('/api/v1', combineRoutes(postRoutes, productRoutes))createRouteGroup registers each route at basePath + route.path (e.g. /posts/:id). A route with path: '/' registers at basePath exactly.
Middleware
Request context
Generates a traceId from X-Request-ID / X-Correlation-ID headers, or creates a UUID. Mount once globally.
import { requestContextMiddleware, getTraceId } from '@api-policy/hono'
app.use('*', requestContextMiddleware)
// In any handler
const traceId = getTraceId(c)Tenant resolver
Resolves tenantId from trusted sources only — never from client-supplied headers.
import { createTenantResolverMiddleware } from '@api-policy/hono'
app.use('*', createTenantResolverMiddleware({
// Source 1 (highest priority): JWT (user.tenantId from authResult)
// Source 2: domain lookup
domainLookup: async (domain) => {
return db.domains.findByHost(domain)
// Returns: { tenantId: string, storeId?: string } | null
},
allowDomainFallback: true,
}))
// In handler
const { tenantId, storeId, source } = ctx.tenantContext!
// source: 'jwt' | 'domain'For handlers that require tenantId to be non-nullable, use TenantPolicyContext:
import type { TenantPolicyContext } from '@api-policy/hono'
handler: async (ctx: TenantPolicyContext<MyBody>) => {
ctx.user.tenantId // string (not string | undefined)
}Rate limiting
import { createRateLimitMiddleware, createInMemoryRateLimitCheck } from '@api-policy/hono'
// Dev/testing — in-memory sliding window
app.use('*', createRateLimitMiddleware({
checkFn: createInMemoryRateLimitCheck(),
defaultLimit: 1000,
}))
// Production — custom backend (Redis, etc.)
app.use('*', createRateLimitMiddleware({
checkFn: async (tenantId, keyId, limit) => redis.slidingWindow(tenantId, keyId, limit),
getKeyIdFn: (c) => c.var.authResult?.method,
skipFn: (c) => c.req.path === '/health',
}))Sets response headers automatically: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, Retry-After.
Validation middleware (standalone)
validate() accepts any Standard Schema compatible library. Zod is not a required dependency.
import { validate, getValidatedParams, getValidatedQuery, slugId } from '@api-policy/hono'
// Zod v4 (optional peer dependency)
import { z } from 'zod'
app.use('/posts/:id', validate({
params: z.object({ id: z.string() }),
query: z.object({ page: z.coerce.number().default(1) }),
}))
// Valibot (optional peer dependency)
import * as v from 'valibot'
app.use('/posts/:id', validate({
params: v.object({ id: v.string() }),
}))
// Built-in — zero dependency
app.use('/posts/:id', validate({ params: slugId }))
app.get('/posts/:id', (c) => {
const params = getValidatedParams<{ id: string }>(c)
const query = getValidatedQuery<{ page: number }>(c)
})Built-in (zero-dependency): slugId (lowercase, numbers, hyphens, 1–100 chars), optionalSlugId.
HTTP response
toHttpResponse is called automatically inside defineRoute. Use it directly if building custom handlers.
import { toHttpResponse, setFeatureErrorsHttpMap } from '@api-policy/hono'
// Register app-specific error codes once at startup
setFeatureErrorsHttpMap({
PRODUCT_NOT_FOUND: 404,
PRODUCT_SLUG_EXISTS: 409,
})
// In a custom handler
return toHttpResponse(c, result, { traceId, successStatus: 201 })Success response:
{ "ok": true, "data": { ... }, "meta": { "traceId": "...", "params": { "timestamp": "..." } } }Error response:
{ "ok": false, "errors": [{ "code": "PERMISSION_DENIED", "message": "..." }], "meta": { ... } }Hono context variables
This package sets the following on Hono's ContextVariableMap:
| Key | Type | Set by |
|-----|------|--------|
| requestId | string | requestContextMiddleware |
| correlationId | string | requestContextMiddleware |
| authResult | HonoAuthResult | createAuthMiddleware |
| tenantContext | TenantContext | createTenantResolverMiddleware |
| validatedData | ValidatedData | validate |
c.var.authResult?.user // UserContext
c.var.tenantContext?.tenantId
c.var.validatedData?.bodyRe-exported from @api-policy/server
All commonly used exports are available directly from @api-policy/hono:
import {
PERM, PERM_ALL, BOUNDARY, AUTH_METHOD,
createJwtAuthMethod, createApiKeyAuthMethod,
createGatewayJwtAuthMethod,
createAuthPolicyEngine, createPermissionEngine,
Forbidden, AlreadyExists, Conflict, RateLimitExceeded,
lintRoutes, validateBuild, createPermissionRegistry,
} from '@api-policy/hono'License
MIT
