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

@entitle/core

v0.2.0

Published

Core types, engine, and policy evaluation for the Entitle library

Downloads

421

Readme

@entitle/core

Strongly-typed feature entitlements, access control and policy evaluation for TypeScript. This package is the engine: the feature registry, the policy factory, the evaluator, the explain tree, the merge strategies, and the HTTP handlers that the four framework adapters serve.

Read Security before you deploy the HTTP adapters. It is in this README, rather than only in the repository, because 0.0.1 mounted the administrative routes with no authentication and its documented example was the vulnerable configuration. 0.1.0 fixed that breakingly -- authorize is a required option on every adapter and expose defaults to 'read-only' -- and this document is the public record of both.

Install

pnpm add @entitle/core zod

zod is a peer dependency, not a dependency -- see One copy of zod.

Quick start

import { z } from 'zod'
import { createPolicyEngine, createPolicyFactory, featureRegistry } from '@entitle/core'
import { createMemoryStore } from '@entitle/store-memory'

// Every field carries a `.default()`, so a policy written before a feature grew
// a field still parses after it does.
const features = featureRegistry()
  .add('seats', z.object({ max: z.number().default(0) }))
  .build()

const { definePolicy } = createPolicyFactory(features, [] as const)

const policies = {
  free: definePolicy({ id: 'free', name: 'Free' }).grant('seats', { max: 5 }).build(),
  pro: definePolicy({ id: 'pro', name: 'Pro' }).grant('seats', { max: 50 }).build(),
}

const store = createMemoryStore()
store.seed([{ subjectId: 'user-1', subjectKind: 'user', policyId: 'pro' }])

const engine = await createPolicyEngine({
  features,
  policies,
  providers: [] as const,
  store,
  // Where a subject's chain comes from: your users, teams and orgs.
  resolver: async (subjectId, kind) => [{ id: subjectId, kind }],
})

const decision = await engine.check('user-1', 'user', 'seats')
if (decision.granted) {
  console.log(decision.limits.max) // 50
} else {
  console.log(decision.reason.kind) // 'no_policy' | 'condition_failed' | ...
}

A Decision is a discriminated union, so limits is unreachable until you have established that the decision granted. engine.explain(...) returns the full trace behind one of them, and engine.resolveAll(...) answers for every feature at once.

What is documented where

This README carries installation, a working example, the dependency contract, everything you need to deploy the HTTP adapters safely, and the staleness contract the engine cache is governed by. The repository's own README is longer -- every merge strategy, the store contract, the event stream, the subject hierarchy -- and it is not published, because the repository is private.

Two references do ship inside this tarball:

  • skills/entitle/SKILL.md -- at node_modules/@entitle/core/skills/entitle/SKILL.md, a condensed reference written for a coding agent and readable by a person. npx entitle setup copies it into .claude/skills/entitle/SKILL.md.
  • the types. Feature names, limit shapes, policy ids and decisions are all inferred, so an editor answers most API questions without a document.

One copy of zod

zod is declared as a peer dependency and nothing else. You write your feature schemas with your copy of zod and this engine parses values against them, so the two halves have to be the same copy: zod's own instanceof and internal symbol checks fail between two installations, and the failure surfaces as validation errors that make no sense rather than as a resolution error.

It was previously declared as both a dependency and a peer dependency, which defeats the peer declaration -- a consumer outside the dependency range got a second, nested zod under @entitle/core and exactly that class of bug.

  • npm 7+ and pnpm 8+ install a missing peer for you, so an upgrade needs nothing.
  • Yarn and --legacy-peer-deps do not. Add zod to your own dependencies; you already import it to declare a feature, so it belongs there.
  • The range is ^3.23.0. zod 4 is not supported: the type that requires every feature field to be self-filling inspects a zod 3 internal that zod 4 removed, and under zod 4 every .add() call becomes an inscrutable type error. Support for it will be an explicit range change, not an accident of >=.

Security

