The opaque wrapper with all() and first(): operation access, the Access Filter as a filter entry, Silent failure - #1216
Conversation
… all()/first() `context.db.<List>` is keyed by the config's own spelling of the list name and `getDbKey` is deleted; every call site through the secured surface, and every ORM handle the engine reaches a model through, moves to the list key. The same member is now a query value. `.where(...)` composes an immutable read; `.all()` and `.first()` are engine-owned terminals that resolve operation-level `query` access lazily, add the Access Filter as a second entry in the collection's own filter list, enter the engine origin around the ORM call, apply Field Visibility and materialise — returning `[]`/`null` on denial. The terminals call the rc.8 collection method names (`where`/`all`/`first`), so the read path reaches a real database through `createTestContext`. The Prisma 7 delegate names on the write path are untouched and stay #1124's. Implements #1146 Part of #1123 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
🦋 Changeset detectedLatest commit: 193bfed The changes in this PR will be included in the next version bump. This PR includes changesets to release 9 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Coverage Report for Core Package Coverage (./packages/core)
File Coverage
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Coverage Report for UI Package Coverage (./packages/ui)
File CoverageNo changed files found. |
Coverage Report for CLI Package Coverage (./packages/cli)
File Coverage
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Coverage Report for Auth Package Coverage (./packages/auth)
File Coverage
|
||||||||||||||||||||||||||||||||||||||||||||
Coverage Report for Storage Package Coverage (./packages/storage)
File CoverageNo changed files found. |
Coverage Report for RAG Package Coverage (./packages/rag)
File Coverage
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Coverage Report for Storage S3 Package Coverage (./packages/storage-s3)
File CoverageNo changed files found. |
Coverage Report for Storage Vercel Package Coverage (./packages/storage-vercel)
File CoverageNo changed files found. |
borisno2
left a comment
There was a problem hiding this comment.
Verdict: REQUEST CHANGES. (GitHub refuses a formal
--request-changesreview because the PR was authored by this same identity, so this is posted as a comment review; treat it as a blocking review.)
Code review — effort high
I reviewed the full diff: the new secured read surface (packages/core/src/secured/read.ts), its tests, the PascalCase re-key across core/cli/ui/auth/rag/MCP, and the mechanical test churn (I spot-checked tests/context.test.ts, tests/resolve-chain.test.ts, tests/sudo.test.ts and src/access/orm-client.test.ts for weakened assertions and found none — the churn really is a rename).
The design is good and the acceptance criteria mostly land. Two things need fixing before merge.
Verdict: REQUEST CHANGES.
High
1. packages/cli/src/mcp/lib/generators/feature-generator.ts:692 — the re-key missed the CLI's code-emitting templates, so generated apps ship broken code.
The blog feature generator writes this into the user's app/blog/page.tsx:
const posts = await context.db.post.findMany({ … })context.db is now keyed by the PascalCase list name, so that is undefined at runtime — TypeError: Cannot read properties of undefined (reading 'findMany') on first render — and it no longer type-checks against the regenerated DB interface (packages/cli/src/generator/types.ts:212 now emits Post: PostList). Same fault at :734 (context.db.post.findFirst) and :515 in the emitted auth dev-guide snippet (context.db.user.findUnique).
packages/cli/src/migration/generators/migration-generator.ts:113 compounds it: the emitted Keystone-migration guide tells users to "Replace with context.db.{listName}.{method}() — list names are camelCase" and shows context.db.post.findMany. That guidance is now exactly backwards.
The sweep covered packages/core, ui, rag and auth call sites but not the strings the CLI emits as user code. git grep -nE 'context\.db\.[a-z]' packages/*/src finds all of them.
Medium
2. packages/core/src/secured/read.ts:153 — lowerPredicate silently drops undefined, which widens the Access Filter.
for (const [column, condition] of Object.entries(predicate)) {
if (condition === undefined) continue
entry[column] = lowerCondition(listName, column, condition)
}The docblock directly above (line 146) says it is "Total or throwing … so a rule the engine cannot lower fails loudly instead of silently widening the read," and line 204 routes the Access Filter through this same function. A conventional rule such as
query: ({ session }) => ({ authorId: session?.userId })returns { authorId: undefined } for an anonymous caller. That lowers to {}, collection.where({}) constrains nothing, and .all() returns every row of the list. The explicit form { authorId: { equals: undefined } } is refused (isWhereValue(undefined) is false at line 137) — so the one shape the docblock promises cannot happen is precisely the shape that slips through, and the two spellings of the same rule behave oppositely.
This is behaviour-compatible with Prisma's own undefined-means-omitted semantics, so it is not a new hole relative to mergeFilters. But the whole point of this seam is to be total, the docblock asserts that it is, and the test fixture never exercises a filter rule that yields undefined. Either throw here (my preference — a dropped access-filter clause should never be silent) or drop the claim and add a test pinning the chosen semantics.
3. packages/core/src/secured/read.ts:225 — the terminals skip resolveReadInclude, so relation-valued Declared Dependencies break.
runAll/runFirst call filterReadableFields(row, fields, ctx, config, 0, listName) with no additions. createFindMany (context/index.ts:1367) folds needs in via resolveReadInclude first (ADR-0025/ADR-0051). Scalar needs are unaffected — .all() returns every column — but a computed field declaring a relation dependency, e.g.
authorName: virtual({ needs: ['author'], hooks: { resolveOutput: ({ item }) => item.author.name } })gets the relation under context.db.Post.findMany() and undefined under context.db.Post.all(). The hook then computes a wrong value silently rather than the two paths agreeing. If deferring to #1147–#1151 is intended, say so in the changeset and file it; right now nothing records the divergence.
Low
4. packages/core/src/context/index.ts:992 — where/all/first are installed for singleton lists, bypassing the singleton guard.
populateDbDelegate adds the three read members unconditionally, but createFindMany (line 1296) deliberately throws Cannot use findMany: X is a singleton list. Use get() instead. The generated type agrees they shouldn't be there — SingletonOps in types/secured-list.ts was not intersected with ListQuery, only ListOps was — so context.db.Settings.all() compiles as an error but works at runtime and returns the raw row array. Gate the three on !isSingletonList(listConfig), or intersect SingletonOps too.
5. packages/core/src/secured/read.ts:120 — dead disjunct. if (typeof condition !== 'object' || condition === undefined) — typeof undefined is 'undefined', so the first clause already caught it.
6. ListConfig<any> is reachable from an exported signature. ReadBinding.listConfig (read.ts:167) carries the repo's usual no-explicit-any disable, and createSecuredRead(binding: ReadBinding) is exported, so the any is structurally part of a public signature. Consistent with the rest of the engine, so I'd accept it — flagging only because CLAUDE.md forbids any in external types. Otherwise the type hygiene here is clean: I found no new any, unknown leak or as cast in the generated DB interface, the wrapper generics or secured-list.ts, and the any-typed client placeholder really is gone.
7. Stale camelCase in docs and TSDoc. packages/cli/src/generator/context.ts:247,251 (and its snapshot) means every newly generated project ships const posts = await context.db.post.findMany() as its worked example. Same spelling survives in editor-visible public TSDoc — access/types.ts:143,152,187,212, fields/index.ts:392,895,1086,1820, config/types.ts:453,2551, testing/context.ts:484, auth/src/mcp/better-auth.ts:84 — and in ~99 places under docs/content/, of which only rag-advanced.md was touched (because it happened to import getDbKey). Not blocking, but this is the release where it is cheapest to fix.
Things I checked that are not problems
- Opacity holds.
SecuredQueryexposes onlywhere/all/first;wherereturnsSecuredQuery, not the delegate. The collection is closed over incollectionForand never handed out. I traced every legacy member left on the wrapper for UI/MCP/RAG compatibility (findUnique,findMany,findFirst,create,update,delete,count,createMany,updateMany,get) — none returns aCollectionorCollectionState, andAccessControlledDelegate extends SecuredQueryadds no leak. The inferred return type is the declared one. - The mixed method-name split is coherent.
isDelegate(orm-client.ts:23) is presence-only, so the legacy paths still resolve their handle under the new key and fail — if at all — at their own call site, exactly as before. The read path is the only thing re-pointed atwhere/all/first, and it is fully covered. No silently-broken call path is introduced by this change; whether rc.8 collections answerfindManyis spec 4's (#1124) pre-existing problem, correctly scoped out. - The negative control is not vacuous, and it does prove its claim. The positive test asserts on
idandcreatedAtthe database minted and onrecorder.plans.some(p => p.kind === 'select'), so it cannot pass without a round trip. The control seeds aDraft(noqueryrule), reads through the identical call, gets[], and showsexpect(rows[0]).toMatchObject(…)throws against it —rows[0]isundefined, sotoMatchObjectgenuinely fails. The siblingrecorder.planstoEqual[]test closes it: denial short-circuits before the database. Minor: the control asserts falsifiability of the assertion form on a different list, and omitscreatedAt; asserting the same three keys would tighten it slightly. - No access oracle in the refusal path.
resolveAccessFilterrunscheckAccessand returnsnullbeforevalidateQueryKeys/validateQueryFieldReadAccessand before any lowering, so a caller with no access to the list gets[]/nulland never anUnsupportedPredicateErroror a key-naming error — matchingcreateFindMany. A caller with partial access can still tell "column does not exist" from "column is read-denied" by the two distinct messages, but that is the established #912/#915 contract, not a regression here. withOrigincorrectness.withOrigin('engine', () => collection.all())awaits inside the ALS scope, so the lazyAsyncIterableResultis still stamped when it executes — the origin tests confirm both terminals recordorigin: 'engine'.- MCP.
pascalToCamelpreserves the wire tool names, and hoisting the list lookup out of thequerybranch is a strict improvement —create/update/deletepreviously indexedcontext.dbwith an unvalidated key. - The rest of the re-key.
ormModel,nav-count,relationship-count,query/index.ts, the RAG storages,packages/uiandauthPlugin'snormalized.models.user.modelName(PascalCase by default) are all correct, including the runtime-string call sites.
The PascalCase re-key missed the code the CLI emits into user projects: the feature generator's blog and auth pages and the Keystone migration guide still spelled `context.db` camelCase, which is `undefined` at runtime and does not type-check against the regenerated `DB` interface. A source-scanning guard test plus behavioural assertions on the generated output keep that class of miss from returning silently. Also: correct `lowerPredicate`'s docblock, which claimed a totality the code does not have, and pin both spellings of an `undefined` condition so #1147 changing them is a visible test change; drop the secured read members from singleton lists so the runtime matches the emitted type; remove `any` from the exported `createSecuredRead` signature; drop a dead `undefined` disjunct; and re-key the stale camelCase in the generated context example and package TSDoc. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Review findings addressed in 193bfed. Per numbered finding: 1 (High) — the CLI's code-emitting templates. Fixed, and swept rather than spot-fixed.
On the method names: I kept New test: 2 (Medium) — 3 (Medium) — terminals skip 4 (Low) — singleton lists. 5 (Low) — dead disjunct. 6 (Low) — 7 (Low) — stale camelCase in package-owned copy. 18 sites re-keyed: the generated context example ( Related, not touched: Changeset Verification on 193bfed: |
Implements #1146
Part of #1123
What this lands
context.db.<List>is now spelled the way the config spells the list —context.db.AuthUser, nevercontext.db.authUser— and it is an opaque wrapper over a Prisma 8 collection:.where(...)composes an immutable value, and.all()/.first()are the engine-owned terminals that run it.A terminal, in one place (
packages/core/src/secured/read.ts):queryaccess lazily (sudoskips it),where/orderBykeys straight to Prisma #912/A fieldreadgate withholds the value but not the predicate — every gated column is a filter oracle #915) — only once the caller is known to have some access to the list, so the error messages are not an oracle,.where(caller).where(accessFilter), which rc.8 ANDs natively. Nothing is hand-merged;mergeFiltersis not on this path,[]/nullon denial — Silent failure, indistinguishable from an empty result, and the database is never reached.getDbKeyis deleted (getUrlKey/getListKeyFromUrlstay). The engine's ORM handle,ormModel,context.db, the MCP handler, nav counts, the fragment runner, the RAG storages and the auth plugin all key by the list name now, as does the generatedDBinterface (packages/cli/src/generator/types.ts), which is wherecontext.db.authUserbecomes a compile error —Property 'authUser' does not exist on type 'DB'. Did you mean 'AuthUser'?is exactly what the CLI's type-fixture tests now report for the old spelling.The delegate-name / short-circuit problem
Spec 3's QA gate (on #1123) recorded two failure modes: the engine called Prisma 7 delegate names (
findManyand siblings) against rc.8 collections, which exposewhere/all/first/create; and withoutsudoa call returned[]/nullbecause deny-by-default short-circuits before the database — so a test could be green while touching nothing.collection.where(...)/all()/first()directly, socontext.db.<List>.all()is a real round-trip against the harness's Postgres.create/update/delete/count/findManyand the fragment reads still carry the Prisma 7 delegate names; writes are spec 4 (Spec: The secured surface, writes — connect, transactions, the row lock and stack-owned errors #1124) and the remaining read shapes are The Where vocabulary and its lowering; the filter module retyped #1147–The nearest() terminal over a native vector column #1151. Nothing here silently leaves the read path broken — the terminals are the read path.packages/core/src/secured/read.test.ts) seeds a row through the Unsafe origin (the harness's own collection, not the engine — the write path is not this ticket's) and reads it back throughcontext.db.Post.all(), asserting on the row'sidandcreatedAt: values the database minted, which a short-circuited read cannot produce.queryrule (deny-by-default), reads it through the identical call, and asserts both that the result is[]and that the positive test's own assertion fails against it (expect(() => expect(rows[0]).toMatchObject(...)).toThrow()). A sibling test asserts the recorder saw no plan at all for that read — the denial short-circuits before the database, and the round-trip assertion is provably falsifiable by it.Test coverage, by
CONTEXT.mdtermEverything is asserted through
createTestContextand the recording middleware — never on wrapper internals.[]andnull, equal to a genuinely empty list's; a denied read compiles no plan.whereis{ kind: 'and', exprs: [caller, accessFilter] }; with no caller predicate it carries the access filter alone; undersudoit carries the caller's predicate alone.origin: 'engine', with the tripwire installed in its only (throwing) mode.where/all/first, andstate/ctx/modelName/registry/tableNameare allundefinedon it.context.db.Postworks,context.db.postisundefined,Object.keys(context.db)is the list names, andgetDbKeyis gone from the package entry.Design notes
wheretakes{ column: value }or{ column: { equals: value } }, andlowerPredicatethrowsUnsupportedPredicateErrornaming the list and column for anything else — a predicate can only ever narrow, so an operator the engine cannot lower is refused rather than dropped. The closed Where vocabulary (in,not, the comparisons,contains,AND/OR/NOT,some/every/none) is The Where vocabulary and its lowering; the filter module retyped #1147, andlowerPredicateis the single seam it replaces. The type layer mirrors the runtime:ListPredicateon the generatedSecuredListaccepts equality only, so{ title: { contains: … } }is a compile error today as well as a runtime refusal..where()call. rc.8 documents repeatedwhereas AND-combined and emits them as sibling entries under oneandnode — which is what makes "the access filter is a filter entry, not a merge" a checkable fact about the plan rather than a claim about our code.context.dbby the list name but still spells its tool nameslist_post_queryviapascalToCamel— the tool name is an identifier assistants have already bound to, and renaming it is not this ticket's to do.findMany,create, …), so the admin UI, MCP and RAG keep compiling while specs 4 and 7 convert them. They expose noCollectionand noCollectionState; the acceptance criterion is about what is reachable, and nothing here hands one out.Verification
Run in this worktree, all green:
pnpm lint— 0 errors (2 pre-existing warnings, untouched)pnpm format:check— clean;pnpm manypkg fix— no changes neededturbo run build --filter='./packages/*'— 10/10turbo run test --filter='./packages/*'— core 1545 passed / 1 skipped, ui 611, cli 332, auth 298 (+4 skipped), rag 362, storage 197, storage-s3 43, storage-vercel 56, create-opensaas-app 42 (+2 skipped)opensaas generateleaves it clean), as CI requires.🤖 Generated with Claude Code