prisma-guard
v1.30.0
Published
Prisma generator + runtime for input validation, query shape enforcement, and row-level tenant isolation
Downloads
1,626
Readme
prisma-guard
Schema-driven security layer for Prisma Generate input validation, query shape enforcement, and tenant isolation directly from your Prisma schema.
prisma-guard helps prevent three common classes of backend mistakes:
- invalid input reaching Prisma
- unsafe or overly broad query shapes
- missing tenant filters in multi-tenant systems
client request
↓
.guard(shape)
↓
validated input + allowed query shape
↓
tenant scoped query
↓
databaseTable of contents
- Why this exists
- What prisma-guard does
- Validation philosophy
- Architecture
- Install
- Quick start
- Generator configuration
- Before / After prisma-guard
- Schema annotations
- The guard API
- Read planning with resolve()
- Relation writes in data shapes
- Logical combinators in where shapes
- Relation filters in where shapes
- Read projection auto-apply
- Mutation return projection
- Enforced projection mode
- Upsert
- Named shapes and caller routing
- Context-dependent shapes
- Automatic tenant isolation
- Multi-root scope behavior
- findUnique behavior
- Output shaping
- Strict Decimal mode
- Security model
- Limitations
- Advanced: SQL-backed runtimes
- Error handling
- How it works internally
- Why this approach
- Performance characteristics
- Security philosophy
- Recommended production configuration
- When to use prisma-guard
- Version compatibility
- Design principles
- Comparison
- Roadmap
- License
Why this exists
Prisma is powerful, but most real backends eventually hit the same problems.
Missing input validation
await prisma.user.create({
data: req.body,
})The client controls the entire payload.
Dangerous query shapes
A client-controlled include chain can traverse relations and select sensitive fields from unrelated models:
await prisma.project.findMany({
include: {
tasks: {
include: {
comments: {
include: {
author: {
select: {
email: true,
passwordHash: true,
resetToken: true,
},
},
},
},
},
},
},
})Tenant isolation bugs
await prisma.project.findFirst({
where: { id: { equals: projectId } },
})If projectId belongs to another tenant, data can leak.
What prisma-guard does
prisma-guard turns your Prisma schema into a runtime data boundary layer.
| Feature | Description | | ----------------------- | ------------------------------------------- | | Input validation | Zod schemas generated from Prisma types | | Query shape enforcement | Only allowed query shapes pass validation | | Tenant isolation | Prisma extension injects tenant filters | | Schema-driven | Rules come directly from your Prisma schema |
The goal is simple:
Clients should not be able to accidentally or maliciously escape the data boundary defined by your schema.
prisma-guard is focused on data boundaries, not RBAC.
Role-based access control is intentionally out of scope.
Validation philosophy
prisma-guard is intentionally not a strict scalar-validation framework by default.
The default runtime behavior focuses on:
- enforcing the allowed query and data shape
- rejecting unknown fields and unsupported operations
- coercing common input values into Prisma-compatible types where reasonable
- preventing dangerous ambiguity, destructive unconstrained operations, and silent shape misconfiguration
This means prisma-guard defaults are practical and frontend-friendly. Inputs often arrive from forms, query strings, JSON bodies, and frontend state where values may need light coercion before reaching Prisma.
Strict business validation is opt-in. Use one of these when exact scalar rules matter:
@zoddirectives in the Prisma schema- inline refine functions in
data,create, orupdateshapes guard.input({ refine })- application-level validation before calling guarded Prisma methods
model User {
id String @id @default(cuid())
/// @zod .email().max(255)
email String
}await prisma.user
.guard({
data: {
age: (base) => base.int().min(18).max(120),
},
})
.create({ data: req.body })Scalar coercion defaults
Default scalar validation is intentionally permissive where that is useful and Prisma-compatible.
Examples:
Intaccepts practical numeric input and coerces to an integer value.Floataccepts JavaScript numeric input.DateTimeaccepts values that can be parsed into a JavaScriptDate.Decimalaccepts JavaScriptnumber, decimal string, and Decimal-like objects unless strict Decimal mode is enabled.
For stricter behavior, add @zod rules or a refine function. The guard should stay easy to use by default; exact business rules belong in explicit validation.
This leniency does not apply to boundary safety. prisma-guard should still reject unknown fields, invalid shape keys, unsafe projection behavior, unconstrained destructive nested writes, and shape configs that look restrictive but would otherwise be silently ignored.
Architecture
prisma-guard sits between your application and Prisma Client. The .guard(shape) call defines the boundary; the chained Prisma method validates and executes in one step.
┌───────────────┐
│ Client │
│ (API / RPC) │
└───────┬───────┘
│ request
▼
┌──────────────────────┐
│ .guard(shape) │
│ validates input + │
│ enforces query shape │
└─────────┬────────────┘
│ validated args
▼
┌──────────────────────┐
│ Tenant Scope Layer │
│ (Prisma extension) │
│ injects tenant filter │
└─────────┬────────────┘
│ scoped query
▼
┌──────────────────────┐
│ Prisma Client │
└─────────┬────────────┘
│ SQL
▼
┌──────────────────────┐
│ Database │
└──────────────────────┘Install
npm install prisma-guard zod @prisma/clientPeer dependencies:
zod ^4@prisma/client ^6 || ^7
Both zod and @prisma/client must be installed before running prisma generate. The generator validates @zod directives against real Zod schemas at generation time.
Generated output files (index.ts, client.ts, and optionally shapes.ts) are TypeScript. A TypeScript-capable build pipeline is required.
Generated internal imports follow the consuming project's TypeScript setup. By default, importStyle = "auto" reads the nearest tsconfig.json from the generator output directory, follows relative and package-style extends, and chooses extensionless, .js, or .ts imports based on module, moduleResolution, allowImportingTsExtensions, and nearest package.json "type". This keeps CommonJS/classic TypeScript projects on extensionless imports and NodeNext/Node16 ESM projects on .js imports. If auto-detection does not match your build, set importStyle explicitly.
Quick start
1. Add the generator
generator guard {
provider = "prisma-guard"
output = "generated/guard"
}2. Generate
npx prisma generateThis emits a ready-to-use client.ts with all type mappings and a pre-wired guard instance.
3. Set up the Prisma client
import { AsyncLocalStorage } from 'node:async_hooks'
import { PrismaClient } from '@prisma/client'
import { guard } from './generated/guard/client'
const store = new AsyncLocalStorage<{ tenantId: string }>()
const prisma = new PrismaClient().$extends(
guard.extension(() => ({
Tenant: store.getStore()?.tenantId,
}))
)4. Provide request context
The context function reads from AsyncLocalStorage, so each request needs to run inside a store scope:
await store.run({ tenantId }, async () => {
await handler()
})In a web framework, set the store once per request and run the route handler inside it.
5. Use it
await prisma.project
.guard({
data: { title: true },
})
.create({ data: req.body })
await prisma.project
.guard({
where: { title: { contains: true } },
orderBy: { title: true },
take: { max: 50, default: 20 },
})
.findMany(req.body)That's it. Input is validated, query shape is enforced, tenant scope is injected. All in one chain.
Generator configuration
All generator config values are strings because Prisma generator config is string-based.
generator guard {
provider = "prisma-guard"
output = "generated/guard"
onInvalidZod = "error" // error | warn
onAmbiguousScope = "error" // error | warn | ignore
onMissingScopeContext = "error" // error | warn | ignore
findUniqueMode = "reject" // reject | verify
onScopeRelationWrite = "error" // error | warn | strip
strictDecimal = "false" // true | false
enforceProjection = "false" // true | false
typedGuardShapes = "true" // true | false
typedGuardRelationDepth = "1" // 0 | 1 | 2 | 3
importStyle = "auto" // auto | none | js | ts
runtimeImportPath = "prisma-guard"
}Typed shape generation depth
typedGuardShapes controls whether the generator emits shapes.ts helper types.
When typedGuardShapes = "false", prisma-guard does not emit shapes.ts. If an older generated shapes.ts exists in the output directory, it is removed on the next generation.
typedGuardRelationDepth controls how deeply generated TypeScript helper types expand relation shapes:
| Value | Behavior |
| ----- | -------- |
| "0" | Do not expand relation shapes in generated helper types |
| "1" | Expand direct relations; default |
| "2" | Expand relations two levels deep |
| "3" | Expand relations three levels deep |
Higher values give richer editor assistance for nested projections and relation filters, but can make generated types heavier. Runtime validation is not limited by this setting.
Import style
importStyle controls imports inside generated client.ts and shapes.ts.
| Value | Behavior |
| ----- | -------- |
| "auto" | Read project tsconfig.json and nearest package.json to choose the import style |
| "none" | Use extensionless imports like ./index |
| "js" | Use .js imports like ./index.js |
| "ts" | Use .ts imports like ./index.ts |
Auto mode chooses:
"ts"whenallowImportingTsExtensionsistrue"js"formoduleormoduleResolutionvalues likeNode16,Node18, orNodeNext"none"for classic CommonJS-style resolution likenode,node10,classic, orbundler"js"when no decisivetsconfig.jsonsetting is found but nearestpackage.jsonhas"type": "module""none"as final fallback
runtimeImportPath controls where generated files import runtime APIs from. The default is prisma-guard. This is mainly useful for monorepos, local package aliases, or development builds.
Before / After prisma-guard
Without prisma-guard
await prisma.project.create({
data: req.body,
})
await prisma.project.findMany(req.query)
await prisma.project.findFirst({
where: { id: { equals: projectId } },
})Problems:
- unvalidated input
- unrestricted query shape
- missing tenant filter
With prisma-guard
await prisma.project
.guard({ data: { title: true } })
.create({ data: req.body })
await prisma.project
.guard({
where: { title: { contains: true } },
take: { max: 50, default: 20 },
})
.findMany(req.body)
await prisma.project
.guard({
where: { id: { equals: true } },
})
.findFirst({ where: { id: { equals: projectId } } })| Risk | Mitigation |
| ------------------- | ------------------------------------------------ |
| arbitrary input | data shape restricts writable fields + Zod |
| expensive queries | shape whitelist on where, include, take, orderBy |
| cross-tenant access | automatic scope injection via extension |
Schema annotations
Mark tenant root models
Use @scope-root on models that represent tenant roots.
/// @scope-root
model Tenant {
id String @id @default(cuid())
name String
}Models with a single unambiguous foreign key to a scope root are auto-scoped. A model can be scoped by multiple roots if it has foreign keys to different scope root models — see Multi-root scope behavior.
If a model has multiple foreign keys to the same scope root, the ambiguous root is excluded from that model's scope entries when onAmbiguousScope is "warn" or "ignore", and causes a generation error when onAmbiguousScope is "error" (the default). Other non-ambiguous roots on the same model are still auto-scoped.
Scope root models themselves are context roots. They are not automatically scoped by their own @scope-root marker. For example, if Tenant is marked @scope-root, child models with tenantId are scoped, but direct tenant.findMany() calls are not scoped by the tenant root marker. Do not expose root model delegates directly unless you add your own shape rules or application-level authorization.
Add field-level validation with @zod
model User {
id String @id @default(cuid())
/// @zod .email().max(255)
email String
/// @zod .min(1).max(100)
name String?
}@zod chains apply automatically when the field appears in a data shape with true.
@zod directives are validated during prisma generate. The generator validates directive syntax, checks that each chained method is in the allowed list, checks argument count, and attempts to construct the final Zod schema from the generated base type. Invalid chains such as .nullable().email() are rejected because schema construction fails. Some argument-level type mismatches are only caught if Zod throws while the schema is being constructed.
For list fields, @zod chains apply to the z.array(...) schema, not to individual elements. For example, .min(1) on a String[] field enforces a minimum array length of 1, not a minimum string length.
Supported @zod methods
The @zod DSL supports a restricted subset of Zod methods. These are the allowed methods:
String validations: min, max, length, email, url, uuid, cuid, cuid2, ulid, trim, toLowerCase, toUpperCase, startsWith, endsWith, includes, regex, datetime, ip, cidr, date, time, duration, base64, nanoid, emoji
Number validations: int, positive, nonnegative, negative, nonpositive, finite, safe, multipleOf, step, gt, gte, lt, lte
Array validations: min, max, length, nonempty
Field modifiers: optional, nullable, nullish, default, catch, readonly
Note on field modifiers: prisma-guard already handles optional and nullable based on Prisma field metadata (isRequired, hasDefault). Adding @zod .optional() or @zod .nullable() explicitly will apply the Zod method on top of what prisma-guard already does, which may cause double-wrapping. Use these only when you need to override prisma-guard's default behavior.
Note on default and catch: a @zod .default(...) chain adds a Zod-level default, which means Zod will fill in the value if it's undefined. A @zod .catch(...) chain provides a fallback value when parsing fails. The generator detects both .default() and .catch() in chains and emits a ZOD_DEFAULTS map. The create completeness check honors Prisma's @default attribute, @zod .default(...), and @zod .catch(...) — a required field with any of these sources of default is not flagged as missing from create data shapes. Prisma's @default remains the primary source of truth; @zod .default(...) and @zod .catch(...) are additional signals.
When a field has @zod .default(...) or @zod .catch(...) and appears in a data shape as true, prisma-guard preserves the Zod default/catch behavior in create mode by not wrapping the schema with .optional(). This ensures that omitting the field from client input triggers the Zod default or catch value rather than passing undefined through. If such a field is omitted from the data shape entirely (not listed as a key), the runtime auto-injects its default value as a forced field — the client cannot provide it, and the Zod default is always applied.
Note on chain ordering: type-changing methods like .nullable(), .optional(), .default(), and .catch() alter the wrapper type. Methods that follow a type-changing method must exist on the resulting wrapper, not on the original base type. For example, .email().nullable() is valid (.email() returns a string schema, .nullable() wraps it), but .nullable().email() is invalid (.nullable() returns a nullable wrapper that does not have .email()).
Supported argument types in @zod directives
The directive parser accepts these argument types: strings ('hello', "hello"), numbers (42, -3.14, 1e2), booleans (true, false), arrays ([1, 2, 3]), regex literals (/^[a-z]+$/i), and object literals ({offset: true}). Identifiers, template literals, null, NaN, Infinity, and function calls are not allowed.
refine replaces @zod chains
Both guard.input({ refine }) and inline refine functions in data shapes bypass @zod chains. The function receives the base Zod type (without @zod chains applied). This is by design — refine is a full override, not a modifier on top of @zod.
guard.input('User', {
refine: {
email: (base) => base.email().max(320),
},
})In this example, any @zod directive on the email field in the Prisma schema is ignored. The refine callback is the sole source of validation for that field.
Refine callbacks and inline refine functions must return a valid Zod schema. If the callback throws or returns a non-Zod value, a ShapeError is raised.
When a refine function returns a schema that handles undefined input (e.g. by including .default(...) or .catch(...)), prisma-guard detects this at runtime and preserves the default/catch behavior by not wrapping the schema with .optional() in create mode.
The guard API
.guard(shape, caller?) is available on every model delegate. It returns an object with all Prisma methods plus resolve(). The shape defines the boundary; the chained Prisma method validates and executes. resolve() is a read-planning helper that resolves the same shape boundary without executing Prisma.
Data shape syntax
Each field in a data shape accepts these value types:
true— the client may provide this value;@zodchains apply automatically- literal value — the server forces this value; the client cannot override it
force(value)— the server forces this value; required when the value is literallytrue(see Theforce()helper)unsupported()— explicitly acknowledge and omit anUnsupported(...)Prisma field from client input- function
(base) => schema— the client may provide this value; the function receives the base Zod type (without@zodchains) and returns a refined schema
import { force } from 'prisma-guard'
await prisma.project
.guard({
data: {
title: (base) => base.min(1, 'Title required').max(200),
status: true,
priority: (base) => base.refine(v => v >= 1 && v <= 5, 'Priority 1-5'),
createdBy: currentUserId,
isActive: force(true),
},
})
.create({ data: req.body })In this example, title and priority use inline refines for custom validation and error messages, status uses @zod chains from the Prisma schema, createdBy is forced to currentUserId, and isActive is forced to true using the force() helper.
Relation fields are supported in data shapes with a config object describing allowed nested write operations. See Relation writes in data shapes for syntax and security implications.
The force() helper
The value true in a shape always means "client-controlled." This creates ambiguity when you need to force a boolean field to the literal value true. The force() helper resolves this:
import { force } from 'prisma-guard'
data: { isActive: true } // client-controlled — client sends any boolean
data: { isActive: false } // forced to false
data: { isActive: force(true) } // forced to true
data: { isActive: force(false) } // also valid — equivalent to just falseforce() works in both data shapes and where shapes:
where: {
published: { equals: true }, // client-controlled — client sends any boolean
isDeleted: { equals: false }, // forced to false
isActive: { equals: force(true) }, // forced to true
}force() wraps the value in a marker object. It can wrap any value type, not just booleans. Using force() on non-true values is allowed but unnecessary — only the literal true collides with the client-controlled sentinel.
Unsupported Prisma field types
Prisma fields declared as Unsupported(...) are never client-controlled. A data shape like this is rejected:
data: { rawVector: true }Use unsupported() when the field exists in the Prisma schema but should be intentionally omitted from guarded input:
import { unsupported } from 'prisma-guard'
data: { rawVector: unsupported() }You may still force an unsupported field from trusted server code:
data: { rawVector: serverComputedValue }Unsupported fields are not filterable in where shapes.
Unsupported fields are also not exposed through guard.input() or guard.model() by default. In data shapes, unsupported fields cannot be client-controlled; use unsupported() to acknowledge that the field is intentionally omitted from guarded input, or provide a forced server-side value.
Query shape syntax
For read operations, true means the client may provide this value and literal values are forced:
Reads
await prisma.project
.guard({
where: { title: { contains: true } },
orderBy: { title: true },
take: { max: 100, default: 25 },
})
.findMany(req.body)The client can only filter by title, sort by title, and take up to 100 rows. Everything else is rejected.
Take shorthand
take accepts either an object or a number. When a number is provided, it serves as both the maximum and the default:
take: 50 // equivalent to { max: 50, default: 50 }
take: { max: 100, default: 25 } // client may send 1..100; omitted value becomes 25
take: { max: 100 } // client may send 1..100; omitted value stays omittedThis shorthand applies anywhere take is supported, including nested relation projections. Use { max } without default only when an omitted take should remain omitted.
Order by shapes
orderBy shape config uses true per sortable field:
orderBy: { createdAt: true, title: true }Client input then uses Prisma sort directions:
{ orderBy: { createdAt: 'desc' } }Do not put filter operators under orderBy. This is rejected:
orderBy: { title: { contains: true } }For groupBy, normal grouped fields and _count fields must also be configured with true.
Unique where shapes
findUnique, findUniqueOrThrow, update, delete, and upsert use Prisma's WhereUniqueInput syntax. For these methods, unique fields are configured directly in the shape:
await prisma.project
.guard({
where: { id: true },
})
.update({
data: { title: 'Updated' },
where: { id: 'abc123' },
})Do not use filter operator objects such as { id: { equals: true } } in unique where shapes. That syntax belongs to normal WhereInput filters used by methods such as findMany, findFirst, count, updateMany, and deleteMany.
If a model has multiple single-field unique selectors and the shape lists more than one, the client may use any allowed selector. For example, where: { id: true, slug: true } allows a request with where: { id: '...' } or where: { slug: '...' }. For compound unique constraints, use Prisma's generated compound selector name:
model ProjectMember {
tenantId String
userId String
@@unique([tenantId, userId])
}await prisma.projectMember
.guard({
where: {
tenantId_userId: {
tenantId: true,
userId: true,
},
},
})
.update({
data: req.body.data,
where: {
tenantId_userId: {
tenantId: 'tenant_1',
userId: 'user_1',
},
},
})Named compound constraints use the configured name as the selector:
@@unique([tenantId, slug], name: "project_slug_per_tenant")where: {
project_slug_per_tenant: {
tenantId: true,
slug: true,
},
}Creates
await prisma.project
.guard({
data: { title: true, status: true },
})
.create({ data: req.body })Only title and status are accepted from the client. @zod chains apply automatically.
For create operations, guard validates that all required fields without defaults are accounted for in the data shape — either as client-allowed (true or function), forced (literal value or force()), as scope foreign keys that the scope extension will inject automatically, or as fields with a @zod .default(...) or @zod .catch(...) directive. If a required field is missing from the shape and has no Prisma @default, no @zod .default(...) or @zod .catch(...), and is not a scope FK, guard throws ShapeError at shape evaluation time.
Fields with @zod .default(...) or @zod .catch(...) that are omitted from the data shape are automatically injected as forced values at runtime. The Zod schema is evaluated with undefined input and the resulting default or catch value is included in the create data. This ensures the field always has a value without requiring the client to provide one or the developer to list it in the shape.
Updates
await prisma.project
.guard({
data: { title: true },
where: { id: true },
})
.update({
data: { title: 'New title' },
where: { id: 'abc123' },
})In update mode, all data fields are optional. The where shape must use Prisma unique selector syntax for update.
Forced values
Literal values in the shape are forced by the server and cannot be overridden by the client:
import { force } from 'prisma-guard'
await prisma.project
.guard({
data: { title: true, status: 'draft', isActive: force(true) },
})
.create({ data: req.body })status is always 'draft' and isActive is always true regardless of what the client sends.
The same applies to where:
await prisma.project
.guard({
where: {
status: { equals: 'published' },
isActive: { equals: force(true) },
title: { contains: true },
},
})
.findMany(req.body)status = 'published' and isActive = true are always enforced. The client can only control the title filter.
Forced where conditions are conflict-checked during shape construction. If the same field and operator appear with different forced values in different parts of a shape (e.g. at the top level and inside a combinator), the shape is rejected with ShapeError. This prevents ambiguous security configurations where one forced value would silently overwrite another. Forced NOT conditions are preserved as separate logical NOT branches when merged with client-provided NOT, rather than being merged like scalar fields.
Deletes
await prisma.project
.guard({
where: { id: true },
})
.delete({ where: { id: 'abc123' } })data is not valid for delete shapes. The where shape must use Prisma unique selector syntax for delete.
Batch creates
await prisma.project
.guard({
data: { title: true, status: true },
})
.createMany({
data: [
{ title: 'Project A', status: 'active' },
{ title: 'Project B', status: 'draft' },
],
})Each item in the array is validated against the same data shape.
In guarded mode, createMany and createManyAndReturn require data to be an array. Single-object data is not silently wrapped.
createMany and createManyAndReturn also accept skipDuplicates: boolean in the request body. This is passed through to Prisma without shape-level configuration.
Bulk mutations
updateMany, updateManyAndReturn, and deleteMany require a where shape in the guard definition. This prevents accidental unconstrained bulk writes.
await prisma.project
.guard({
data: { status: true },
where: { status: { equals: true } },
})
.updateMany({
data: { status: 'archived' },
where: { status: { equals: 'draft' } },
})A guard shape without where on a bulk mutation method throws ShapeError. Additionally, if the client provides no where conditions at runtime (or the resolved where is empty), the request is rejected. Each where operator object must contain at least one operator with a value — empty operator objects like { status: {} } are rejected.
When combinators (AND, OR, NOT) are exposed in the where shape, the guard also prevents vacuous filters. Combinator arrays must contain at least one element, and each element must specify at least one condition with a defined value. Structures like { AND: [] }, { AND: [{}] }, and { NOT: [] } are rejected. See Logical combinators in where shapes for details.
Mutation body validation
Mutation bodies are strictly validated. The accepted keys depend on whether the shape defines a return projection (select or include):
Without projection in shape:
create:datacreateMany,createManyAndReturn:data,skipDuplicatesupdate,updateMany,updateManyAndReturn:data,whereupsert:where,create,update,select,includedelete,deleteMany:where
With projection in shape (methods that support it):
create:data,select,includecreateManyAndReturn:data,select,include,skipDuplicatesupdate,updateManyAndReturn:data,where,select,includeupsert:where,create,update,select,includedelete:where,select,include
For createMany and createManyAndReturn, skipDuplicates is also accepted as a body key. It must be a boolean if provided.
Unknown keys are rejected with ShapeError. If the body contains select or include but the shape does not define them, the request is rejected.
Guard shape keys are also validated per method:
- create methods accept
data, and optionallyselect/include(if the method supports projection) - update methods accept
data,where, and optionallyselect/include - upsert accepts
where,create,update, and optionallyselect/include - delete methods accept
where, and optionallyselect/include
Shape keys not valid for the method throw ShapeError.
Supported shape keys
For reads: where, include, select, orderBy, cursor, take, skip, distinct, _count, _avg, _sum, _min, _max, by, having
For writes: data, where, select, include (select/include only on methods that return records)
For upsert: where, create, update, select, include
Where shapes accept scalar field filters, relation filters (some, every, none, is, isNot), and logical combinators (AND, OR, NOT).
Shape config value validation
Shape config values are strictly validated at construction time. Fields in orderBy, cursor, having, _count (object form), _avg, _sum, _min, _max must have the value true. The skip config must be exactly true. Passing any other value (including false, numbers, or strings) throws ShapeError. This prevents accidental misconfiguration where a developer writes { orderBy: { title: false } } expecting it to disable ordering — instead of silently enabling it, the shape is rejected.
Where DSL: Prisma-compatible subset
The where shape syntax supports a subset of Prisma's where filter API. This section documents both supported features and known differences.
Supported operators:
All standard scalar operators are supported: equals, not, contains, startsWith, endsWith, in, notIn, gt, gte, lt, lte, search.
The not operator accepts either a scalar value or a nested filter object:
where: {
age: { not: true }, // client can send { age: { not: 5 } } or { age: { not: { gt: 5 } } }
}The search operator is supported for String fields with @@fulltext indexes:
where: {
title: { search: true },
}Case-insensitive string filtering with mode:
String fields support Prisma's mode modifier alongside contains, startsWith, endsWith, and equals. Use mode: true to let the client choose, or force a specific mode in the shape:
where: {
// client controls mode
title: { contains: true, mode: true },
// server forces case-insensitive matching
description: { contains: true, mode: 'insensitive' },
// server forces case-insensitive matching using force()
slug: { contains: true, mode: force('insensitive') },
}mode is also supported on Json fields with the string_contains, string_starts_with, and string_ends_with operators. The shape must include at least one mode-compatible operator alongside mode — { title: { mode: true } } alone is rejected.
When mode is forced (e.g. mode: 'insensitive') and the client provides a compatible operator value (e.g. { contains: 'foo' }), the forced mode is inlined into the same operator object, producing { contains: 'foo', mode: 'insensitive' } in the final Prisma query. This is required for mode to actually affect the query — Prisma's mode is a modifier that must be co-located with the string operator it modifies.
Relation existence checks with is: null / isNot: null:
To-one relation filters support null checks for testing relation existence. In the shape config, use null as the operator value to force a null check:
where: {
author: {
is: null, // forced: always filters for records where author IS null
},
}This produces { author: { is: null } } in the Prisma query. Since null in a shape is always a forced value (the client cannot control it), this is equivalent to a forced where condition.
Notable differences from raw Prisma where clauses:
ANDandORin client input must be arrays with at least one element. Prisma accepts a single object forAND; prisma-guard requires an array. Empty arrays are rejected.NOTin client input accepts a single object or an array with at least one element. Empty arrays are rejected.- Each combinator member must specify at least one condition with a defined value. Empty objects inside combinators (e.g.
{ AND: [{}] }) are rejected when no forced values exist. - Relation filter operators (
some,every,none,is,isNot) require at least one nested condition when all conditions are client-controlled. Empty relation filters like{ posts: { some: {} } }are rejected. - Relation operator containers require at least one operator.
{ posts: {} }is rejected. - Empty combinator and relation filter definitions in shapes are rejected at shape construction time. A shape like
where: { AND: {} }throwsShapeError. - Forced where conditions are conflict-checked at shape construction time. The same field and operator with different forced values across shape branches (e.g. top-level vs inside a combinator) is rejected with
ShapeError.
These restrictions are intentional. They prevent clients from sending structurally valid but semantically vacuous filters that could broaden query scope, particularly in bulk mutation where clauses.
Body normalization
Read methods and mutation methods accept undefined or null as body input across all API surfaces. Missing bodies are consistently normalized to {} (empty object). This applies to single shapes, named shapes, and guard.query().parse(). An explicit body, when provided, must be a plain object.
Supported methods
Reads: findMany, findFirst, findFirstOrThrow, findUnique, findUniqueOrThrow, count, aggregate, groupBy
Writes: create, createMany, createManyAndReturn, update, updateMany, updateManyAndReturn, upsert, delete, deleteMany
findManyPaginated
findManyPaginated is an intentional operation shape used by the integrated
prisma-generator-express stack.
It is not expected to map 1:1 to a native Prisma Client method. It exists so guard shapes can be generated for the paginated route/helper layer.
Read planning with resolve()
.guard(shape, caller?).resolve(body?) resolves a read shape without executing a Prisma query.
Use it when integration code needs to inspect the concrete guard shape and effective read body before deciding how to run a read. Typical use cases are generated routers, progressive response streaming, query planners, and adapters that need the same shape resolution as the real guarded method.
const resolved = prisma.user
.guard(
{
me: (ctx) => ({
where: {
id: { equals: force(ctx.userId) },
},
select: {
id: true,
email: true,
profile: {
select: {
id: true,
displayName: true,
},
},
},
}),
},
'me',
)
.resolve()The return value is:
{
shape: GuardShape
body: Record<string, unknown>
effectiveReadBody: Record<string, unknown>
matchedKey: string
wasDynamic: boolean
}| Field | Meaning |
| ----- | ------- |
| shape | The concrete resolved guard shape after caller matching and dynamic shape execution |
| body | The normalized request body. Omitted input becomes {} |
| effectiveReadBody | The body used for read planning. If the body has no select or include, the shape's default read projection is applied |
| matchedKey | The selected named-shape key, such as default, admin, or /user/me |
| wasDynamic | true when the matched shape was a function and was executed with guard context |
resolve() uses the same guard extension context and caller selection path as .findMany(), .findFirst(), and the other guarded methods. If the shape is context-dependent, the shape function is called with the current guard context.
resolve() is intentionally read-only. It rejects top-level data, create, and update keys on either the resolved shape or the request body. Use the corresponding guarded write method for writes.
effectiveReadBody is planning input, not final Prisma args. It does not replace the normal guarded read execution pipeline. The actual guarded read method still validates the body, applies forced values, applies default projection rules, injects scope filters, and calls Prisma.
No Prisma delegate method is called by resolve().
Relation writes in data shapes
Data shapes support relation fields with a config object describing which nested write operations the client may use. Each operation (connect, create, disconnect, etc.) is configured individually.
⚠️ Security warning: The automatic tenant scope extension only intercepts top-level operations. Nested writes through relation configs bypass scope entirely — no FK injection on nested creates, no tenant filtering on nested updates/deletes, and no tenant filtering on nested connects. If you use relation writes on scoped models, handle tenant isolation manually in application code or enforce it via database constraints such as RLS, triggers, and foreign keys.
Be especially careful with destructive nested operations. An unconstrained nested delete such as
{ posts: { deleteMany: {} } }can delete all related children under the parent. prisma-guard treats unconstrained destructive nested writes as unsafe boundary configuration; expose them only through explicit server-side code.
Syntax
await prisma.post
.guard({
data: {
title: true,
content: true,
tags: {
connect: { id: true },
disconnect: { id: true },
},
},
where: { id: true },
})
.update({
data: {
title: 'Updated',
tags: {
connect: [{ id: 'tag1' }, { id: 'tag2' }],
disconnect: [{ id: 'tag3' }],
},
},
where: { id: 'post1' },
})Supported operations
All 11 Prisma nested write operations are supported:
| Operation | To-one | To-many | Config type |
| ----------------- | ------ | ------- | ------------------------------------------------ |
| connect | yes | yes | unique selector config, e.g. { id: true } |
| connectOrCreate | yes | yes | { where: unique selector, create: { ... } } |
| create | yes | yes | { fieldName: true, ... } |
| createMany | no | yes | { data: { fieldName: true, ... } } |
| disconnect | yes | yes | true (to-one) or unique selector config (to-many) |
| delete | yes | yes | true (to-one) or unique selector config (to-many) |
| set | no | yes | unique selector config |
| update | yes | yes | { fieldName: true } or { where: unique selector, data: ... } |
| updateMany | no | yes | { where: { ... }, data: { ... } } |
| upsert | yes | yes | { where?: unique selector, create: { ... }, update: { ... } } |
| deleteMany | no | yes | filter config; unconstrained empty object is unsafe |
For to-many relation operations that use unique selectors, prisma-guard accepts Prisma-compatible single-object and array forms:
tags: {
connect: { id: 'tag1' },
disconnect: [{ id: 'tag2' }],
set: { id: 'tag3' },
}Empty arrays are accepted where Prisma accepts them, for example set: [] to clear a to-many relation.
Example with multiple operations
await prisma.user
.guard({
data: {
name: true,
posts: {
create: { title: true, content: true },
connect: { id: true },
disconnect: { id: true },
update: {
where: { id: true },
data: { title: true },
},
},
},
where: { id: true },
})
.update({
data: {
name: 'Updated Name',
posts: {
create: { title: 'New Post', content: 'Content' },
connect: [{ id: 'existing-post-id' }],
},
},
where: { id: userId },
})Validation
Each operation's config is validated at shape construction time:
- Unknown operations throw
ShapeError - Operations invalid for the relation cardinality throw
ShapeError(e.g.seton to-one,disconnect: trueon to-many) - Nested data fields are validated against the related model's type map
- Relation fields inside nested data are not recursively expanded; nested write shapes support one relation-write level
- Nested create paths validate configured fields with create semantics, but final required-field completeness may still be enforced by Prisma
- Nested update paths use update semantics: all configured data fields are optional at runtime
- Operations that use Prisma
WhereUniqueInputsemantics should be configured with unique fields or compound unique selector names @zodchains apply to nested data fields
Unique selectors in relation writes
Relation write operations such as connect, disconnect, delete, set, connectOrCreate.where, update.where, and upsert.where use Prisma unique selector semantics.
For single-field unique selectors, configure the field directly:
posts: {
connect: { id: true },
}For compound unique constraints, use Prisma's compound selector name, same as top-level unique where shapes:
members: {
connect: {
tenantId_userId: {
tenantId: true,
userId: true,
},
},
}Scope implications
Nested writes bypass the scope extension because Prisma extension hooks only fire for top-level operations. This means:
- Nested creates do not get scope FK injection — the related record will not have the tenant FK set automatically
- Nested updates/deletes do not get tenant where conditions — they can affect records across tenants
- Nested connects reference records by unique fields without tenant filtering
For multi-tenant applications, consider:
- Using database-level constraints (foreign keys, RLS policies) to enforce tenant boundaries on related models
- Restricting relation write operations to
connectanddisconnectonly (which reference existing records by ID) - Using forced values for tenant FK fields in nested create configs where possible
Logical combinators in where shapes
Where shapes support AND, OR, and NOT to compose filter conditions. The combinator value is a where config defining allowed fields inside the combinator:
await prisma.project
.guard({
where: {
OR: {
title: { contains: true },
description: { contains: true },
},
},
take: { max: 50 },
})
.findMany({
where: {
OR: [
{ title: { contains: 'demo' } },
{ description: { contains: 'demo' } },
],
},
})The shape defines which fields are allowed inside each combinator. The client sends arrays for AND/OR and an object or array for NOT.
Forced values inside combinators are lifted to the top-level query as AND conditions, regardless of the combinator type. This means a forced value inside an OR shape does not become an OR branch — it becomes an additional AND constraint on the entire query. This is consistent with the fail-closed design: forced values always restrict, never broaden.
import { force } from 'prisma-guard'
await prisma.project
.guard({
where: {
title: { contains: true },
NOT: {
status: { equals: 'archived' },
},
},
})
.findMany({
where: { title: { contains: 'demo' } },
})status = 'archived' is always excluded regardless of client input.
Combinators can be nested and mixed with scalar fields freely. The same field can appear both at the top level and inside a combinator.
If the same field and operator appear as forced values in different parts of the shape (e.g. top-level and inside an AND combinator), the forced values are conflict-checked. Identical values are deduplicated. Different values throw ShapeError — this prevents ambiguous security configurations from silently degrading.
Combinator validation rules
Combinator definitions in shapes must define at least one field. An empty combinator like where: { AND: {} } throws ShapeError at shape construction time. This prevents silent no-op branches that look restrictive but contribute nothing.
At runtime, combinator arrays from client input must contain at least one element. { AND: [] }, { OR: [] }, and { NOT: [] } are all rejected.
When no forced values exist inside a combinator, each member object must specify at least one condition with a defined value. { AND: [{}] } is rejected because the empty object carries no filtering constraint. This prevents clients from satisfying structural validation while bypassing semantic filtering, which is particularly important for bulk mutations where a vacuous where clause could affect all rows.
When a combinator branch contains forced values, client members may be empty because the forced values still provide meaningful filtering constraints.
Relation filters in where shapes
Where shapes support relation-level filters using Prisma's relation operators. To-many relations support some, every, and none. To-one relations support is and isNot.
await prisma.user
.guard({
where: {
posts: {
some: {
title: { contains: true },
published: { equals: true },
},
},
},
})
.findMany({
where: {
posts: {
some: {
title: { contains: 'guide' },
published: { equals: true },
},
},
},
})Each relation operator value is a nested where config for the related model. All where features — scalar operators, forced values, logical combinators, and nested relation filters — work recursively inside relation filters.
Null existence checks for to-one relations
To-one relation operators support null for testing whether a relation exists:
await prisma.post
.guard({
where: {
author: {
is: null, // forced: filter for posts where author IS null
},
},
})
.findMany(req.body)In the shape config, null as an operator value is always forced — the client cannot control it. This is the standard Prisma pattern for checking to-one relation existence.
Forced values in relation filters
import { force } from 'prisma-guard'
await prisma.user
.guard({
where: {
posts: {
some: {
title: { contains: true },
status: { equals: 'published' },
},
},
},
})
.findMany({
where: {
posts: {
some: { title: { contains: 'guide' } },
},
},
})status = 'published' is always enforced inside the some operator.
To-one relations
await prisma.post
.guard({
where: {
author: {
is: {
role: { equals: true },
},
},
},
})
.findMany({
where: {
author: {
is: { role: { equals: 'ADMIN' } },
},
},
})Combined with logical combinators
await prisma.user
.guard({
where: {
OR: {
posts: {
some: { published: { equals: true } },
},
profile: {
is: { bio: { contains: true } },
},
},
},
})
.findMany({
where: {
OR: [
{ posts: { some: { published: { equals: true } } } },
{ profile: { is: { bio: { contains: 'engineer' } } } },
],
},
})Using an unsupported operator for the relation type throws ShapeError. For example, some on a to-one relation or is on a to-many relation is rejected.
Relation filter validation rules
Relation filter definitions in shapes must define at least one operator. An empty relation filter like where: { posts: {} } throws ShapeError at shape construction time.
Each operator's nested where config must define at least one field. An empty nested where like where: { posts: { some: {} } } throws ShapeError at shape construction time.
When all conditions inside a relation operator are client-controlled (no forced values), the client must provide at least one condition. Empty nested where objects are rejected:
where: {
posts: {
some: {
title: { contains: true },
},
},
}
// Rejected — at least one condition required
{ where: { posts: { some: {} } } }
// Accepted
{ where: { posts: { some: { title: { contains: 'demo' } } } } }When a relation operator contains forced values, the client may omit all client-controlled conditions. The forced values are still injected:
where: {
posts: {
some: {
status: { equals: 'published' },
title: { contains: true },
},
},
}
// Accepted — forced status is still applied
{ where: { posts: { some: {} } } }
// Becomes: { posts: { some: { status: { equals: 'published' } } } }Read projection auto-apply
When a read shape defines select or include, the projection serves two roles: it whitelists what the client is allowed to request, and it provides the default projection when the client omits select/include from the body.
The same default-projection behavior is exposed for planning through resolve(): effectiveReadBody contains the request body plus the synthesized default projection when the client omitted projection.
A client-provided select or include is treated as a narrowing request inside the shape's whitelist. It should not widen back to the full default projection. If the client asks for fewer fields than the shape allows, only the requested allowed fields are returned.
If the client sends a body without select or include, the shape's projection is automatically synthesized and passed to Prisma. This eliminates the need for the client to duplicate the field list that the backend already defines.
await prisma.company
.guard({
where: { id: { equals: true } },
select: {
id: true,
name: true,
description: true,
posts: {
select: { id: true, title: true },
take: { max: 10, default: 5 },
where: { isDeleted: { equals: false } },
},
},
})
.findFirst({ where: { id: { equals: 'abc' } } })The client sends only { where: { id: { equals: 'abc' } } }. The shape's select is applied automatically, nested take defaults and forced where conditions are resolved through the normal pipeline.
If the client does send select or include, the shape acts as a whitelist — only the fields and relations defined in the shape are accepted. When the shape defines a relation with an object config and the client sends relation: true, prisma-guard expands true to the relation's default projection skeleton before validation. This means nested defaults such as take.default, nested whitelists, and forced where rules still apply.
The synthesized projection includes the structural skeleton only: scalar fields as true, nested select/include trees, and object skeletons for relation configs that need nested defaults. Client-controllable args like orderBy, take, skip, and cursor on nested relations are omitted from the synthesized body before parsing; defaults (e.g. take: { default: 5 }) are filled by zod schema parsing, and forced where conditions are merged by the forced tree pipeline. Empty object skeletons that remain empty after parsing collapse back to true.
This applies to all read methods: findMany, findFirst, findFirstOrThrow, findUnique, findUniqueOrThrow, count, aggregate, and groupBy. Methods where select/include is not valid (aggregate, groupBy) already reject those shape keys upstream, so auto-apply never triggers for them.
Mutation return projection
Mutations that return records can use select and include in the guard shape to control which fields and relations are returned. This uses the same shape syntax as reads — the shape whitelists what the client may request, and forced where conditions on nested includes work identically.
Which methods support projection
| Method | Returns | select/include |
| --------------------- | ----------------- | -------------- |
| create | record | yes |
| createMany | BatchPayload | no |
| createManyAndReturn | record[] | yes |
| update | record | yes |
| updateMany | BatchPayload | no |
| updateManyAndReturn | record[] | yes |
| upsert | record | yes |
| delete | record | yes |
| deleteMany | BatchPayload | no |
Create with projection
await prisma.project
.guard({
data: { title: true },
include: {
members: true,
},
})
.create({
data: { title: 'New project' },
include: { members: true },
})Update with select
await prisma.project
.guard({
data: { title: true },
where: { id: true },
select: {
id: true,
title: true,
members: {
select: { id: true, email: true },
},
},
})
.update({
data: { title: 'Updated' },
where: { id: 'abc123' },
select: {
id: true,
title: true,
members: {
select: { id: true, email: true },
},
},
})Delete with include
await prisma.project
.guard({
where: { id: true },
include: { members: true },
})
.delete({
where: { id: 'abc123' },
include: { members: true },
})Forced where on nested includes in mutations
Forced where conditions work the same as in reads. This is useful for ensuring tenant-scoped nested data in mutation responses:
import { force } from 'prisma-guard'
await prisma.project
.guard({
data: { title: true },
include: {
members: {
where: { isActive: { equals: force(true) } },
},
},
})
.create({
data: { title: 'New project' },
include: { members: true },
})The returned members will always be filtered to isActive = true, regardless of what the client sends.
Mutation projection is optional by default
For mutation methods, if the shape defines select or include but the client omits them from the body, the mutation returns the full record (default Prisma behavior). Mutation projection shapes only validate and constrain client-requested projections unless enforced projection mode is enabled.
This differs from read methods, where the shape's projection is automatically applied as default when the client omits it.
select and include are mutually exclusive
Same as reads: a shape (and a body) cannot define both select and include at the same level. Doing so throws ShapeError.
Batch methods do not support projection
createMany, updateMany, and deleteMany return BatchPayload (a count), not records. Passing select or include in the shape or body for these methods throws ShapeError.
Enforced projection mode
By default, mutation projection shapes only constrain client-requested projections. If the client omits select/include from the mutation body, Prisma returns its default full payload.
When enforceProjection is enabled, mutation shapes' projection is always applied — even when the client does not request one. If the shape defines select or include and the client omits them, prisma-guard synthesizes a projection from the shape and passes it to Prisma.
This setting applies to mutation methods only. Read methods always auto-apply the shape's projection as default when the client omits it — see Read projection auto-apply.
Configuration
generator guard {
provider = "prisma-guard"
output = "generated/guard"
enforceProjection = "true"
}Behavior
With enforced projection enabled:
await prisma.project
.guard({
data: { title: true },
select: { id: true, title: true },
})
.create({ data: { title: 'New' } })Even though the client omits select from the body, Prisma receives { select: { id: true, title: true } } and returns only those fields.
Without enforced projection (default): Prisma returns all fields.
Synthesized projection
When the client omits select/include, prisma-guard synthesizes a default projection body from the shape:
- Scalar fields marked
truein the shape producetruein the synthesized body - Nested relation shapes produce their structural equivalent (nested
select/include) or an object skeleton when needed for nested defaults _countconfigurations are preserved- Client-controllable args like
where,orderBy,take,skipon nested includes are omitted from the synthesized body before parsing; defaults are then applied by the projection schema, and forced where conditions are applied through the forced-tree pipeline
When the client does provide select/include, behavior is identical regardless of this setting: the client's projection is validated against the shape.
This mode applies to mutation methods that support projection: create, update, upsert, delete, createManyAndReturn, and updateManyAndReturn.
Upsert
Upsert is supported with dedicated create and update shape keys that mirror Prisma's upsert API. The data key is not valid for upsert — use create and update instead.
await prisma.project
.guard({
where: { id: true },
create: { title: true, status: true },
update: { title: true },
})
.upsert({
where: { id: 'abc123' },
create: { title: 'New Project', status: 'active' },
update: { title: 'Updated Title' },
})Shape requirements
Upsert shapes must define all three: where, create, and update. Missing any of them throws ShapeError. Using data instead of create/update throws ShapeError.
The create branch follows the same rules as regular create shapes: all required fields without defaults must be accounted for (as client-allowed, forced, scope FK, or @zod .default(...)/@zod .catch(...)). The update branch follows update rules: all fields are optional.
The where must satisfy a unique constraint using Prisma unique selector syntax, same as update and delete. Filter operator objects such as { id: { equals: true } } are rejected in unique where shapes.
All data shape value types work
import { force } from 'prisma-guard'
await prisma.project
.guard({
where: { id: true },
create: {
title: (base) => base.min(1).max(200),
status: 'draft',
isActive: force(true),
},
update: {
title: (base) => base.min(1).max(200),
},
})
.upsert({
where: { id: 'abc123' },
create: { title: 'New Project' },
update: { title: 'Updated' },
})Projection support
Upsert returns a record and supports select and include:
await prisma.project
.guard({
where: { id: true },
create: { title: true, status: true },
update: { title: true },
select: { id: true, title: true, status: true },
})
.upsert({
where: { id: 'abc123' },
create: { title: 'New', status: 'active' },
update: { title: 'Updated' },
select: { id: true, title: true },
})Scope behavior
On scoped models, upsert is fully supported:
- Scope condition is merged into
whereusing unique-preserving merge (same asupdateanddelete) - Scope FK is injected into
createdata (same as regular creates) - Scope FK is stripped from
updatedata (same as regular updates) - All scope roots must be present in context — missing roots throw
PolicyError
Named shapes and context-dependent shapes
Upsert works with named shapes and context-dependent shapes:
await prisma.project
.guard({
'/admin/projects/:id': {
where: { id: true },
create: { title: true, status: true, priority: true },
update: { title: true, status: true, priority: true },
},
'/editor/projects/:id': {
where: { id: true },
create: { title: true, status: 'draft' },
update: { title: true },
},
}, req.headers['x-caller'])
.upsert({
where: { id: req.params.id },
create: req.body.create,
update: req.body.update,
})Body keys
Upsert accepts: where, create, update, select, include. Unknown keys are rejected with ShapeError.
Named shapes and caller routing
Dif