The engine answers what may this subject do. The HTTP adapters expose that answer and the ability to change it: /assign and /override can hand any policy, or any limit, to any subject. The library cannot know your authentication system, so it does not guess -- every adapter requires an authorize hook, and there is no way to mount one without deciding who may call it.

That requirement is a compile error, not a runtime warning. If you are upgrading from 0.0.1, where the hook did not exist, your mount will not build until you add one -- which is the intended outcome. Read this section before you satisfy the compiler.

What the routes are, and which of them write

All four adapters serve the same POST routes, generated from the same maps in @entitle/core, so the table below is the whole surface on every framework. access is the value your hook receives as ctx.access.

| Route | Operation | Access | Handler | Description | | ----------------------- | ----------------- | ------- | ---------------- | ------------------------------------ | | POST /check | check | read | check | Check a single feature for a subject | | POST /explain | explain | read | explain | Full evaluation trace for a feature | | POST /resolve | resolve_all | read | resolveAll | Resolve all features for a subject | | POST /assign | assign_policy | write | assignPolicy | Assign a policy to a subject | | POST /revoke | revoke_policy | write | revokePolicy | Revoke a policy from a subject | | POST /override | set_override | write | setOverride | Set a per-subject feature override | | POST /override/remove | remove_override | write | removeOverride | Remove a per-subject override |

This table is not a fourth hand-maintained copy of that mapping: it is checked against the exported OPERATION_PATH, OPERATION_ACCESS and OPERATION_HANDLER maps by a test, so a route that is added, renamed or reclassified in code and not here fails the build.

read and write describe the store, not the sensitivity of the response. All three reads disclose entitlement data about whichever subject the request body names, and two of them disclose a great deal -- see explain is information disclosure and Serving a subject its own grants below. A hook that allows every read because reads are harmless is the most likely mistake to make from here.

There is no /dispatch route. engine.createHandlers().dispatch() remains available to trusted server code, which is the only place a single entry point that can perform any operation belongs.

dispatch answers every message with a HandlerResult. An op this engine does not perform is a VALIDATION_ERROR naming the ones it does, and so is an operation with no body object, or a message that is not an object at all -- the shapes a queue consumer sees and a TypeScript signature does not. It previously returned undefined for an unrecognised op, which three adapters turned into a 500 and one into an empty 200.

expose defaults to 'read-only'

expose decides which of those seven routes exist at all, and it defaults to 'read-only': check, explain and resolve, and nothing else. The four mutating routes are not registered until you pass expose: 'all', so until then they return 404 rather than 403 -- they genuinely do not exist on that mount.

This is the thing most likely to surprise you on an upgrade. A missing authorize is a compile error and you cannot miss it; a missing expose is a silent 404 on /assign from a deployment that used to work. That is deliberate: most hosts mount an adapter to serve resolve to their own frontend and do assignment from trusted server code, and the narrower default is the right one for them. If you do want the mutating routes over HTTP, ask for them, and the choice is then legible in your own source rather than implied by the absence of an option.

Serving a subject its own grants

The most common real deployment is my frontend asks for the current user's own grants. The obvious implementation reads subjectId from the request body, which is an enumeration hole: any authenticated user can ask for anyone's bundle. Close it in the hook by refusing to answer for a subject other than the caller.

import { createHonoApp } from '@entitle/hono'

// Read-only: `expose` is omitted, so the mutating routes are not mounted.
const entitle = createHonoApp(engine, {
  authorize: async ({ operation, input, request }) => {
    const session = await getSession(request.req.header('cookie'))
    if (!session) return { ok: false, code: 'UNAUTHENTICATED' }

    // Every read names its subject in the body. Answer only for the caller.
    const { subjectId, subjectKind } = input as { subjectId?: string; subjectKind?: string }
    if (subjectKind !== 'user' || subjectId !== session.user.id) {
      return { ok: false, code: 'FORBIDDEN' }
    }

    // `explain` returns resolved attribute values; admins only.
    if (operation === 'explain' && !session.user.isAdmin) {
      return { ok: false, code: 'FORBIDDEN' }
    }

    return { ok: true, actorId: session.user.id }
  },
})

