PermDock
Standards

Standard Schema

How PermDock consumes Standard Schema v1 and Standard JSON Schema so resources can be defined with Zod, Valibot, ArkType or Effect Schema without adapters.

What it is

Standard Schema is a small interface that validation libraries implement so downstream tools can accept any of them without per-library adapters. The spec is published as the types-only package @standard-schema/spec and defines three interfaces under a single ~standard key:

  • StandardTypedV1: the base. version: 1, vendor, and optional types: { input, output } used purely for type inference.
  • StandardSchemaV1: adds validate(value, options?), which returns either { value } or { issues: [{ message, path? }] }, synchronously or as a Promise.
  • StandardJSONSchemaV1 (Standard JSON Schema): adds jsonSchema.input(opts) / jsonSchema.output(opts) with a target such as draft-2020-12, draft-07 or openapi-3.0.

Type extraction is StandardSchemaV1.InferInput and StandardSchemaV1.InferOutput, which read the types slot. The spec documents the pattern that sync-only consumers may throw when validate returns a Promise.

Implementers listed on the site include Zod 3.24+, Valibot 1.0+, ArkType 2.0+ and Effect Schema 3.13+ (via adapter), plus yup, joi, typia, Mongoose, VineJS and many smaller libraries. Consumers include tRPC, TanStack Form and Router, Hono middleware, Elysia, oRPC, React Hook Form and several MCP server frameworks.

Why it matters for PermDock

A permissions library has to know the shape of the things it protects. CASL infers nothing from schemas; permix asked users to write parallel interfaces; Kilpi hand-types resource objects per policy. Only @zap-studio/permit builds on Standard Schema, and it has no adapters. PermDock takes the same foundation and uses it in four places:

  1. Types. resource(Post, ...) infers the instance type from InferOutput so permissions.post.update is a Permission over Post and can(permissions.post.update, post) type-checks the second argument.
  2. Conditions. Portable where / check conditions are typed against the schema output, so { where: { authorId: principal.id } } fails to compile if Post has no authorId.
  3. Boundary validation. Data that crossed a trust boundary (HTTP body, MCP tool arguments, client refresh) is validated with the resource schema before a rule runs. See validation and validation.
  4. Export. Standard JSON Schema turns the same resource schemas into JSON Schema for the catalog, permdock catalog, and OpenAPI components, without Zod-specific converters.

Because the interface is a dependency-free set of types, PermDock keeps its zero-runtime-dependency budget while supporting every validator on the list.

How PermDock uses it

import { definePermissions, resource } from "permdock";
import { z } from "zod"; // or valibot / arktype / effect: any Standard Schema

const Post = z.object({
  id: z.string(),
  authorId: z.string(),
  orgId: z.string(),
  published: z.boolean(),
});

export const permissions = definePermissions({
  post: resource(Post, {
    id: "id",
    actions: ["read", "update", "delete", "publish"],
    collection: ["create", "list"],
  }),
  billing: {
    plan: resource({ collection: ["view", "change"] }), // schema-less resource: no instance type, no validation
  },
});

Rules of consumption:

  • resource() accepts anything that satisfies StandardSchemaV1. It reads ~standard.types.output for inference and stores the schema on the resource node, never on the permission leaf, so leaves stay plain JSON.
  • Validation is synchronous at boundaries. validate: 'boundary' (the default in definePolicy) calls ~standard.validate on untrusted data. If the validator returns a Promise, decide and assert throw PermDockValidationError with code async-schema rather than silently denying (can still returns false, never throws), which is the failure mode @zap-studio/permit has with async rules. validate: 'always' and 'never' are the other two modes.
  • Issues become denials. A failed validation yields outcome: 'denied' with a reason of kind validation carrying the issues array, so an MCP client or an HTTP caller sees which field was wrong.
  • JSON Schema export calls ~standard.jsonSchema.output({ target: 'draft-2020-12' }) when the validator implements StandardJSONSchemaV1; a schema-less resource, a validator without it and a converter that throws all produce schema: null in the catalog.
  • @standard-schema/spec is a regular dependency of permdock, not a peer, because its types are part of PermDock's public API. Users never install it themselves.
  • Generated definitions from permdock rls import emit code for the validator chosen with --schema zod|valibot|arktype, or reference existing Drizzle tables via drizzle-zod; the output is still just a Standard Schema.

Mapping table

Standard Schema conceptPermDock concept
StandardSchemaV1 objectFirst argument of resource(schema, options)
InferOutputInstance type of Permission leaves, filter element type, condition field names
InferInputNot used; PermDock reasons about parsed values
validate returning { value }Rule receives the validated value
validate returning { issues }Decision denied with reason.kind === 'validation' and the issues
validate returning a PromisePermDockValidationError at the boundary (sync requirement)
StandardJSONSchemaV1.jsonSchema.outputCatalog JSON Schema, OpenAPI components.schemas, MCP inputSchema cross-check
vendorNot read; every implementer is treated the same

Supported validators

Any implementer works. The ones PermDock documents and tests in tests/types are Zod, Valibot, ArkType and Effect Schema. Schema-less resources (resource({ collection: [...] })) are supported for actions that have no instance.

The boundary is strictly synchronous: there is no async validation mode, and an Effect Schema user passes its synchronous adapter. Field-level grants type their fields list from InferOutput, so a field name the schema does not declare is a type error. permdock rls import ships no built-in schema object: generated definitions import the validator named by --schema (Zod by default).

Sources

Last updated on

On this page