DEV Community

Build One Guarded Prisma Endpoint, Then Break It Five Ways

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. 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. Start with a small tenant model. Nursery is the scope root, and Plant carries the foreign key that the guard extension can constrain. /// @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. 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 () 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. 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" 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. 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. 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: { "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. 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. 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. Break: expect a conflicting top-level force to throw Now send isPublished.equals: false while the shape forces true. It is tempting to expect a 400. On the pinned runtime, that expectation is wrong. A wholly forced scalar predicate at the top level of where is the one lenient forced position. Client input for that field is accepted, discarded, and replaced. const publicationBoundary = { where: { isPublished: { equals: force(true) } }, take: { max: 50, default: 20 }, } const args = guard .query('Plant', 'findMany', publicationBoundary) .parse({ where: { isPublished: { equals: false } } }) console.log(JSON.stringify(args)) // {"take":20,"where":{"isPublished":{"equals":true}}} The same outcome occurs when the client supplies true, false, or another supported operator. That does not make the field client-controlled. It means the runtime merges this position rather than removing it from the request schema. A test that sends a foreign value and checks only for success proves nothing. The request succeeds with the forced predicate, and it can also succeed after a refactor removes the force. Either inspect emitted arguments directly or seed two tenants with distinguishable rows and assert the returned ownership boundary. The runtime does detect a different kind of conflict: two incompatible forced values authored inside the server shape. For example, forcing publication true at the top level and false inside a combinator fails shape construction. That protects configuration consistency. It does not turn conflicting client input into an error. Tests should keep those cases separate because one proves the server contract is internally coherent and the other proves how the contract handles an untrusted request. Break: assert that a forced predicate is always flat The previous probe emitted where.isPublished directly because the request touched the same field. Add a client filter on a different field and the guard preserves both conditions by wrapping them in AND : { "where": { "AND": [ { "name": { "contains": "fern" } }, { "isPublished": { "equals": true } } ] }, "take": 20 } An assertion such as args.where.isPublished.equals === true now fails even though the boundary is intact. The emitted container depends on the request. Tests should walk Boolean branches instead of assuming one layout. type QueryNode = Record const findPredicate = (node: QueryNode | undefined, field: string): QueryNode | undefined => { if (!node) return undefined const direct = node[field] if (direct && !Array.isArray(direct) && typeof direct === 'object') return direct for (const key of ['AND', 'OR', 'NOT']) { const branch = node[key] const entries = Array.isArray(branch) ? branch : branch ? [branch] : [] for (const entry of entries) { if (typeof entry !== 'object' || Array.isArray(entry)) continue const found = findPredicate(entry, field) if (found) return found } } return undefined } The walker also matters for forced NOT . With no client NOT , the emitted value can be an object. Once the client supplies a NOT , the emitted value becomes an array containing separate client and forced branches. Indexing one spelling makes the test request-dependent. Forced values written inside AND or OR introduce another structural change: they are lifted out and applied as top-level AND constraints. That is safe for restrictions such as β€œpublic only,” but it cannot express β€œowned by me or public.” The force turns that intended disjunction into a conjunction. Use a single scope column, a purpose-built server query, or database policy for a genuinely disjunctive authorization rule. A generated shape should not be stretched past the Boolean policy it can represent. Break: test default projection through the parser Read projection is both a whitelist and an execution-time default. But guard.query().parse() does not auto-apply that default. The guarded delegate does. A parser-only projection assertion reports a missing boundary that execution would have applied: guard.query('Plant', 'findMany', { select: { id: true, name: true

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.