@zap-studio/permit deep-dive
Source study of @zap-studio/permit 2.0.1, the only authorization library built on Standard Schema, and what PermDock adopts, adapts and avoids from its schema-first resources and fail-closed evaluation.
Source: read from main of the zap-studio monorepo and the published npm tarball on 2026-09-06. v2.0.1 was published 2026-09-01; about 970 downloads a month; the monorepo has 171 stars, zero open issues, and was last pushed 2026-09-04. Docs live at zapstudio.dev/permit with sources in apps/docs/src/pages/permit. Five source files: index.ts, types.ts (328 lines), policy.ts (311), conditions.ts (251), errors.ts, _otel.ts. The same report covers Kilpi v1.
permit matters because it is the only authorization library in the survey that consumes Standard Schema. It shows both how resource schemas should feed rule types and how a purely boolean, adapter-less design runs out of road.
Release history
The changelog shows 17 releases since 0.1.0 with three breaking API shifts: can() became async in 0.2; positional ("read", "post", post) became the string "post:read" in 0.3; the OpenTelemetry peer dependency became required in 2.0.
Public API
Runtime exports: createPolicy, mergePoliciesAnd, mergePoliciesOr, allow, deny, when, and, or, not, has, hasRole, collectInheritedRoles, PolicyError.
Types: Resources, Actions, Rules, ActionPolicyMap, PolicyFn, ConditionFn, Decision ("allow" | "deny"), Context, Role, RoleHierarchy, InferResource, InferAction, InferPermission, PermitConfig, Policy. Subpath exports: ./conditions, ./errors, ./policy, ./types.
The whole Policy interface is one method (types.ts):
export interface Policy<TContext, TResources, TActions> {
can: <K extends keyof TResources & keyof TActions>(
context: TContext,
permission: `${K & string}:${InferAction<TResources, TActions, K> & string}`,
resource: InferResource<TResources, K>,
) => Promise<boolean>;
}Developer experience
Quick start from the README:
const resources = {
post: z.object({ id: z.string(), authorId: z.string() }),
} satisfies Resources;
const actions = {
post: ["read", "write", "delete"],
} as const satisfies Actions<typeof resources>;
const policy = createPolicy<AppContext>({
resources, actions,
rules: {
post: {
read: allow(),
write: when((ctx, action, resource) => ctx.user.id === resource.authorId),
delete: deny(),
},
},
});
await policy.can(ctx, "post:write", post); // Promise<boolean>Role hierarchy (hasRole requires the context to have a role of type Role | Role[]):
const hierarchy = { guest: [], user: ["guest"], admin: ["user"] };
rules: { post: { read: when(hasRole("guest", hierarchy)) } }Splitting rules per domain (scaling-policies.mdx) needs a triple manual generic:
export const postRules: ActionPolicyMap<
AppContext,
(typeof postActions)["post"][number],
z.infer<typeof postResources.post>
> = { read: when((_, __, post) => post.visibility === "public"), delete: deny() };Layered merging, from the "layered permissions pattern" test in index.browser.test.ts:
const merged = mergePoliciesOr(publicPolicy, ownerPolicy);
await expect(merged.can(user, "post:read", privateOwnPost)).resolves.toBeTruthy();Standard Schema usage
Resources is a record of names to StandardSchemaV1; InferResource is StandardSchemaV1.InferOutput of the schema, so transforms are visible to rules. At createPolicy time one validator per resource is built via createStandardValidator(schema) from the sibling @zap-studio/validation package, which wraps schema["~standard"].validate and handles thenables. Every can() call validates the resource at runtime: issues means false; a validator throw means false plus logger.warn. permit never touches the ~standard key directly.
This is the pattern PermDock adopts for resource(Schema, ...), with one change: validation runs only on data that crossed a trust boundary (ADR 0009), not on every call against trusted server rows.
Runtime model and evaluation
(policy.ts.)
- Rules are a plain nested object,
rules[resource][action]is aPolicyFn.allow(),deny()andwhen(cond)are factories returning a function of(ctx, action, resource)that yields"allow"or"deny". There is one function per action and no rule lists, so deny precedence does not exist inside a policy:whenreturns"deny"when its condition is false, full stop. - The
can()pipeline:withCheckSpan, thenparsePermission(split on:, reject extra segments or unknown resource keys), then check thatactions[resource]includes the action, then validate the resource, then look uprules[resource]?.[action](missing meansfalse), then compare the policy function's result to"allow". - Policy functions are strictly synchronous.
can()is async only for schema validation; the rule result is compared with=== "allow"without awaiting, so anasyncrule silently always denies. No database lookups are possible in rules. - Combinators
and,or,notshort-circuit over synchronousConditionFns;has(key, value)iscontext[key] === value. - Merging:
mergePoliciesAndandmergePoliciesOrrun all sub-policies withPromise.allSettled(no short-circuit; a rejection is a deny; an empty list is a deny), each wrapped in its own OTel span. can()never throws.createPolicythrowsPolicyErroronly if a resource schema isundefined, which is unreachable with correct types.
Typing strategy
Three generics <TContext, TResources, TActions>; TContext cannot be inferred so you always write createPolicy<AppContext>(...). Rules requires a key for every resource but each action map is Partial, so forgetting an action compiles and denies at runtime. Permission strings are a template-literal union (InferPermission). The result is a boolean only: no reason, message or narrowed subject.
Observability
Optional logger typed structurally against @zap-studio/logger (type-only import; any pino-like shape works): allow logs at debug, deny at info, errors at warn. OTel (_otel.ts): @opentelemetry/api is a required peer; a span named permit.check with the resource and action carries a permit.decision attribute, plus a permit.checks counter. The meter is re-resolved per call to avoid the module-initialised-before-SDK problem.
Tests, size, adapters
- Vitest:
index.browser.test.tsis 2,045 lines with about 110 cases (combinators, hierarchy including diamonds, malformed strings, validation failures, async schemas, merge semantics, logging), plus OTel tests in Node and browser modes. - Size: dist JavaScript 14.3 kB unminified; about 2.8 kB minified ESM excluding
@opentelemetry/apiand@zap-studio/validation; npmunpackedSize76 kB with maps and types. - No adapters at all (no React, Next.js, client or endpoint), no subject or
getSubjectconcept, no per-request caching, no introspection ("what can this user do"), no resource-less actions (theresourceargument is required, sopost:createneeds a dummy object), no async rules, no denial reasons, no wildcard actions.
What PermDock takes from permit
Attach the Standard Schema to the resource node so that can(permissions.post.update, post) types post as the schema's inferred output and can validate it at untrusted boundaries (which also fixes Kilpi's z.any() hole). Keep every grant keyed under a permission reference so a missing grant is simply "denied" and reported by permdock usage as granted-by-no-role, rather than permit's Partial map that lets a rule be forgotten silently. Fail closed everywhere: can() never throws, invalid input denies, a rejected sub-check denies. Compose with Promise.allSettled semantics. Keep the footprint small and the observability optional (permdock/otel).
Adopt / adapt / avoid
Adopt:
- Resources as a record of Standard Schema validators, with
InferOutputfeeding rule and condition parameter types. - Fail-closed evaluation:
can()never throws; a missing rule, an invalid resource or a rejected sub-policy is a deny. Promise.allSettledcomposition where a rejecting sub-policy is a deny.- Tiny, tree-shakeable footprint with subpath exports.
- Logger as a structural, type-only interface.
- An OTel span per check with a decision attribute and a counter (as an optional adapter, see below).
- A long Vitest matrix on malformed input.
Adapt:
- Schema validation is opt-in per boundary (
validate: 'boundary'by default): validate client- or agent-supplied data, skip trusted server rows, rather than validating every call. - The
allow,deny,when,and,or,not,hasRolevocabulary is good; PermDock's conditions are portable data and async-capable throughcontext, and role hierarchy comes from spreadingmember.grantsintoadminrather than requiringctx.role.
Avoid:
- Boolean-only results.
- Sync-only rules behind an async API: an
asyncrule silently denies. - Runtime string parsing of
"post:write"; permissions are typed references and strings only appear as.keyand.scope. - Requiring a resource object for every action;
collectionactions take none. Partialaction maps that let you forget a rule.@opentelemetry/apias a required peer.- Triple manual generics to split policies across files;
definePermissionsper feature andmergePermissionsneed none.
Decisions informed
- ADR 0003: reference-based permissions
- ADR 0004: actions vs collection
- ADR 0007: decide, not explain
- ADR 0009: boundary validation
- ADR 0010: policy as data, portable conditions
- ADR 0015: no runtime dependencies in core
- Pages shaped: permissions, validation, policies, audit and observability, Standard Schema, otel, usage, larger apps.
Kilpi v1 deep-dive
Source study of @kilpi/core 1.1.3 and its client and React packages, the v0 to v1 API correction, and what PermDock adopts, adapts and avoids from its decision object, subject narrowing and client caching.
Postgres and Supabase RLS research
What Postgres row-level security, Supabase helpers, Drizzle pgPolicy, Prisma 8 policies and the surrounding tooling make possible for a round-trip between PermDock conditions and database policies.