@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.1mounted the administrative routes with no authentication and its documented example was the vulnerable configuration.0.1.0fixed that breakingly --authorizeis a required option on every adapter andexposedefaults to'read-only'-- and this document is the public record of both.
Install
pnpm add @entitle/core zodzod 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-- atnode_modules/@entitle/core/skills/entitle/SKILL.md, a condensed reference written for a coding agent and readable by a person.npx entitle setupcopies 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-depsdo not. Addzodto 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:
- A write through this library.
assignPolicy,revokePolicy,setOverrideandremoveOverrideforget 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. - An
expiresAtlapsing, on an assignment or on an override. The clock is never cached, so a time-boxed grant ends exactly when its row says, and nottlMscan extend it. - A change to the policies themselves. They were never in the cache.
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 shorterttlMs, orengine.invalidateat 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 itsstack. 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;detailscarries the samepath. - an exception that escaped a handler. Every handler is wrapped, so an
unforeseen throw becomes an ordinary
INTERNAL_ERRORenvelope 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 sendserror.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,lineandroutine;detailquotes row data andinternalQueryandwherecan carry SQL. For SQLite it includes the database file path. - any part of a
ZodError: no messages, noexpected, noreceived, 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
authorizehook 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
