prisma-guard 1.1.0 → 1.2.1
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 +700 -104
- package/dist/generator/index.js +189 -48
- package/dist/generator/index.js.map +1 -1
- package/dist/runtime/index.d.ts +16 -5
- package/dist/runtime/index.js +1793 -1057
- package/dist/runtime/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,6 +8,7 @@ Generate input validation, query shape enforcement, and tenant isolation directl
|
|
|
8
8
|
[](./package.json)
|
|
9
9
|
[](https://www.prisma.io/)
|
|
10
10
|
[](https://zod.dev/)
|
|
11
|
+
[](https://codecov.io/gh/multipliedtwice/prisma-guard)
|
|
11
12
|
|
|
12
13
|
`prisma-guard` helps prevent three common classes of backend mistakes:
|
|
13
14
|
|
|
@@ -38,12 +39,18 @@ database
|
|
|
38
39
|
* [Before / After prisma-guard](#before--after-prisma-guard)
|
|
39
40
|
* [Schema annotations](#schema-annotations)
|
|
40
41
|
* [The guard API](#the-guard-api)
|
|
42
|
+
* [Logical combinators in where shapes](#logical-combinators-in-where-shapes)
|
|
43
|
+
* [Relation filters in where shapes](#relation-filters-in-where-shapes)
|
|
41
44
|
* [Mutation return projection](#mutation-return-projection)
|
|
45
|
+
* [Enforced projection mode](#enforced-projection-mode)
|
|
46
|
+
* [Upsert](#upsert)
|
|
42
47
|
* [Named shapes and caller routing](#named-shapes-and-caller-routing)
|
|
43
48
|
* [Context-dependent shapes](#context-dependent-shapes)
|
|
44
49
|
* [Automatic tenant isolation](#automatic-tenant-isolation)
|
|
50
|
+
* [Multi-root scope behavior](#multi-root-scope-behavior)
|
|
45
51
|
* [findUnique behavior](#findunique-behavior)
|
|
46
52
|
* [Output shaping](#output-shaping)
|
|
53
|
+
* [Strict Decimal mode](#strict-decimal-mode)
|
|
47
54
|
* [Security model](#security-model)
|
|
48
55
|
* [Limitations](#limitations)
|
|
49
56
|
* [Advanced: SQL-backed runtimes](#advanced-sql-backed-runtimes)
|
|
@@ -300,9 +307,9 @@ model Tenant {
|
|
|
300
307
|
}
|
|
301
308
|
```
|
|
302
309
|
|
|
303
|
-
Models with a single unambiguous foreign key to a scope root are auto-scoped.
|
|
310
|
+
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](#multi-root-scope-behavior).
|
|
304
311
|
|
|
305
|
-
If a model has multiple foreign keys to the same scope root,
|
|
312
|
+
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.
|
|
306
313
|
|
|
307
314
|
### Add field-level validation with `@zod`
|
|
308
315
|
```prisma
|
|
@@ -317,7 +324,7 @@ model User {
|
|
|
317
324
|
|
|
318
325
|
`@zod` chains apply automatically when the field appears in a `data` shape with `true`.
|
|
319
326
|
|
|
320
|
-
`@zod` directives are validated during `prisma generate`. The generator validates directive syntax, checks that each chained method
|
|
327
|
+
`@zod` directives are validated during `prisma generate`. The generator validates directive syntax, checks that each chained method is in the allowed list, and verifies method compatibility by advancing through the chain. Type-changing methods (such as `.nullable()`, `.optional()`, `.default()`) advance the schema type, so a chain like `.nullable().email()` is correctly rejected if `.email()` does not exist on the nullable wrapper. Note: some argument-level type mismatches may only be caught if Zod throws at schema construction time.
|
|
321
328
|
|
|
322
329
|
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
330
|
|
|
@@ -335,7 +342,11 @@ The `@zod` DSL supports a restricted subset of Zod methods. These are the allowe
|
|
|
335
342
|
|
|
336
343
|
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
344
|
|
|
338
|
-
Note on `default`: a `@zod .default(...)` chain
|
|
345
|
+
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.
|
|
346
|
+
|
|
347
|
+
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.
|
|
348
|
+
|
|
349
|
+
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()`).
|
|
339
350
|
|
|
340
351
|
### Supported argument types in `@zod` directives
|
|
341
352
|
|
|
@@ -354,6 +365,10 @@ guard.input('User', {
|
|
|
354
365
|
|
|
355
366
|
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
367
|
|
|
368
|
+
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.
|
|
369
|
+
|
|
370
|
+
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.
|
|
371
|
+
|
|
357
372
|
---
|
|
358
373
|
|
|
359
374
|
## The guard API
|
|
@@ -362,12 +377,15 @@ In this example, any `@zod` directive on the `email` field in the Prisma schema
|
|
|
362
377
|
|
|
363
378
|
### Data shape syntax
|
|
364
379
|
|
|
365
|
-
Each field in a `data` shape accepts one of
|
|
380
|
+
Each field in a `data` shape accepts one of four value types:
|
|
366
381
|
|
|
367
382
|
* `true` — the client may provide this value; `@zod` chains apply automatically
|
|
368
383
|
* literal value — the server forces this value; the client cannot override it
|
|
384
|
+
* `force(value)` — the server forces this value; required when the value is literally `true` (see [The `force()` helper](#the-force-helper))
|
|
369
385
|
* 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
386
|
```ts
|
|
387
|
+
import { force } from 'prisma-guard'
|
|
388
|
+
|
|
371
389
|
await prisma.project
|
|
372
390
|
.guard({
|
|
373
391
|
data: {
|
|
@@ -375,46 +393,39 @@ await prisma.project
|
|
|
375
393
|
status: true,
|
|
376
394
|
priority: (base) => base.refine(v => v >= 1 && v <= 5, 'Priority 1-5'),
|
|
377
395
|
createdBy: currentUserId,
|
|
396
|
+
isActive: force(true),
|
|
378
397
|
},
|
|
379
398
|
})
|
|
380
399
|
.create({ data: req.body })
|
|
381
400
|
```
|
|
382
401
|
|
|
383
|
-
In this example, `title` and `priority` use inline refines for custom validation and error messages, `status` uses `@zod` chains from the Prisma schema,
|
|
402
|
+
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.
|
|
403
|
+
|
|
404
|
+
Relation fields are not permitted in `data` shapes. Attempting to use a relation field in a data shape throws `ShapeError`. See [Limitations](#guarded-data-shapes-do-not-permit-relation-fields).
|
|
384
405
|
|
|
385
|
-
|
|
406
|
+
### The `force()` helper
|
|
407
|
+
|
|
408
|
+
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:
|
|
386
409
|
```ts
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
})
|
|
410
|
+
import { force } from 'prisma-guard'
|
|
411
|
+
|
|
412
|
+
data: { isActive: true } // client-controlled — client sends any boolean
|
|
413
|
+
data: { isActive: false } // forced to false
|
|
414
|
+
data: { isActive: force(true) } // forced to true
|
|
415
|
+
data: { isActive: force(false) } // also valid — equivalent to just false
|
|
406
416
|
```
|
|
417
|
+
|
|
418
|
+
`force()` works in both `data` shapes and `where` shapes:
|
|
407
419
|
```ts
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
},
|
|
414
|
-
}))
|
|
415
|
-
.create({ data: req.body })
|
|
420
|
+
where: {
|
|
421
|
+
published: { equals: true }, // client-controlled — client sends any boolean
|
|
422
|
+
isDeleted: { equals: false }, // forced to false
|
|
423
|
+
isActive: { equals: force(true) }, // forced to true
|
|
424
|
+
}
|
|
416
425
|
```
|
|
417
426
|
|
|
427
|
+
`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.
|
|
428
|
+
|
|
418
429
|
### Query shape syntax
|
|
419
430
|
|
|
420
431
|
For read operations, `true` means the client may provide this value and literal values are forced:
|
|
@@ -443,7 +454,9 @@ await prisma.project
|
|
|
443
454
|
|
|
444
455
|
Only `title` and `status` are accepted from the client. `@zod` chains apply automatically.
|
|
445
456
|
|
|
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),
|
|
457
|
+
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.
|
|
458
|
+
|
|
459
|
+
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.
|
|
447
460
|
|
|
448
461
|
### Updates
|
|
449
462
|
```ts
|
|
@@ -464,14 +477,16 @@ In update mode, all `data` fields are optional. The `where` shape enforces which
|
|
|
464
477
|
|
|
465
478
|
Literal values in the shape are forced by the server and cannot be overridden by the client:
|
|
466
479
|
```ts
|
|
480
|
+
import { force } from 'prisma-guard'
|
|
481
|
+
|
|
467
482
|
await prisma.project
|
|
468
483
|
.guard({
|
|
469
|
-
data: { title: true, status: 'draft' },
|
|
484
|
+
data: { title: true, status: 'draft', isActive: force(true) },
|
|
470
485
|
})
|
|
471
486
|
.create({ data: req.body })
|
|
472
487
|
```
|
|
473
488
|
|
|
474
|
-
`status` is always `'draft'` regardless of what the client sends.
|
|
489
|
+
`status` is always `'draft'` and `isActive` is always `true` regardless of what the client sends.
|
|
475
490
|
|
|
476
491
|
The same applies to `where`:
|
|
477
492
|
```ts
|
|
@@ -479,13 +494,16 @@ await prisma.project
|
|
|
479
494
|
.guard({
|
|
480
495
|
where: {
|
|
481
496
|
status: { equals: 'published' },
|
|
497
|
+
isActive: { equals: force(true) },
|
|
482
498
|
title: { contains: true },
|
|
483
499
|
},
|
|
484
500
|
})
|
|
485
501
|
.findMany(req.body)
|
|
486
502
|
```
|
|
487
503
|
|
|
488
|
-
`status = 'published'`
|
|
504
|
+
`status = 'published'` and `isActive = true` are always enforced. The client can only control the `title` filter.
|
|
505
|
+
|
|
506
|
+
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.
|
|
489
507
|
|
|
490
508
|
### Deletes
|
|
491
509
|
```ts
|
|
@@ -516,6 +534,8 @@ Each item in the array is validated against the same data shape.
|
|
|
516
534
|
|
|
517
535
|
In guarded mode, `createMany` and `createManyAndReturn` require `data` to be an array. Single-object data is not silently wrapped.
|
|
518
536
|
|
|
537
|
+
`createMany` and `createManyAndReturn` also accept `skipDuplicates: boolean` in the request body. This is passed through to Prisma without shape-level configuration.
|
|
538
|
+
|
|
519
539
|
### Bulk mutations
|
|
520
540
|
|
|
521
541
|
`updateMany`, `updateManyAndReturn`, and `deleteMany` require a `where` shape in the guard definition. This prevents accidental unconstrained bulk writes.
|
|
@@ -533,28 +553,37 @@ await prisma.project
|
|
|
533
553
|
|
|
534
554
|
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
555
|
|
|
556
|
+
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](#logical-combinators-in-where-shapes) for details.
|
|
557
|
+
|
|
536
558
|
### Mutation body validation
|
|
537
559
|
|
|
538
560
|
Mutation bodies are strictly validated. The accepted keys depend on whether the shape defines a return projection (`select` or `include`):
|
|
539
561
|
|
|
540
562
|
Without projection in shape:
|
|
541
563
|
|
|
542
|
-
* `create
|
|
564
|
+
* `create`: `data`
|
|
565
|
+
* `createMany`, `createManyAndReturn`: `data`, `skipDuplicates`
|
|
543
566
|
* `update`, `updateMany`, `updateManyAndReturn`: `data`, `where`
|
|
567
|
+
* `upsert`: `where`, `create`, `update`, `select`, `include`
|
|
544
568
|
* `delete`, `deleteMany`: `where`
|
|
545
569
|
|
|
546
570
|
With projection in shape (methods that support it):
|
|
547
571
|
|
|
548
|
-
* `create
|
|
572
|
+
* `create`: `data`, `select`, `include`
|
|
573
|
+
* `createManyAndReturn`: `data`, `select`, `include`, `skipDuplicates`
|
|
549
574
|
* `update`, `updateManyAndReturn`: `data`, `where`, `select`, `include`
|
|
575
|
+
* `upsert`: `where`, `create`, `update`, `select`, `include`
|
|
550
576
|
* `delete`: `where`, `select`, `include`
|
|
551
577
|
|
|
578
|
+
For `createMany` and `createManyAndReturn`, `skipDuplicates` is also accepted as a body key. It must be a boolean if provided.
|
|
579
|
+
|
|
552
580
|
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
581
|
|
|
554
582
|
Guard shape keys are also validated per method:
|
|
555
583
|
|
|
556
584
|
* create methods accept `data`, and optionally `select`/`include` (if the method supports projection)
|
|
557
585
|
* update methods accept `data`, `where`, and optionally `select`/`include`
|
|
586
|
+
* upsert accepts `where`, `create`, `update`, and optionally `select`/`include`
|
|
558
587
|
* delete methods accept `where`, and optionally `select`/`include`
|
|
559
588
|
|
|
560
589
|
Shape keys not valid for the method throw `ShapeError`.
|
|
@@ -565,11 +594,244 @@ For reads: `where`, `include`, `select`, `orderBy`, `cursor`, `take`, `skip`, `d
|
|
|
565
594
|
|
|
566
595
|
For writes: `data`, `where`, `select`, `include` (select/include only on methods that return records)
|
|
567
596
|
|
|
597
|
+
For upsert: `where`, `create`, `update`, `select`, `include`
|
|
598
|
+
|
|
599
|
+
Where shapes accept scalar field filters, relation filters (`some`, `every`, `none`, `is`, `isNot`), and logical combinators (`AND`, `OR`, `NOT`).
|
|
600
|
+
|
|
601
|
+
### Shape config value validation
|
|
602
|
+
|
|
603
|
+
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.
|
|
604
|
+
|
|
605
|
+
### Where DSL is a constrained Prisma subset
|
|
606
|
+
|
|
607
|
+
The where shape syntax supports a subset of Prisma's where filter API. Notable differences from raw Prisma where clauses:
|
|
608
|
+
|
|
609
|
+
* The `not` operator accepts a scalar value only, not a nested filter object. Prisma's `{ not: { gt: 5 } }` form is not supported.
|
|
610
|
+
* `AND` and `OR` in client input must be arrays with at least one element. Prisma accepts a single object for `AND`; prisma-guard requires an array. Empty arrays are rejected.
|
|
611
|
+
* `NOT` in client input accepts a single object or an array with at least one element. Empty arrays are rejected.
|
|
612
|
+
* 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.
|
|
613
|
+
* 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.
|
|
614
|
+
* Relation operator containers require at least one operator. `{ posts: {} }` is rejected.
|
|
615
|
+
* Empty combinator and relation filter definitions in shapes are rejected at shape construction time. A shape like `where: { AND: {} }` throws `ShapeError`.
|
|
616
|
+
* 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`.
|
|
617
|
+
|
|
618
|
+
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.
|
|
619
|
+
|
|
620
|
+
### Body normalization
|
|
621
|
+
|
|
622
|
+
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.
|
|
623
|
+
|
|
568
624
|
### Supported methods
|
|
569
625
|
|
|
570
626
|
Reads: `findMany`, `findFirst`, `findFirstOrThrow`, `findUnique`, `findUniqueOrThrow`, `count`, `aggregate`, `groupBy`
|
|
571
627
|
|
|
572
|
-
Writes: `create`, `createMany`, `createManyAndReturn`, `update`, `updateMany`, `updateManyAndReturn`, `delete`, `deleteMany`
|
|
628
|
+
Writes: `create`, `createMany`, `createManyAndReturn`, `update`, `updateMany`, `updateManyAndReturn`, `upsert`, `delete`, `deleteMany`
|
|
629
|
+
|
|
630
|
+
---
|
|
631
|
+
|
|
632
|
+
## Logical combinators in where shapes
|
|
633
|
+
|
|
634
|
+
Where shapes support `AND`, `OR`, and `NOT` to compose filter conditions. The combinator value is a where config defining allowed fields inside the combinator:
|
|
635
|
+
```ts
|
|
636
|
+
await prisma.project
|
|
637
|
+
.guard({
|
|
638
|
+
where: {
|
|
639
|
+
OR: {
|
|
640
|
+
title: { contains: true },
|
|
641
|
+
description: { contains: true },
|
|
642
|
+
},
|
|
643
|
+
},
|
|
644
|
+
take: { max: 50 },
|
|
645
|
+
})
|
|
646
|
+
.findMany({
|
|
647
|
+
where: {
|
|
648
|
+
OR: [
|
|
649
|
+
{ title: { contains: 'demo' } },
|
|
650
|
+
{ description: { contains: 'demo' } },
|
|
651
|
+
],
|
|
652
|
+
},
|
|
653
|
+
})
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
The shape defines which fields are allowed inside each combinator. The client sends arrays for `AND`/`OR` and an object or array for `NOT`.
|
|
657
|
+
|
|
658
|
+
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.
|
|
659
|
+
```ts
|
|
660
|
+
import { force } from 'prisma-guard'
|
|
661
|
+
|
|
662
|
+
await prisma.project
|
|
663
|
+
.guard({
|
|
664
|
+
where: {
|
|
665
|
+
title: { contains: true },
|
|
666
|
+
NOT: {
|
|
667
|
+
status: { equals: 'archived' },
|
|
668
|
+
},
|
|
669
|
+
},
|
|
670
|
+
})
|
|
671
|
+
.findMany({
|
|
672
|
+
where: { title: { contains: 'demo' } },
|
|
673
|
+
})
|
|
674
|
+
```
|
|
675
|
+
|
|
676
|
+
`status = 'archived'` is always excluded regardless of client input.
|
|
677
|
+
|
|
678
|
+
Combinators can be nested and mixed with scalar fields freely. The same field can appear both at the top level and inside a combinator.
|
|
679
|
+
|
|
680
|
+
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.
|
|
681
|
+
|
|
682
|
+
### Combinator validation rules
|
|
683
|
+
|
|
684
|
+
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.
|
|
685
|
+
|
|
686
|
+
At runtime, combinator arrays from client input must contain at least one element. `{ AND: [] }`, `{ OR: [] }`, and `{ NOT: [] }` are all rejected.
|
|
687
|
+
|
|
688
|
+
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.
|
|
689
|
+
|
|
690
|
+
When a combinator branch contains forced values, client members may be empty because the forced values still provide meaningful filtering constraints.
|
|
691
|
+
|
|
692
|
+
---
|
|
693
|
+
|
|
694
|
+
## Relation filters in where shapes
|
|
695
|
+
|
|
696
|
+
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`.
|
|
697
|
+
```ts
|
|
698
|
+
await prisma.user
|
|
699
|
+
.guard({
|
|
700
|
+
where: {
|
|
701
|
+
posts: {
|
|
702
|
+
some: {
|
|
703
|
+
title: { contains: true },
|
|
704
|
+
published: { equals: true },
|
|
705
|
+
},
|
|
706
|
+
},
|
|
707
|
+
},
|
|
708
|
+
})
|
|
709
|
+
.findMany({
|
|
710
|
+
where: {
|
|
711
|
+
posts: {
|
|
712
|
+
some: {
|
|
713
|
+
title: { contains: 'guide' },
|
|
714
|
+
published: { equals: true },
|
|
715
|
+
},
|
|
716
|
+
},
|
|
717
|
+
},
|
|
718
|
+
})
|
|
719
|
+
```
|
|
720
|
+
|
|
721
|
+
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.
|
|
722
|
+
|
|
723
|
+
### Forced values in relation filters
|
|
724
|
+
```ts
|
|
725
|
+
import { force } from 'prisma-guard'
|
|
726
|
+
|
|
727
|
+
await prisma.user
|
|
728
|
+
.guard({
|
|
729
|
+
where: {
|
|
730
|
+
posts: {
|
|
731
|
+
some: {
|
|
732
|
+
title: { contains: true },
|
|
733
|
+
status: { equals: 'published' },
|
|
734
|
+
},
|
|
735
|
+
},
|
|
736
|
+
},
|
|
737
|
+
})
|
|
738
|
+
.findMany({
|
|
739
|
+
where: {
|
|
740
|
+
posts: {
|
|
741
|
+
some: { title: { contains: 'guide' } },
|
|
742
|
+
},
|
|
743
|
+
},
|
|
744
|
+
})
|
|
745
|
+
```
|
|
746
|
+
|
|
747
|
+
`status = 'published'` is always enforced inside the `some` operator.
|
|
748
|
+
|
|
749
|
+
### To-one relations
|
|
750
|
+
```ts
|
|
751
|
+
await prisma.post
|
|
752
|
+
.guard({
|
|
753
|
+
where: {
|
|
754
|
+
author: {
|
|
755
|
+
is: {
|
|
756
|
+
role: { equals: true },
|
|
757
|
+
},
|
|
758
|
+
},
|
|
759
|
+
},
|
|
760
|
+
})
|
|
761
|
+
.findMany({
|
|
762
|
+
where: {
|
|
763
|
+
author: {
|
|
764
|
+
is: { role: { equals: 'ADMIN' } },
|
|
765
|
+
},
|
|
766
|
+
},
|
|
767
|
+
})
|
|
768
|
+
```
|
|
769
|
+
|
|
770
|
+
### Combined with logical combinators
|
|
771
|
+
```ts
|
|
772
|
+
await prisma.user
|
|
773
|
+
.guard({
|
|
774
|
+
where: {
|
|
775
|
+
OR: {
|
|
776
|
+
posts: {
|
|
777
|
+
some: { published: { equals: true } },
|
|
778
|
+
},
|
|
779
|
+
profile: {
|
|
780
|
+
is: { bio: { contains: true } },
|
|
781
|
+
},
|
|
782
|
+
},
|
|
783
|
+
},
|
|
784
|
+
})
|
|
785
|
+
.findMany({
|
|
786
|
+
where: {
|
|
787
|
+
OR: [
|
|
788
|
+
{ posts: { some: { published: { equals: true } } } },
|
|
789
|
+
{ profile: { is: { bio: { contains: 'engineer' } } } },
|
|
790
|
+
],
|
|
791
|
+
},
|
|
792
|
+
})
|
|
793
|
+
```
|
|
794
|
+
|
|
795
|
+
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.
|
|
796
|
+
|
|
797
|
+
### Relation filter validation rules
|
|
798
|
+
|
|
799
|
+
Relation filter definitions in shapes must define at least one operator. An empty relation filter like `where: { posts: {} }` throws `ShapeError` at shape construction time.
|
|
800
|
+
|
|
801
|
+
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.
|
|
802
|
+
|
|
803
|
+
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:
|
|
804
|
+
```ts
|
|
805
|
+
where: {
|
|
806
|
+
posts: {
|
|
807
|
+
some: {
|
|
808
|
+
title: { contains: true },
|
|
809
|
+
},
|
|
810
|
+
},
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// Rejected — at least one condition required
|
|
814
|
+
{ where: { posts: { some: {} } } }
|
|
815
|
+
|
|
816
|
+
// Accepted
|
|
817
|
+
{ where: { posts: { some: { title: { contains: 'demo' } } } } }
|
|
818
|
+
```
|
|
819
|
+
|
|
820
|
+
When a relation operator contains forced values, the client may omit all client-controlled conditions. The forced values are still injected:
|
|
821
|
+
```ts
|
|
822
|
+
where: {
|
|
823
|
+
posts: {
|
|
824
|
+
some: {
|
|
825
|
+
status: { equals: 'published' },
|
|
826
|
+
title: { contains: true },
|
|
827
|
+
},
|
|
828
|
+
},
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// Accepted — forced status is still applied
|
|
832
|
+
{ where: { posts: { some: {} } } }
|
|
833
|
+
// Becomes: { posts: { some: { status: { equals: 'published' } } } }
|
|
834
|
+
```
|
|
573
835
|
|
|
574
836
|
---
|
|
575
837
|
|
|
@@ -587,6 +849,7 @@ Mutations that return records can use `select` and `include` in the guard shape
|
|
|
587
849
|
| `update` | record | yes |
|
|
588
850
|
| `updateMany` | BatchPayload | no |
|
|
589
851
|
| `updateManyAndReturn` | record[] | yes |
|
|
852
|
+
| `upsert` | record | yes |
|
|
590
853
|
| `delete` | record | yes |
|
|
591
854
|
| `deleteMany` | BatchPayload | no |
|
|
592
855
|
|
|
@@ -649,12 +912,14 @@ await prisma.project
|
|
|
649
912
|
|
|
650
913
|
Forced where conditions work the same as in reads. This is useful for ensuring tenant-scoped nested data in mutation responses:
|
|
651
914
|
```ts
|
|
915
|
+
import { force } from 'prisma-guard'
|
|
916
|
+
|
|
652
917
|
await prisma.project
|
|
653
918
|
.guard({
|
|
654
919
|
data: { title: true },
|
|
655
920
|
include: {
|
|
656
921
|
members: {
|
|
657
|
-
where: { isActive: { equals: true } },
|
|
922
|
+
where: { isActive: { equals: force(true) } },
|
|
658
923
|
},
|
|
659
924
|
},
|
|
660
925
|
})
|
|
@@ -666,9 +931,9 @@ await prisma.project
|
|
|
666
931
|
|
|
667
932
|
The returned `members` will always be filtered to `isActive = true`, regardless of what the client sends.
|
|
668
933
|
|
|
669
|
-
### Projection is optional
|
|
934
|
+
### Projection is optional by default
|
|
670
935
|
|
|
671
|
-
If the shape does not define `select` or `include`, the mutation returns the full record (default Prisma behavior).
|
|
936
|
+
If the shape does not define `select` or `include`, but the caller also omits them from the body, the mutation returns the full record (default Prisma behavior). Projection shapes only validate and constrain **client-requested** projections — they do not enforce a fixed output boundary unless [enforced projection mode](#enforced-projection-mode) is enabled.
|
|
672
937
|
|
|
673
938
|
### select and include are mutually exclusive
|
|
674
939
|
|
|
@@ -680,11 +945,165 @@ Same as reads: a shape (and a body) cannot define both `select` and `include` at
|
|
|
680
945
|
|
|
681
946
|
---
|
|
682
947
|
|
|
948
|
+
## Enforced projection mode
|
|
949
|
+
|
|
950
|
+
By default, projection shapes only constrain client-requested projections. If the client omits `select`/`include` from the body, Prisma returns its default full payload.
|
|
951
|
+
|
|
952
|
+
When `enforceProjection` is enabled, the shape's 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.
|
|
953
|
+
|
|
954
|
+
### Configuration
|
|
955
|
+
```prisma
|
|
956
|
+
generator guard {
|
|
957
|
+
provider = "prisma-guard"
|
|
958
|
+
output = "generated/guard"
|
|
959
|
+
enforceProjection = "true"
|
|
960
|
+
}
|
|
961
|
+
```
|
|
962
|
+
|
|
963
|
+
### Behavior
|
|
964
|
+
|
|
965
|
+
With enforced projection enabled:
|
|
966
|
+
```ts
|
|
967
|
+
await prisma.project
|
|
968
|
+
.guard({
|
|
969
|
+
data: { title: true },
|
|
970
|
+
select: { id: true, title: true },
|
|
971
|
+
})
|
|
972
|
+
.create({ data: { title: 'New' } })
|
|
973
|
+
```
|
|
974
|
+
|
|
975
|
+
Even though the client omits `select` from the body, Prisma receives `{ select: { id: true, title: true } }` and returns only those fields.
|
|
976
|
+
|
|
977
|
+
Without enforced projection (default): Prisma returns all fields.
|
|
978
|
+
|
|
979
|
+
### Synthesized projection
|
|
980
|
+
|
|
981
|
+
When the client omits `select`/`include`, prisma-guard synthesizes a default projection body from the shape:
|
|
982
|
+
|
|
983
|
+
* Scalar fields marked `true` in the shape produce `true` in the synthesized body
|
|
984
|
+
* Nested relation shapes produce their structural equivalent (nested `select`/`include`)
|
|
985
|
+
* `_count` configurations are preserved
|
|
986
|
+
* Client-controllable args like `where`, `orderBy`, `take`, `skip` on nested includes are omitted from the synthesized body — only forced where conditions are applied through the existing forced-tree pipeline
|
|
987
|
+
|
|
988
|
+
When the client does provide `select`/`include`, behavior is identical regardless of this setting: the client's projection is validated against the shape.
|
|
989
|
+
|
|
990
|
+
This mode applies to all methods that support projection: `create`, `update`, `upsert`, `delete`, `createManyAndReturn`, and `updateManyAndReturn`.
|
|
991
|
+
|
|
992
|
+
---
|
|
993
|
+
|
|
994
|
+
## Upsert
|
|
995
|
+
|
|
996
|
+
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.
|
|
997
|
+
```ts
|
|
998
|
+
await prisma.project
|
|
999
|
+
.guard({
|
|
1000
|
+
where: { id: { equals: true } },
|
|
1001
|
+
create: { title: true, status: true },
|
|
1002
|
+
update: { title: true },
|
|
1003
|
+
})
|
|
1004
|
+
.upsert({
|
|
1005
|
+
where: { id: { equals: 'abc123' } },
|
|
1006
|
+
create: { title: 'New Project', status: 'active' },
|
|
1007
|
+
update: { title: 'Updated Title' },
|
|
1008
|
+
})
|
|
1009
|
+
```
|
|
1010
|
+
|
|
1011
|
+
### Shape requirements
|
|
1012
|
+
|
|
1013
|
+
Upsert shapes must define all three: `where`, `create`, and `update`. Missing any of them throws `ShapeError`. Using `data` instead of `create`/`update` throws `ShapeError`.
|
|
1014
|
+
|
|
1015
|
+
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.
|
|
1016
|
+
|
|
1017
|
+
The `where` must satisfy a unique constraint with equality operators, same as `update` and `delete`.
|
|
1018
|
+
|
|
1019
|
+
### All data shape value types work
|
|
1020
|
+
```ts
|
|
1021
|
+
import { force } from 'prisma-guard'
|
|
1022
|
+
|
|
1023
|
+
await prisma.project
|
|
1024
|
+
.guard({
|
|
1025
|
+
where: { id: { equals: true } },
|
|
1026
|
+
create: {
|
|
1027
|
+
title: (base) => base.min(1).max(200),
|
|
1028
|
+
status: 'draft',
|
|
1029
|
+
isActive: force(true),
|
|
1030
|
+
},
|
|
1031
|
+
update: {
|
|
1032
|
+
title: (base) => base.min(1).max(200),
|
|
1033
|
+
},
|
|
1034
|
+
})
|
|
1035
|
+
.upsert({
|
|
1036
|
+
where: { id: { equals: 'abc123' } },
|
|
1037
|
+
create: { title: 'New Project' },
|
|
1038
|
+
update: { title: 'Updated' },
|
|
1039
|
+
})
|
|
1040
|
+
```
|
|
1041
|
+
|
|
1042
|
+
### Projection support
|
|
1043
|
+
|
|
1044
|
+
Upsert returns a record and supports `select` and `include`:
|
|
1045
|
+
```ts
|
|
1046
|
+
await prisma.project
|
|
1047
|
+
.guard({
|
|
1048
|
+
where: { id: { equals: true } },
|
|
1049
|
+
create: { title: true, status: true },
|
|
1050
|
+
update: { title: true },
|
|
1051
|
+
select: { id: true, title: true, status: true },
|
|
1052
|
+
})
|
|
1053
|
+
.upsert({
|
|
1054
|
+
where: { id: { equals: 'abc123' } },
|
|
1055
|
+
create: { title: 'New', status: 'active' },
|
|
1056
|
+
update: { title: 'Updated' },
|
|
1057
|
+
select: { id: true, title: true },
|
|
1058
|
+
})
|
|
1059
|
+
```
|
|
1060
|
+
|
|
1061
|
+
### Scope behavior
|
|
1062
|
+
|
|
1063
|
+
On scoped models, upsert is fully supported:
|
|
1064
|
+
|
|
1065
|
+
* Scope condition is merged into `where` using unique-preserving merge (same as `update` and `delete`)
|
|
1066
|
+
* Scope FK is injected into `create` data (same as regular creates)
|
|
1067
|
+
* Scope FK is stripped from `update` data (same as regular updates)
|
|
1068
|
+
* All scope roots must be present in context — missing roots throw `PolicyError`
|
|
1069
|
+
|
|
1070
|
+
### Named shapes and context-dependent shapes
|
|
1071
|
+
|
|
1072
|
+
Upsert works with named shapes and context-dependent shapes:
|
|
1073
|
+
```ts
|
|
1074
|
+
await prisma.project
|
|
1075
|
+
.guard({
|
|
1076
|
+
'/admin/projects/:id': {
|
|
1077
|
+
where: { id: { equals: true } },
|
|
1078
|
+
create: { title: true, status: true, priority: true },
|
|
1079
|
+
update: { title: true, status: true, priority: true },
|
|
1080
|
+
},
|
|
1081
|
+
'/editor/projects/:id': {
|
|
1082
|
+
where: { id: { equals: true } },
|
|
1083
|
+
create: { title: true, status: 'draft' },
|
|
1084
|
+
update: { title: true },
|
|
1085
|
+
},
|
|
1086
|
+
}, req.headers['x-caller'])
|
|
1087
|
+
.upsert({
|
|
1088
|
+
where: { id: { equals: req.params.id } },
|
|
1089
|
+
create: req.body.create,
|
|
1090
|
+
update: req.body.update,
|
|
1091
|
+
})
|
|
1092
|
+
```
|
|
1093
|
+
|
|
1094
|
+
### Body keys
|
|
1095
|
+
|
|
1096
|
+
Upsert accepts: `where`, `create`, `update`, `select`, `include`. Unknown keys are rejected with `ShapeError`.
|
|
1097
|
+
|
|
1098
|
+
---
|
|
1099
|
+
|
|
683
1100
|
## Named shapes and caller routing
|
|
684
1101
|
|
|
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
|
|
1102
|
+
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 value.
|
|
686
1103
|
|
|
687
|
-
Caller
|
|
1104
|
+
Caller is provided as the second argument to `.guard()` or via the context function — never in the request body. This keeps the method body clean and Prisma-compatible.
|
|
1105
|
+
|
|
1106
|
+
Caller keys must not collide with reserved shape config keys (`where`, `data`, `create`, `update`, `include`, `select`, `orderBy`, etc.). Using a reserved key as a caller path throws `ShapeError`.
|
|
688
1107
|
|
|
689
1108
|
### Define named shapes
|
|
690
1109
|
```ts
|
|
@@ -698,11 +1117,8 @@ await prisma.project
|
|
|
698
1117
|
where: { title: { contains: true } },
|
|
699
1118
|
take: { max: 20, default: 10 },
|
|
700
1119
|
},
|
|
701
|
-
})
|
|
702
|
-
.findMany(
|
|
703
|
-
caller: req.headers['x-caller'],
|
|
704
|
-
...req.body,
|
|
705
|
-
})
|
|
1120
|
+
}, req.headers['x-caller'])
|
|
1121
|
+
.findMany(req.body)
|
|
706
1122
|
```
|
|
707
1123
|
|
|
708
1124
|
The frontend sends its current route as a header:
|
|
@@ -715,7 +1131,7 @@ fetch('/api/projects', {
|
|
|
715
1131
|
})
|
|
716
1132
|
```
|
|
717
1133
|
|
|
718
|
-
The backend
|
|
1134
|
+
The backend passes `caller` as the second argument to `.guard()`. The request body contains only Prisma-compatible fields.
|
|
719
1135
|
|
|
720
1136
|
### Named mutation shapes
|
|
721
1137
|
```ts
|
|
@@ -729,9 +1145,8 @@ await prisma.project
|
|
|
729
1145
|
data: { title: true },
|
|
730
1146
|
where: { id: { equals: true } },
|
|
731
1147
|
},
|
|
732
|
-
})
|
|
1148
|
+
}, req.headers['x-caller'])
|
|
733
1149
|
.update({
|
|
734
|
-
caller: req.headers['x-caller'],
|
|
735
1150
|
data: req.body.data,
|
|
736
1151
|
where: { id: { equals: req.params.id } },
|
|
737
1152
|
})
|
|
@@ -752,14 +1167,37 @@ await prisma.project
|
|
|
752
1167
|
title: (base) => base.min(1).max(100),
|
|
753
1168
|
},
|
|
754
1169
|
},
|
|
755
|
-
})
|
|
756
|
-
.create({
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
1170
|
+
}, req.headers['x-caller'])
|
|
1171
|
+
.create({ data: req.body })
|
|
1172
|
+
```
|
|
1173
|
+
|
|
1174
|
+
All data shape value types (`true`, literal, `force()`, function) work in named shapes, context-dependent shapes, and single shapes.
|
|
1175
|
+
|
|
1176
|
+
### Caller resolution order
|
|
1177
|
+
|
|
1178
|
+
Caller is resolved in priority order:
|
|
1179
|
+
|
|
1180
|
+
1. **Explicit argument** — `.guard(shapes, '/admin/projects')` always wins
|
|
1181
|
+
2. **Context function** — if the context object has a `caller` string property, it is used as the default
|
|
1182
|
+
3. **None** — if neither source provides a caller and the shape is a named map, `CallerError` is thrown
|
|
1183
|
+
|
|
1184
|
+
This enables three usage patterns:
|
|
1185
|
+
```ts
|
|
1186
|
+
// 1. Per-request via context (set once, used everywhere)
|
|
1187
|
+
guard.extension(() => ({
|
|
1188
|
+
Tenant: store.getStore()?.tenantId,
|
|
1189
|
+
caller: store.getStore()?.caller,
|
|
1190
|
+
}))
|
|
1191
|
+
await prisma.project.guard(shapes).findMany(req.body)
|
|
1192
|
+
|
|
1193
|
+
// 2. Explicit override per call
|
|
1194
|
+
await prisma.project.guard(shapes, '/admin/projects').findMany(req.body)
|
|
1195
|
+
|
|
1196
|
+
// 3. Single shape (no caller needed)
|
|
1197
|
+
await prisma.project.guard({ where: { ... } }).findMany(req.body)
|
|
760
1198
|
```
|
|
761
1199
|
|
|
762
|
-
|
|
1200
|
+
The `caller` key in the context object is not used for scope injection — it is only used for shape routing. Scope roots are identified by matching context keys against `@scope-root` model names.
|
|
763
1201
|
|
|
764
1202
|
### Parameterized caller patterns
|
|
765
1203
|
```text
|
|
@@ -773,21 +1211,30 @@ Matching is case-sensitive. Exact matches are checked first. If no exact match i
|
|
|
773
1211
|
|
|
774
1212
|
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
1213
|
|
|
1214
|
+
If a request body contains a `caller` field when using named shapes, it is rejected with a `CallerError` that directs the developer to use the second argument to `.guard()` or the context function instead.
|
|
1215
|
+
|
|
776
1216
|
---
|
|
777
1217
|
|
|
778
1218
|
## Context-dependent shapes
|
|
779
1219
|
|
|
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.
|
|
1220
|
+
Shapes can be functions that receive the context provided to `guard.extension()`. This is the same context used for tenant scoping and caller routing — no separate mechanism.
|
|
781
1221
|
```ts
|
|
782
1222
|
const prisma = new PrismaClient().$extends(
|
|
783
1223
|
guard.extension(() => ({
|
|
784
1224
|
Tenant: store.getStore()?.tenantId,
|
|
785
1225
|
role: store.getStore()?.role,
|
|
1226
|
+
caller: store.getStore()?.caller,
|
|
786
1227
|
}))
|
|
787
1228
|
)
|
|
788
1229
|
```
|
|
789
1230
|
|
|
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.
|
|
1231
|
+
The context function returns an object with arbitrary keys. Keys whose values are `string`, `number`, or `bigint` and that match a scope root model name are used as scope context for tenant isolation. The `caller` key (if a string) is used as the default caller for named shape routing. Other keys (like `role` in the example above) are passed through to shape functions but are not used for scoping or routing.
|
|
1232
|
+
|
|
1233
|
+
The context function must return a plain object. If it returns `null`, `undefined`, an array, a primitive, or any non-plain-object value, a `PolicyError` is thrown. This is enforced consistently across all code paths that consume context — scope injection, caller resolution, and dynamic shape evaluation.
|
|
1234
|
+
|
|
1235
|
+
If a context key matches a known scope root model name but has a non-primitive value (e.g. an object or array instead of a string, number, or bigint), a `PolicyError` is thrown immediately. This prevents bugs in the context function from silently weakening scope enforcement.
|
|
1236
|
+
|
|
1237
|
+
Dynamic shape functions must return a plain guard shape object. If the function throws or returns a non-object value, a `ShapeError` is raised.
|
|
791
1238
|
|
|
792
1239
|
### Single context-dependent shape
|
|
793
1240
|
```ts
|
|
@@ -804,11 +1251,14 @@ await prisma.project
|
|
|
804
1251
|
|
|
805
1252
|
### Context-dependent data shapes with inline refines
|
|
806
1253
|
```ts
|
|
1254
|
+
import { force } from 'prisma-guard'
|
|
1255
|
+
|
|
807
1256
|
await prisma.project
|
|
808
1257
|
.guard((ctx) => ({
|
|
809
1258
|
data: {
|
|
810
1259
|
title: (base) => base.min(1).max(ctx.role === 'admin' ? 500 : 200),
|
|
811
1260
|
status: ctx.role === 'admin' ? true : 'draft',
|
|
1261
|
+
isActive: force(true),
|
|
812
1262
|
},
|
|
813
1263
|
}))
|
|
814
1264
|
.create({ data: req.body })
|
|
@@ -832,13 +1282,10 @@ await prisma.project
|
|
|
832
1282
|
take: { max: 20 },
|
|
833
1283
|
},
|
|
834
1284
|
})
|
|
835
|
-
.findMany(
|
|
836
|
-
caller: req.headers['x-caller'],
|
|
837
|
-
...req.body,
|
|
838
|
-
})
|
|
1285
|
+
.findMany(req.body)
|
|
839
1286
|
```
|
|
840
1287
|
|
|
841
|
-
Static shapes and function shapes can be mixed freely in the same shape map.
|
|
1288
|
+
Static shapes and function shapes can be mixed freely in the same shape map. In this example, the caller is resolved from `contextFn().caller` since no explicit caller is passed.
|
|
842
1289
|
|
|
843
1290
|
---
|
|
844
1291
|
|
|
@@ -859,7 +1306,7 @@ WHERE id = ?
|
|
|
859
1306
|
AND tenantId = ?
|
|
860
1307
|
```
|
|
861
1308
|
|
|
862
|
-
This applies to all top-level operations on scoped models, including reads, writes, and deletes.
|
|
1309
|
+
This applies to all top-level operations on scoped models, including reads, writes, upserts, and deletes.
|
|
863
1310
|
|
|
864
1311
|
### What is scoped
|
|
865
1312
|
|
|
@@ -867,13 +1314,13 @@ This applies to all top-level operations on scoped models, including reads, writ
|
|
|
867
1314
|
* All top-level creates (`create`, `createMany`, `createManyAndReturn`) — scope FK is injected into data
|
|
868
1315
|
* All top-level unique mutations (`update`, `delete`) — scope condition is merged into where
|
|
869
1316
|
* All top-level bulk mutations (`updateMany`, `updateManyAndReturn`, `deleteMany`) — scope condition is merged into where, scope FK is stripped from data
|
|
1317
|
+
* `upsert` — scope condition is merged into where, scope FK is injected into create data, scope FK is stripped from update data
|
|
870
1318
|
|
|
871
1319
|
### What is NOT scoped
|
|
872
1320
|
|
|
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
|
|
1321
|
+
* Nested reads loaded via `include` or `select` — use forced where conditions in the shape to restrict these (to-many relations only; see [Limitations](#limitations))
|
|
1322
|
+
* Nested writes — Prisma extension hooks operate on top-level operations only. Guarded data shapes reject relation fields entirely, so nested writes are only possible through raw (unguarded) Prisma calls.
|
|
875
1323
|
* `$queryRaw` and `$executeRaw` — raw SQL bypasses all guard protections
|
|
876
|
-
* `upsert` on scoped models — rejected with `PolicyError`; handle explicitly in route logic
|
|
877
1324
|
|
|
878
1325
|
### Scope relation writes
|
|
879
1326
|
|
|
@@ -896,6 +1343,20 @@ generator guard {
|
|
|
896
1343
|
|
|
897
1344
|
---
|
|
898
1345
|
|
|
1346
|
+
## Multi-root scope behavior
|
|
1347
|
+
|
|
1348
|
+
A model can be scoped by multiple scope roots simultaneously. If `Project` has a foreign key to both `Tenant` and `Organization` (both marked `@scope-root`), the scope extension enforces both.
|
|
1349
|
+
|
|
1350
|
+
On reads, both scope conditions are combined with `AND`. If `onMissingScopeContext` is `"warn"` or `"ignore"`, only present roots are enforced — missing roots are skipped. If `"error"` (the default), all roots must be present.
|
|
1351
|
+
|
|
1352
|
+
On writes (including upsert), all scope roots must be present in the context. A missing root always throws `PolicyError`, regardless of `onMissingScopeContext`.
|
|
1353
|
+
|
|
1354
|
+
Scope foreign keys for all present roots are injected into create data and stripped from update/delete data.
|
|
1355
|
+
|
|
1356
|
+
If this behavior is not what you want, restructure your schema so the model references only one scope root, or handle scoping explicitly via shape rules.
|
|
1357
|
+
|
|
1358
|
+
---
|
|
1359
|
+
|
|
899
1360
|
## findUnique behavior
|
|
900
1361
|
|
|
901
1362
|
Prisma `findUnique` only accepts declared unique selectors.
|
|
@@ -942,6 +1403,8 @@ This is weaker because:
|
|
|
942
1403
|
|
|
943
1404
|
For tenant isolation, `"reject"` is the safer production default.
|
|
944
1405
|
|
|
1406
|
+
Guard shapes for `findUnique` and `findUniqueOrThrow` must define `where`. A shape without `where` for these methods throws `ShapeError`.
|
|
1407
|
+
|
|
945
1408
|
---
|
|
946
1409
|
|
|
947
1410
|
## Output shaping
|
|
@@ -959,7 +1422,7 @@ const userOutput = guard.model('User', {
|
|
|
959
1422
|
})
|
|
960
1423
|
```
|
|
961
1424
|
|
|
962
|
-
`guard.model()` produces a non-strict schema by default, meaning
|
|
1425
|
+
`guard.model()` produces a non-strict schema by default, meaning unknown fields in the data are silently stripped from the output. For output validation where unknown fields should be rejected instead of stripped, pass `strict: true` as shown above.
|
|
963
1426
|
|
|
964
1427
|
Notes:
|
|
965
1428
|
|
|
@@ -970,6 +1433,44 @@ Notes:
|
|
|
970
1433
|
|
|
971
1434
|
---
|
|
972
1435
|
|
|
1436
|
+
## Strict Decimal mode
|
|
1437
|
+
|
|
1438
|
+
By default, `Decimal` fields accept JavaScript `number`, decimal string, and Decimal-like objects. Accepting `number` is convenient but carries a precision risk: floating-point precision may already be lost by the time the validator sees the value (e.g. `0.1 + 0.2` arrives as `0.30000000000000004`).
|
|
1439
|
+
|
|
1440
|
+
Strict Decimal mode removes `number` from the accepted types, requiring decimal string or Prisma `Decimal` objects only.
|
|
1441
|
+
|
|
1442
|
+
### Configuration
|
|
1443
|
+
```prisma
|
|
1444
|
+
generator guard {
|
|
1445
|
+
provider = "prisma-guard"
|
|
1446
|
+
output = "generated/guard"
|
|
1447
|
+
strictDecimal = "true"
|
|
1448
|
+
}
|
|
1449
|
+
```
|
|
1450
|
+
|
|
1451
|
+
### Behavior
|
|
1452
|
+
|
|
1453
|
+
| Mode | Accepted types |
|
|
1454
|
+
| -------- | -------------------------------------------------- |
|
|
1455
|
+
| default | `number`, decimal string, Decimal-like object |
|
|
1456
|
+
| strict | decimal string, Decimal-like object |
|
|
1457
|
+
|
|
1458
|
+
With strict mode enabled:
|
|
1459
|
+
```ts
|
|
1460
|
+
// Accepted
|
|
1461
|
+
{ price: "29.99" }
|
|
1462
|
+
{ price: new Prisma.Decimal("29.99") }
|
|
1463
|
+
|
|
1464
|
+
// Rejected
|
|
1465
|
+
{ price: 29.99 }
|
|
1466
|
+
```
|
|
1467
|
+
|
|
1468
|
+
This applies globally to all `Decimal` fields across all models, in both `data` shapes and `where` filters.
|
|
1469
|
+
|
|
1470
|
+
For money or high-precision values, strict mode is recommended. Pass decimal strings (e.g. `"0.30"`) or Prisma `Decimal` objects instead of JavaScript numbers.
|
|
1471
|
+
|
|
1472
|
+
---
|
|
1473
|
+
|
|
973
1474
|
## Security model
|
|
974
1475
|
|
|
975
1476
|
`prisma-guard` enforces three layers.
|
|
@@ -996,33 +1497,33 @@ These limitations are real and should be treated as part of the security model.
|
|
|
996
1497
|
|
|
997
1498
|
Prisma extension hooks operate on top-level operations. Nested writes do not trigger separate scope interception.
|
|
998
1499
|
|
|
999
|
-
|
|
1500
|
+
Guarded data shapes reject relation fields entirely — using a relation field in a `data`, `create`, or `update` shape throws `ShapeError`. This means nested writes (e.g. `{ author: { connect: { id: '...' } } }`) are only possible through raw (unguarded) Prisma calls. The guard layer prevents them in guarded mutations, not by intercepting nested writes, but by refusing to include relation fields in the data shape.
|
|
1000
1501
|
|
|
1001
1502
|
### Nested reads via include are not scope-filtered
|
|
1002
1503
|
|
|
1003
1504
|
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
1505
|
|
|
1005
|
-
###
|
|
1506
|
+
### Forced where on nested reads is limited to to-many relations
|
|
1006
1507
|
|
|
1007
|
-
|
|
1508
|
+
Prisma does not support `where` on to-one relation includes. Because of this, forced `where` conditions in nested include/select shapes only work on to-many relations.
|
|
1008
1509
|
|
|
1009
|
-
|
|
1510
|
+
For to-one relations (e.g. `author` on a `Post`), the available mitigations are: omit the relation from the include/select shape entirely, restrict which scalar fields are returned using nested `select`, or rely on database-level constraints (e.g. RLS, foreign key guarantees).
|
|
1010
1511
|
|
|
1011
|
-
|
|
1512
|
+
This is a Prisma API constraint, not a prisma-guard limitation.
|
|
1012
1513
|
|
|
1013
|
-
|
|
1514
|
+
### `findUnique` cannot be safely scoped in Prisma extension mode
|
|
1014
1515
|
|
|
1015
|
-
|
|
1516
|
+
This is a Prisma API limitation, not a conceptual limitation of scoped unique lookups.
|
|
1016
1517
|
|
|
1017
|
-
|
|
1518
|
+
That is why `findUniqueMode = "reject"` is recommended.
|
|
1018
1519
|
|
|
1019
|
-
Guard
|
|
1520
|
+
Guard shapes for `findUnique` and `findUniqueOrThrow` must define `where`. A shape without `where` throws `ShapeError`.
|
|
1020
1521
|
|
|
1021
|
-
|
|
1522
|
+
### Composite foreign keys to scope roots
|
|
1022
1523
|
|
|
1023
|
-
|
|
1524
|
+
If a model references a scope root through composite foreign keys, that specific root is excluded from the 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.
|
|
1024
1525
|
|
|
1025
|
-
|
|
1526
|
+
Handle these models explicitly via shape rules.
|
|
1026
1527
|
|
|
1027
1528
|
### Cursor fields must cover a unique constraint
|
|
1028
1529
|
|
|
@@ -1046,11 +1547,13 @@ Guard `having` shapes support scalar field filters with their type-appropriate o
|
|
|
1046
1547
|
|
|
1047
1548
|
### Json fields accept any JSON-serializable value
|
|
1048
1549
|
|
|
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`,
|
|
1550
|
+
`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`, `Infinity`, and circular references — 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
1551
|
|
|
1051
1552
|
### `refine` and inline refine functions replace `@zod` chains
|
|
1052
1553
|
|
|
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).
|
|
1554
|
+
When a `refine` callback is provided for a field in `guard.input()`, or when a function is used instead of `true` in a `data`/`create`/`update` 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).
|
|
1555
|
+
|
|
1556
|
+
If the refine function returns a schema that handles undefined input (produces a non-undefined value for undefined), prisma-guard detects this and preserves the behavior by not wrapping with `.optional()` in create mode.
|
|
1054
1557
|
|
|
1055
1558
|
### `pick` and `omit` are mutually exclusive
|
|
1056
1559
|
|
|
@@ -1058,16 +1561,60 @@ Both `guard.input()` and `guard.model()` reject configurations that specify both
|
|
|
1058
1561
|
|
|
1059
1562
|
### `@zod` field modifiers interact with prisma-guard nullability
|
|
1060
1563
|
|
|
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.
|
|
1564
|
+
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. Exception: when a chain contains `.default()` or `.catch()`, prisma-guard skips adding `.optional()` in create mode to preserve the default/catch behavior.
|
|
1066
1565
|
|
|
1067
1566
|
### Inline refine functions are not cached
|
|
1068
1567
|
|
|
1069
1568
|
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
1569
|
|
|
1570
|
+
### `take` does not support negative values
|
|
1571
|
+
|
|
1572
|
+
Prisma supports negative `take` for reverse cursor pagination. prisma-guard restricts `take` to positive integers (minimum 1). If you need reverse pagination, construct the query server-side using a context-dependent shape.
|
|
1573
|
+
|
|
1574
|
+
### `skip` in shape config is a permission flag
|
|
1575
|
+
|
|
1576
|
+
`skip: true` in a shape config means the client is allowed to provide a `skip` value. The actual `skip` value must be a non-negative integer. The value must be exactly `true` — other truthy values are rejected with `ShapeError`. This is consistent with other shape flags but differs from `take`, which uses `{ max, default? }` syntax.
|
|
1577
|
+
|
|
1578
|
+
### `guard.input()` defaults to allowing null for nullable fields
|
|
1579
|
+
|
|
1580
|
+
`guard.input()` defaults `allowNull` to `true`, matching the behavior of `.guard({ data: ... })` and Prisma's own nullable field handling. Pass `allowNull: false` to reject null values for nullable fields.
|
|
1581
|
+
|
|
1582
|
+
### `Decimal` fields accept JavaScript numbers by default
|
|
1583
|
+
|
|
1584
|
+
The `Decimal` base type accepts JavaScript `number`, decimal string, and Decimal-like objects by default. Accepting `number` is convenient but carries a precision risk: by the time the validator sees the value, floating-point precision may already be lost. For example, `0.1 + 0.2` arrives as `0.30000000000000004`. For money or high-precision values, enable [strict Decimal mode](#strict-decimal-mode) or pass decimal strings (e.g. `"0.30"`) or Prisma `Decimal` objects instead of JavaScript numbers.
|
|
1585
|
+
|
|
1586
|
+
### `skipDuplicates` is supported for batch create methods
|
|
1587
|
+
|
|
1588
|
+
`createMany` and `createManyAndReturn` accept `skipDuplicates: boolean` in the request body. This is passed through to Prisma without shape-level configuration. It is not available on `create`.
|
|
1589
|
+
|
|
1590
|
+
### Guarded data shapes do not permit relation fields
|
|
1591
|
+
|
|
1592
|
+
Relation fields in `data`, `create`, and `update` shapes are rejected with `ShapeError`. Nested writes (e.g. `{ author: { connect: { id: '...' } } }`) are only possible through raw (unguarded) Prisma calls. The guard layer does not intercept or validate nested writes — it prevents them entirely in guarded mutations.
|
|
1593
|
+
|
|
1594
|
+
### Conflicting forced where values are rejected
|
|
1595
|
+
|
|
1596
|
+
If the same field and operator appear as forced values in different parts of a where shape (e.g. at the top level and inside an `AND` combinator) with different values, the shape is rejected with `ShapeError` at construction time. Identical duplicate forced values are deduplicated silently. This prevents ambiguous security configurations from silently degrading.
|
|
1597
|
+
|
|
1598
|
+
### Mutation projection shapes do not enforce a fixed output boundary by default
|
|
1599
|
+
|
|
1600
|
+
If a mutation shape defines `select` or `include` but the client omits them from the body, Prisma returns its default full payload. Projection shapes only validate and constrain client-requested projections. Enable [enforced projection mode](#enforced-projection-mode) to always apply the shape's projection.
|
|
1601
|
+
|
|
1602
|
+
### `create` and `update` are reserved shape keys
|
|
1603
|
+
|
|
1604
|
+
The bare words `create` and `update` cannot be used as caller keys in named shape routing, as they are reserved for upsert shape configuration. Full paths like `'/admin/create'` or `'/api/users/update'` are unaffected — only the bare words collide.
|
|
1605
|
+
|
|
1606
|
+
### Empty `select` and `include` shapes are rejected
|
|
1607
|
+
|
|
1608
|
+
An empty `select: {}` or `include: {}` in a guard shape throws `ShapeError` at shape construction time. This is consistent with the fail-closed design applied to empty combinators, empty relation filters, and empty operator objects.
|
|
1609
|
+
|
|
1610
|
+
### Shape config values must be exactly `true`
|
|
1611
|
+
|
|
1612
|
+
Fields in `orderBy`, `cursor`, `having`, `_count` (object form), `_avg`, `_sum`, `_min`, `_max` config objects must have the value `true`. The `skip` config must be exactly `true`. Passing `false`, numbers, strings, or any other value throws `ShapeError`. This prevents misconfiguration where `false` is silently treated as enabled.
|
|
1613
|
+
|
|
1614
|
+
### `@zod .catch()` fields are tracked alongside `.default()` fields
|
|
1615
|
+
|
|
1616
|
+
Both `@zod .default(...)` and `@zod .catch(...)` are tracked in the generated `ZOD_DEFAULTS` map. Fields with either directive are exempted from create completeness checks and auto-injected as forced values when omitted from data shapes. The `.catch()` behavior (fallback on parse error) is preserved in create mode by not wrapping the schema with `.optional()`.
|
|
1617
|
+
|
|
1071
1618
|
---
|
|
1072
1619
|
|
|
1073
1620
|
## Advanced: SQL-backed runtimes
|
|
@@ -1094,9 +1641,9 @@ Libraries like **prisma-sql** make this possible for advanced architectures.
|
|
|
1094
1641
|
`.guard(shape).method(body)` may throw:
|
|
1095
1642
|
|
|
1096
1643
|
* `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,
|
|
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.
|
|
1644
|
+
* `ShapeError` — invalid shape config, unknown shape config keys, wrong method for shape, body format issues, unexpected body keys, incomplete create data shapes, invalid inline refine functions, dynamic shape functions returning invalid values, conflicting forced where values, empty combinator or relation filter definitions, empty projection shapes, vacuous combinator input, non-`true` config values in shape builders, or using `data` instead of `create`/`update` for upsert
|
|
1645
|
+
* `CallerError` — missing, unknown, or ambiguous caller in named shapes, or `caller` found in request body
|
|
1646
|
+
* `PolicyError` — denied scope, missing tenant context, invalid context function return value, invalid scope root value type, or rejected operations on scoped models (e.g. findUnique in reject mode)
|
|
1100
1647
|
|
|
1101
1648
|
All guard errors include `status` and `code` properties for HTTP response mapping:
|
|
1102
1649
|
|
|
@@ -1137,8 +1684,9 @@ It reads the Prisma DMMF and emits:
|
|
|
1137
1684
|
* `TYPE_MAP` — field metadata per model
|
|
1138
1685
|
* `ENUM_MAP` — enum values
|
|
1139
1686
|
* `SCOPE_MAP` — foreign key → scope root mappings
|
|
1140
|
-
* `ZOD_CHAINS` — `@zod` directive chains (validated
|
|
1141
|
-
* `
|
|
1687
|
+
* `ZOD_CHAINS` — `@zod` directive chains (validated for syntax, method allowlist, argument arity, and basic type compatibility (method existence and type advancement for wrapper-changing methods like .nullable(), .optional(), .default()). Argument type mismatches for non-type-changing methods (e.g. .min('x') on a number field) are not caught at generation time and will fail at runtime when the schema is built.)
|
|
1688
|
+
* `ZOD_DEFAULTS` — per-model list of fields that have `@zod .default(...)` or `@zod .catch(...)`, used by the create completeness check and by runtime default injection for omitted fields
|
|
1689
|
+
* `GUARD_CONFIG` — generator config values (including `strictDecimal` and `enforceProjection`)
|
|
1142
1690
|
* `UNIQUE_MAP` — unique constraint metadata per model
|
|
1143
1691
|
* `client.ts` — pre-wired guard instance with typed model extensions
|
|
1144
1692
|
|
|
@@ -1146,11 +1694,23 @@ It reads the Prisma DMMF and emits:
|
|
|
1146
1694
|
|
|
1147
1695
|
At runtime, `guard.extension()` creates a Prisma extension that provides:
|
|
1148
1696
|
|
|
1149
|
-
* `.guard(shape)` on every model delegate — validates input, enforces query shapes, returns typed Prisma methods
|
|
1697
|
+
* `.guard(shape, caller?)` on every model delegate — validates input, enforces query shapes, returns typed Prisma methods
|
|
1150
1698
|
* `$allOperations` query hook — injects tenant scope into every top-level database operation
|
|
1151
1699
|
|
|
1152
1700
|
The `.guard()` call validates against the shape, merges forced values, and delegates to the underlying Prisma method. The scope layer runs transparently underneath.
|
|
1153
1701
|
|
|
1702
|
+
For create operations, fields tracked in `ZOD_DEFAULTS` that are omitted from the data shape are auto-injected as forced values. The runtime evaluates the field's Zod schema with `undefined` input and uses the resulting value. This ensures `@zod .default(...)` and `@zod .catch(...)` produce correct data even when the field is not listed in the shape.
|
|
1703
|
+
|
|
1704
|
+
For fields that ARE listed in the data shape with `true` and have `@zod .default(...)` or `@zod .catch(...)`, the runtime skips wrapping the schema with `.optional()` in create mode. This preserves the Zod default/catch behavior: omitting the field from client input triggers the default rather than passing `undefined` through.
|
|
1705
|
+
|
|
1706
|
+
Caller routing is resolved before method execution: the explicit `caller` argument takes priority, then `contextFn().caller`, then absent (which is fine for single shapes but throws `CallerError` for named shape maps).
|
|
1707
|
+
|
|
1708
|
+
The context function is validated on every code path that consumes it — scope injection, caller resolution, and dynamic shape evaluation all enforce the plain-object contract and throw `PolicyError` for invalid returns. Additionally, if a context key matches a known scope root but has a non-primitive value, `PolicyError` is thrown immediately rather than silently dropping the scope.
|
|
1709
|
+
|
|
1710
|
+
### The `force()` helper
|
|
1711
|
+
|
|
1712
|
+
The `force()` function is exported from `prisma-guard` and creates a wrapper object with an internal symbol marker. At runtime, shape processing checks for this marker to distinguish forced values from the `true` sentinel. The wrapper is unwrapped before Zod validation, so the forced value is validated against the field's schema like any other literal. The symbol is not enumerable and does not interfere with serialization or inspection of shape objects.
|
|
1713
|
+
|
|
1154
1714
|
---
|
|
1155
1715
|
|
|
1156
1716
|
## Why this approach
|
|
@@ -1174,9 +1734,13 @@ The runtime does lightweight argument rewriting and Zod validation.
|
|
|
1174
1734
|
|
|
1175
1735
|
In most real applications, overhead should be negligible relative to database round-trip time.
|
|
1176
1736
|
|
|
1177
|
-
Static shapes
|
|
1737
|
+
**`guard.query()` caching:** Static shapes passed to `guard.query()` are cached for the lifetime of the returned `QuerySchema` object. 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.
|
|
1738
|
+
|
|
1739
|
+
**`.guard()` caching:** The `.guard(shape).method(body)` chain creates per-invocation caches. In typical usage, the cache is created, used for one method call, and discarded. If you store the result of `.guard(shape)` and call multiple methods on it, the cache is shared across those calls. For hot paths where the same static shape is used repeatedly, prefer `guard.query()` for persistent caching.
|
|
1740
|
+
|
|
1741
|
+
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 within their invocation scope. Projection schemas (select/include on mutations) are cached independently for static shapes within their invocation scope.
|
|
1178
1742
|
|
|
1179
|
-
|
|
1743
|
+
For upsert, `create` and `update` data schemas are cached independently under namespaced keys (`upsert:create`, `upsert:update`) to avoid cache collisions with regular create/update operations.
|
|
1180
1744
|
|
|
1181
1745
|
---
|
|
1182
1746
|
|
|
@@ -1188,26 +1752,44 @@ Data schemas containing inline refine functions are also not cached, since the f
|
|
|
1188
1752
|
| -------------------------------------- | ----------------------------------------------------- |
|
|
1189
1753
|
| ambiguous scope mapping | error by default |
|
|
1190
1754
|
| missing scope context | error by default |
|
|
1755
|
+
| invalid scope root value type | error always |
|
|
1191
1756
|
| `onMissingScopeContext = "ignore"` | scope bypassed for missing roots; present roots still enforced |
|
|
1192
1757
|
| unsafe scoped `findUnique` | reject recommended |
|
|
1193
1758
|
| invalid `@zod` directive | error by default |
|
|
1194
1759
|
| missing `caller` in named shapes | error always |
|
|
1760
|
+
| `caller` in request body | error always |
|
|
1195
1761
|
| `data` in read shape | error always |
|
|
1762
|
+
| `data` in upsert shape | error always (use `create`/`update`) |
|
|
1763
|
+
| missing `create` or `update` in upsert | error always |
|
|
1196
1764
|
| missing `data` in write shape | error always |
|
|
1197
1765
|
| bulk mutation without `where` shape | error always |
|
|
1198
1766
|
| bulk mutation with empty `where` | error always |
|
|
1199
|
-
|
|
|
1767
|
+
| vacuous combinator input | error always |
|
|
1768
|
+
| empty combinator/relation shape branch | error always |
|
|
1769
|
+
| empty `select`/`include` shape | error always |
|
|
1200
1770
|
| unexpected keys in mutation body | error always |
|
|
1201
1771
|
| unknown keys in shape config | error always |
|
|
1772
|
+
| non-`true` values in shape config | error always |
|
|
1202
1773
|
| cursor not covering unique constraint | error always |
|
|
1203
1774
|
| caller key collides with shape config | error always |
|
|
1204
1775
|
| empty operator objects in where | error always |
|
|
1776
|
+
| empty relation filter (no forced) | error always |
|
|
1777
|
+
| empty relation operator container | error always |
|
|
1778
|
+
| empty relation filter shape definition | error always |
|
|
1205
1779
|
| `pick` and `omit` both specified | error always |
|
|
1206
1780
|
| scope relation in mutation data | controlled by `onScopeRelationWrite` (default: error) |
|
|
1207
1781
|
| incomplete create data shape | error always |
|
|
1208
1782
|
| invalid inline refine function | error always (ShapeError) |
|
|
1209
1783
|
| projection on batch method | error always |
|
|
1210
1784
|
| body projection without shape | error always |
|
|
1785
|
+
| dynamic shape returns non-object | error always (ShapeError) |
|
|
1786
|
+
| refine callback returns non-Zod schema | error always (ShapeError) |
|
|
1787
|
+
| `findUnique` shape without `where` | error always (ShapeError) |
|
|
1788
|
+
| invalid relation operator for type | error always (ShapeError) |
|
|
1789
|
+
| context function returns non-object | error always (PolicyError) |
|
|
1790
|
+
| conflicting forced where values | error always (ShapeError) |
|
|
1791
|
+
| invalid context function return | error always (PolicyError) |
|
|
1792
|
+
| `@zod .default()`/`.catch()` field omitted from shape | auto-injected as forced value |
|
|
1211
1793
|
|
|
1212
1794
|
---
|
|
1213
1795
|
|
|
@@ -1221,6 +1803,8 @@ generator guard {
|
|
|
1221
1803
|
onMissingScopeContext = "error"
|
|
1222
1804
|
findUniqueMode = "reject"
|
|
1223
1805
|
onScopeRelationWrite = "error"
|
|
1806
|
+
strictDecimal = "true"
|
|
1807
|
+
enforceProjection = "true"
|
|
1224
1808
|
}
|
|
1225
1809
|
```
|
|
1226
1810
|
|
|
@@ -1267,6 +1851,10 @@ Node 22
|
|
|
1267
1851
|
4. Avoid automatic relation traversal
|
|
1268
1852
|
5. Keep scope rules explicit and schema-driven
|
|
1269
1853
|
6. One chain — shape defines the boundary, method executes
|
|
1854
|
+
7. Method bodies stay Prisma-compatible — routing and context live in `.guard()`
|
|
1855
|
+
8. No overloaded sentinel values — `true` always means client-controlled, `force()` for forced booleans
|
|
1856
|
+
9. Upsert uses `create`/`update` keys, not `data` — matches Prisma's own API shape
|
|
1857
|
+
10. Shape config values are validated strictly — `true` means enabled, anything else is rejected
|
|
1270
1858
|
|
|
1271
1859
|
---
|
|
1272
1860
|
|
|
@@ -1282,12 +1870,23 @@ Node 22
|
|
|
1282
1870
|
| Caller-based shape routing | yes | no |
|
|
1283
1871
|
| Typed method chaining | yes | n/a |
|
|
1284
1872
|
| Bulk mutation safety | required where | not handled |
|
|
1873
|
+
| Vacuous combinator rejection | yes | not handled |
|
|
1285
1874
|
| Mutation body validation | strict keys | no |
|
|
1286
|
-
| Shape config validation | strict
|
|
1875
|
+
| Shape config validation | strict values | n/a |
|
|
1287
1876
|
| Create completeness validation | yes | no |
|
|
1288
1877
|
| Mutation return projection | yes | manual |
|
|
1878
|
+
| Enforced projection mode | opt-in | no |
|
|
1879
|
+
| Upsert support | yes | manual |
|
|
1289
1880
|
| Inline field refine in data shapes | yes | n/a |
|
|
1290
1881
|
| ZodError wrapping | opt-in | n/a |
|
|
1882
|
+
| Logical combinators in where | yes | manual |
|
|
1883
|
+
| Relation filters in where | yes | manual |
|
|
1884
|
+
| Empty relation filter rejection | yes | n/a |
|
|
1885
|
+
| Empty projection shape rejection | yes | n/a |
|
|
1886
|
+
| Forced where conflict detection | yes | n/a |
|
|
1887
|
+
| Forced boolean values via `force()` | yes | n/a |
|
|
1888
|
+
| Strict Decimal mode | opt-in | n/a |
|
|
1889
|
+
| `@zod .default()`/`.catch()` auto-injection | yes | n/a |
|
|
1291
1890
|
|
|
1292
1891
|
---
|
|
1293
1892
|
|
|
@@ -1297,9 +1896,6 @@ Possible future improvements:
|
|
|
1297
1896
|
|
|
1298
1897
|
* optional nested-write enforcement helpers
|
|
1299
1898
|
* 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
1899
|
* adapter integrations for SQL-backed runtimes
|
|
1304
1900
|
* model-specific generated types for stronger compile-time shape validation
|
|
1305
1901
|
* structured JSON field validation via schema annotations
|