prisma-guard 1.31.0 → 1.32.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 CHANGED
@@ -1121,7 +1121,7 @@ The return value is:
1121
1121
  | `shape` | The concrete resolved guard shape after caller matching and dynamic shape execution |
1122
1122
  | `body` | The normalized request body. Omitted input becomes `{}` |
1123
1123
  | `effectiveReadBody` | The body used for read planning. If the body has no `select` or `include`, the shape's default read projection is applied |
1124
- | `matchedKey` | The selected named-shape key, such as `default`, `admin`, or `/user/me` |
1124
+ | `matchedKey` | The selected declared key, such as `default`, `admin`, or `/user/:id`. For parameterized callers this is the pattern key, not the concrete caller value |
1125
1125
  | `wasDynamic` | `true` when the matched shape was a function and was executed with guard context |
1126
1126
 
1127
1127
  `resolve()` uses the same guard extension context and caller selection path as `.findMany()`, `.findFirst()`, and the other guarded methods. If the shape is context-dependent, the shape function is called with the current guard context.
@@ -1915,22 +1915,40 @@ The `default` fallback works consistently across both `.guard()` and `guard.quer
1915
1915
 
1916
1916
  ### Note for generated route configs
1917
1917
 
1918
- The direct `.guard({ where: ... })` form is still valid in the runtime API. Generated route configs from `prisma-generator-express`, however, type operation `shape` as a named shape map. For a single generated-route shape, put it under `default`:
1918
+ The direct runtime API accepts all three guard input forms:
1919
+
1920
+ - one direct guard shape
1921
+ - one context-dependent shape function
1922
+ - a named shape map
1923
+
1924
+ `prisma-generator-express` mirrors those forms under an operation's `shape` property. It also provides a `variants` descriptor API when each named shape needs its own route hooks:
1919
1925
 
1920
1926
  ```ts
