@api-policy/elysia
v2.0.0
Published
Elysia adapter for API Policy. Auth plugin, permissions, routing.
Downloads
85
Maintainers
Readme
@api-policy/elysia
Elysia adapter for @api-policy/server. Auth middleware, permission enforcement, and route registration for Elysia applications.
npm install @api-policy/elysia @api-policy/server elysia joseUses jose for JWT verification. Install it alongside.
Validation schemas use TypeBox (
@sinclair/typebox), which is bundled with Elysia.
Setup
Two usage patterns: plugin (recommended) or adapter directly.
Plugin (recommended)
import { Elysia } from 'elysia'
import {
policyPlugin,
createElysiaAdapter,
defineRoute,
definePublicRoute,
PERM, BOUNDARY,
} from '@api-policy/elysia'
import { ok } from 'ts-micro-result'
// 1. Mount plugin — injects authEngine, permissionEngine, errorHttpMap into context
const app = new Elysia()
.use(policyPlugin({
auth: {
jwt: { secret: process.env.JWT_SECRET! },
},
}))
// 2. Create adapter from plugin context
const { authEngine, permissionEngine } = app // accessed via context
const adapter = createElysiaAdapter(authEngine, permissionEngine)
// 3. Define routes using @api-policy/server's defineRoute
const listPosts = defineRoute({
method: 'GET',
path: '/posts',
auth: true,
permission: { resource: 'post', action: PERM.READ, boundary: BOUNDARY.GLOBAL },
handler: async ({ user }) => {
const posts = await db.posts.findByAuthor(user.id)
return ok(posts)
},
})
const health = definePublicRoute({
method: 'GET',
path: '/health',
handler: async () => ok({ status: 'ok' }),
})
// 4. Register
adapter.registerAll(app, [listPosts, health])
app.listen(3000)Adapter without plugin
import { Elysia } from 'elysia'
import {
createElysiaAdapter,
createDefaultJwtVerify,
createAuthPolicyEngine,
createPermissionEngine,
defineRoute,
} from '@api-policy/elysia'
import { createJwtAuthMethod } from '@api-policy/server'
import { ok } from 'ts-micro-result'
const jwtConfig = { publicKey: process.env.JWT_PUBLIC_KEY!, algorithm: 'EdDSA' as const }
const jwtMethod = createJwtAuthMethod(jwtConfig, createDefaultJwtVerify(jwtConfig))
const authEngine = createAuthPolicyEngine([jwtMethod])
const permissionEngine = createPermissionEngine()
const adapter = createElysiaAdapter(authEngine, permissionEngine)
const app = new Elysia()
adapter.registerAll(app, [listPosts, createPost])
app.listen(3000)policyPlugin
Creates auth and permission engines and injects them into Elysia's context via decorate and derive.
import { policyPlugin } from '@api-policy/elysia'
import { createGatewayJwtAuthMethod, createDefaultJwtVerify } from '@api-policy/server'
app.use(policyPlugin({
auth: {
// Default methods when route has auth: true
defaultMethods: ['jwt', 'apiKey'],
// JWT — symmetric (HS256/384/512)
jwt: { secret: process.env.JWT_SECRET! },
// JWT — asymmetric (RS256, ES256, EdDSA, etc.)
// jwt: { publicKey: process.env.JWT_PUBLIC_KEY!, algorithm: 'EdDSA' },
// JWT — custom verify function
// jwt: { publicKey: '...', verify: myVerifyFn },
// 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: [
createGatewayJwtAuthMethod({
gateway: {
publicKeyHex: process.env.BFF_PUBLIC_KEY_HEX, // omit for local dev
expectedIssuer: 'bff-worker',
expectedAudience: 'my-backend',
},
accessJwt: {
config: { secret: process.env.JWT_SECRET! },
verifyFn: createDefaultJwtVerify({ secret: process.env.JWT_SECRET! }),
},
}),
],
},
// Custom error → HTTP status codes (merged with defaults)
errorHttpMap: {
PRODUCT_NOT_FOUND: 404,
ORDER_ALREADY_EXISTS: 409,
},
}))
// Injects into context:
// ctx.authEngine → AuthPolicyEngine
// ctx.permissionEngine → PermissionEngine
// ctx.errorHttpMap → Record<string, number>
// ctx.user → UserContext | null (set after auth)
// ctx.authMethod → string | null (set after auth)Access engines inside custom handlers:
import { getAuthEngine, getPermissionEngine } from '@api-policy/elysia'
app.get('/custom', (ctx) => {
const authEngine = getAuthEngine(ctx)
const permissionEngine = getPermissionEngine(ctx)
})Route definition
Use defineRoute and definePublicRoute from @api-policy/server (also re-exported from this package):
import { defineRoute, definePublicRoute, PERM, BOUNDARY } from '@api-policy/elysia'
import { t } from 'elysia' // TypeBox — Elysia's built-in schema
import { ok } from 'ts-micro-result'
const createPost = defineRoute({
method: 'POST',
path: '/posts',
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)
},
input: {
body: t.Object({ title: t.String(), content: t.String() }),
query: t.Object({ draft: t.Optional(t.Boolean()) }),
},
handler: async ({ user, body, query, params }) => {
const post = await db.posts.create({ ...body, authorId: user.id })
return ok(post)
},
})Handler receives PolicyContext:
| Field | Type | Description |
|-------|------|-------------|
| user | UserContext | Authenticated user |
| body | TBody | Parsed + validated request body |
| query | TQuery | Parsed + validated query params |
| params | TParams | Parsed + validated path params |
Permission check flow (in order):
1. Auth → verify credentials (JWT / API Key / Gateway JWT)
2. Permission → roles gate → owner bypass → bitmask → boundary
3. Handler → business logic, returns Result<T>For routes with boundary !== 'global', use loadResource to provide the resource for boundary check:
const updatePost = defineRoute({
method: 'PUT',
path: '/posts/:id',
auth: true,
permission: {
resource: 'post',
action: PERM.WRITE,
boundary: BOUNDARY.OWNER,
allowOwner: true,
},
loadResource: async ({ params }) => {
const post = await db.posts.findById(params.id)
return post ? { ownerId: post.authorId, tenantId: post.tenantId } : undefined
},
handler: async ({ user, body, params }) => {
const post = await db.posts.update(params.id, body)
return ok(post)
},
})If loadResource returns undefined, the request is denied with PermissionDenied.
createElysiaAdapter
Registers RouteSpec routes onto an Elysia app.
import { createElysiaAdapter } from '@api-policy/elysia'
const adapter = createElysiaAdapter(
authEngine,
permissionEngine,
errorHttpMap, // optional, defaults to built-in map
{ requiredTenantType: 'merchant' } // optional adapter options
)
// Register single route
adapter.register(app, myRoute)
adapter.register(app, myRoute, { prefix: '/v1', tags: ['Posts'], summary: 'List posts' })
// Register multiple routes
adapter.registerAll(app, [route1, route2, route3])
adapter.registerAll(app, routes, { prefix: '/v1' })Adapter options:
| Option | Type | Description |
|--------|------|-------------|
| requiredTenantType | string | Reject requests where user.tenantType !== value |
Route registration options (RouteOptions):
| Option | Type | Description |
|--------|------|-------------|
| prefix | string | Additional path prefix |
| tags | string[] | OpenAPI tags |
| summary | string | OpenAPI summary |
| description | string | OpenAPI description |
| deprecated | boolean | Mark route as deprecated in OpenAPI |
Route groups
import { createRouteGroup } from '@api-policy/elysia'
const postGroup = createRouteGroup(app, '/posts', authEngine, permissionEngine)
postGroup
.registerAll([listPosts, getPost, createPost, deletePost])
.mount()
// Routes registered at /posts, /posts/:id, etc.Or with per-route options:
postGroup.register(createPost, { tags: ['Posts'], summary: 'Create a post' })
postGroup.register(deletePost, { tags: ['Posts'], summary: 'Delete a post' })
postGroup.mount()OpenAPI
The adapter automatically injects security schemes and x-policy metadata:
adapter.register(app, updatePost, {
tags: ['Posts'],
summary: 'Update a post',
})Generated OpenAPI:
{
"paths": {
"/posts/{id}": {
"put": {
"tags": ["Posts"],
"summary": "Update a post",
"security": [{ "bearerAuth": [] }],
"x-policy": {
"action": 2,
"resource": "post",
"boundary": "owner"
}
}
}
}
}Security schemes added per auth method: bearerAuth (JWT / Gateway JWT), apiKeyAuth (API Key).
Response mapping
Handlers return Result<T> from ts-micro-result. The adapter maps them to HTTP responses automatically.
import { ok, err } from 'ts-micro-result'
import { NotFound, ValidationError } from '@api-policy/server'
// Success → 200
handler: async () => ok({ id: 1, title: 'Hello' })
// Empty success → 204 No Content
handler: async () => ok()
// Error → mapped status via errorHttpMap
handler: async () => err(NotFound({ message: 'Post not found' }))
// → 404 { ok: false, errors: [{ code: 'NOT_FOUND', message: 'Post not found' }] }Default error → HTTP status map:
| Code | Status |
|------|--------|
| AUTH_NOT_AUTHENTICATED, AUTH_TOKEN_EXPIRED, etc. | 401 |
| PERM_DENIED | 403 |
| VALIDATION_ERROR | 400 |
| NOT_FOUND, RESOURCE_NOT_FOUND | 404 |
| INTERNAL_ERROR | 500 |
Override by passing a custom map to createElysiaAdapter or policyPlugin:
const adapter = createElysiaAdapter(authEngine, permissionEngine, {
...defaultErrorHttpMap,
PRODUCT_NOT_FOUND: 404,
PRODUCT_SLUG_EXISTS: 409,
})Re-exported from @api-policy/server
All commonly used exports are available directly from @api-policy/elysia:
import {
defineRoute, definePublicRoute,
createAuthPolicyEngine, createPermissionEngine,
// types
type RouteSpec, type PolicyContext,
type UserContext, type ResourceContext,
type PermissionSpec, type BoundaryType,
} from '@api-policy/elysia'License
MIT