Better still, do not expose resolve over the adapter at all. If the only thing your frontend needs is the current user's bundle, serve it from a route your application already owns and already authenticates, and take the subject from the session instead of from the body:

import { Hono } from 'hono'

const app = new Hono()

// No adapter, no `authorize` hook, and no body-supplied subject to validate.
app.get('/api/entitlements', async (c) => {
  const session = await getSession(c.req.header('cookie'))
  if (!session) return c.json({ error: 'unauthenticated' }, 401)

  const bundle = await engine.resolveAll(session.user.id, 'user')
  return c.json(bundle)
})

That removes the class of bug rather than guarding against it: there is no subjectId in the request to get wrong. Mount an adapter when you want the administrative surface, or a service-to-service API, over HTTP.

explain is information disclosure

explain returns the full evaluation trace: every policy considered, every condition and whether it passed, every merge -- and the resolved attribute values those conditions read. If a condition reads billing.plan or usage.tokensUsed, the response contains that subject's billing plan and usage counters, because that is what makes the trace explainable. tree.chain adds the subject's team and org ids.

Treat POST /explain as an admin or support endpoint. It is the right tool for "why is this customer denied?" and the wrong thing to expose to the customer. If you need it in front of end users, serve it from your own route and pass the tree through redactAttributeValues (exported from @entitle/core), which is the same function the engine applies to the explain.completed event.

The audit sink is a separate boundary, and it is closed. The explain.completed event carries the tree with attributesUsed emptied and attributesRedacted: true, unconditionally. What remains on the event is attributeKeys: which inputs each decision depended on, without their values.

Writes are attributed to the authenticated actor, never to the body

Every mutating operation carries exactly one actor field, and the adapter fills it in from the actorId your hook returned. Whatever the body said is dropped:

| Operation | Actor field | Reaches | | ----------------- | ----------- | ----------------------- | | assign_policy | grantedBy | the store and the event | | set_override | grantedBy | the store and the event | | revoke_policy | revokedBy | the event stream only | | remove_override | removedBy | the event stream only |

A client cannot attribute its own writes, so the audit trail records who your authentication system says was calling rather than who the caller claimed to be -- which is the only version of an audit trail worth keeping. A body that carries one of those fields is overwritten silently rather than rejected: a client innocently echoing back a row it read earlier should not get a 400, and the value never reaches the store either way.

A body cannot reach the field through __proto__ either. That key arrives from JSON.parse as an ordinary own key, so the rebuild that drops the client's actor used to assign it -- which runs the Object.prototype setter and replaces the rebuilt body's prototype, leaving every field the client sent readable off that prototype, actor field included. It is now copied as data.

Return an actorId wherever you have an identity. { ok: true } with none leaves the change unattributed rather than misattributed -- the field is absent, not the client's value. If your deployment must never persist an unattributed privilege change, create the engine with requireGrantedBy: true and an assign or override that names no author is refused with VALIDATION_ERROR. It is off by default, because the programmatic API is legitimately called by trusted code that has no HTTP actor to name.

Every request body is validated against its own schema

Each of the seven operations has one Zod schema, and every one of them is .strict(). A property the operation does not declare is a 400 VALIDATION_ERROR naming that property, not a field silently dropped:

{
  "ok": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Invalid body for 'check': traceId.",
    "details": {
      "kind": "invalid_fields",
      "fields": [{ "path": "traceId", "code": "unrecognized_keys" }]
    }
  }
}

This is the same rule an undeclared limits key already got, and having two rules for one class of mistake is how a field goes missing for a whole release: expiresAt was absent from the override body while the evaluator honoured it and all three stores round-tripped it, so every override minted over HTTP was permanent -- and a caller who sent the field got a 200 and no hint that it had gone nowhere.

