npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@api-policy/server

v2.0.0

Published

Complete API authorization framework. JWT, API Key, Gateway JWT, permissions, routing.

Downloads

174

Readme

@api-policy/server

API authorization framework for Node.js. JWT, API Key, and Gateway JWT authentication with a bitmask permission engine.

Built on @api-policy/core. All core builders (role, perm, owner, and, or, not, evaluate, explain) are re-exported from this package — no need to install core separately.

npm install @api-policy/server jose

Permission Model

Users carry per-resource bitmasks. Each resource is independent — product permissions never affect order.

import { PERM, PERM_ALL } from '@api-policy/server'

// Regular user
user.perms = {
  product: PERM.READ | PERM.WRITE,  // = 3
  order: PERM.READ,                  // = 1
}

// Admin: wildcard grants access to all resources
user.perms = { '*': PERM_ALL }  // = 31

Permission bits:

| Constant | Value | Bit | |----------|-------|-----| | PERM.READ | 1 | 1 << 0 | | PERM.WRITE | 2 | 1 << 1 | | PERM.DELETE | 4 | 1 << 2 | | PERM.APPROVE | 8 | 1 << 3 | | PERM.EXECUTE | 16 | 1 << 4 | | PERM_ALL | 31 | all bits |

Resolution order: resource-specific → wildcard '*' → 0 (deny)

If a user has both product: 1 and '*': 31, the specific mask (1) wins for product.


UserContext

Shape of the authenticated user passed through your system:

type UserContext = {
  id: string
  perms?: Record<string, number>   // per-resource bitmasks
  roles?: string[]                 // optional role array
  tenantId?: string
  tenantType?: string
}

JWT payload expected by the default JWT auth method:

{
  "sub": "user-123",
  "perms": { "product": 7, "order": 1 },
  "roles": ["editor"],
  "tid": "tenant-abc"
}

Authentication

JWT

import {
  createAuthPolicyEngine,
  createJwtAuthMethod,
  createDefaultJwtVerify,
} from '@api-policy/server'

// Symmetric (HS256)
const jwtMethod = createJwtAuthMethod(
  { secret: process.env.JWT_SECRET! },
  createDefaultJwtVerify({ secret: process.env.JWT_SECRET! })
)

// Asymmetric (RS256, ES256, EdDSA, etc.)
const jwtMethod = createJwtAuthMethod(
  { publicKey: process.env.JWT_PUBLIC_KEY!, algorithm: 'EdDSA' },
  createDefaultJwtVerify({ publicKey: process.env.JWT_PUBLIC_KEY!, algorithm: 'EdDSA' })
)

// Custom verify function (e.g. JWKS endpoint)
const jwtMethod = createJwtAuthMethod(
  { issuer: 'https://auth.example.com' },
  async (token, config) => {
    // your custom verification logic
    // return ok(payload) or err({ code: 'TOKEN_EXPIRED', message: '...' })
  }
)

Reads from Authorization: Bearer <token> header.

API Key

import { createApiKeyAuthMethod } from '@api-policy/server'

const apiKeyMethod = createApiKeyAuthMethod({
  lookup: async (key) => {
    const record = await db.apiKeys.findOne({ key })
    if (!record) return null
    return {
      ownerId: record.userId,
      perms: { '*': PERM_ALL },
      tenantId: record.tenantId,
    }
  },
})

Reads from X-API-Key header.

Gateway JWT

For BFF → backend flows. Verifies two JWTs per request: a short-lived Gateway JWT that identifies the trusted BFF, and the user's Access JWT forwarded from the client.

Client → BFF (verify access JWT, sign Gateway JWT) → Backend (verify both)
import { createGatewayJwtAuthMethod, createDefaultJwtVerify } from '@api-policy/server'

const gatewayMethod = createGatewayJwtAuthMethod({
  gateway: {
    // Ed25519 public key of the BFF (64 hex chars = 32 bytes)
    // Omit for local dev — skips gateway verification entirely
    publicKeyHex: process.env.BFF_PUBLIC_KEY_HEX,
    expectedIssuer: 'bff-worker',
    expectedAudience: 'my-backend',
    // header: 'x-gateway-auth',  // default
    // clockSkewSeconds: 5,       // default — keep ≤10s for NTP-synced envs
    // validateJti: async (jti) => cache.setNX(jti, 1),  // optional replay protection
  },
  accessJwt: {
    config: { secret: process.env.JWT_SECRET! },
    verifyFn: createDefaultJwtVerify({ secret: process.env.JWT_SECRET! }),
  },
})

