pentad
v0.1.0
Published
Typed, explainable, filterable authorization contracts. Control Content from Contact via Contracts Contextually.
Maintainers
Readme
pentad
Typed, explainable, filterable authorization contracts for TypeScript. An implementation of the C5 paradigm: Control Content from Contact via Contracts Contextually.
A contract says who (contact) may do what (action) to which data (content) under what circumstances (context). Every contract answers three questions:
- check: may they? A decision, never a bare boolean.
- explain: why, or why not? A full pass/fail trace of every rule.
- filter: which rows? The same contract compiled into a database query.
Zero runtime dependencies. Runs in Node, the browser, and edge runtimes. Deny by default.
Quickstart
Declare your world once. Everything after this line is inferred.
import { all, any, c5 } from 'pentad'
type World = {
contact: { userId: string; role: 'admin' | 'editor' | 'viewer'; orgId: string }
content: {
post: { id: string; authorId: string; orgId: string; status: 'draft' | 'published'; likes: number }
}
context: { channel: 'web' | 'api'; hour: number }
}
const auth = c5<World>()Write a contract. The lambda runs once at build time against path-recording proxies and produces a plain JSON tree; it never sees real data.
const editPost = auth.contract('edit', 'post').when(({ contact, content, context }) =>
any(
contact.role.eq('admin'),
all(
content.authorId.eq(contact.userId), // cross-block reference, type-checked
content.status.neq('published'),
context.channel.oneOf(['web']),
),
),
)Operators are offered per field type: contact.role. autocompletes eq / neq / oneOf / notOneOf / matches, while number fields add gt / gte / lt / lte / between. Typos (contact.rol), wrong literals (status.eq('archived')), and wrong-type operators (role.gt(5)) are compile errors. A field literally named eq is reachable via the escape hatch content.$field('eq').
Question 1: check
const decision = editPost.check({
contact: { userId: 'u_1', role: 'editor', orgId: 'o_1' },
content: { id: 'p_9', authorId: 'u_1', orgId: 'o_1', status: 'published', likes: 3 },
context: { channel: 'web', hour: 14 },
})
decision.allowed // false
decision.denied // true, the literal-typed complement; no more !decision.allowedallowed and denied are halves of a discriminated union, so TypeScript narrows on either one, and only denied decisions carry a reason. For the most common call site there is orThrow():
editPost.check(payload).orThrow() // throws ForbiddenError(reason) on deny
editPost.check(payload).orThrow(() => new HttpError(403)) // or your own errorQuestion 2: explain
console.log(decision.explain())
// ✗ edit post
// ✗ any
// ✗ contact.role equals "admin" (found "editor")
// ✗ all
// ✓ content.authorId equals contact.userId ("u_1")
// ✗ content.status not "published" (found "published")
// ✓ context.channel in ["web"]decision.reason is the one-line version, taken from the nearest-miss branch: the branch that came closest to passing, since that is the most actionable explanation.
Question 3: filter
Bind contact and context, leave content unknown, and the tree partially evaluates into a residual predicate over content alone, compiled to your target:
import { prismaAdapter } from 'pentad/adapters/prisma'
import { memoryAdapter } from 'pentad/adapters/memory'
const where = editPost.filter({ contact: me, context: { channel: 'web', hour: 14 } }, prismaAdapter())
// { AND: [{ authorId: 'u_1' }, { status: { not: 'published' } }] }
const editable = await prisma.post.findMany({ where })
const keep = editPost.filter({ contact: me, context }, memoryAdapter<Post>())
const visible = posts.filter(keep)The library's honesty invariant: filtering then fetching returns exactly the rows that check would allow, and the test suite enforces it.
Context providers
Register how ambient context is resolved once, and it disappears from every call site:
import { time } from 'pentad/ambient'
const auth = c5<World, { req: Request }>().provide('context', {
hour: time.hour(),
channel: ({ req }) => (req.headers.get('x-client') === 'api' ? 'api' : 'web'),
})
// context is no longer accepted in the payload; supplying it is a compile error
const decision = editPost.check({ contact: me, content: post }, { req })Provided fields cannot be spoofed: providers overwrite anything a caller smuggles in at runtime, and the types reject it at compile time. Providers may be async; when any registered provider returns a promise, check and filter are typed as returning promises.
Control: the contract registry
const control = auth.control([editPost, deletePost, readPost])
control.check('edit', 'post', payload) // routed by action and resource
control.check('delete', 'post', payload) // no contract registered: denied, reason "no contract"Multiple contracts for the same action and resource are OR'd: first allow wins.
Testing
Policies are production code. The matrix harness ships in the box:
import { matrix } from 'pentad/testing'
matrix(editPost)
.context({ hour: 14, channel: 'web' })
.allow('author edits own draft', { contact: author, content: draft })
.deny('published is locked', { contact: author, content: published })
.deny('stranger denied', { contact: stranger, content: draft })
.run() // a wrong expectation throws with the full explain traceThe matrix bypasses context providers on purpose: spoofing context is the point of a test.
Serialization
A contract is plain JSON, so it can be stored anywhere and rehydrated with validation:
const saved = JSON.stringify(editPost)
// {"on":"edit","to":"post","when":{"any":[{"op":"eq","left":{"ref":"contact.role"},"right":"admin"},...]}}
const revived = auth.fromJSON(JSON.parse(saved)) // throws InvalidContractError on unknown operatorsReferences are explicitly tagged ({"ref": "contact.userId"}), so a literal and a reference can never be confused.
Design notes
- One operator table (
OPERATORS) is the single source of truth. The evaluator, the explainer, and every filter adapter consume it, and adapters implementRecord<Op, ...>which the compiler checks for exhaustiveness. An operator cannot exist without a complete implementation everywhere. - Deny by default: a missing attribute fails its rule, an empty
anyfails, an unregistered action and resource pair fails. - The evaluator does not short-circuit, so the trace reports every branch. Policy trees are small; trace fidelity is worth more than the skipped comparisons.
Package map
pentad: core. Builder, evaluator, decisions, explain, partial evaluation, control.pentad/adapters/memory: residual tree to a row predicate.pentad/adapters/prisma: residual tree to a Prisma where clause.pentad/ambient: clock-injectable time providers.pentad/testing: the matrix harness.
Roadmap
- Drizzle and raw SQL filter adapters.
- Property-based tests over the evaluator and the filter round-trip invariant.
- An optional standalone HTTP decision service (
@pentad/server) with ad-hoc and saved-contract checks. - Contract versioning and decision audit logs.