A request with no body at all -- an Express app that forgot express.json(), a text/plain POST -- is a 400 naming the missing fields, before any field is read. It used to be a 500, which tells a client to retry something that cannot work and tells the one person who could fix it nothing.

The schemas decide the key set and the JSON kind of each value, and nothing else. Every value-level rule keeps the validator that already had a better message for it: policyId answers POLICY_UNKNOWN, feature answers FEATURE_UNKNOWN, limits is judged by the feature's own schema, and expiresAt by the same parser that refuses a past instant.

features[] on resolve is capped, de-duplicated, and checked

POST /resolve takes an optional features array naming a subset to evaluate. Three things happen to it:

| Rule | Refusal | | ------------------------------------------ | --------------------------------------------------- | | at most 100 entries, as it arrived | VALIDATION_ERROR, quoting both counts and the cap | | duplicates collapsed | none -- a repeated name is answered once | | every entry a registered feature | FEATURE_UNKNOWN, naming every unregistered entry |

The cap is not a cost control, and the number is not tuned. A bundle's store I/O is independent of how many features it names -- one resolver call and one batched read per row kind, whatever the list says -- and a subset does not shrink the response either, because grants carries an entry for every registered feature and the ones outside the subset get a not_evaluated decision. What the cap is for is input hygiene: omitting features already resolves everything, so the field exists to name fewer features than the registry holds, and a body naming a hundred is asking for a whole catalogue the long way round. Read the bound from MAX_RESOLVE_FEATURES rather than copying it.

An unregistered name is a refusal rather than a feature_unknown grant inside an ok: true bundle. That is a deliberate change: a typo'd feature name reported as a per-feature denial reads like an entitlement decision and is not one, and any key a caller invented came back reflected in a map served to browsers.

subjectKind is bounded, and can be an allowlist

A subject kind names one of a fixed set of subject types. It is required and non-empty on every operation, and at most 64 characters (MAX_SUBJECT_KIND_LENGTH) -- unbounded, it is a 4 KB string handed to your HierarchyResolver and written into a key column in both SQL stores. The refusal reports the length and does not quote the value back: echoing a 4 KB string into an error body, and from there into a log aggregator, is the defect answering itself.

For the tighter check, tell the engine which kinds it resolves:

import { createPolicyEngine } from '@entitle/core'

const engine = await createPolicyEngine({
  features,
  policies,
  providers,
  store,
  resolver: async (subjectId, kind) => [{ id: subjectId, kind }],
  subjectKinds: ['user', 'team', 'org'],
})

Any other kind is then refused with a VALIDATION_ERROR that names the ones it would have taken. subjectKinds is the runtime half of TSubjectKind: the type parameter is a compile-time claim, and a POST /check carrying {"subjectKind":"__proto__"} was never checked against it, because a HierarchyResolver is a function and a function does not enumerate its own domain.

It is opt-in rather than the default because the set is genuinely yours to state -- a deployment whose kinds come from its own directory has no list to give -- and because turning an accepted body into a 400 is a decision a deployment makes rather than one a release makes for it. An empty array is refused at startup: it would refuse every request while the programmatic API kept working, which is an HTTP surface that is off and says so nowhere.

A feature name is matched on the registry's own keys

A feature on a request body is looked up with Object.hasOwn, not with in or a plain bracket read. A plain object answers for Object.prototype as well as for itself, so constructor, toString, valueOf, hasOwnProperty and __proto__ all used to be known features whose definition came from Object.prototype -- POST /override {"feature":"constructor"} threw a TypeError from outside the handler's error boundary and became a 500 with a stack trace, and check denied with no_policy instead of saying the feature does not exist. All of those names are now a 400 FEATURE_UNKNOWN, on every adapter and on every operation -- resolve included, which used to report them per-feature as a feature_unknown grant inside an ok: true bundle.

The test is ownership, not a list of forbidden words: a feature may still be named constructor, or any other inherited name. Register it and it grants, overrides and resolves like any other feature. The one exception is a stored override keyed __proto__, which the three stores build their override map with and cannot currently hold safely -- the evaluator ignores such a row rather than letting it decide a feature it does not name.