Headers expected:

| Header | Value | |--------|-------| | x-gateway-auth | Bearer <gateway_jwt> — identifies the BFF | | Authorization | Bearer <access_jwt> — user's token (forwarded as-is) |

Gateway JWT payload (identity only, no user context):

{
  "iss": "bff-worker",
  "aud": "my-backend",
  "sub": "worker-id-1",
  "exp": "<now + 30s>",
  "jti": "<uuid>"
}

User context is extracted from the access JWT payload (sub, perms, tid, roles) — never from headers.

Local dev mode — omit publicKeyHex to skip gateway verification:

createGatewayJwtAuthMethod({
  gateway: {
    // no publicKeyHex → x-gateway-auth header not required
    expectedIssuer: 'bff-worker',
    expectedAudience: 'my-backend',
  },
  accessJwt: { config: { secret: 'dev-secret' }, verifyFn: createDefaultJwtVerify(...) },
})

AuthPolicyEngine

Combines multiple auth methods. Tries each in order until one succeeds.

import { createAuthPolicyEngine, AUTH_METHOD } from '@api-policy/server'

const engine = createAuthPolicyEngine(
  [jwtMethod, apiKeyMethod, gatewayMethod],
  { defaultMethods: [AUTH_METHOD.JWT, AUTH_METHOD.API_KEY] }
)

const result = await engine.resolve('jwt', ctx)

if (result.ok) {
  const { user, method } = result.data
} else {
  // result.errors[0] is a typed error (NotAuthenticated, InvalidCredentials, etc.)
}

AuthRequirement can be:

  • true — try default methods
  • 'jwt' | 'apiKey' | 'gatewayJwt' — specific method
  • ['jwt', 'apiKey'] — try list in order

PermissionEngine

Standalone permission checker. Used by adapters internally, or call directly in handlers.

import {
  createPermissionEngine,
  PERM,
  BOUNDARY,
} from '@api-policy/server'

const engine = createPermissionEngine()

// check() — full flow: roles → allowOwner → bitmask → boundary
const result = engine.check(
  user,
  {
    resource: 'product',
    action: PERM.WRITE,
    boundary: BOUNDARY.TENANT,
  },
  { tenantId: 'tenant-abc', ownerId: 'user-123' }
)

if (!result.ok) {
  throw result.error  // PermissionDenied or BoundaryViolation
}

// can() — simple bitmask check, no boundary
if (engine.can(user, 'product', PERM.WRITE)) {
  // user has WRITE on product
}

// compile() — pre-bind spec for repeated checks
const canDelete = engine.compile({
  resource: 'product',
  action: PERM.DELETE,
  boundary: BOUNDARY.TENANT,
})

const allowed = canDelete(user, resource)

Permission check flow

1. Roles gate       — deny if user does not have required role(s)
2. Owner bypass     — allow immediately if resource.ownerId === user.id (when allowOwner: true)
3. Capability check — deny if (user.perms[resource] & action) !== action
4. Boundary check   — deny if tenant/owner constraint not satisfied

Boundaries

| Value | Check | |-------|-------| | BOUNDARY.GLOBAL | No boundary check | | BOUNDARY.TENANT | user.tenantId === resource.tenantId | | BOUNDARY.OWNER | user.id === resource.ownerId | | BOUNDARY.SELF | Same as OWNER (alias) |

Roles gate

// OR logic (default): user has ANY of the listed roles
{ roles: ['admin', 'moderator'], resource: 'post', action: PERM.DELETE, boundary: BOUNDARY.GLOBAL }

// AND logic: user must have ALL listed roles
{ roles: ['admin', 'superuser'], requireAllRoles: true, ... }

Roles are checked first. If the user fails the role check, allowOwner does not save it.

allowOwner

engine.check(user, {
  resource: 'post',
  action: PERM.WRITE,
  boundary: BOUNDARY.GLOBAL,
  allowOwner: true,
}, { ownerId: post.authorId })

