Build One Guarded Prisma Endpoint, Then Break It Five Ways
Build One Guarded Prisma Endpoint, Then Break It Five Ways
构建一个受保护的 Prisma 端点,并尝试五种破坏方式
A generated route can remove repetitive Express handlers without removing the API contract. That distinction becomes concrete when one endpoint is deliberately broken in five small ways. Each break below changes either shape construction, request validation, emitted Prisma arguments, or execution-time projection. The status code alone is not enough to identify which layer moved. 生成的路由可以在不移除 API 契约的前提下,消除重复的 Express 处理程序。当一个端点被故意以五种细微方式破坏时,这种区别就变得具体了。下文中的每一次破坏都会改变形状构建(shape construction)、请求验证、发出的 Prisma 参数或执行时的投影。仅凭状态码不足以识别是哪一层发生了变动。
The examples use prisma-guard 1.33.0, Prisma 6.19.3, and Zod 4.4.3. Those versions are pinned because several observations concern exact runtime behavior. The goal is a test you can rerun during upgrades, not a rule inferred from one successful response. 这些示例使用了 prisma-guard 1.33.0、Prisma 6.19.3 和 Zod 4.4.3。这些版本被锁定是因为部分观察结果涉及精确的运行时行为。我们的目标是建立一个可以在升级过程中重复运行的测试,而不是从一次成功的响应中推断出的规则。
Start with a small tenant model. Nursery is the scope root, and Plant carries the foreign key that the guard extension can constrain. 从一个小型租户模型开始。Nursery(苗圃)是作用域根节点,Plant(植物)携带了可以被 guard 扩展约束的外键。
/// @scope-root
model Nursery {
id String @id @default(cuid())
name String
plants Plant[]
}
model Plant {
id String @id @default(cuid())
name String
priceCents Int
isPublished Boolean @default(false)
nurseryId String
nursery Nursery @relation(fields: [nurseryId], references: [id])
}
The generated router still needs an extended Prisma client and trusted request context. Authentication remains application code. The important detail is that the tenant ID comes from the authenticated session, not from the query string or body. 生成的路由器仍然需要一个扩展的 Prisma 客户端和受信任的请求上下文。身份验证仍属于应用程序代码。关键细节在于,租户 ID 来自经过身份验证的会话,而不是来自查询字符串或请求体。
import { AsyncLocalStorage } from 'node:async_hooks'
import { PrismaClient } from '@prisma/client'
import { guard } from './generated/guard/client'
type RequestContext = { nurseryId: string; audience: 'public' | 'seller' }
const requestStore = new AsyncLocalStorage<RequestContext>()
const prisma = new PrismaClient().$extends(
guard.extension(() => {
const context = requestStore.getStore()
return { Nursery: context?.nurseryId, caller: context?.audience }
}),
)
Now define one public read contract. In a guard shape, true means the client may choose a value. A literal means the server chose it. force(true) is required to pin a Boolean to true because bare true is already the permission sentinel.
现在定义一个公共读取契约。在 guard 形状中,true 表示客户端可以选择一个值,而字面量则表示由服务器指定。必须使用 force(true) 将布尔值固定为 true,因为单纯的 true 已经被用作权限哨兵。
import { force } from 'prisma-guard'
const publicPlants = {
where: {
name: { contains: true, mode: 'insensitive' },
isPublished: { equals: force(true) },
},
select: { id: true, name: true, priceCents: true },
orderBy: { name: true, priceCents: true },
take: { max: 50, default: 20 },
}
const plantRoutes = {
findMany: { shape: { public: publicPlants } },
guard: { resolveVariant: () => 'public' },
}
This contract says more than “validate a query.” It pins publication state, fixes case-insensitive search, limits filter and sort fields, supplies a default projection, and bounds page size. The router selects the public variant on the server. A client cannot upgrade itself by inventing a variant header. 这个契约不仅仅是“验证查询”。它固定了发布状态、锁定了不区分大小写的搜索、限制了过滤和排序字段、提供了默认投影并限制了页面大小。路由器在服务器端选择公共变体。客户端无法通过伪造变体请求头来提升自身权限。
Tenant scope is a separate layer from the public shape. The extension can inject a mapped top-level foreign key when trusted root context exists, but the root model does not scope itself and nested relation reads do not inherit the filter. Keep the authenticated context test separate from the forced publication test so a failure identifies which boundary moved. 租户作用域与公共形状是独立的层。当存在受信任的根上下文时,扩展可以注入映射的顶级外键,但根模型本身不会进行作用域限制,嵌套关系读取也不会继承该过滤器。请将身份验证上下文测试与强制发布测试分开,以便在失败时能识别出是哪一个边界发生了变动。
With the working shape in place, break it on purpose. 在形状正常工作后,我们故意破坏它。
Break: force the field instead of its operator
破坏:强制字段而非其操作符
Change the publication predicate to isPublished: force(true). That resembles the correct mutation syntax, but a where field expects an operator object. Shape construction fails before any client input is examined: Operator "value" not supported for type "Boolean".
将发布谓词更改为 isPublished: force(true)。这看起来像正确的变更语法,但 where 字段期望的是一个操作符对象。形状构建会在检查任何客户端输入之前就失败,报错:Operator "value" not supported for type "Boolean"。
The correction is not to remove the force. Put it under the comparison operator: { equals: force(true) }. Remember the asymmetry: a where shape forces an operator; a data shape forces the field itself. This is a startup or first-use configuration defect, depending on when the application builds the shape. Retrying the request cannot repair it.
修正方法不是移除 force,而是将其放在比较操作符之下:{ equals: force(true) }。请记住这种不对称性:where 形状强制要求操作符,而数据形状则强制要求字段本身。这属于启动或首次使用时的配置缺陷,具体取决于应用程序构建形状的时间。重试请求无法修复此问题。
That phase distinction prevents a common debugging detour. Shape construction examines the server-authored contract. Request validation examines a particular body. If the same error appears with an empty body and with every caller, reduce the shape before investigating transport encoding. Conversely, a path such as where.name in an invalid-query message identifies the client-facing schema that rejected input. The model and operation in the message are evidence about where the boundary was built.
这种阶段区分避免了常见的调试弯路。形状构建检查的是服务器编写的契约,而请求验证检查的是特定的请求体。如果空请求体和所有调用者都出现相同的错误,请在调查传输编码之前简化形状。相反,无效查询消息中的路径(如 where.name)标识了拒绝输入的面向客户端的模式。消息中的模型和操作是边界构建位置的证据。
Break: send a server-owned modifier from the client
破坏:从客户端发送服务器拥有的修饰符
The shape lets the client choose contains but fixes mode beside it. That makes mode strict. If a frontend sends the value anyway, even the same value, validation rejects the key:
该形状允许客户端选择 contains,但固定了旁边的 mode。这使得 mode 变得严格。如果前端仍然发送该值(即使值相同),验证也会拒绝该键:
{ "where": { "name": { "contains": "fern", "mode": "insensitive" } } }
The pinned guard returns Invalid query on model "Plant": where.name: Unrecognized key(s): mode. This failure is useful. It says the frontend and shape disagree about value ownership. Removing mode from the request preserves the endpoint’s case-insensitive contract because the server adds it. Changing the shape to mode: true is a different API: callers may choose, and omission becomes case-sensitive.
锁定的 guard 返回 Invalid query on model "Plant": where.name: Unrecognized key(s): mode。这个失败很有用,它说明前端和形状在值的所有权上存在分歧。从请求中移除 mode 可以保留端点的不区分大小写契约,因为服务器会自动添加它。将形状更改为 mode: true 则会变成另一个 API:调用者可以进行选择,而省略该字段则变为区分大小写。
The same strict behavior appears in three other positions: forced predicates inside relation filters, forced fields inside a nested include’s where, and forced fields in mutation data. Sending a server-owned key in those positions produces a validation error even when the client repeats the correct value. Do not generalize from one forced field. First classify its position, then decide whether the client must omit it or whether a conflicting value will be discarded.
同样的严格行为出现在其他三个位置:关系过滤器内的强制谓词、嵌套 include 的 where 内的强制字段,以及变更数据中的强制字段。在这些位置发送服务器拥有的键,即使客户端重复了正确的值,也会产生验证错误。不要从一个强制字段进行泛化。首先对其位置进行分类,然后决定客户端是必须省略它,还是冲突的值会被丢弃。
There is also a construction-only edge outside that position matrix. On the pinned guard, a forced condition under a negative relation operator such as to-many none or to-one isNot is rejected while the shape is built. The message discusses mixing client and forced conditions, but the rejection also occurs when the negative branch is wholly forced. Positive some, every, and to-one is shapes accept the corresponding condition. Treat that behavior as version-specific and keep it in the upgrade suite.
在上述位置矩阵之外,还有一个仅在构建时出现的边缘情况。在锁定的 guard 上,当在负向关系操作符(如一对多 none 或一对一 isNot)下使用强制条件时,形状构建会失败。错误消息讨论了混合客户端条件和强制条件的情况,但即使负向分支完全被强制,拒绝也会发生。正向的 some、every 和一对一 is 形状则接受相应的条件。请将此行为视为特定版本特性,并将其保留在升级测试套件中。