UNSAFE_allowUnauthenticated

Some deployments genuinely want the API open: a sidecar bound to 127.0.0.1, a service mesh that terminates mTLS at the edge and authorizes there, a test harness. For those, pass the escape hatch rather than writing your own always-allow hook.

import { createHonoApp, UNSAFE_allowUnauthenticated } from '@entitle/hono'

const entitle = createHonoApp(engine, {
  expose: 'all',
  authorize: UNSAFE_allowUnauthenticated,
})

The name is deliberately ugly and greppable: it shows up in a diff, and an organisation that wants to forbid it needs one CI grep. Constructing an adapter with it warns once per process.

Before you use it, all of these have to hold:

  • The port is not reachable from the network. Bound to loopback, or to a private interface with a policy that actually enforces it -- not a security group you believe is closed.
  • Something in front does the authorization. A mesh, a gateway, an authenticating reverse proxy. "Nothing else runs on this host" is not that thing.
  • Nothing untrusted runs in the same network namespace. Another container in the same pod, or a browser on the same machine, reaches loopback.
  • You have said so where it will be found, in the deployment manifest or the runbook, not only in the source.

If you cannot state which of those holds, write a real hook.

Client-side checks are advisory UX only

@entitle/client's GrantReader reads a bundle the server already produced. It is a convenience for deciding whether to render a button, not a security boundary: the bundle arrived over the network, it lives in a process the user controls, and nothing stops them editing it. Every operation the grant gates must be re-checked on the server, with engine.check or engine.resolveAll, at the point it is performed.

The staleness contract

createPolicyEngine({ cache: { ttlMs } }) is off by default, and turning it on is a security decision rather than a tuning one. For an access-control system, "your revoke takes effect within 60 seconds" is a property of the system, so this section states exactly what ttlMs bounds. The normative version is ADR 0008, what an entitlement cache may remember.

What is cached is the inputs to a decision, never a decision. The resolved subject chain, each chain member's assignment rows and override rows, and each provider's value. Every check re-runs the policy conditions, the override precedence, the merge and the expiry test.

Four things are therefore immediate at any ttlMs:

  1. A write through this library. assignPolicy, revokePolicy, setOverride and removeOverride forget everything cached about that subject as part of the write -- synchronously, whether the store write succeeded or reported a failure, and not through the event emitter, which is best-effort. An evaluation already in flight when the write lands cannot re-install what it read. So a revocation made through this library is visible to the very next check, with no window.
  2. An expiresAt lapsing, on an assignment or on an override. The clock is never cached, so a time-boxed grant ends exactly when its row says, and no ttlMs can extend it.
  3. A change to the policies themselves. They were never in the cache.
  4. engine.invalidate(subjectId, kind), which is public for the cases this library cannot see.

A failure is never cached. A chain member whose store read failed contributes nothing, and a provider that rejected, timed out, was not registered or returned a value its own schema refused is not stored. So the fail-closed denial this engine produces for an input it could not determine lasts exactly as long as the failure, and never ttlMs longer. One transient outage at a billing provider cannot become a TTL-long denial across every subject.

What ttlMs does bound is a change this library did not observe: a plan changed directly in the billing provider, a database row edited by hand, and a write made in another process (each process holds its own cache; the cross-process fan-out described in the root README rides the emitter and is best-effort, with ttlMs as the backstop).

A cached deny and a cached grant

They are the same thing, and that is the answer rather than a dodge. No decision is cached; the row set a decision is computed from is, and the same row set answers both -- a revocation is the removal of a row, so a cache that kept grants but not denials would be caching precisely the case that goes stale.

  • A stale grant is a privilege that should already have been withdrawn. It is reachable only for a change made outside this library, for at most ttlMs. Its remedy is a shorter ttlMs, or engine.invalidate at the point the outside change is made.
  • A stale deny is a delay in access, not an exposure, under the same bound -- minus the class that matters most operationally: a denial caused by a failure is never cached at all.

