PermDock
Adapters

Elysia

permdock/elysia derives a request-scoped PermDock into Elysia's context and provides protect as a typed beforeHandle hook that keeps validated route types.

Purpose

Elysia is Fetch-native and Bun-first, so the server kernel runs unchanged. permdock/elysia is a plugin that uses derive to attach permdock to the context and a protect hook for beforeHandle. The adapter is typed against Elysia's inferred route context so params and body validated with TypeBox or Zod keep their types inside loadData (permix's Elysia guard was typed against the raw Context, breaking with validated routes).

API

import { Elysia } from "elysia";
import { createPermDock } from "permdock/elysia";
import { policy } from "./policy";
import { permissions } from "./permissions";

export const { permdock, protect } = createPermDock(policy, {
  subject: ({ store }) => store.user ?? null,
});

const app = new Elysia()
  .use(permdock()) // ctx.permdock is the request-scoped PermDock
  .delete(
    "/posts/:id",
    async ({ permdockData: post, set }) => {
      await deletePost(post);
      set.status = 204;
    },
    {
      beforeHandle: protect(permissions.post.delete, ({ params }) =>
        loadPost(params.id),
      ),
      detail: openapi.security(permissions.post.delete), // @elysiajs/openapi
    },
  );
ExportRole
permdock()Plugin. derive calls subject(ctx) once per request and adds permdock to the context, typed with the policy's roles, plans and permissions in every later handler. Uses as: 'global' scoping so routes registered after .use see it. Each createPermDock result gets its own plugin seed, so two factories in one app are not deduplicated into one, while .use(permdock()) in a child app and its parent is.
protect(permission, loadData?, options?)beforeHandle hook. The tenant option ((ctx) => ctx.params?.org) is resolved for each protect. Loads data (ctx.permdockData), validates untrusted input, decides. On denial it returns a Response with Problem Details, which Elysia sends without running the handler.
permdockHandler()Plugin mounting the AuthZEN-shaped decision endpoint under a prefix.
openapiKernel hook contract; openapi.security(permission) returns the detail fragment for @elysiajs/openapi and openapi.securitySchemes() the document-level components.

Guarding a group

Elysia's guard and group apply hooks to many routes at once. protect composes with them for collection actions, and instance actions still take a per-route loader:

app.group("/admin", (admin) =>
  admin
    .guard({ beforeHandle: protect(permissions.admin.access) })
    .get("/audit", listAudit)
    .delete("/users/:id", removeUser, {
      beforeHandle: protect(permissions.user.delete, ({ params }) =>
        loadUser(params.id),
      ),
    }),
);

Because hooks run in registration order, the group-level protect decides first and the route-level one only runs for callers that already hold admin.access.

WebSockets

connection(ws, options?) opens a kernel Connection for an Elysia .ws socket (typed ElysiaSocket), memoised per socket, and closes the socket with 1008 and the Problem Details type when the connection aborts. Pass revocations to createPermDock.

app.ws("/ws", {
  async open(ws) {
    await connection(ws, { permission: permissions.project.read });
  },
  async message(ws, message) {
    const conn = await connection(ws);
    const decision = conn.check(permissions.post.update, message);
    if (decision.outcome !== "granted") ws.send({ denied: decision.denials });
  },
  async close(ws) {
    (await connection(ws)).close();
  },
});

Request lifecycle

permdock/elysia follows the shared adapter contract through the server kernel. What differs on Elysia:

  • The plugin uses derive, not resolve, so the subject is resolved once per request before route validation; a subject that depends on a header reads it in the subject resolver.
  • protect runs in beforeHandle after Elysia's own schema validation and puts the loaded, validated row on permdockData, so the handler does not run the loader again.
  • The plugin's global onError maps PermDock errors thrown by permdock.assert(...) to Problem Details.
  • protect without permdock() fails at startup with a message naming both.
  • The decision event carries the permission, resource, subject, tenant and outcome, not the HTTP method or route; a sink that needs them reads its own request context.

The peer range is elysia >=1. Denials are the kernel's Problem Details responses.

Example app

apps/examples/elysia: Bun runtime, posts API with the plugin and protect, @elysiajs/openapi emitting securitySchemes, the decision endpoint, and bun test cases using app.handle(new Request(...)) asserting exact denial bodies.

The example serves app.fetch with @hono/node-server under Node. tests/integration/src/http/elysia.test.ts runs the testHttpAdapter scenarios through app.handle, including a prefixed sub-app with its own .use(permdock()). Return new Response(null, { status: 204 }) for an empty response: set.status = 204 with an undefined return makes Elysia build an invalid 204 with a body.

Last updated on

On this page