prisma-guard 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,1311 @@
1
+ # prisma-guard
2
+
3
+ **Schema-driven security layer for Prisma**
4
+ Generate input validation, query shape enforcement, and tenant isolation directly from your Prisma schema.
5
+
6
+ [![npm version](https://img.shields.io/npm/v/prisma-guard)](https://www.npmjs.com/package/prisma-guard)
7
+ [![license](https://img.shields.io/npm/l/prisma-guard)](./LICENSE)
8
+ [![node](https://img.shields.io/node/v/prisma-guard)](./package.json)
9
+ [![prisma](https://img.shields.io/badge/Prisma-6%20%7C%207-2D3748)](https://www.prisma.io/)
10
+ [![zod](https://img.shields.io/badge/Zod-v4-3E67B1)](https://zod.dev/)
11
+
12
+ `prisma-guard` helps prevent three common classes of backend mistakes:
13
+
14
+ * **invalid input** reaching Prisma
15
+ * **unsafe or overly broad query shapes**
16
+ * **missing tenant filters** in multi-tenant systems
17
+ ```text
18
+ client request
19
+
20
+ .guard(shape)
21
+
22
+ validated input + allowed query shape
23
+
24
+ tenant scoped query
25
+
26
+ database
27
+ ```
28
+
29
+ ---
30
+
31
+ ## Table of contents
32
+
33
+ * [Why this exists](#why-this-exists)
34
+ * [What prisma-guard does](#what-prisma-guard-does)
35
+ * [Architecture](#architecture)
36
+ * [Install](#install)
37
+ * [Quick start](#quick-start)
38
+ * [Before / After prisma-guard](#before--after-prisma-guard)
39
+ * [Schema annotations](#schema-annotations)
40
+ * [The guard API](#the-guard-api)
41
+ * [Mutation return projection](#mutation-return-projection)
42
+ * [Named shapes and caller routing](#named-shapes-and-caller-routing)
43
+ * [Context-dependent shapes](#context-dependent-shapes)
44
+ * [Automatic tenant isolation](#automatic-tenant-isolation)
45
+ * [findUnique behavior](#findunique-behavior)
46
+ * [Output shaping](#output-shaping)
47
+ * [Security model](#security-model)
48
+ * [Limitations](#limitations)
49
+ * [Advanced: SQL-backed runtimes](#advanced-sql-backed-runtimes)
50
+ * [Error handling](#error-handling)
51
+ * [How it works internally](#how-it-works-internally)
52
+ * [Why this approach](#why-this-approach)
53
+ * [Performance characteristics](#performance-characteristics)
54
+ * [Security philosophy](#security-philosophy)
55
+ * [Recommended production configuration](#recommended-production-configuration)
56
+ * [When to use prisma-guard](#when-to-use-prisma-guard)
57
+ * [Version compatibility](#version-compatibility)
58
+ * [Design principles](#design-principles)
59
+ * [Comparison](#comparison)
60
+ * [Roadmap](#roadmap)
61
+ * [License](#license)
62
+
63
+ ---
64
+
65
+ ## Why this exists
66
+
67
+ Prisma is powerful, but most real backends eventually hit the same problems.
68
+
69
+ ### Missing input validation
70
+ ```ts
71
+ await prisma.user.create({
72
+ data: req.body,
73
+ })
74
+ ```
75
+
76
+ The client controls the entire payload.
77
+
78
+ ### Dangerous query shapes
79
+
80
+ A client-controlled include chain can traverse relations and select sensitive fields from unrelated models:
81
+ ```ts
82
+ await prisma.project.findMany({
83
+ include: {
84
+ tasks: {
85
+ include: {
86
+ comments: {
87
+ include: {
88
+ author: {
89
+ select: {
90
+ email: true,
91
+ passwordHash: true,
92
+ resetToken: true,
93
+ },
94
+ },
95
+ },
96
+ },
97
+ },
98
+ },
99
+ },
100
+ })
101
+ ```
102
+
103
+ ### Tenant isolation bugs
104
+ ```ts
105
+ await prisma.project.findFirst({
106
+ where: { id: { equals: projectId } },
107
+ })
108
+ ```
109
+
110
+ If `projectId` belongs to another tenant, data can leak.
111
+
112
+ ---
113
+
114
+ ## What prisma-guard does
115
+
116
+ `prisma-guard` turns your Prisma schema into a runtime **data boundary layer**.
117
+
118
+ | Feature | Description |
119
+ | ----------------------- | ------------------------------------------- |
120
+ | Input validation | Zod schemas generated from Prisma types |
121
+ | Query shape enforcement | Only allowed query shapes pass validation |
122
+ | Tenant isolation | Prisma extension injects tenant filters |
123
+ | Schema-driven | Rules come directly from your Prisma schema |
124
+
125
+ The goal is simple:
126
+
127
+ > Clients should not be able to accidentally or maliciously escape the data boundary defined by your schema.
128
+
129
+ `prisma-guard` is focused on **data boundaries**, not RBAC.
130
+ Role-based access control is intentionally out of scope.
131
+
132
+ ---
133
+
134
+ ## Architecture
135
+
136
+ `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.
137
+ ```text
138
+ ┌───────────────┐
139
+ │ Client │
140
+ │ (API / RPC) │
141
+ └───────┬───────┘
142
+ │ request
143
+
144
+ ┌──────────────────────┐
145
+ │ .guard(shape) │
146
+ │ validates input + │
147
+ │ enforces query shape │
148
+ └─────────┬────────────┘
149
+ │ validated args
150
+
151
+ ┌──────────────────────┐
152
+ │ Tenant Scope Layer │
153
+ │ (Prisma extension) │
154
+ │ injects tenant filter │
155
+ └─────────┬────────────┘
156
+ │ scoped query
157
+
158
+ ┌──────────────────────┐
159
+ │ Prisma Client │
160
+ └─────────┬────────────┘
161
+ │ SQL
162
+
163
+ ┌──────────────────────┐
164
+ │ Database │
165
+ └──────────────────────┘
166
+ ```
167
+
168
+ ---
169
+
170
+ ## Install
171
+ ```bash
172
+ npm install prisma-guard zod @prisma/client
173
+ ```
174
+
175
+ Peer dependencies:
176
+
177
+ * `zod ^4`
178
+ * `@prisma/client ^6 || ^7`
179
+
180
+ Both `zod` and `@prisma/client` must be installed before running `prisma generate`. The generator validates `@zod` directives against real Zod schemas at generation time.
181
+
182
+ Generated output files (`index.ts`, `client.ts`) are TypeScript. A TypeScript-capable build pipeline is required.
183
+
184
+ The generated output uses `.js` extension imports in TypeScript source (e.g. `import { ... } from './index.js'`). This requires ESM-aware module resolution in your TypeScript config — either `"moduleResolution": "NodeNext"` or `"moduleResolution": "Bundler"` in `tsconfig.json`. The classic `"moduleResolution": "node"` setting is not compatible.
185
+
186
+ ---
187
+
188
+ ## Quick start
189
+
190
+ ### 1. Add the generator
191
+ ```prisma
192
+ generator guard {
193
+ provider = "prisma-guard"
194
+ output = "generated/guard"
195
+ }
196
+ ```
197
+
198
+ ### 2. Generate
199
+ ```bash
200
+ npx prisma generate
201
+ ```
202
+
203
+ This emits a ready-to-use `client.ts` with all type mappings and a pre-wired guard instance.
204
+
205
+ ### 3. Set up the Prisma client
206
+ ```ts
207
+ import { AsyncLocalStorage } from 'node:async_hooks'
208
+ import { PrismaClient } from '@prisma/client'
209
+ import { guard } from './generated/guard/client'
210
+
211
+ const store = new AsyncLocalStorage<{ tenantId: string }>()
212
+
213
+ const prisma = new PrismaClient().$extends(
214
+ guard.extension(() => ({
215
+ Tenant: store.getStore()?.tenantId,
216
+ }))
217
+ )
218
+ ```
219
+
220
+ ### 4. Use it
221
+ ```ts
222
+ await prisma.project
223
+ .guard({
224
+ data: { title: true },
225
+ })
226
+ .create({ data: req.body })
227
+
228
+ await prisma.project
229
+ .guard({
230
+ where: { title: { contains: true } },
231
+ orderBy: { title: true },
232
+ take: { max: 50, default: 20 },
233
+ })
234
+ .findMany(req.body)
235
+ ```
236
+
237
+ That's it. Input is validated, query shape is enforced, tenant scope is injected. All in one chain.
238
+
239
+ ---
240
+
241
+ ## Before / After prisma-guard
242
+
243
+ ### Without prisma-guard
244
+ ```ts
245
+ await prisma.project.create({
246
+ data: req.body,
247
+ })
248
+
249
+ await prisma.project.findMany(req.query)
250
+
251
+ await prisma.project.findFirst({
252
+ where: { id: { equals: projectId } },
253
+ })
254
+ ```
255
+
256
+ Problems:
257
+
258
+ * unvalidated input
259
+ * unrestricted query shape
260
+ * missing tenant filter
261
+
262
+ ### With prisma-guard
263
+ ```ts
264
+ await prisma.project
265
+ .guard({ data: { title: true } })
266
+ .create({ data: req.body })
267
+
268
+ await prisma.project
269
+ .guard({
270
+ where: { title: { contains: true } },
271
+ take: { max: 50, default: 20 },
272
+ })
273
+ .findMany(req.body)
274
+
275
+ await prisma.project
276
+ .guard({
277
+ where: { id: { equals: true } },
278
+ })
279
+ .findFirst({ where: { id: { equals: projectId } } })
280
+ ```
281
+
282
+ | Risk | Mitigation |
283
+ | ------------------- | ------------------------------------------------ |
284
+ | arbitrary input | `data` shape restricts writable fields + Zod |
285
+ | expensive queries | shape whitelist on where, include, take, orderBy |
286
+ | cross-tenant access | automatic scope injection via extension |
287
+
288
+ ---
289
+
290
+ ## Schema annotations
291
+
292
+ ### Mark tenant root models
293
+
294
+ Use `@scope-root` on models that represent tenant roots.
295
+ ```prisma
296
+ /// @scope-root
297
+ model Tenant {
298
+ id String @id @default(cuid())
299
+ name String
300
+ }
301
+ ```
302
+
303
+ Models with a single unambiguous foreign key to a scope root are auto-scoped.
304
+
305
+ If a model has multiple foreign keys to the same scope root, it is excluded from the auto-scope map when `onAmbiguousScope` is `"warn"` or `"ignore"`, and causes a generation error when `onAmbiguousScope` is `"error"` (the default).
306
+
307
+ ### Add field-level validation with `@zod`
308
+ ```prisma
309
+ model User {
310
+ id String @id @default(cuid())
311
+ /// @zod .email().max(255)
312
+ email String
313
+ /// @zod .min(1).max(100)
314
+ name String?
315
+ }
316
+ ```
317
+
318
+ `@zod` chains apply automatically when the field appears in a `data` shape with `true`.
319
+
320
+ `@zod` directives are validated during `prisma generate`. The generator validates directive syntax, checks that each chained method exists on the field's Zod base type, and attempts to construct the full chain. Invalid chains — such as `.email()` on a Boolean field or `.min("abc")` on a String field — fail generation with a clear error message. Note: some argument-level type mismatches may only be caught if Zod throws at schema construction time.
321
+
322
+ 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.
323
+
324
+ ### Supported `@zod` methods
325
+
326
+ The `@zod` DSL supports a restricted subset of Zod methods. These are the allowed methods:
327
+
328
+ **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`
329
+
330
+ **Number validations:** `int`, `positive`, `nonnegative`, `negative`, `nonpositive`, `finite`, `safe`, `multipleOf`, `step`, `gt`, `gte`, `lt`, `lte`
331
+
332
+ **Array validations:** `min`, `max`, `length`, `nonempty`
333
+
334
+ **Field modifiers:** `optional`, `nullable`, `nullish`, `default`, `catch`, `readonly`
335
+
336
+ 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.
337
+
338
+ Note on `default`: a `@zod .default(...)` chain does not set `hasDefault` in the type map — that comes from Prisma's `@default` attribute. A `@zod .default(...)` adds a Zod-level default, which means Zod will fill in the value if it's undefined, but prisma-guard's create completeness check still treats the field based on the Prisma schema metadata.
339
+
340
+ ### Supported argument types in `@zod` directives
341
+
342
+ 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.
343
+
344
+ ### `refine` replaces `@zod` chains
345
+
346
+ 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`.
347
+ ```ts
348
+ guard.input('User', {
349
+ refine: {
350
+ email: (base) => base.email().max(320),
351
+ },
352
+ })
353
+ ```
354
+
355
+ 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.
356
+
357
+ ---
358
+
359
+ ## The guard API
360
+
361
+ `.guard(shape)` is available on every model delegate. It returns an object with all Prisma methods. The shape defines the boundary; the method validates and executes.
362
+
363
+ ### Data shape syntax
364
+
365
+ Each field in a `data` shape accepts one of three value types:
366
+
367
+ * `true` — the client may provide this value; `@zod` chains apply automatically
368
+ * literal value — the server forces this value; the client cannot override it
369
+ * function `(base) => schema` — the client may provide this value; the function receives the base Zod type (without `@zod` chains) and returns a refined schema
370
+ ```ts
371
+ await prisma.project
372
+ .guard({
373
+ data: {
374
+ title: (base) => base.min(1, 'Title required').max(200),
375
+ status: true,
376
+ priority: (base) => base.refine(v => v >= 1 && v <= 5, 'Priority 1-5'),
377
+ createdBy: currentUserId,
378
+ },
379
+ })
380
+ .create({ data: req.body })
381
+ ```
382
+
383
+ In this example, `title` and `priority` use inline refines for custom validation and error messages, `status` uses `@zod` chains from the Prisma schema, and `createdBy` is forced to `currentUserId` regardless of client input.
384
+
385
+ Inline refines work everywhere data shapes are used — single shapes, named shapes, and context-dependent shapes:
386
+ ```ts
387
+ await prisma.project
388
+ .guard({
389
+ '/admin/projects': {
390
+ data: {
391
+ title: (base) => base.min(1).max(500),
392
+ status: true,
393
+ priority: (base) => base.refine(v => v >= 1 && v <= 10, 'Priority 1-10'),
394
+ },
395
+ },
396
+ '/editor/projects': {
397
+ data: {
398
+ title: (base) => base.min(1).max(200),
399
+ },
400
+ },
401
+ })
402
+ .create({
403
+ caller: req.headers['x-caller'],
404
+ data: req.body,
405
+ })
406
+ ```
407
+ ```ts
408
+ await prisma.project
409
+ .guard((ctx) => ({
410
+ data: {
411
+ title: (base) => base.min(1).max(ctx.role === 'admin' ? 500 : 200),
412
+ status: ctx.role === 'admin' ? true : 'draft',
413
+ },
414
+ }))
415
+ .create({ data: req.body })
416
+ ```
417
+
418
+ ### Query shape syntax
419
+
420
+ For read operations, `true` means the client may provide this value and literal values are forced:
421
+
422
+ ### Reads
423
+ ```ts
424
+ await prisma.project
425
+ .guard({
426
+ where: { title: { contains: true } },
427
+ orderBy: { title: true },
428
+ take: { max: 100, default: 25 },
429
+ })
430
+ .findMany(req.body)
431
+ ```
432
+
433
+ The client can only filter by `title`, sort by `title`, and take up to 100 rows. Everything else is rejected.
434
+
435
+ ### Creates
436
+ ```ts
437
+ await prisma.project
438
+ .guard({
439
+ data: { title: true, status: true },
440
+ })
441
+ .create({ data: req.body })
442
+ ```
443
+
444
+ Only `title` and `status` are accepted from the client. `@zod` chains apply automatically.
445
+
446
+ 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 as scope foreign keys that the scope extension will inject automatically. If a required non-default field is missing from the shape and is not a scope FK, guard throws `ShapeError` at shape evaluation time.
447
+
448
+ ### Updates
449
+ ```ts
450
+ await prisma.project
451
+ .guard({
452
+ data: { title: true },
453
+ where: { id: { equals: true } },
454
+ })
455
+ .update({
456
+ data: { title: 'New title' },
457
+ where: { id: { equals: 'abc123' } },
458
+ })
459
+ ```
460
+
461
+ In update mode, all `data` fields are optional. The `where` shape enforces which filters the client can use.
462
+
463
+ ### Forced values
464
+
465
+ Literal values in the shape are forced by the server and cannot be overridden by the client:
466
+ ```ts
467
+ await prisma.project
468
+ .guard({
469
+ data: { title: true, status: 'draft' },
470
+ })
471
+ .create({ data: req.body })
472
+ ```
473
+
474
+ `status` is always `'draft'` regardless of what the client sends.
475
+
476
+ The same applies to `where`:
477
+ ```ts
478
+ await prisma.project
479
+ .guard({
480
+ where: {
481
+ status: { equals: 'published' },
482
+ title: { contains: true },
483
+ },
484
+ })
485
+ .findMany(req.body)
486
+ ```
487
+
488
+ `status = 'published'` is always enforced. The client can only control the `title` filter.
489
+
490
+ ### Deletes
491
+ ```ts
492
+ await prisma.project
493
+ .guard({
494
+ where: { id: { equals: true } },
495
+ })
496
+ .delete({ where: { id: { equals: 'abc123' } } })
497
+ ```
498
+
499
+ `data` is not valid for delete shapes.
500
+
501
+ ### Batch creates
502
+ ```ts
503
+ await prisma.project
504
+ .guard({
505
+ data: { title: true, status: true },
506
+ })
507
+ .createMany({
508
+ data: [
509
+ { title: 'Project A', status: 'active' },
510
+ { title: 'Project B', status: 'draft' },
511
+ ],
512
+ })
513
+ ```
514
+
515
+ Each item in the array is validated against the same data shape.
516
+
517
+ In guarded mode, `createMany` and `createManyAndReturn` require `data` to be an array. Single-object data is not silently wrapped.
518
+
519
+ ### Bulk mutations
520
+
521
+ `updateMany`, `updateManyAndReturn`, and `deleteMany` require a `where` shape in the guard definition. This prevents accidental unconstrained bulk writes.
522
+ ```ts
523
+ await prisma.project
524
+ .guard({
525
+ data: { status: true },
526
+ where: { status: { equals: true } },
527
+ })
528
+ .updateMany({
529
+ data: { status: 'archived' },
530
+ where: { status: { equals: 'draft' } },
531
+ })
532
+ ```
533
+
534
+ 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.
535
+
536
+ ### Mutation body validation
537
+
538
+ Mutation bodies are strictly validated. The accepted keys depend on whether the shape defines a return projection (`select` or `include`):
539
+
540
+ Without projection in shape:
541
+
542
+ * `create`, `createMany`, `createManyAndReturn`: `data`
543
+ * `update`, `updateMany`, `updateManyAndReturn`: `data`, `where`
544
+ * `delete`, `deleteMany`: `where`
545
+
546
+ With projection in shape (methods that support it):
547
+
548
+ * `create`, `createManyAndReturn`: `data`, `select`, `include`
549
+ * `update`, `updateManyAndReturn`: `data`, `where`, `select`, `include`
550
+ * `delete`: `where`, `select`, `include`
551
+
552
+ Unknown keys are rejected with `ShapeError`. If the body contains `select` or `include` but the shape does not define them, the request is rejected.
553
+
554
+ Guard shape keys are also validated per method:
555
+
556
+ * create methods accept `data`, and optionally `select`/`include` (if the method supports projection)
557
+ * update methods accept `data`, `where`, and optionally `select`/`include`
558
+ * delete methods accept `where`, and optionally `select`/`include`
559
+
560
+ Shape keys not valid for the method throw `ShapeError`.
561
+
562
+ ### Supported shape keys
563
+
564
+ For reads: `where`, `include`, `select`, `orderBy`, `cursor`, `take`, `skip`, `distinct`, `_count`, `_avg`, `_sum`, `_min`, `_max`, `by`, `having`
565
+
566
+ For writes: `data`, `where`, `select`, `include` (select/include only on methods that return records)
567
+
568
+ ### Supported methods
569
+
570
+ Reads: `findMany`, `findFirst`, `findFirstOrThrow`, `findUnique`, `findUniqueOrThrow`, `count`, `aggregate`, `groupBy`
571
+
572
+ Writes: `create`, `createMany`, `createManyAndReturn`, `update`, `updateMany`, `updateManyAndReturn`, `delete`, `deleteMany`
573
+
574
+ ---
575
+
576
+ ## Mutation return projection
577
+
578
+ 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.
579
+
580
+ ### Which methods support projection
581
+
582
+ | Method | Returns | select/include |
583
+ | --------------------- | ----------------- | -------------- |
584
+ | `create` | record | yes |
585
+ | `createMany` | BatchPayload | no |
586
+ | `createManyAndReturn` | record[] | yes |
587
+ | `update` | record | yes |
588
+ | `updateMany` | BatchPayload | no |
589
+ | `updateManyAndReturn` | record[] | yes |
590
+ | `delete` | record | yes |
591
+ | `deleteMany` | BatchPayload | no |
592
+
593
+ ### Create with projection
594
+ ```ts
595
+ await prisma.project
596
+ .guard({
597
+ data: { title: true },
598
+ include: {
599
+ members: true,
600
+ },
601
+ })
602
+ .create({
603
+ data: { title: 'New project' },
604
+ include: { members: true },
605
+ })
606
+ ```
607
+
608
+ ### Update with select
609
+ ```ts
610
+ await prisma.project
611
+ .guard({
612
+ data: { title: true },
613
+ where: { id: { equals: true } },
614
+ select: {
615
+ id: true,
616
+ title: true,
617
+ members: {
618
+ select: { id: true, email: true },
619
+ },
620
+ },
621
+ })
622
+ .update({
623
+ data: { title: 'Updated' },
624
+ where: { id: { equals: 'abc123' } },
625
+ select: {
626
+ id: true,
627
+ title: true,
628
+ members: {
629
+ select: { id: true, email: true },
630
+ },
631
+ },
632
+ })
633
+ ```
634
+
635
+ ### Delete with include
636
+ ```ts
637
+ await prisma.project
638
+ .guard({
639
+ where: { id: { equals: true } },
640
+ include: { members: true },
641
+ })
642
+ .delete({
643
+ where: { id: { equals: 'abc123' } },
644
+ include: { members: true },
645
+ })
646
+ ```
647
+
648
+ ### Forced where on nested includes in mutations
649
+
650
+ Forced where conditions work the same as in reads. This is useful for ensuring tenant-scoped nested data in mutation responses:
651
+ ```ts
652
+ await prisma.project
653
+ .guard({
654
+ data: { title: true },
655
+ include: {
656
+ members: {
657
+ where: { isActive: { equals: true } },
658
+ },
659
+ },
660
+ })
661
+ .create({
662
+ data: { title: 'New project' },
663
+ include: { members: true },
664
+ })
665
+ ```
666
+
667
+ The returned `members` will always be filtered to `isActive = true`, regardless of what the client sends.
668
+
669
+ ### Projection is optional
670
+
671
+ If the shape does not define `select` or `include`, the mutation returns the full record (default Prisma behavior). The projection shape is purely additive — omitting it changes nothing about existing behavior.
672
+
673
+ ### select and include are mutually exclusive
674
+
675
+ Same as reads: a shape (and a body) cannot define both `select` and `include` at the same level. Doing so throws `ShapeError`.
676
+
677
+ ### Batch methods do not support projection
678
+
679
+ `createMany`, `updateMany`, and `deleteMany` return `BatchPayload` (a count), not records. Passing `select` or `include` in the shape or body for these methods throws `ShapeError`.
680
+
681
+ ---
682
+
683
+ ## Named shapes and caller routing
684
+
685
+ Different pages or API consumers often need different shapes for the same model. Named shapes route requests to the right shape based on a `caller` field.
686
+
687
+ Caller keys must not collide with reserved shape config keys (`where`, `data`, `include`, `select`, `orderBy`, etc.). Using a reserved key as a caller path throws `ShapeError`.
688
+
689
+ ### Define named shapes
690
+ ```ts
691
+ await prisma.project
692
+ .guard({
693
+ '/admin/projects': {
694
+ where: { title: { contains: true }, status: { equals: true } },
695
+ take: { max: 100 },
696
+ },
697
+ '/public/projects': {
698
+ where: { title: { contains: true } },
699
+ take: { max: 20, default: 10 },
700
+ },
701
+ })
702
+ .findMany({
703
+ caller: req.headers['x-caller'],
704
+ ...req.body,
705
+ })
706
+ ```
707
+
708
+ The frontend sends its current route as a header:
709
+ ```ts
710
+ fetch('/api/projects', {
711
+ headers: { 'x-caller': window.location.pathname },
712
+ body: JSON.stringify({
713
+ where: { title: { contains: 'demo' } },
714
+ }),
715
+ })
716
+ ```
717
+
718
+ The backend extracts `caller` from the body, matches it against the shape map, strips it, and validates the rest.
719
+
720
+ ### Named mutation shapes
721
+ ```ts
722
+ await prisma.project
723
+ .guard({
724
+ '/admin/projects/:id': {
725
+ data: { title: true, status: true, priority: true },
726
+ where: { id: { equals: true } },
727
+ },
728
+ '/editor/projects/:id': {
729
+ data: { title: true },
730
+ where: { id: { equals: true } },
731
+ },
732
+ })
733
+ .update({
734
+ caller: req.headers['x-caller'],
735
+ data: req.body.data,
736
+ where: { id: { equals: req.params.id } },
737
+ })
738
+ ```
739
+
740
+ ### Named shapes with inline refines
741
+ ```ts
742
+ await prisma.project
743
+ .guard({
744
+ '/admin/projects': {
745
+ data: {
746
+ title: (base) => base.min(1).max(500),
747
+ status: true,
748
+ },
749
+ },
750
+ '/public/projects': {
751
+ data: {
752
+ title: (base) => base.min(1).max(100),
753
+ },
754
+ },
755
+ })
756
+ .create({
757
+ caller: req.headers['x-caller'],
758
+ data: req.body,
759
+ })
760
+ ```
761
+
762
+ All data shape value types (`true`, literal, function) work in named shapes, context-dependent shapes, and single shapes.
763
+
764
+ ### Parameterized caller patterns
765
+ ```text
766
+ /org/:orgId/users
767
+ /org/:orgId/users/:userId
768
+ ```
769
+
770
+ Matching is case-sensitive. Exact matches are checked first. If no exact match is found, parameterized patterns are evaluated. Parameters are routing-only and are not extracted into context.
771
+
772
+ ### Fail-closed behavior
773
+
774
+ If `caller` is missing or doesn't match any pattern, the request is rejected with a `CallerError`. If a caller matches multiple parameterized patterns, it is also rejected with a `CallerError`.
775
+
776
+ ---
777
+
778
+ ## Context-dependent shapes
779
+
780
+ Shapes can be functions that receive the context provided to `guard.extension()`. This is the same context used for tenant scoping — no separate mechanism.
781
+ ```ts
782
+ const prisma = new PrismaClient().$extends(
783
+ guard.extension(() => ({
784
+ Tenant: store.getStore()?.tenantId,
785
+ role: store.getStore()?.role,
786
+ }))
787
+ )
788
+ ```
789
+
790
+ The context function returns an object with arbitrary keys. Keys whose values are `string`, `number`, or `bigint` are used as scope context for tenant isolation. Other keys (like `role` in the example above) are passed through to shape functions but are not used for scoping.
791
+
792
+ ### Single context-dependent shape
793
+ ```ts
794
+ await prisma.project
795
+ .guard((ctx) => ({
796
+ where: {
797
+ tenantId: { equals: ctx.Tenant },
798
+ title: { contains: true },
799
+ },
800
+ take: ctx.role === 'admin' ? { max: 100 } : { max: 20 },
801
+ }))
802
+ .findMany(req.body)
803
+ ```
804
+
805
+ ### Context-dependent data shapes with inline refines
806
+ ```ts
807
+ await prisma.project
808
+ .guard((ctx) => ({
809
+ data: {
810
+ title: (base) => base.min(1).max(ctx.role === 'admin' ? 500 : 200),
811
+ status: ctx.role === 'admin' ? true : 'draft',
812
+ },
813
+ }))
814
+ .create({ data: req.body })
815
+ ```
816
+
817
+ Context-dependent shapes can use the context both for structural decisions (which fields to expose, forced vs client-provided) and within inline refine functions (dynamic validation limits).
818
+
819
+ ### Named context-dependent shapes
820
+ ```ts
821
+ await prisma.project
822
+ .guard({
823
+ '/admin/projects': (ctx) => ({
824
+ where: {
825
+ tenantId: { equals: ctx.Tenant },
826
+ title: { contains: true },
827
+ },
828
+ take: { max: 100 },
829
+ }),
830
+ '/public/projects': {
831
+ where: { title: { contains: true } },
832
+ take: { max: 20 },
833
+ },
834
+ })
835
+ .findMany({
836
+ caller: req.headers['x-caller'],
837
+ ...req.body,
838
+ })
839
+ ```
840
+
841
+ Static shapes and function shapes can be mixed freely in the same shape map.
842
+
843
+ ---
844
+
845
+ ## Automatic tenant isolation
846
+
847
+ Tenant predicates are injected into top-level scoped queries only. Nested reads via `include` or `select` and nested writes are **not** automatically scoped by the extension. See [Limitations](#limitations) for details.
848
+
849
+ Input query:
850
+ ```ts
851
+ await prisma.project
852
+ .guard({ where: { id: { equals: true } } })
853
+ .findFirst({ where: { id: { equals: projectId } } })
854
+ ```
855
+
856
+ Actual enforced condition:
857
+ ```text
858
+ WHERE id = ?
859
+ AND tenantId = ?
860
+ ```
861
+
862
+ This applies to all top-level operations on scoped models, including reads, writes, and deletes.
863
+
864
+ ### What is scoped
865
+
866
+ * All top-level reads (`findMany`, `findFirst`, `findFirstOrThrow`, `count`, `aggregate`, `groupBy`)
867
+ * All top-level creates (`create`, `createMany`, `createManyAndReturn`) — scope FK is injected into data
868
+ * All top-level unique mutations (`update`, `delete`) — scope condition is merged into where
869
+ * All top-level bulk mutations (`updateMany`, `updateManyAndReturn`, `deleteMany`) — scope condition is merged into where, scope FK is stripped from data
870
+
871
+ ### What is NOT scoped
872
+
873
+ * Nested reads loaded via `include` or `select` — use forced where conditions in the shape to restrict these
874
+ * Nested writes — Prisma extension hooks operate on top-level operations only
875
+ * `$queryRaw` and `$executeRaw` — raw SQL bypasses all guard protections
876
+ * `upsert` on scoped models — rejected with `PolicyError`; handle explicitly in route logic
877
+
878
+ ### Scope relation writes
879
+
880
+ When a mutation includes data for a scoped model, the scope extension manages the foreign key field automatically. The `onScopeRelationWrite` generator config controls what happens if the mutation data also includes the Prisma relation field (e.g. writing `tenant: { connect: { id: '...' } }` alongside the managed `tenantId` FK):
881
+
882
+ | Value | Behavior |
883
+ | --------- | ----------------------------------------------------- |
884
+ | `"error"` | Reject with `ShapeError` (default) |
885
+ | `"warn"` | Remove the relation field and log a warning |
886
+ | `"strip"` | Remove the relation field silently |
887
+
888
+ This setting is configured in the generator block:
889
+ ```prisma
890
+ generator guard {
891
+ provider = "prisma-guard"
892
+ output = "generated/guard"
893
+ onScopeRelationWrite = "error"
894
+ }
895
+ ```
896
+
897
+ ---
898
+
899
+ ## findUnique behavior
900
+
901
+ Prisma `findUnique` only accepts declared unique selectors.
902
+
903
+ This is valid:
904
+ ```ts
905
+ await prisma.project.findUnique({
906
+ where: { id: { equals: projectId } },
907
+ })
908
+ ```
909
+
910
+ This is not generally valid unless declared as a composite unique:
911
+ ```ts
912
+ where: {
913
+ id: { equals: projectId },
914
+ tenantId: { equals: tenantId },
915
+ }
916
+ ```
917
+
918
+ Because of this, `prisma-guard` supports two modes.
919
+
920
+ ### `findUniqueMode = "reject"` recommended
921
+
922
+ Scoped `findUnique` and `findUniqueOrThrow` are rejected.
923
+
924
+ Use `findFirst` instead:
925
+ ```ts
926
+ await prisma.project
927
+ .guard({ where: { id: { equals: true } } })
928
+ .findFirst({ where: { id: { equals: projectId } } })
929
+ ```
930
+
931
+ This allows tenant scope to be enforced at query time.
932
+
933
+ ### `findUniqueMode = "verify"`
934
+
935
+ The query runs first, then the result is verified against tenant scope.
936
+
937
+ This is weaker because:
938
+
939
+ * it is post-read verification
940
+ * it can require an extra query
941
+ * it has a TOCTOU race window
942
+
943
+ For tenant isolation, `"reject"` is the safer production default.
944
+
945
+ ---
946
+
947
+ ## Output shaping
948
+
949
+ `guard.model()` creates output schemas for validating and shaping returned data.
950
+
951
+ These schemas use base Prisma field types and do not apply `@zod` input constraints. This is intentional — `@zod` directives define input validation rules (e.g. `.email()`, `.min(1)`) that are not meaningful for validating data already stored in the database.
952
+ ```ts
953
+ const userOutput = guard.model('User', {
954
+ pick: ['id', 'email', 'name'],
955
+ include: {
956
+ profile: { pick: ['bio'] },
957
+ },
958
+ strict: true,
959
+ })
960
+ ```
961
+
962
+ `guard.model()` produces a non-strict schema by default, meaning extra fields in the data are passed through without error. For output validation where extra fields should be rejected, pass `strict: true` as shown above.
963
+
964
+ Notes:
965
+
966
+ * `pick` and `omit` apply to scalar fields and are mutually exclusive — passing both throws `ShapeError`
967
+ * relations must be added through `include`
968
+ * include depth defaults to 5 and can be overridden with `maxDepth`
969
+ * models may appear more than once in the include tree (e.g. `User → posts → author`) as long as the total depth does not exceed `maxDepth`
970
+
971
+ ---
972
+
973
+ ## Security model
974
+
975
+ `prisma-guard` enforces three layers.
976
+
977
+ | Layer | Purpose |
978
+ | -------------- | ------------------------------ |
979
+ | Input boundary | prevents invalid input |
980
+ | Query boundary | restricts allowed query shapes |
981
+ | Data boundary | injects tenant scope |
982
+
983
+ These layers are complementary. Together they provide a fail-closed data boundary around Prisma usage at the **top-level operation** only. Nested reads and writes are not intercepted by the scope layer — see [Limitations](#limitations).
984
+
985
+ ---
986
+
987
+ ## Limitations
988
+
989
+ These limitations are real and should be treated as part of the security model.
990
+
991
+ ### Raw SQL bypasses guard protections
992
+
993
+ `$queryRaw` and `$executeRaw` are not intercepted.
994
+
995
+ ### Nested writes are not intercepted
996
+
997
+ Prisma extension hooks operate on top-level operations. Nested writes do not trigger separate scope interception.
998
+
999
+ Use query shape rules to restrict nested write paths you do not want to expose.
1000
+
1001
+ ### Nested reads via include are not scope-filtered
1002
+
1003
+ The scope extension operates on the top-level operation only. If a query uses `include` or `select` to load a relation that is itself a scoped model, the nested results are not tenant-filtered by the extension. Use forced where conditions in the include/select shape to restrict nested reads. This applies to both read operations and mutation return projections.
1004
+
1005
+ ### `findUnique` cannot be safely scoped in Prisma extension mode
1006
+
1007
+ This is a Prisma API limitation, not a conceptual limitation of scoped unique lookups.
1008
+
1009
+ That is why `findUniqueMode = "reject"` is recommended.
1010
+
1011
+ ### Composite foreign keys to scope roots
1012
+
1013
+ If a model references a scope root through composite foreign keys, it is excluded from the auto-scope map when `onAmbiguousScope` is `"warn"` or `"ignore"`, and causes a generation error when `onAmbiguousScope` is `"error"` (the default).
1014
+
1015
+ Handle these models explicitly via shape rules.
1016
+
1017
+ ### No logical combinators in where shapes
1018
+
1019
+ Guard `where` shapes define field-level operator filters. Logical combinators (`AND`, `OR`, `NOT`) are not supported in shape definitions. These are Prisma client-side features that cannot be meaningfully restricted through a static shape.
1020
+
1021
+ If you need logical combinators, use context-dependent shapes to construct the full where condition server-side.
1022
+
1023
+ ### No relation filters in where shapes
1024
+
1025
+ Guard `where` shapes only support scalar field filters. Relation-level filters (e.g. `posts: { some: { ... } }`) are not supported.
1026
+
1027
+ ### Cursor fields must cover a unique constraint
1028
+
1029
+ Prisma requires cursor-based pagination to use uniquely-identifiable fields. Guard enforces this at shape construction time: cursor fields must cover at least one unique constraint from the model. Non-unique cursor shapes are rejected with `ShapeError`.
1030
+
1031
+ ### `@zod` on list fields applies to the array
1032
+
1033
+ `@zod` directives on list fields (e.g. `String[]`) 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 per element.
1034
+
1035
+ ### Batch methods do not support return projection
1036
+
1037
+ `createMany`, `updateMany`, and `deleteMany` return `BatchPayload` (a count). Passing `select` or `include` in the shape or body for these methods throws `ShapeError`.
1038
+
1039
+ ### Generated output is TypeScript with ESM imports
1040
+
1041
+ The generator writes `index.ts` and `client.ts` using `.js` extension imports. A TypeScript-capable build pipeline with ESM-aware module resolution is required (`"moduleResolution": "NodeNext"` or `"Bundler"` in `tsconfig.json`). The classic `"moduleResolution": "node"` setting is not compatible.
1042
+
1043
+ ### `having` is limited to scalar fields
1044
+
1045
+ Guard `having` shapes support scalar field filters with their type-appropriate operators. Aggregate-level having expressions (e.g. `_count`, `_avg` inside having) are not supported.
1046
+
1047
+ ### Json fields accept any JSON-serializable value
1048
+
1049
+ `Json` fields are recursively validated as JSON-serializable values (string, number, boolean, null, plain objects, arrays). Values that are not JSON-serializable — including `undefined`, functions, symbols, class instances (such as `Date`), `NaN`, and `Infinity` — are rejected. This does not enforce any particular JSON structure. If you need structured JSON validation, use a context-dependent shape or validate before calling guard.
1050
+
1051
+ ### `refine` and inline refine functions replace `@zod` chains
1052
+
1053
+ When a `refine` callback is provided for a field in `guard.input()`, or when a function is used instead of `true` in a `data` shape, the callback receives the base Zod type without `@zod` chains. The `@zod` directive for that field is bypassed entirely. See [Schema annotations](#refine-replaces-zod-chains).
1054
+
1055
+ ### `pick` and `omit` are mutually exclusive
1056
+
1057
+ Both `guard.input()` and `guard.model()` reject configurations that specify both `pick` and `omit`. This is enforced at both the type level and at runtime.
1058
+
1059
+ ### `@zod` field modifiers interact with prisma-guard nullability
1060
+
1061
+ Using `@zod .optional()`, `.nullable()`, or `.nullish()` applies the Zod method on top of prisma-guard's own nullability/optionality handling. This can cause double-wrapping. These modifiers are available but should only be used when intentionally overriding default behavior.
1062
+
1063
+ ### `@zod .default()` does not affect create completeness checks
1064
+
1065
+ A `@zod .default(...)` directive adds a Zod-level default but does not set `hasDefault` in the type map. Create completeness validation still uses Prisma schema metadata (`@default`). A field with only `@zod .default(...)` and no Prisma `@default` will still be flagged as missing from create data shapes.
1066
+
1067
+ ### Inline refine functions are not cached
1068
+
1069
+ Data schemas containing inline refine functions are rebuilt on every request, since the function reference could be context-dependent (e.g. when used inside a dynamic shape that closes over context values). Static data shapes using only `true` and literal values are cached normally.
1070
+
1071
+ ---
1072
+
1073
+ ## Advanced: SQL-backed runtimes
1074
+
1075
+ The `findUnique` limitation exists in Prisma Client extension mode because Prisma requires a unique selector input type.
1076
+
1077
+ At the SQL level, scoped unique lookups are straightforward:
1078
+ ```sql
1079
+ SELECT *
1080
+ FROM "Project"
1081
+ WHERE "id" = $1
1082
+ AND "tenantId" = $2
1083
+ LIMIT 1
1084
+ ```
1085
+
1086
+ If your runtime controls SQL generation directly, it can enforce unique lookup plus tenant predicate in a single query.
1087
+
1088
+ Libraries like **prisma-sql** make this possible for advanced architectures.
1089
+
1090
+ ---
1091
+
1092
+ ## Error handling
1093
+
1094
+ `.guard(shape).method(body)` may throw:
1095
+
1096
+ * `ZodError` — Zod validation failures on data or query args (unless `wrapZodErrors` is enabled)
1097
+ * `ShapeError` — invalid shape config, unknown shape config keys, wrong method for shape, body format issues, unexpected body keys, incomplete create data shapes, or invalid inline refine functions
1098
+ * `CallerError` — missing, unknown, or ambiguous caller in named shapes
1099
+ * `PolicyError` — denied scope, missing tenant context, or rejected operations on scoped models (e.g. upsert, findUnique in reject mode)
1100
+
1101
+ All guard errors include `status` and `code` properties for HTTP response mapping:
1102
+
1103
+ | Error | status | code |
1104
+ | ------------- | ------ | ---------------- |
1105
+ | `ShapeError` | 400 | `SHAPE_INVALID` |
1106
+ | `CallerError` | 400 | `CALLER_UNKNOWN` |
1107
+ | `PolicyError` | 403 | `POLICY_DENIED` |
1108
+
1109
+ ### ZodError wrapping
1110
+
1111
+ By default, Zod validation failures throw a raw `ZodError`. This means error handling code must check for both `ZodError` and guard error types.
1112
+
1113
+ To unify error handling, pass `wrapZodErrors: true` in the guard config:
1114
+ ```ts
1115
+ const guard = createGuard({
1116
+ ...generatedConfig,
1117
+ wrapZodErrors: true,
1118
+ })
1119
+ ```
1120
+
1121
+ When enabled, all `ZodError` thrown during validation is caught and rethrown as `ShapeError` with `status: 400` and `code: 'SHAPE_INVALID'`. The original `ZodError` is preserved as the `cause` property. The error message includes a formatted summary of all Zod issues.
1122
+
1123
+ This applies to `guard.input().parse()`, `guard.query().parse()`, and all `.guard(shape).*` methods. `guard.model()` returns a raw `z.ZodObject` and is not affected.
1124
+
1125
+ ---
1126
+
1127
+ ## How it works internally
1128
+
1129
+ `prisma-guard` has two main parts.
1130
+
1131
+ ### 1. Generator
1132
+
1133
+ Runs during `prisma generate`. Requires `zod` and `@prisma/client` to be installed.
1134
+
1135
+ It reads the Prisma DMMF and emits:
1136
+
1137
+ * `TYPE_MAP` — field metadata per model
1138
+ * `ENUM_MAP` — enum values
1139
+ * `SCOPE_MAP` — foreign key → scope root mappings
1140
+ * `ZOD_CHAINS` — `@zod` directive chains (validated against real Zod base types at generation time)
1141
+ * `GUARD_CONFIG` — generator config values
1142
+ * `UNIQUE_MAP` — unique constraint metadata per model
1143
+ * `client.ts` — pre-wired guard instance with typed model extensions
1144
+
1145
+ ### 2. Runtime
1146
+
1147
+ At runtime, `guard.extension()` creates a Prisma extension that provides:
1148
+
1149
+ * `.guard(shape)` on every model delegate — validates input, enforces query shapes, returns typed Prisma methods
1150
+ * `$allOperations` query hook — injects tenant scope into every top-level database operation
1151
+
1152
+ The `.guard()` call validates against the shape, merges forced values, and delegates to the underlying Prisma method. The scope layer runs transparently underneath.
1153
+
1154
+ ---
1155
+
1156
+ ## Why this approach
1157
+
1158
+ Common alternatives have tradeoffs.
1159
+
1160
+ | Approach | Tradeoff |
1161
+ | -------------------------------- | --------------------------------- |
1162
+ | ad hoc route validation | repetitive and inconsistent |
1163
+ | middleware-only filtering | too easy to miss edge cases |
1164
+ | runtime reflection-heavy systems | slower and harder to reason about |
1165
+ | full ORM replacement | larger migration cost |
1166
+
1167
+ `prisma-guard` focuses narrowly on **data boundaries**, not ORM replacement.
1168
+
1169
+ ---
1170
+
1171
+ ## Performance characteristics
1172
+
1173
+ The runtime does lightweight argument rewriting and Zod validation.
1174
+
1175
+ In most real applications, overhead should be negligible relative to database round-trip time.
1176
+
1177
+ Static shapes (both single and named) are cached per guard instance and method. In a named shape map, each static entry is cached independently — a map with 9 static entries and 1 context-dependent entry will cache the 9 static entries. Context-dependent shapes (functions) resolve the function on each call because they depend on runtime context and are never cached.
1178
+
1179
+ Data schemas containing inline refine functions are also not cached, since the function could close over runtime context values. Data shapes using only `true` and literal values are cached normally. Projection schemas (select/include on mutations) are cached independently for static shapes.
1180
+
1181
+ ---
1182
+
1183
+ ## Security philosophy
1184
+
1185
+ `prisma-guard` is designed to fail closed.
1186
+
1187
+ | Condition | Behavior |
1188
+ | -------------------------------------- | ----------------------------------------------------- |
1189
+ | ambiguous scope mapping | error by default |
1190
+ | missing scope context | error by default |
1191
+ | `onMissingScopeContext = "ignore"` | scope bypassed for missing roots; present roots still enforced |
1192
+ | unsafe scoped `findUnique` | reject recommended |
1193
+ | invalid `@zod` directive | error by default |
1194
+ | missing `caller` in named shapes | error always |
1195
+ | `data` in read shape | error always |
1196
+ | missing `data` in write shape | error always |
1197
+ | bulk mutation without `where` shape | error always |
1198
+ | bulk mutation with empty `where` | error always |
1199
+ | upsert on scoped model | error always |
1200
+ | unexpected keys in mutation body | error always |
1201
+ | unknown keys in shape config | error always |
1202
+ | cursor not covering unique constraint | error always |
1203
+ | caller key collides with shape config | error always |
1204
+ | empty operator objects in where | error always |
1205
+ | `pick` and `omit` both specified | error always |
1206
+ | scope relation in mutation data | controlled by `onScopeRelationWrite` (default: error) |
1207
+ | incomplete create data shape | error always |
1208
+ | invalid inline refine function | error always (ShapeError) |
1209
+ | projection on batch method | error always |
1210
+ | body projection without shape | error always |
1211
+
1212
+ ---
1213
+
1214
+ ## Recommended production configuration
1215
+ ```prisma
1216
+ generator guard {
1217
+ provider = "prisma-guard"
1218
+ output = "generated/guard"
1219
+ onInvalidZod = "error"
1220
+ onAmbiguousScope = "error"
1221
+ onMissingScopeContext = "error"
1222
+ findUniqueMode = "reject"
1223
+ onScopeRelationWrite = "error"
1224
+ }
1225
+ ```
1226
+
1227
+ ---
1228
+
1229
+ ## When to use prisma-guard
1230
+
1231
+ Best fit:
1232
+
1233
+ * multi-tenant SaaS backends
1234
+ * Prisma-based microservices
1235
+ * RPC / internal API backends
1236
+ * systems that want schema-driven validation and scoping
1237
+
1238
+ Less suitable:
1239
+
1240
+ * raw SQL-heavy systems
1241
+ * architectures that bypass Prisma Client
1242
+ * cases where another layer already owns validation and authorization comprehensively
1243
+
1244
+ ---
1245
+
1246
+ ## Version compatibility
1247
+
1248
+ Supported Prisma versions:
1249
+ ```text
1250
+ Prisma 6
1251
+ Prisma 7
1252
+ ```
1253
+
1254
+ Supported Node versions:
1255
+ ```text
1256
+ Node 20
1257
+ Node 22
1258
+ ```
1259
+
1260
+ ---
1261
+
1262
+ ## Design principles
1263
+
1264
+ 1. Fail closed on ambiguous security conditions
1265
+ 2. Prefer query-time enforcement over verification
1266
+ 3. Generate minimal runtime metadata
1267
+ 4. Avoid automatic relation traversal
1268
+ 5. Keep scope rules explicit and schema-driven
1269
+ 6. One chain — shape defines the boundary, method executes
1270
+
1271
+ ---
1272
+
1273
+ ## Comparison
1274
+
1275
+ | Feature | prisma-guard | raw Prisma |
1276
+ | ------------------------------------------ | -------------- | ----------- |
1277
+ | Input validation | yes | no |
1278
+ | Query shape enforcement | yes | no |
1279
+ | Automatic tenant scoping (top-level) | yes | no |
1280
+ | Safe scoped `findUnique` in extension mode | reject | not handled |
1281
+ | Schema-driven rules | yes | no |
1282
+ | Caller-based shape routing | yes | no |
1283
+ | Typed method chaining | yes | n/a |
1284
+ | Bulk mutation safety | required where | not handled |
1285
+ | Mutation body validation | strict keys | no |
1286
+ | Shape config validation | strict keys | n/a |
1287
+ | Create completeness validation | yes | no |
1288
+ | Mutation return projection | yes | manual |
1289
+ | Inline field refine in data shapes | yes | n/a |
1290
+ | ZodError wrapping | opt-in | n/a |
1291
+
1292
+ ---
1293
+
1294
+ ## Roadmap
1295
+
1296
+ Possible future improvements:
1297
+
1298
+ * optional nested-write enforcement helpers
1299
+ * richer relation-level policies
1300
+ * logical combinator support in where shapes (`AND`/`OR`/`NOT`)
1301
+ * relation-level where filters
1302
+ * more query method coverage
1303
+ * adapter integrations for SQL-backed runtimes
1304
+ * model-specific generated types for stronger compile-time shape validation
1305
+ * structured JSON field validation via schema annotations
1306
+
1307
+ ---
1308
+
1309
+ ## License
1310
+
1311
+ MIT