One case is worth stating on its own. A mode: 'deny' override is a kill switch. Flipped through POST /entitle/override it takes effect immediately. Flipped by editing the row in the database, it takes up to ttlMs -- so either use the route, or call engine.invalidate after the edit.

If you cannot accept a bounded window on an unobserved change, leave cache unset. That is the default, and it has no staleness at all.

Reporting a vulnerability

Report privately, and not in a public issue. If you can open this project's repository, use GitHub's private advisory form. If you cannot -- it is a private repository -- contact the maintainer at https://github.com/fponticelli.

Please include the package and version, which store and adapter you were using, what an attacker can read or change and what they need to start with, and a reproduction if you have one; a failing test against @entitle/store-memory needs no infrastructure. Expect acknowledgement within 7 days and an assessment within 14.

0.0.1 is deprecated and unsupported. It mounted these administrative routes with no authentication, and its documented mounting example was the vulnerable configuration, so an adopter was affected by construction rather than by mistake. There is no GitHub security advisory for it and there will not be one: GitHub files advisories only from public repositories, and this one is private by decision. The npm deprecation notice and this section are the whole public record.

What a success response contains

Every success, on every adapter and every route, is the same body:

{
  "ok": true,
  "data": null
}

data is always present. It carries the operation's result -- a Decision for /check, an ExplainTree for /explain, a GrantBundle for /resolve -- and it is null for the four operations that return nothing: /assign, /revoke, /override and /override/remove.

This is a contract, like the error body below, and for the same reason: a client should not have to know which adapter is serving it. Before 0.1.1 it did. @entitle/express omitted the key; @entitle/fastify sent data: undefined, which its serializer drops, so it omitted the key too; @entitle/hono and @entitle/next sent null. A client written against one and moved to another broke on a data === null check. All four now send null.

The two removals answer ok when there was nothing to remove. /revoke and /override/remove are idempotent: a key that matches nothing removes nothing and answers { "ok": true, "data": null }, with a 200. That is deliberate -- it is how an orphaned assignment naming a policy you have since deleted gets cleared -- and it means neither removal is a way to ask whether a row exists.

Three failures the envelope does not cover

The envelope starts once a request has reached a mounted route with a body the transport could parse. Three things happen before that, and each is answered by the framework rather than by this library:

| What | What answers it | | --- | --- | | A path that is not a mounted operation | The framework's own 404 -- HTML from Express, plain text from Hono, JSON from Fastify. @entitle/next is the exception and answers a structured NOT_FOUND, because it owns its whole [...path] segment and there is nothing else for an unmatched path to be. | | A body the transport cannot parse | Express and Fastify: their body parser's own 400. Hono: its error handler. Next: a 500 INTERNAL_ERROR. | | A handler that throws rather than returning a failure | The framework's error path -- Express's next(err), Fastify's setErrorHandler, Hono's onError. No conformant engine does this. |

The first is not a defect and will not be closed: making an unmounted path answer in the envelope needs a catch-all per adapter, which would swallow the host application's own routes under the same prefix -- and an unmounted operation genuinely does not exist on that mount, which is also why an unexposed route 404s rather than 403ing. The second is: a body a client got wrong is a client error, and a 500 says "our fault, retry" for something retrying cannot fix.

What an error response may contain

An error tells the caller what they did wrong. Everything else goes to the logger. This is a contract, not an implementation detail: it is the same on all four adapters, it is enforced by a type rather than by review, and a change to it is a breaking change.

Every failure, on every adapter, is serialized by one function -- errorResponse in @entitle/core -- which rebuilds the body from these fields and forwards nothing:

{
  "ok": false,
  "error": {
    "code": "STORE_ERROR",
    "message": "Failed to assign policy",
    "correlationId": "0b9c1f8e-6f2a-4b51-9a2e-2d3f8f0f7c11"
  }
}

