@entitle/next
v0.2.0
Published
Next.js HTTP adapter for the Entitle policy engine
Readme
@entitle/next
Next.js App Router adapter for Entitle, a strongly-typed feature entitlements and policy engine for TypeScript.
It builds the route handlers for a catch-all App Router segment.
These routes can change who is entitled to what.
/assign,/revokeand/overridegrant any policy, or any limit, to any subject.authorizeis a required option on every Entitle adapter and there is no way to mount one without it:0.0.1served these routes with no authentication, which was an anonymous privilege-escalation API rather than a missing feature, and0.1.0made the hook required in order to close it.exposedefaults to'read-only', so the four mutating routes are not registered at all until you passexpose: 'all'.Read Security in
@entitle/core's README before you deploy this. It is the same text asnode_modules/@entitle/core/README.md, which this package installs alongside itself.
Install
pnpm add @entitle/next @entitle/coreUsage
Create a catch-all route at app/api/entitle/[...path]/route.ts:
import { createNextHandlers } from '@entitle/next'
const handlers = createNextHandlers({
engine,
// Omit `expose` for a read-only mount: check / explain / resolve only.
// `'all'` additionally serves assign / revoke / override / override/remove.
expose: 'all',
authorize: async ({ access, request }) => {
const session = await getSession(request.headers.get('cookie') ?? undefined)
if (!session) return { ok: false, code: 'UNAUTHENTICATED' }
if (access === 'write' && !session.user.isAdmin) {
return { ok: false, code: 'FORBIDDEN' }
}
return { ok: true, actorId: session.user.id }
},
})
export const POST = handlers.POSTRequests to /api/entitle/check, /api/entitle/explain and the rest are routed
to the corresponding handler. A path that is not a mounted operation is a 404.
Options
| Option | Required | Default | Meaning |
| ----------- | -------- | ------------- | ----------------------------------------------------- |
| engine | yes | -- | A configured PolicyEngine |
| authorize | yes | -- | Decides whether one request may perform one operation |
| expose | no | 'read-only' | Which routes exist: the three reads, or all seven |
authorize
Called once per request, before the operation runs, with:
| Field | Type | Meaning |
| ----------- | -------------------- | ------------------------------------------------ |
| operation | AdminOperationName | Canonical operation name, e.g. 'assign_policy' |
| access | 'read' \| 'write' | Whether the operation observes or changes state |
| input | unknown | The parsed request body |
| request | Web Request | For request.headers and cookies |
Return { ok: true, actorId } to allow -- actorId is what the write is
attributed to. Return { ok: false, code: 'UNAUTHENTICATED' } for a 401 or
{ ok: false, code: 'FORBIDDEN' } for a 403; a bare { ok: false } is a 403. A
hook that throws or rejects denies the request: it fails closed, and the
handler is never reached.
A body that is not valid JSON is reported before the hook runs, which matches how Express and Fastify behave -- both parse upstream of any route handler.
expose
Defaults to 'read-only', which serves check, explain and resolve. The
four mutating paths are not in the route table at all until expose: 'all', so
until then they 404 rather than 403 -- they genuinely do not exist on that mount.
This is the option most likely to surprise you on an upgrade, because a missing
authorize is a compile error while a missing expose is a silent 404.
Routes
All four Entitle adapters serve the same POST routes at the same paths:
| Route | Access | Description |
| ----------------------- | ------- | ------------------------------------ |
| POST /check | read | Check a single feature for a subject |
| POST /explain | read | Full evaluation trace for a feature |
| POST /resolve | read | Resolve all features for a subject |
| POST /assign | write | Assign a policy to a subject |
| POST /revoke | write | Revoke a policy from a subject |
| POST /override | write | Set a per-subject feature override |
| POST /override/remove | write | Remove a per-subject override |
Every body names the subject by subjectId and subjectKind, removals
included.
Responses
Every success is the same body on all four adapters, with data always
present and null for the four operations that return nothing:
{
"ok": true,
"data": null
}All four adapters agree on this. Before 0.1.1 @entitle/express omitted the
data key and @entitle/fastify sent data: undefined, which its serializer
drops, so a client written against this adapter broke on a data === null check
when moved to either of those. The contract, the idempotent removals, and the three failures the envelope
does not cover are What a success response contains in
@entitle/core's README.
Errors
Every failure is the same body on all four adapters:
{
"ok": false,
"error": {
"code": "STORE_ERROR",
"message": "Failed to assign policy",
"correlationId": "0b9c1f8e-6f2a-4b51-9a2e-2d3f8f0f7c11"
}
}code decides the status; message is a constant chosen by the code path;
correlationId is present exactly when something was withheld, and matches the
logger.error line that carries the cause in full. A driver's error fields, a
store's SQL or file path, a feature's schema and an internal exception never
reach a response. The full contract is What an error response may contain
in @entitle/core's README.
Serving your own users their own grants
In a Next.js app you usually do not want this adapter at all for the read path: you already have an authenticated route, so serve the bundle from there and take the subject from the session rather than from the request body.
// app/api/entitlements/route.ts
export async function GET(request: Request): Promise<Response> {
const session = await getSession(request.headers.get('cookie') ?? undefined)
if (!session) return Response.json({ error: 'unauthenticated' }, { status: 401 })
const bundle = await engine.resolveAll(session.user.id, 'user')
return Response.json(bundle)
}That removes the body-supplied subjectId entirely, and with it the enumeration
hole that comes from trusting one. See Serving a subject its own grants in
@entitle/core's README.
Serving the API open, on purpose
For a sidecar bound to loopback, a mesh that authorizes at the edge, or a test
harness, pass UNSAFE_allowUnauthenticated as authorize rather than writing
your own always-allow hook. It warns once per process, and the name is
deliberately greppable. The deployment
assumptions that have to hold first are under UNSAFE_allowUnauthenticated
in @entitle/core's README.
Testing
@entitle/testing exports createAuthorizeStub, which is the hook plus a record
of what the adapter asked it -- so a test can assert that a refused request never
reached the operation.
Links
@entitle/core-- the engine, and the guidance the sections above point at: what the routes are and which of them write,expose, serving a subject its own grants,explainas information disclosure, actor attribution,UNSAFE_allowUnauthenticatedand the preconditions it needs, and what an error response may contain. Also atnode_modules/@entitle/core/README.md.- Reporting a vulnerability -- privately, to https://github.com/fponticelli,
and not in a public issue.
0.0.1is deprecated and unsupported: it served these routes with no authentication.
License
MIT
