@o3co/auth.policy-verifier.builtins
v0.14.0
Published
Last updated: 2026-09-24
Readme
@o3co/auth.policy-verifier.builtins
Last updated: 2026-09-24
Built-in attribute collectors, rule collectors, and resource parser for auth.policy-verifier.
Runtime: Server- and edge-side JavaScript runtimes that support BigInt and Map.groupBy — Node.js 22+ (declared via engines.node so older Node installs are blocked at install time), Cloudflare Workers, Vercel Edge, Deno, Bun. Browsers are out of scope by design: authorization decisions must be enforced server-side. The server companion package remains Node-only.
Responsibility
A small, ready-made set of implementations of core's interfaces: attribute collectors, rules,
rule collectors, a resource parser, and builtinCollectorsModule, which registers them for
a host such as the server's createApp. It depends on @o3co/auth.policy-verifier.core alone;
templates/standalone wires it in, and server uses it only in its tests
(devDependencies).
- Owns what these implementations read, write and match: the mapping from JWT claims
(
sub,azp,scope, declared claims) to attribute keys (#170), the matching rules ofHasScope/HasPermissionand the comparison rules, and the dot-notation resource grammar. - Does not own the pipelines, the grouping or the decision (core), authentication or HTTP
(
server), policy engines orAsyncRules (cedar), or collectors that do I/O — those are the consumer's to write (docs/extending.md). - Why a separate package from core: core is the engine-neutral contract and names no field of the subject (#170); the claim vocabulary and the concrete matching live here, on the other side of that line (AGENTS.md — Core Vocabulary Scope). It is also optional: builtins is a deliberately basic set, not a catalog (docs/extending.md — Positioning), so a deployment that writes its own collectors and rules does not have to install it, and core does not grow with it.
Responsibility, role and invariants of each source directory:
src/collectors/README.md (attribute collectors) and
src/rules/README.md (rules and rule collectors).
Install
npm install @o3co/auth.policy-verifier.builtinsAttribute Collectors
All collectors implement AttributeCollector.
| Name | Reads from | Emits | Constructor args |
| --- | --- | --- | --- |
| PayloadScopeCollector | the scope claim — subject.scope by default, a space-separated string or an array of strings | ATTR_SCOPES: string[] | { claim?: string } (#219: "scp" for Okta, "permissions" for Auth0) |
| PayloadSubjectIdCollector | subject.sub, subject.azp | ATTR_USER_ID, ATTR_CLIENT_ID | none |
| StaticPermissionCollector | — | ATTR_PERMISSIONS: string[] | { permissions: string[] } |
| StaticRoleCollector | — | ATTR_ROLES: Role[] | { roles: Role[] } |
| RequestContextAttributeCollector | declared fields of requestContext | the operator's own keys | { attributes: Mapping[] } |
| PayloadClaimAttributeCollector | declared claims of the verified subject | the operator's own keys, or core's five | { attributes: Mapping[] } (#219) |
StaticPermissionCollector and StaticRoleCollector always emit the values supplied at construction time, regardless of request context. They copy what they were given at construction — the array, and for StaticRoleCollector each Role and its permissions — so mutating the config afterwards changes nothing they emit (#255). permissions and roles must be arrays: anything else, a string included (permissions = "posts.*" where ["posts.*"] was meant), is refused with a TypeError at construction, so the deployment fails at boot (#264).
PayloadClaimAttributeCollector
Promotes declared claims of the verified subject into attributes (#219) — the operator-declared way to turn an external IdP's claims into what the rules read, without a bespoke collector:
{ collector = "PayloadClaimAttributeCollector"
attributes = [
{ from = "o.rol", to = "roles", type = "string[]" } # Clerk: org role, by dot path
{ from = "https://example.com/roles", to = "roles", type = "string[]" } # Auth0: a namespaced claim is one key, not a path
{ from = "tid", to = "tenantId" }
] }Same mapping shape as RequestContextAttributeCollector ({ from, to?, type? }, an exact key winning over a dot path, own properties only). What differs is the source, and therefore the trust: the subject bag is what the authenticator verified, so a mapping may land on core's five keys — scopes, permissions, roles, userId, clientId — where the request-context collector refuses them. Two collectors writing one list key union it; that is the deployment composing two issuer-derived sources, and it says so in config. A scalar key written by two collectors with different values throws AttributeConflictError and denies every request — do not map onto userId / clientId while PayloadSubjectIdCollector also writes them. Keys another package reserved (cedar's request*) stay refused, because they are derived from the request, not from the subject. And map only claims the IdP populates from its own registration or admin data: a claim minted from user-editable metadata (Clerk's unsafe_metadata, Auth0's user_metadata) is signed, not trusted.
For the scope claim specifically, prefer PayloadScopeCollector { claim = "scp" }, which also reads the space-delimited string form and pairs with ResourceActionScopeRuleCollector { claim = "scp" } — set on both, since each keeps its own claim and nothing checks they match — so both look at the same claim. The rule collector checks only that the claim is present: a claim holding no usable scope list ("", a number) yields no scopes but still counts as scoped under scopeless = "skip".
RequestContextAttributeCollector
Promotes declared fields of CollectorContext.requestContext into attributes:
{ collector = "RequestContextAttributeCollector"
attributes = [
{ from = "tenant.id", to = "tenantId" } # dot path; `to` defaults to `from`
{ from = "groups", type = "string[]" }
] }Each mapping is { from: string; to?: string; type?: "string" | "number" | "boolean" | "string[]" }, type defaulting to "string". An exact key on context wins over walking a dot path: a literal "tenant.id" field is read before tenant → id. A malformed mapping throws at construction; an unusable value does not — requestContext is caller-supplied request data, so a field that is missing, empty, or not of its declared type is simply not promoted.
The declaration is the trust boundary — this collector is the ready-made way to stay on the right side of the one described in docs/extending.md. requestContext is free-form and unvalidated, so nothing undeclared becomes an attribute and a configured dot path traverses own properties only (constructor.name reads nothing). This collector invents no vocabulary of its own: the operator names both the fields and the keys, which is what keeps AGENTS.md — Core Vocabulary Scope intact while still shipping something usable. For anything beyond read-check-write — deriving a value, calling out to a store — write a focused project-side AttributeCollector as that section describes.
Reserved vocabulary is not a valid destination
A mapping's to may not name a reserved attribute key. Naming one is a configuration error, refused at construction, so a deployment that writes it fails at boot rather than on the first request:
# refused at boot — core's own vocabulary
{ from = "groups", to = "scopes" }
# refused at boot — @o3co/auth.policy-verifier.cedar's vocabulary,
# reserved as soon as that package is loaded
{ from = "rid", to = "requestResourceId" }
# fine — the field may be called anything; only the attribute key is reserved
{ from = "groups", to = "requestGroups", type = "string[]" }
{ from = "scopes", to = "requestedScopes", type = "string[]" }The reserved set is not a list this package keeps. It is core's registry (RESERVED_ATTRIBUTE_KEYS, reserveAttributeKeys, attributeKeyReservation): core reserves its own five — scopes, permissions, roles, userId, clientId — and every package that owns attribute vocabulary reserves its own at module load. @o3co/auth.policy-verifier.cedar reserves requestAction, requestResourceType, requestResourceId and requestResourceRaw; a project-side collector should reserve the keys it writes the same way. A composition that can name a package's collectors in config has already imported that package, so its keys are registered before this collector is constructed — which is why the guard covers vocabulary core cannot see. The refusal names the owning package, and suggests a rename that is not itself reserved.
context is the caller's, and those keys are the deployment's. Under the default server scopes, userId and clientId are read out of the signature-verified token, permissions / roles carry the entitlements the builtin rules decide from, and cedar's four carry the parsed request — so the two sides of such a mapping carry entirely different trust, and the request body must not join them in one bucket.
What makes it worth refusing rather than documenting is the merge: AttributePipeline unions array-valued attributes across collectors. A mapping onto scopes therefore does not overwrite what PayloadScopeCollector produced and lose an argument with it — it extends it. A caller sending context.groups = ["admin:write"] would be authorized for a scope its token never carried, and nothing in the decision, the logs or the metrics would tell that apart from an issuer that granted it. See AttributePipeline's merge doc comment.
A scalar key is no safer, in two ways. Where both sides write it the values disagree and AttributeConflictError denies the request — fail-closed, but an unannounced denial rather than a refusal at boot. And where the owning collector writes its key only sometimes there is no second writer at all: cedar's RequestFactsCollector omits requestResourceId for an id-less resource such as "document", so { from = "rid", to = "requestResourceId" } would land unopposed and the Cedar resource entity would be built from the caller's own request body.
Rules
HasPermission
new HasPermission(permission: string)ruleType:"permission",code:"no_permission"- Checks
ATTR_PERMISSIONS(direct) andATTR_ROLES[].permissions(via roles). - Matching is exact and case-sensitive — the same discipline
HasScopeapplies to scopes andDotNotationResourceParserapplies to resources: compare what was written, never a normalized guess at what was meant. The parser preserves case, soProject:1.perm:readandproject:1.perm:readare different permissions, exactly asProject:1andproject:1are different resources to a scope rule. - Wildcards in a granted permission are honoured — written match structure, not normalization; the literal halves around the
*still compare exactly:"*"matches any permission."foo*"matches any permission with prefixfoo."*bar"matches any permission with suffixbar."foo*bar"matches any permission starting withfooand ending withbar.- More than one
*in a granted permission never matches (rejecting beats silently over-granting).
HasScope
new HasScope(scope: string, options?: { allowBareScopeRewrite?: boolean })ruleType:"scope",code:"invalid_scope"- Checks
ATTR_SCOPES. - Matching is exact and case-sensitive. OAuth 2.0 scope values are case-sensitive opaque strings (RFC 6749 §3.3), so
read:PROJECTdoes not satisfyread:project. - A scope containing more than one
:is a value in its own right — nothing is split off at the second:.read:project:restricteddoes not satisfyread:project(a deliberately narrowed grant must not collapse into the broader one), andread:projectdoes not satisfyread:project:restricted. allowBareScopeRewrite(defaultfalse) opts in to treating a bare granted scope"resource"as"read:resource"as well as literally. Only a scope with no:is ever rewritten;"project:restricted"is left alone, because which segment is the action is unknowable and guessing over-grants. Leave it off unless your issuer emits bare resource names.- Non-string entries in
ATTR_SCOPESnever match and never throw.
AttrMatchRule
Deprecated. Use AttrPairEqual instead. AttrMatchRule is kept as a thin wrapper class that extends AttrPairEqual and preserves the legacy ruleType (attr_match:${a}:${b}) and legacy message wording for backward compatibility. The type AttrMatchRuleConfig is a type alias of AttrPairEqualConfig. Deprecated since v0.3; it may be removed in a future release (subject to change).
new AttrMatchRule({ a: string, b: string, group?: string })code:"attr_mismatch".- Passes when
attrs.get(a)andattrs.get(b)are both non-empty strings and equal. Any other case returnsfalse(fail closed). - Pure predicate — does not read
CollectorContext. Consuming projects provide the two values to compare through upstreamAttributeCollectors and wire the rule through their ownRuleCollector. ruleTypedefaults to"attr_match:${a}:${b}". The evaluator ORs rules within aruleTypeand ANDs across differentruleTypes, so the default ensures two independent comparisons are AND-combined (required together). Passgroupexplicitly when you want two comparisons to be OR-combined (for example, "identify by DID or by email") — both rules then share the providedgroupas theirruleType.
Attribute Comparison Rules
The attribute comparison rules form a 2 × 5 matrix over two axes: family (Literal vs. Pair) and operator (Equal, NotEqual, In, NotIn, Compare).
- Literal rules compare a single named attribute against a static value (or set of values) supplied at construction time.
- Pair rules compare two named attributes resolved from the
Attributesmap at evaluation time. In/NotInvariants exist for the Literal family only. A pair-over-set operation does not generalize cleanly to a finite list, soAttrPairIn/AttrPairNotInare intentionally absent.
| Family | Equal | NotEqual | In | NotIn | Compare |
| ------- | ------------------ | --------------------- | --------------- | ------------------ | -------------------- |
| Literal | AttrLiteralEqual | AttrLiteralNotEqual | AttrLiteralIn | AttrLiteralNotIn | AttrLiteralCompare |
| Pair | AttrPairEqual | AttrPairNotEqual | — | — | AttrPairCompare |
AttrLiteralEqual
new AttrLiteralEqual({ a: string, v: string | number | boolean, group?: string })code:"attr_not_equal".- Default
ruleType:`attr_literal_equal:${a}:${typeof v}:${String(v)}`. Thetypeof vsegment prevents silent collisions between distinct-type literals that stringify the same way (e.g.truevs"true"). - Passes when
attrs.get(a)is the same type and strictly equal tov. No type coercion.
AttrLiteralNotEqual
new AttrLiteralNotEqual({ a: string, v: string | number | boolean, group?: string })code:"attr_equal".- Default
ruleType:`attr_literal_not_equal:${a}:${typeof v}:${String(v)}`. Thetypeof vsegment prevents silent collisions between distinct-type literals (same rationale asAttrLiteralEqual). - Passes when
attrs.get(a)is the same type asvand strictly not equal to it. Missing, wrong-type orNaNattributes returnfalse(safe-deny):NaNis unequal to every number, and a restriction must not pass on a value that is not a number at all (#254).
AttrLiteralIn
new AttrLiteralIn({ a: string, values: (string | number | boolean)[], group?: string })code:"attr_not_in_set".- Default
ruleType:`attr_literal_in:${a}:${type}:${count}:${hashPrefix}`— wherecountis the post-deduplication element count andhashPrefixis a 16-hex-character FNV-1a 64-bit hash over the deduplicated, sorted, stringified values. The hash is non-cryptographic but the 64-bit width makes accidental and adversarial collisions vanishingly unlikely for any realistic policy size; the package has nonode:*dependency and loads in any supported server/edge runtime (see "Runtime" above). Two instances with the sameaand logically equivalentvalues(duplicates and order do not matter) share the sameruleTypeand are OR-combined by the evaluator. valuesmust be a non-empty, homogeneous array (string[],number[], orboolean[]). Passes whenattrs.get(a)is in the set. Duplicate elements invaluesare ignored (the rule usesSetsemantics internally).
AttrLiteralNotIn
new AttrLiteralNotIn({ a: string, values: (string | number | boolean)[], group?: string })code:"attr_in_set".- Default
ruleType:`attr_literal_not_in:${a}:${type}:${count}:${hashPrefix}`— same stable, deduplication-aware hash scheme asAttrLiteralIn. valuesmust be a non-empty, homogeneous array. Passes whenattrs.get(a)is the same type as the values and NOT in the set. Missing, wrong-type orNaNattributes returnfalse(safe-deny);NaNis in no set, so it would otherwise pass (#254). Duplicate elements invaluesare ignored.
AttrLiteralCompare
new AttrLiteralCompare({ a: string, op: "lt" | "le" | "gt" | "ge", v: number, group?: string })code:"attr_compare_violated".- Default
ruleType:`attr_literal_compare:${a}:${op}:${String(v)}`. - Passes when
attrs.get(a)is a number satisfyinga op v. NaN asvis rejected at construction time. NaN attributes always returnfalse.
AttrPairEqual
new AttrPairEqual({ a: string, b: string, group?: string })code:"attr_mismatch".- Default
ruleType:`attr_pair_equal:${a}:${b}`. - Passes when both
attrs.get(a)andattrs.get(b)are non-empty strings and strictly equal. This is the successor to the deprecatedAttrMatchRule.
AttrPairNotEqual
new AttrPairNotEqual({ a: string, b: string, group?: string })code:"attr_match".- Default
ruleType:`attr_pair_not_equal:${a}:${b}`. - Passes when both
attrs.get(a)andattrs.get(b)are non-empty strings and strictly not equal. Missing, empty, or non-string attributes returnfalse(safe-deny).
AttrPairCompare
new AttrPairCompare({ a: string, op: "lt" | "le" | "gt" | "ge", b: string, group?: string })code:"attr_compare_violated".- Default
ruleType:`attr_pair_compare:${a}:${op}:${b}`. - Passes when both
attrs.get(a)andattrs.get(b)are numbers satisfyinga op b. NaN on either side returnsfalse(JS comparison semantics).
Grouping: AND by default, group for OR
All attribute comparison rules follow the same grouping semantics described for AttrMatchRule above. By default, each rule's ruleType is derived from its distinguishing parameters so that distinct requirements are AND-combined by the evaluator. Pass the same group string to two rules to give them the same ruleType — the evaluator then OR-combines them (either condition satisfies the requirement).
Configuration is copied at construction
Every attribute comparison rule reads each field of its config once, validates it, and keeps its own copy (#255). Mutating the config object afterwards — replacing a, b, op or v, or editing values — changes neither the rule's answers nor its ruleType and message, and a value the constructor would refuse (such as a NaN literal) cannot be installed after it.
Rule Collectors
| Name | Derives permission/scope | Returns |
| --- | --- | --- |
| ResourceActionPermissionRuleCollector | "<resource.raw>.perm:<action>" | [HasPermission(...)] |
| ResourceActionScopeRuleCollector | "<action>:<resource.resourceType>" | [HasScope(...)] |
ResourceActionPermissionRuleCollector takes no constructor arguments.
ResourceActionScopeRuleCollector accepts { scopeless?: "deny" | "skip", allowBareScopeRewrite?: boolean, claim?: string } — claim names the claim whose presence says the token asserted scopes (default scope; set it to what PayloadScopeCollector reads, e.g. scp).
scopeless(default"deny"): it emits theHasScoperule for every request, so a token carrying no scope claim (scope, or the claimclaimnames) fails it."skip"emits no rule for a scopeless token — only use it in a pipeline where another rule group authorizes the request, since a request that collects no rule at all is denied.allowBareScopeRewrite(defaultfalse): forwarded toHasScope. Set it totrueonly if your issuer emits bare resource names (project) rather than{action}:{resourceType}scopes (read:project).
Resource Parser
DotNotationResourceParser
Parses a dot-notation string into a Resource.
new DotNotationResourceParser()Grammar:
resource = segment *( "." segment )
segment = type [ ":" id ]
type = 1*tchar
id = 1*tchar
tchar = %x21 / %x23-2D / %x2F-39 / %x3B-5B / %x5D-7E
; RFC 6749 NQCHAR less "." and ":"
; i.e. printable ASCII except space, `"`, `\`, `.` and `:`Example: "foo.bar:123" → { raw: "foo.bar:123", resourceType: "foo.bar", resourceId: "123" }
- Segments are split by
.. Each segment may include:id. resourceTypeis the segment types joined with.— the separator is preserved, not rewritten.resourceIdis the id of the last segment, if present.rawis the input verbatim.
Anything the grammar does not accept raises ResourceParseError
(from @o3co/auth.policy-verifier.core); the parser never repairs its input. The server answers
such a request 400 invalid_request, not a decision. Refused, among others:
| Input | Why |
| --- | --- |
| "", a..b, .a, a. | an empty segment — every segment needs a type |
| a:, :1 | an empty type or id |
| a:1:2 | more than one : in a segment — the tail is refused, not truncated away |
| a:1 , a : 1 | whitespace — it is refused, not trimmed |
| プロジェクト, a"b, a\b | a character no OAuth scope value may carry |
resourceType is the authorization namespace: ResourceActionScopeRuleCollector turns it into the
{action}:{resourceType} scope that must be granted. Two distinct resources that parse to the same
type are therefore authorized identically, so the grammar is built to make that impossible —
. is reserved as the separator, which keeps the nested type a.b distinct from the flat type
literally named a_b (both were a_b before). This is the same principle
HasScope applies to scope values: compare what was written, never a normalized guess
at what was meant.
An id that needs ., : or a character outside the set must be encoded by the caller
(percent-encoding round-trips through this grammar) or handled by a ResourceParser written for
that syntax.
builtinCollectorsModule
builtinCollectorsModule is a Module (name: "builtin-collectors") that registers all built-in implementations into their respective registries.
import { builtinCollectorsModule } from "@o3co/auth.policy-verifier.builtins";Registrations as of this writing; the source of truth is src/module.mts.
| Registry | Name | Factory |
| --- | --- | --- |
| attributeCollector | "PayloadScopeCollector" | (config) => new PayloadScopeCollector(config) |
| attributeCollector | "PayloadSubjectIdCollector" | () => new PayloadSubjectIdCollector() |
| attributeCollector | "StaticPermissionCollector" | (config) => new StaticPermissionCollector(config) |
| attributeCollector | "StaticRoleCollector" | (config) => new StaticRoleCollector(config) |
| attributeCollector | "RequestContextAttributeCollector" | (config) => new RequestContextAttributeCollector(config) |
| attributeCollector | "PayloadClaimAttributeCollector" | (config) => new PayloadClaimAttributeCollector(config) |
| ruleCollector | "ResourceActionScopeRuleCollector" | (config) => new ResourceActionScopeRuleCollector(config) |
| ruleCollector | "ResourceActionPermissionRuleCollector" | () => new ResourceActionPermissionRuleCollector() |
| resourceParser | "DotNotationResourceParser" | () => new DotNotationResourceParser() |
See Also
src/collectors/README.md,src/rules/README.md— responsibility, role and invariants of this package's source directories- Extension guide (
docs/extending.md) — how to write customRuleandAttributeCollectorimplementations; positioning ofbuiltinsas a basic set @o3co/auth.policy-verifier.core— core interfaces and attribute constants- auth.policy-verifier root README — full setup and configuration reference
