Hono
permdock/hono wraps the Fetch kernel as a Hono middleware and a protect route guard, with OpenAPI 3.2 security emitted through hono-openapi or @hono/zod-openapi.
Purpose
Hono is the reference HTTP adapter because it already speaks Fetch, runs on Node, Bun, Deno and Workers, and is what the example backends for the React, Vue, Solid and Expo examples use. permdock/hono is a thin wrapper over the server kernel: a middleware that puts the request-scoped PermDock on the context, a protect guard for routes, and hooks that emit OpenAPI 3.2 security for the two common OpenAPI integrations.
API
import { Hono } from "hono";
import {
createPermDock,
discoverViaSignatureAgent,
verifyWebBotAuth,
} from "permdock/hono";
import { policy } from "./policy";
import { permissions } from "./permissions";
export const { permdock, protect } = createPermDock(policy, {
subject: (c) => c.get("user"),
webBotAuth: (request) =>
verifyWebBotAuth(request, {
keys: discoverViaSignatureAgent({ allow: ["agents.example.com"] }),
}),
});
const app = new Hono<Env>();
app.use(permdock()); // c.get('permdock') is the request-scoped PermDock
app.delete(
"/posts/:id",
protect(permissions.post.delete, (c) => loadPost(c.req.param("id"))),
handler,
);
// deny → 403 application/problem+json { type, title, permission, denials, alternatives }; approval-required → 403 with type .../approval-required
// OpenAPI 3.2 `security`, `securitySchemes` (incl. oauth2MetadataUrl, deviceAuthorization, deprecated) emitted via the framework hook; registered x-oai-* fallbacks (plus x-permdock-oauth2MetadataUrl) for 3.1
// Optional Web Bot Auth verification (RFC 9421) fills `actor` for agent callers| Export | Role |
|---|---|
permdock() | Middleware. Resolves the subject with the subject option (receives the Hono context c), builds the instance through the kernel and sets it as c.set('permdock', instance). It returns MiddlewareHandler<PermDockEnv>, and Hono merges that Variables type into the route's Env, so c.get('permdock') is typed without global module augmentation and without a factory generic. |
protect(permission, loadData?, options?) | Route middleware. The tenant option ((c) => c.req.param('org')) is resolved for each protect, where route params exist. For instance actions loadData(c) runs first; the result is available to the handler as c.get('permdockData') and validated at the boundary if the loader is marked untrusted. Denials short-circuit with a Problem Details response. |
permdockHandler() | Mounts the AuthZEN-shaped decision endpoint (POST /) for permdock/react clients on any sub-app: app.route('/api/permdock', permdockHandler()). |
openapi | Kernel hook contract, used by the two integrations below. |
OpenAPI hooks
With hono-openapi:
import { describeRoute } from "hono-openapi";
app.delete(
"/posts/:id",
describeRoute({
...openapi.security(permissions.post.delete),
description: "Delete a post",
}),
protect(permissions.post.delete, (c) => loadPost(c.req.param("id"))),
handler,
);With @hono/zod-openapi:
const route = createRoute({
method: "delete",
path: "/posts/{id}",
security: openapi.security(permissions.post.delete).security,
responses: { 403: openapi.problemResponse() },
});openapi.securitySchemes() returns the oauth2 scheme with every scope from listPermissions(permissions), plus oauth2MetadataUrl and deviceAuthorization when configured. Emitted documents target OpenAPI 3.2; for a 3.1 document deviceAuthorization and deprecated are written under the registered x-oai-deviceAuthorization / x-oai-deviceAuthorizationUrl / x-oai-deprecated extensions and oauth2MetadataUrl under x-permdock-oauth2MetadataUrl (see OpenAPI registries). permdock openapi in the CLI reads the same hook output to check that every protected route is documented.
Streams and sockets
connection(c, options?) opens a kernel Connection for the request behind c; pass revocations to createPermDock so revoked sessions and membership changes reach it. sse(conn, stream, source, options?) writes an async iterable to a streamSSE stream: items the subscriber cannot read under options.items are dropped (unwrap picks the row inside an envelope, format builds the frame), and when the connection aborts it writes event: permdock with the Problem Details body and resolves. Await it last in the callback, since streamSSE closes the stream when the callback returns. socket(conn, events) wraps upgradeWebSocket events so an abort closes the socket with 1008 and the Problem Details type as reason, and a closed socket closes the connection.
const { protect, connection, sse, socket } = createPermDock(policy, {
subject,
revocations,
});
app.get(
"/projects/:id/events",
protect(permissions.project.read, loadProject),
async (c) => {
const conn = await connection(c, {
permission: permissions.project.read,
data: c.get("permdockData"),
});
return streamSSE(c, async (stream) => {
await sse(conn, stream, projectEvents(c.req.param("id"), conn.signal), {
items: permissions.project.read,
});
});
},
);
app.get(
"/ws",
upgradeWebSocket(async (c) => {
const conn = await connection(c);
return socket(conn, {
onMessage(event, ws) {
const decision = conn.check(
permissions.post.update,
JSON.parse(String(event.data)),
);
if (decision.outcome !== "granted")
ws.send(JSON.stringify({ denied: decision.denials }));
},
});
}),
);A denied message leaves the socket open; only a revocation, an expiry or a re-denied opening permission closes it.
With better-supabase
better-supabase's createHono verifies the session and puts it on c.var.auth; bs.middleware() adds c.var.db. Map the session with subjectFromBetterSupabase and pair both middlewares in one helper used on every route, instead of app.use(bs.middleware()), so a route that skips the helper has no c.var.db and bs.handler answers 500 instead of running unguarded:
import { createHono } from "better-supabase/hono";
import type { Permission } from "permdock";
import { createPermDock } from "permdock/hono";
import { subjectFromBetterSupabase } from "permdock/better-supabase";
const bs = createHono(betterSupabase);
const { protect } = createPermDock(policy, {
subject: (c) =>
subjectFromBetterSupabase(c.get("auth"), { anonymousSignIns: "deny" }),
});
const guarded = (permission: Permission) =>
[bs.middleware(), protect(permission)] as const;
export const app = bs.app().get(
"/customers",
...guarded(permissions.customers.read),
bs.handler((_c, { db }) => db.customers.findMany({ select: ["id"] })),
);protect sets c.get('permdock') itself, so the route needs no permdock() middleware. A support session's token is the user's, with the admin as the actor; it reaches only what a policy delegation names (Supabase).
Request lifecycle
permdock/hono follows the shared adapter contract through the server kernel. What differs on Hono:
subject(c)typically reads a user set by an auth middleware earlier in the chain.protectexposes the loaded row asc.get('permdockData'), so the handler does not run the loader again. A loader that returnsnullorundefinedanswers404.- After
await next(),permdock()andprotect()read the error Hono recorded for the downstream handler (for example fromc.get('permdock').assert(...)) and replace the response with the same Problem Details body. Noapp.onErroris needed, and an app's ownonErrorkeeps handling every other error. protectbuilds the instance itself, so it works with or withoutpermdock()earlier in the chain; there is no ordering to get wrong.- The decision event carries the permission, resource, subject, tenant and outcome, not the HTTP method or route.
context(c),onDeniedandwraptake the Hono context and behave as on the server kernel:context: (c) => ({ region: c.get('geo').region })adds server-derived request values, andonDeniedreplaces the refusal body.
Denials are the kernel's Problem Details responses, or what onDenied returned.
Why
Hono types context variables through the Env generic of each middleware and merges them along the chain, so the adapter only has to say what it sets. permdock() returns MiddlewareHandler<PermDockEnv> and protect(permission, loadData) returns MiddlewareHandler<PermDockEnv<Row>>, where Row is the loader's non-null return type. That works with new Hono(), with an app Env that declares its own variables, and inside createMiddleware<PermDockEnv> for an app middleware that reads c.get('permdock'), with no createFactory generic and no declare module 'hono' augmentation (which would leak one policy's types into every app in a monorepo). All three cases are type-tested.
Example app
apps/examples/hono: src/permissions.ts, src/policy.ts, createPermDock from permdock/hono, protect on PATCH /posts/:id (granted) and POST /posts/:id/publish (denied for member), plus src/serve.ts, which serves app.fetch with @hono/node-server, for pnpm start. tests/integration/src/http/hono.test.ts runs the testHttpAdapter scenarios on @hono/node-server with a global permdock(), a tenant from the :org param and a sub-app. The factory enables otel with a structural logger so decision lines are recorded locally.
Related standards
- Problem Details.
- OpenAPI 3.2.
- Web Bot Auth.
- AuthZEN for the decision endpoint.
Last updated on
Server kernel
permdock/server is the Fetch-first kernel every HTTP and RPC adapter wraps; it resolves the subject from a Request, scopes one PermDock per request, runs protect, emits Problem Details and exposes the OpenAPI hook contract.
Express
permdock/express wraps the Fetch kernel as Express middleware, exposing req.permdock and a protect guard, with Problem Details errors routed through Express error handling.