| Field | Always | What it is | | --------------- | ------ | ---------------------------------------------------------------------------------------------------- | | code | yes | An ErrorCode. Fixed set; maps to the HTTP status. | | message | yes | Chosen by the code path -- a constant, or a refusal this library authored about the caller's own input | | correlationId | no | Present exactly when something was withheld. The same id is on the logger.error line that has it. | | details | no | One of two closed shapes, below. Nothing else is representable. |

details may only be:

  • { "kind": "undeclared_fields", "feature": "seats", "undeclared": ["__proto__"] } -- limits fields the caller sent that the feature does not declare.
  • { "kind": "invalid_fields", "fields": [{ "path": "maxSeats", "code": "invalid_type" }] } -- fields of the request that failed validation, as a path and a machine-readable issue code.

A response never contains:

  • an exception raised outside this library -- a driver, a runtime, a host callback -- in any form: not the value, not its message, not its stack. There is exactly one exception message a client can see, and it is one this library constructs for the caller: LimitsNotRepresentableError, raised when an override's limits cannot survive being stored (NaN, a circular reference, a value that does not round-trip through JSON). Its text describes the caller's own value and locates the field, which is the whole reason it exists; details carries the same path.
  • an exception that escaped a handler. Every handler is wrapped, so an unforeseen throw becomes an ordinary INTERNAL_ERROR envelope with the cause on the logger, rather than leaving the handler as a rejected promise for the framework to serialize -- Express prints a stack in a development environment, Fastify sends error.message. What a host's own middleware does with an exception raised outside these handlers is the host's to answer for; this guarantee covers everything Entitle serializes.
  • any field of a database driver's error -- for PostgreSQL that is severity, code, detail, hint, position, internalPosition, internalQuery, where, schema, table, column, dataType, constraint, file, line and routine; detail quotes row data and internalQuery and where can carry SQL. For SQLite it includes the database file path.
  • any part of a ZodError: no messages, no expected, no received, no constraint bounds, and no received values
  • a feature's schema: a field name the caller did not send is not named, except where the caller's own request required naming it (a required field they omitted)
  • anything about the host's authorize hook beyond the denial itself

A response may quote the caller's own input back, and does: an unknown feature name, an undeclared limits key, an unparseable expiresAt. That is deliberate -- a refusal a caller cannot act on gets retried with more fields -- and it is why message and details are about the request rather than about the system.

Correlating a client error with a log line

An error built from a caught cause carries a correlationId, and the same id appears on the logger.error entry that carries the cause in full:

logger.error('Failed to assign policy', {
  operation: 'assign_policy',
  subjectId: 'u1',
  subjectKind: 'user',
  policyId: 'enterprise',
  code: 'STORE_ERROR',
  correlationId: '0b9c1f8e-6f2a-4b51-9a2e-2d3f8f0f7c11',
  cause: <the driver error, untouched>,
})

So a caller reporting a 503 and its correlation id gives an operator the whole diagnosis, and gives an attacker nothing. Pass a logger when creating the engine: an engine created without one gets noopLogger, which discards the diagnosis -- that is a deployment's decision, and it is the only way to lose it.

An operator who needs the caller to distinguish a retryable failure from a bad request already has that in code: STORE_ERROR (503) and PROVIDER_FAILED (502) are the library's "try again or page someone", VALIDATION_ERROR / FEATURE_UNKNOWN / POLICY_UNKNOWN (400) are "your input is wrong". Which constraint a write violated is deliberately not on that list: it is a fact about the database, and a client that branches on it is coupled to a schema it cannot see.

AUDIT_NOT_RECORDED (500) is the one code that does not mean the request failed to take effect. It is reachable only under auditDurability: 'required', and only from the four mutating operations: the change was applied and its audit event could not be recorded. It is not STORE_ERROR, precisely because that status says nothing was written. All four operations are idempotent, so a caller that retries re-applies nothing and gets the event recorded. Events, in the repository README, states what each durability mode guarantees.

License

MIT