Invalid input reaches Prisma
The client controls the entire payload. Unknown fields, wrong types, mass assignment.
prisma.user.create({ data: req.body })
prisma-guard turns your Prisma schema into a runtime data boundary: input validation, query shape enforcement, and tenant isolation — declared in one .guard(shape) chain instead of scattered middleware.
// one chain: validate + enforce shape + tenant filter await prisma.project .guard((ctx) => ({ where: { tenantId: { equals: force(ctx.Tenant) } }, select: { id: true, title: true }, take: { max: 50, default: 20 }, })) .findMany(req.body)
prisma-guard prevents all three classes at the data layer — not per route, not per developer's discipline.
The client controls the entire payload. Unknown fields, wrong types, mass assignment.
prisma.user.create({ data: req.body })
A client-controlled include chain traverses relations and selects sensitive fields from unrelated models.
include: { author: { select: { passwordHash: true } } }
If projectId belongs to another tenant, data leaks across the boundary you never enforced.
where: { id: { equals: projectId } }
Zod schemas generated from Prisma types. @zod directives in the schema are validated at generate-time and applied automatically.
Whitelist where filters, orderBy, include trees and take limits. Everything else is rejected with a typed ShapeError.
Tenant filters declared in context-dependent shapes as primary enforcement. Automatic scope injection runs underneath as a top-level backstop.
Named shapes route requests by caller with parameterized patterns. Fail-closed: unmatched callers throw unless a default exists.
Read projections auto-apply as defaults and whitelists. Forced where conditions reach nested to-many includes.
Ambiguous scope, vacuous filters, empty combinators, unconstrained bulk deletes — rejected at construction or runtime. Never silently ignored.
true means client-controlled. Literals and force() mean server-enforced. Functions refine the Zod type. That is the whole language.
import { force } from 'prisma-guard' await prisma.project .guard({ data: { title: (base) => base.min(1, 'Title required').max(200), status: true, // client-controlled, @zod chains apply createdBy: currentUserId, // forced server value isActive: force(true), // forced boolean }, }) .create({ data: req.body })
await prisma.project .guard({ where: { title: { contains: true } }, // only allowed filter orderBy: { createdAt: true, title: true }, take: { max: 100, default: 25 }, select: { id: true, title: true, tasks: { select: { id: true }, take: { max: 10 } }, }, }) .findMany(req.body) // client omits select? the shape's projection is applied automatically
// primary enforcement: declare tenants in the shape const prisma = new PrismaClient().$extends( guard.extension(() => ({ Tenant: store.getStore()?.tenantId })) ) await prisma.project .guard((ctx) => ({ where: { tenantId: { equals: force(ctx.Tenant) } }, include: { tasks: { where: { tenantId: { equals: force(ctx.Tenant) } } }, }, })) .findMany(req.body) // scope extension still runs as a top-level backstop
await prisma.post .guard({ data: { title: true }, where: { id: true, // unique selector from client authorId: force(userId), // permission check merged into where }, }) .update({ data: req.body.data, where: { id: req.params.id }, }) // final query is constrained by both id and authorId
| Feature | prisma-guard | raw Prisma |
|---|---|---|
| Input validation | yes | no |
| Query shape enforcement | yes | no |
| Tenant filters via context-dependent shapes | yes | manual |
| Nested read tenant filtering (to-many, via shapes) | yes | manual |
| Automatic scope injection backstop (top-level) | yes | no |
| Bulk mutation safety | required where | not handled |
| Vacuous combinator rejection | yes | not handled |
| Create completeness validation | yes | no |
| Nested write scope enforcement | declare tenant filters in shapes | no |
Install, add one generator block, run prisma generate. Your boundary layer compiles with your types.