The resource owner bypasses bitmask and boundary checks entirely. Non-owners go through the normal flow.

Do not use allowOwner when owners should have restricted actions (e.g. cannot approve their own request).


Route Definition

import { defineRoute, definePublicRoute, PERM, BOUNDARY } from '@api-policy/server'

const getPost = defineRoute({
  method: 'GET',
  path: '/posts/:id',
  auth: true,
  permission: {
    resource: 'post',
    action: PERM.READ,
    boundary: BOUNDARY.TENANT,
  },
  handler: async (ctx) => {
    const post = await db.posts.findById(ctx.params.id)
    return ok(post)
  },
})

const createPost = defineRoute({
  method: 'POST',
  path: '/posts',
  auth: 'jwt',
  permission: {
    resource: 'post',
    action: PERM.WRITE,
    boundary: BOUNDARY.GLOBAL,
    roles: ['editor', 'admin'],
  },
  input: { body: PostCreateSchema },
  handler: async (ctx) => {
    const post = await db.posts.create(ctx.body)
    return ok(post)
  },
})

const publicHealth = definePublicRoute({
  method: 'GET',
  path: '/health',
  handler: async () => ok({ status: 'ok' }),
})

Route config options:

| Field | Type | Description | |-------|------|-------------| | auth | boolean \| AuthMethodName \| AuthMethodName[] | Auth requirement | | permission | PermissionSpec | Permission check | | loadResource | (ctx) => Promise<ResourceContext> | Load resource for boundary check | | input | { body?, query?, params? } | Validation schemas (Zod/TypeBox) | | successStatus | number | Override response status (default: POST=201, others=200) |


Admin pattern

No special bypass flag. Admin is a user with full permission on the wildcard resource:

// Assign in your auth method / lookup function
user.perms = { '*': PERM_ALL }

// Or specific resources with full access
user.perms = {
  '*': PERM_ALL,
  'audit-log': PERM.READ,  // even admins can't write audit logs
}

The engine resolves: perms['order'] ?? perms['*'] ?? 0. Resource-specific mask always wins.


Type-safe resources

Pass your resource union as a generic to catch typos at compile time:

type Resource = 'product' | 'order' | 'post'

const user: UserContext<Resource> = {
  id: 'user-123',
  perms: {
    product: PERM.READ | PERM.WRITE,
    order: PERM.READ,
    // post: ...  ← TS error if you typo 'psot'
  },
}

const spec: PermissionSpec<Resource> = {
  resource: 'product',  // TS enforces valid resource names
  action: PERM.WRITE,
  boundary: BOUNDARY.GLOBAL,
}

Using core builders in handlers

All @api-policy/core exports are available from @api-policy/server. Use toSubject() to bridge UserContext to the core engine's subject shape:

import { evaluate, or, role, owner, toSubject } from '@api-policy/server'

const allowed = evaluate(
  or(role('admin'), owner('authorId')),
  {
    subject: toSubject(ctx.user, 'post'),  // picks user.perms['post']
    resource: post,
  }
)

Errors

All errors are typed and carry a code field:

import {
  NotAuthenticated,
  InvalidCredentials,
  TokenExpired,
  TokenMalformed,
  PermissionDenied,
  BoundaryViolation,
  Forbidden,
  NotFound,
  Conflict,
  RateLimitExceeded,
  InternalError,
  getHttpStatus,
} from '@api-policy/server'

// Each error factory accepts an optional message:
throw PermissionDenied({ message: 'Insufficient role' })

// Map to HTTP status:
getHttpStatus(error)  // → 403

Build-time tools

Route linter

Catch misconfigured routes at build time:

import { lintRoutes, formatLintResult } from '@api-policy/server'

const result = lintRoutes(routes)
if (!result.valid) {
  console.error(formatLintResult(result))
  process.exit(1)
}

Permission registry

Validate that all perms references in your routes match your declared permissions:

import { createPermissionRegistry, validatePermissions } from '@api-policy/server'

const registry = createPermissionRegistry({
  product: { READ: true, WRITE: true, DELETE: true },
  order: { READ: true },
})

const result = validatePermissions(routes, registry)

License

MIT