1921
1927
  const projectConfig = {
1922
1928
  findMany: {
1923
- shape: {
1929
+ before: [authenticate],
1930
+ variants: {
1931
+ admin: {
1932
+ shape: {
1933
+ where: { title: { contains: true }, status: { equals: true } },
1934
+ take: { max: 100 },
1935
+ },
1936
+ before: [requireAdmin],
1937
+ },
1924
1938
  default: {
1925
- where: { title: { contains: true } },
1926
- take: { max: 50, default: 20 },
1939
+ shape: {
1940
+ where: { title: { contains: true } },
1941
+ take: { max: 20, default: 10 },
1942
+ },
1927
1943
  },
1928
1944
  },
1929
1945
  },
1930
1946
  }
1931
1947
  ```
1932
1948
 
1933
- Additional keys such as `admin`, `public`, or path patterns are caller-routed variants. Reserved shape keys such as `where`, `data`, `include`, and `select` cannot be used as caller keys.
1949
+ Within one generated operation, `shape` and `variants` cannot both be defined. Both may be omitted when the operation only needs operation-wide hooks or pagination. Each `variants[key].shape` is one direct shape or one context-dependent shape function; it is not another nested named map.
1950
+
1951
+ Use `shape` when only guard routing is needed. Use `variants` when hooks need to differ by the matched named shape.
1934
1952
 
1935
1953
  ### Caller resolution order
1936
1954
 
@@ -1959,16 +1977,50 @@ await prisma.project.guard({ where: { ... } }).findMany(req.body)
1959
1977
  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.
1960
1978
 
1961
1979
  ### Parameterized caller patterns
1980
+
1962
1981
  ```text
1963
1982
  /org/:orgId/users
1964
1983
  /org/:orgId/users/:userId
1965
1984
  ```
1966
1985
 
1967
- 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.
1986
+ Matching is case-sensitive. A parameter segment starts with `:` and matches exactly one path segment. Segment counts must be equal. Parameters are routing-only and are not extracted into context.
1987
+
1988
+ Caller routing uses this precedence:
1989
+
1990
+ 1. Reject any named key that collides with a reserved guard shape key.
1991
+ 2. If caller is not a string, use `default` when present; otherwise throw the missing-caller `CallerError`.
1992
+ 3. If caller is blank or whitespace-only, skip exact and parameterized matching. Use `default` when present; otherwise throw unknown-caller `CallerError`.
1993
+ 4. A non-blank exact key match wins immediately.
1994
+ 5. If there is no exact match, evaluate parameterized patterns.
1995
+ 6. One matching pattern selects that declared pattern key.
1996
+ 7. Multiple matching patterns throw an ambiguous-caller `CallerError` listing every match.
1997
+ 8. With no match, use `default` when present; otherwise throw unknown-caller `CallerError`.
1998
+
1999
+ Exact matching therefore wins over a pattern that would also match:
2000
+
2001
+ ```ts
2002
+ const shapes = {
2003
+ 'customer/123': exactShape,
2004
+ 'customer/:id': parameterizedShape,
2005
+ }
2006
+
2007
+ // Selects "customer/123", not "customer/:id".
2008
+ await prisma.order.guard(shapes, 'customer/123').findMany()
2009
+ ```
2010
+
2011
+ For a parameterized caller, `matchedKey` is the declared pattern key. A caller of `customer/123` matched by `customer/:id` produces `matchedKey === 'customer/:id'`. This is the key used for shape memoization and returned by `resolve()`.
1968
2012
 
1969
2013
  ### Fail-closed behavior
1970
2014
 
1971
- If `caller` is missing or doesn't match any pattern and no `default` key exists, the request is rejected with a `CallerError`. If a caller matches multiple parameterized patterns, it is also rejected with a `CallerError`.
2015
+ Named routing fails closed:
2016
+
2017
+ - A non-string caller with no `default` throws the missing-caller `CallerError`.
2018
+ - A blank or whitespace-only caller never matches a blank key or a parameterized key. It uses `default` or throws unknown-caller `CallerError`.
2019
+ - An unmatched caller uses `default` or throws unknown-caller `CallerError`.
2020
+ - Multiple matching parameterized patterns throw ambiguous-caller `CallerError`.
2021
+ - A caller key that collides with a reserved shape key throws `ShapeError`.
2022
+
2023
+ The same precedence and error behavior apply to `.guard()` and `guard.query().parse()`.
1972
2024
 
1973
2025
  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.
1974
2026
 
@@ -2738,6 +2790,4 @@ Possible future improvements:
2738
2790
 
2739
2791
  ## License
2740
2792
 
2741
- MIT
2742
-
2743
-
2793
+ MIT
@@ -975,40 +975,75 @@ function unsupported() {
975
975
  return marker;
976
976
  }
977
977
 
978
- // src/shared/match-caller.ts
979
- function matchCallerPattern(patterns, caller) {
980
- if (typeof caller !== "string" || caller.trim().length === 0)
981
- return null;
982
- if (patterns.includes(caller))
983
- return caller;
978
+ // src/shared/guard-variant-routing.ts
979
+ function matchVariantPatterns(keys, caller) {
980
+ const callerParts = caller.split("/");
984
981
  const matches = [];
985
- for (const pattern of patterns) {
982
+ for (const pattern of keys) {
986
983
  if (!pattern.includes(":"))
987
984
  continue;
988
985
  const patternParts = pattern.split("/");
989
- const callerParts = caller.split("/");
990
986
  if (patternParts.length !== callerParts.length)
991
987
  continue;
992
- let ok = true;
993
- for (let i = 0; i < patternParts.length; i++) {
994
- if (patternParts[i].startsWith(":"))
988
+ let matchesCaller = true;
989
+ for (let index = 0; index < patternParts.length; index++) {
990
+ if (patternParts[index].startsWith(":"))
995
991
  continue;
996
- if (patternParts[i] !== callerParts[i]) {
997
- ok = false;
992
+ if (patternParts[index] !== callerParts[index]) {
993
+ matchesCaller = false;
998
994
  break;
999
995
  }
1000
996
  }
1001
- if (ok)
997
+ if (matchesCaller)
1002
998
  matches.push(pattern);
1003
999
  }
1004
- if (matches.length === 0)
1005
- return null;
1000
+ return matches;
1001
+ }
1002
+ function resolveGuardVariantKey(input) {
1003
+ if (input.kind === "single") {
1004
+ return { ok: true, key: "_default" };
1005
+ }
1006
+ const { keys, caller, reservedKeys } = input;
1007
+ for (const key of keys) {
1008
+ if (reservedKeys.has(key)) {
1009
+ return {
1010
+ ok: false,
1011
+ code: "reserved-key",
1012
+ key,
1013
+ keys
1014
+ };
1015
+ }
1016
+ }
1017
+ const hasDefault = keys.includes("default");
1018
+ if (typeof caller !== "string") {
1019
+ if (hasDefault)
1020
+ return { ok: true, key: "default" };
1021
+ return { ok: false, code: "missing-caller", keys };
1022
+ }
1023
+ if (caller.trim().length === 0) {
1024
+ if (hasDefault)
1025
+ return { ok: true, key: "default" };
1026
+ return { ok: false, code: "unknown-caller", caller, keys };
1027
+ }
1028
+ if (keys.includes(caller)) {
1029
+ return { ok: true, key: caller };
1030
+ }
1031
+ const matches = matchVariantPatterns(keys, caller);
1032
+ if (matches.length === 1) {
1033
+ return { ok: true, key: matches[0] };
1034
+ }
1006
1035
  if (matches.length > 1) {
1007
- throw new CallerError(
1008
- `Ambiguous caller "${caller}" matches multiple patterns: ${matches.map((p) => `"${p}"`).join(", ")}`
1009
- );
1036
+ return {
1037
+ ok: false,
1038
+ code: "ambiguous-caller",
1039
+ caller,
1040
+ keys,
1041
+ matches
1042
+ };
1010
1043
  }
1011
- return matches[0];
1044
+ if (hasDefault)
1045
+ return { ok: true, key: "default" };
1046
+ return { ok: false, code: "unknown-caller", caller, keys };
1012
1047
  }
1013
1048
 
1014
1049
  // src/runtime/policy.ts
@@ -3441,22 +3476,27 @@ function createQueryBuilder(typeMap, enumMap, uniqueMap, scalarBase) {
3441
3476
  forcedSelectCountWhere
3442
3477
  };
3443
3478
  }
3444
- function matchCaller(shapes, caller) {
3445
- const matched = matchCallerPattern(Object.keys(shapes), caller);
3446
- if (!matched)
3447
- return null;
3448
- return { key: matched, shape: shapes[matched] };
3449
- }
3450
- function resolveDefaultShape(config, model, method, normalizedBody, opts, builtCache, isUnique) {
3451
- const shapeOrFn = config.default;
3452
- let built;
3453
- if (typeof shapeOrFn === "function") {
3454
- const resolved = resolveAndValidateShape(shapeOrFn, opts?.ctx);
3455
- built = buildShapeZodSchema(model, method, resolved);
3456
- } else {
3457
- built = builtCache.get("default");
3479
+ function throwQueryVariantResolution(resolution) {
3480
+ const keys = resolution.keys.map((key) => `"${key}"`).join(", ");
3481
+ if (resolution.code === "reserved-key") {
3482
+ throw new ShapeError(
3483
+ `Caller key "${resolution.key}" collides with reserved shape config key. Rename the caller path.`
3484
+ );
3485
+ }
3486
+ if (resolution.code === "missing-caller") {
3487
+ throw new CallerError(
3488
+ `Missing caller. This query uses named shape routing with keys: ${keys}. Provide caller via opts.caller.`
3489
+ );
3458
3490
  }
3459
- return applyBuiltShape(built, normalizedBody, isUnique);
3491
+ if (resolution.code === "ambiguous-caller") {
3492
+ const matches = (resolution.matches ?? []).map((pattern) => `"${pattern}"`).join(", ");
3493
+ throw new CallerError(
3494
+ `Ambiguous caller "${resolution.caller}" matches multiple patterns: ${matches}`
3495
+ );
3496
+ }
3497
+ throw new CallerError(
3498
+ `Unknown caller: "${resolution.caller}". Allowed: ${keys}`
3499
+ );
3460
3500
  }
3461
3501
  function buildQuerySchema(model, method, config) {
3462
3502
  const isSingle = typeof config === "function" || isShapeConfig(config);
@@ -3468,12 +3508,15 @@ function createQueryBuilder(typeMap, enumMap, uniqueMap, scalarBase) {
3468
3508
  );
3469
3509
  }
3470
3510
  if (!isSingle) {
3471
- for (const key of Object.keys(config)) {
3472
- if (SHAPE_CONFIG_KEYS.has(key)) {
3473
- throw new ShapeError(
3474
- `Caller key "${key}" collides with reserved shape config key. Rename the caller path.`
3475
- );
3476
- }
3511
+ const keys = Object.keys(config);
3512
+ const validation = resolveGuardVariantKey({
3513
+ kind: "named",
3514
+ keys,
3515
+ caller: void 0,
3516
+ reservedKeys: SHAPE_CONFIG_KEYS
3517
+ });
3518
+ if (!validation.ok && validation.code === "reserved-key") {
3519
+ throwQueryVariantResolution(validation);
3477
3520
  }
3478
3521
  for (const [key, shapeOrFn] of Object.entries(
3479
3522
  config
@@ -3511,47 +3554,20 @@ function createQueryBuilder(typeMap, enumMap, uniqueMap, scalarBase) {
3511
3554
  );
3512
3555
  }
3513
3556
  const namedConfig = config;
3514
- const caller = opts?.caller;
3515
- if (typeof caller !== "string") {
3516
- if ("default" in namedConfig) {
3517
- return resolveDefaultShape(
3518
- namedConfig,
3519
- model,
3520
- method,
3521
- normalizedBody,
3522
- opts,
3523
- builtCache,
3524
- isUnique
3525
- );
3526
- }
3527
- const allowed = Object.keys(namedConfig);
3528
- throw new CallerError(
3529
- `Missing caller. This query uses named shape routing with keys: ${allowed.map((k) => `"${k}"`).join(", ")}. Provide caller via opts.caller.`
3530
- );
3531
- }
3532
- const matched = matchCaller(namedConfig, caller);
3533
- if (!matched) {
3534
- if ("default" in namedConfig) {
3535
- return resolveDefaultShape(
3536
- namedConfig,
3537
- model,
3538
- method,
3539
- normalizedBody,
3540
- opts,
3541
- builtCache,
3542
- isUnique
3543
- );
3544
- }
3545
- const allowed = Object.keys(namedConfig);
3546
- throw new CallerError(
3547
- `Unknown caller: "${caller}". Allowed: ${allowed.map((k) => `"${k}"`).join(", ")}`
3548
- );
3549
- }
3550
- if (typeof matched.shape === "function") {
3551
- const resolved = resolveAndValidateShape(matched.shape, opts?.ctx);
3557
+ const resolution = resolveGuardVariantKey({
3558
+ kind: "named",
3559
+ keys: Object.keys(namedConfig),
3560
+ caller: opts?.caller,
3561
+ reservedKeys: SHAPE_CONFIG_KEYS
3562
+ });
3563
+ if (!resolution.ok)
3564
+ throwQueryVariantResolution(resolution);
3565
+ const shapeOrFn = namedConfig[resolution.key];
3566
+ if (typeof shapeOrFn === "function") {
3567
+ const resolved = resolveAndValidateShape(shapeOrFn, opts?.ctx);
3552
3568
  built = buildShapeZodSchema(model, method, resolved);
3553
3569
  } else {
3554
- built = builtCache.get(matched.key);
3570
+ built = builtCache.get(resolution.key);
3555
3571
  }
3556
3572
  }
3557
3573
  return applyBuiltShape(built, normalizedBody, isUnique);
@@ -4786,34 +4802,43 @@ function resolveDynamicShape(shapeFn, ctx, context) {
4786
4802
  }
4787
4803
  return result;
4788
4804
  }
4789
- function resolveNamedShape(input, body, contextFn, explicitCaller) {
4790
- assertNoCallerInBody(body);
4791
- const caller = explicitCaller;
4792
- const keys = Object.keys(input);
4793
- for (const key of keys) {
4794
- if (GUARD_SHAPE_KEYS.has(key)) {
4795
- throw new ShapeError(
4796
- `Caller key "${key}" collides with reserved guard shape key. Rename the caller path.`
4797
- );
4798
- }
4805
+ function throwVariantResolution(resolution) {
4806
+ const keys = resolution.keys.map((key) => `"${key}"`).join(", ");
4807
+ if (resolution.code === "reserved-key") {
4808
+ throw new ShapeError(
4809
+ `Caller key "${resolution.key}" collides with reserved guard shape key. Rename the caller path.`
4810
+ );
4799
4811
  }
4800
- if (typeof caller !== "string") {
4801
- if ("default" in input) {
4802
- return resolveShapeEntry(input.default, body, contextFn, "default");
4803
- }
4812
+ if (resolution.code === "missing-caller") {
4804
4813
  throw new CallerError(
4805
- `Missing caller. This guard uses named shape routing with keys: ${keys.map((k) => `"${k}"`).join(", ")}. Provide caller via guard(input, caller).`
4814
+ `Missing caller. This guard uses named shape routing with keys: ${keys}. Provide caller via guard(input, caller).`
4806
4815
  );
4807
4816
  }
4808
- const matched = matchCallerPattern(keys, caller);
4809
- if (matched) {
4810
- return resolveShapeEntry(input[matched], body, contextFn, matched);
4811
- }
4812
- if ("default" in input) {
4813
- return resolveShapeEntry(input.default, body, contextFn, "default");
4817
+ if (resolution.code === "ambiguous-caller") {
4818
+ const matches = (resolution.matches ?? []).map((pattern) => `"${pattern}"`).join(", ");
4819
+ throw new CallerError(
4820
+ `Ambiguous caller "${resolution.caller}" matches multiple patterns: ${matches}`
4821
+ );
4814
4822
  }
4815
4823
  throw new CallerError(
4816
- `Unknown caller: "${caller}". Allowed: ${keys.map((k) => `"${k}"`).join(", ")}`
4824
+ `Unknown caller: "${resolution.caller}". Allowed: ${keys}`
4825
+ );
4826
+ }
4827
+ function resolveNamedShape(input, body, contextFn, explicitCaller) {
4828
+ assertNoCallerInBody(body);
4829
+ const resolution = resolveGuardVariantKey({
4830
+ kind: "named",
4831
+ keys: Object.keys(input),
4832
+ caller: explicitCaller,
4833
+ reservedKeys: GUARD_SHAPE_KEYS
4834
+ });
4835
+ if (!resolution.ok)
4836
+ throwVariantResolution(resolution);
4837
+ return resolveShapeEntry(
4838
+ input[resolution.key],
4839
+ body,
4840
+ contextFn,
4841
+ resolution.key
4817
4842
  );
4818
4843
  }
4819
4844
  function resolveShapeEntry(entry, body, contextFn, matchedKey) {