@opensaas/stack-core 0.37.0 → 0.39.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/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +191 -0
- package/CLAUDE.md +42 -3
- package/dist/access/access-filter.d.ts +70 -113
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +183 -210
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/access-filter.test.js +148 -188
- package/dist/access/access-filter.test.js.map +1 -1
- package/dist/access/declared-dependencies.d.ts +66 -26
- package/dist/access/declared-dependencies.d.ts.map +1 -1
- package/dist/access/declared-dependencies.js +62 -17
- package/dist/access/declared-dependencies.js.map +1 -1
- package/dist/access/declared-dependencies.test.d.ts +2 -0
- package/dist/access/declared-dependencies.test.d.ts.map +1 -0
- package/dist/access/declared-dependencies.test.js +226 -0
- package/dist/access/declared-dependencies.test.js.map +1 -0
- package/dist/access/depth-limits.d.ts +8 -7
- package/dist/access/depth-limits.d.ts.map +1 -1
- package/dist/access/depth-limits.js +8 -7
- package/dist/access/depth-limits.js.map +1 -1
- package/dist/access/engine.d.ts +0 -20
- package/dist/access/engine.d.ts.map +1 -1
- package/dist/access/engine.js +0 -25
- package/dist/access/engine.js.map +1 -1
- package/dist/access/errors.d.ts +61 -8
- package/dist/access/errors.d.ts.map +1 -1
- package/dist/access/errors.js +91 -12
- package/dist/access/errors.js.map +1 -1
- package/dist/access/field-access.d.ts +39 -1
- package/dist/access/field-access.d.ts.map +1 -1
- package/dist/access/field-access.js +108 -42
- package/dist/access/field-access.js.map +1 -1
- package/dist/access/field-access.test.js +229 -2
- package/dist/access/field-access.test.js.map +1 -1
- package/dist/access/field-transforms.d.ts +0 -27
- package/dist/access/field-transforms.d.ts.map +1 -1
- package/dist/access/field-visibility.d.ts +2 -5
- package/dist/access/field-visibility.d.ts.map +1 -1
- package/dist/access/field-visibility.js +89 -41
- package/dist/access/field-visibility.js.map +1 -1
- package/dist/access/index.d.ts +6 -3
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +12 -2
- package/dist/access/index.js.map +1 -1
- package/dist/access/query-validation.d.ts +121 -0
- package/dist/access/query-validation.d.ts.map +1 -0
- package/dist/access/query-validation.js +292 -0
- package/dist/access/query-validation.js.map +1 -0
- package/dist/access/relationship-count.d.ts +1 -1
- package/dist/access/relationship-count.d.ts.map +1 -1
- package/dist/access/relationship-count.js +2 -10
- package/dist/access/relationship-count.js.map +1 -1
- package/dist/access/relationship-label-filter.d.ts +23 -23
- package/dist/access/relationship-label-filter.d.ts.map +1 -1
- package/dist/access/relationship-label-filter.js +5 -76
- package/dist/access/relationship-label-filter.js.map +1 -1
- package/dist/access/relationship-label-filter.test.js +19 -56
- package/dist/access/relationship-label-filter.test.js.map +1 -1
- package/dist/access/transaction-registry.d.ts +45 -0
- package/dist/access/transaction-registry.d.ts.map +1 -0
- package/dist/access/transaction-registry.js +37 -0
- package/dist/access/transaction-registry.js.map +1 -0
- package/dist/access/types.d.ts +55 -59
- package/dist/access/types.d.ts.map +1 -1
- package/dist/config/index.d.ts +9 -7
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +10 -18
- package/dist/config/index.js.map +1 -1
- package/dist/config/label.d.ts +6 -13
- package/dist/config/label.d.ts.map +1 -1
- package/dist/config/label.js +6 -13
- package/dist/config/label.js.map +1 -1
- package/dist/config/nav-count.d.ts +11 -19
- package/dist/config/nav-count.d.ts.map +1 -1
- package/dist/config/nav-count.js +11 -19
- package/dist/config/nav-count.js.map +1 -1
- package/dist/config/plugin-engine.d.ts +0 -10
- package/dist/config/plugin-engine.d.ts.map +1 -1
- package/dist/config/plugin-engine.js +1 -37
- package/dist/config/plugin-engine.js.map +1 -1
- package/dist/config/types.d.ts +94 -109
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/apply-defaults.d.ts +8 -31
- package/dist/context/apply-defaults.d.ts.map +1 -1
- package/dist/context/apply-defaults.js +12 -41
- package/dist/context/apply-defaults.js.map +1 -1
- package/dist/context/hook-pipeline.d.ts +4 -29
- package/dist/context/hook-pipeline.d.ts.map +1 -1
- package/dist/context/hook-pipeline.js +8 -41
- package/dist/context/hook-pipeline.js.map +1 -1
- package/dist/context/index.d.ts +4 -15
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +223 -215
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts +7 -26
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +73 -171
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/transaction-boundary.d.ts +36 -47
- package/dist/context/transaction-boundary.d.ts.map +1 -1
- package/dist/context/transaction-boundary.js +92 -79
- package/dist/context/transaction-boundary.js.map +1 -1
- package/dist/context/write-pipeline.d.ts +25 -64
- package/dist/context/write-pipeline.d.ts.map +1 -1
- package/dist/context/write-pipeline.js +81 -111
- package/dist/context/write-pipeline.js.map +1 -1
- package/dist/fields/format-prisma-default.d.ts.map +1 -1
- package/dist/fields/format-prisma-default.js +4 -7
- package/dist/fields/format-prisma-default.js.map +1 -1
- package/dist/fields/index.d.ts +17 -2
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +136 -147
- package/dist/fields/index.js.map +1 -1
- package/dist/filter/collect.d.ts +26 -3
- package/dist/filter/collect.d.ts.map +1 -1
- package/dist/filter/collect.js +30 -12
- package/dist/filter/collect.js.map +1 -1
- package/dist/filter/filter.test.js +98 -15
- package/dist/filter/filter.test.js.map +1 -1
- package/dist/filter/index.d.ts +1 -0
- package/dist/filter/index.d.ts.map +1 -1
- package/dist/filter/index.js.map +1 -1
- package/dist/filter/map.d.ts.map +1 -1
- package/dist/filter/map.js +0 -5
- package/dist/filter/map.js.map +1 -1
- package/dist/filter/parse.d.ts.map +1 -1
- package/dist/filter/parse.js +2 -9
- package/dist/filter/parse.js.map +1 -1
- package/dist/filter/serialize.d.ts.map +1 -1
- package/dist/filter/serialize.js +2 -4
- package/dist/filter/serialize.js.map +1 -1
- package/dist/hooks/index.d.ts +4 -42
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +5 -59
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -4
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +1 -0
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +4 -0
- package/dist/internal.js.map +1 -1
- package/dist/lib/case-utils.d.ts +0 -32
- package/dist/lib/case-utils.d.ts.map +1 -1
- package/dist/lib/case-utils.js +0 -32
- package/dist/lib/case-utils.js.map +1 -1
- package/dist/mcp/handler.d.ts +0 -4
- package/dist/mcp/handler.d.ts.map +1 -1
- package/dist/mcp/handler.js +12 -69
- package/dist/mcp/handler.js.map +1 -1
- package/dist/query/index.d.ts +55 -227
- package/dist/query/index.d.ts.map +1 -1
- package/dist/query/index.js +41 -117
- package/dist/query/index.js.map +1 -1
- package/dist/query/relationship-options.d.ts +1 -1
- package/dist/query/relationship-options.js +1 -1
- package/dist/utils/password.d.ts +0 -68
- package/dist/utils/password.d.ts.map +1 -1
- package/dist/utils/password.js +7 -76
- package/dist/utils/password.js.map +1 -1
- package/dist/validation/field-config.d.ts.map +1 -1
- package/dist/validation/field-config.js +0 -6
- package/dist/validation/field-config.js.map +1 -1
- package/dist/validation/schema.d.ts +0 -7
- package/dist/validation/schema.d.ts.map +1 -1
- package/dist/validation/schema.js +0 -10
- package/dist/validation/schema.js.map +1 -1
- package/package.json +1 -1
- package/src/access/access-filter.test.ts +205 -275
- package/src/access/access-filter.ts +236 -272
- package/src/access/declared-dependencies.test.ts +277 -0
- package/src/access/declared-dependencies.ts +117 -37
- package/src/access/depth-limits.ts +8 -7
- package/src/access/engine.ts +0 -25
- package/src/access/errors.ts +97 -12
- package/src/access/field-access.test.ts +312 -2
- package/src/access/field-access.ts +118 -42
- package/src/access/field-transforms.ts +8 -39
- package/src/access/field-visibility.ts +99 -45
- package/src/access/index.ts +17 -5
- package/src/access/query-validation.ts +359 -0
- package/src/access/relationship-count.ts +3 -11
- package/src/access/relationship-label-filter.test.ts +19 -68
- package/src/access/relationship-label-filter.ts +27 -104
- package/src/access/transaction-registry.ts +58 -0
- package/src/access/types.ts +83 -86
- package/src/config/index.ts +10 -22
- package/src/config/label.ts +6 -13
- package/src/config/nav-count.ts +11 -19
- package/src/config/plugin-engine.ts +1 -37
- package/src/config/types.ts +95 -110
- package/src/context/apply-defaults.ts +12 -44
- package/src/context/hook-pipeline.ts +12 -67
- package/src/context/index.ts +311 -276
- package/src/context/nested-operations.ts +94 -226
- package/src/context/transaction-boundary.ts +113 -109
- package/src/context/write-pipeline.ts +99 -155
- package/src/fields/format-prisma-default.ts +4 -7
- package/src/fields/index.ts +155 -147
- package/src/filter/collect.ts +48 -12
- package/src/filter/filter.test.ts +153 -15
- package/src/filter/index.ts +1 -0
- package/src/filter/map.ts +0 -5
- package/src/filter/parse.ts +2 -9
- package/src/filter/serialize.ts +2 -4
- package/src/hooks/index.ts +5 -59
- package/src/index.ts +23 -4
- package/src/internal.ts +5 -0
- package/src/lib/case-utils.ts +0 -32
- package/src/mcp/handler.ts +13 -71
- package/src/query/index.ts +82 -235
- package/src/query/relationship-options.ts +1 -1
- package/src/utils/password.ts +7 -76
- package/src/validation/field-config.ts +0 -6
- package/src/validation/schema.ts +0 -10
- package/tests/access-relationships.test.ts +18 -16
- package/tests/computed-field-selective-evaluation.test.ts +418 -0
- package/tests/context.test.ts +727 -1
- package/tests/field-types.test.ts +222 -0
- package/tests/mcp-handler.test.ts +34 -0
- package/tests/needs-declared-dependencies.test.ts +7 -4
- package/tests/resolve-chain.test.ts +11 -11
- package/tests/transaction-boundary-outermost.test.ts +530 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import type { ListConfig, OpenSaasConfig } from '../config/types.js'
|
|
2
|
+
import type { Session, AccessContext } from './types.js'
|
|
3
|
+
import { getRelatedListConfig } from './engine.js'
|
|
4
|
+
import { isFieldReadableForPredicate } from './field-access.js'
|
|
5
|
+
import { ValidationError } from '../hooks/index.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* #912 — read-path key validation.
|
|
9
|
+
*
|
|
10
|
+
* The write path settled #564: an undeclared key in `data` throws, because the
|
|
11
|
+
* generated Prisma model has strictly more fields than the config declares (most
|
|
12
|
+
* notably back-relations — Prisma emits one for every inbound foreign key, whether
|
|
13
|
+
* or not the list config declares the reverse relationship). Reads had no
|
|
14
|
+
* equivalent — a caller's `where`/`orderBy` reached Prisma unchanged, so an
|
|
15
|
+
* anonymous caller could filter or order by a relation the config never exposed.
|
|
16
|
+
*
|
|
17
|
+
* This module is the read-path counterpart: every key named in a caller's `where`
|
|
18
|
+
* or `orderBy` is resolved against the list config before the query runs. A key
|
|
19
|
+
* with no entry in the config throws, naming the list and the key. `sudo` is the
|
|
20
|
+
* single trusted bypass, mirroring the write path.
|
|
21
|
+
*
|
|
22
|
+
* Deliberately NOT walked: the access filter produced by the list's own `query`
|
|
23
|
+
* access control. That filter is trusted config authored by the same person who
|
|
24
|
+
* declares the fields — walking it would make this an access-control decision
|
|
25
|
+
* (that's #915/#916), not the key-existence seam this ticket establishes.
|
|
26
|
+
*
|
|
27
|
+
* `validateQueryFieldReadAccess` below is that access-control decision for
|
|
28
|
+
* #915: it re-walks `where`/`orderBy` (reusing `resolveQueryField`) and checks
|
|
29
|
+
* each resolved field's `read` access via the canonical evaluator, so a field
|
|
30
|
+
* the session cannot read cannot be named in a predicate either. It runs
|
|
31
|
+
* strictly after this module's key-existence check — an undeclared key is
|
|
32
|
+
* #912's rejection, not a field-access decision — and stays scoped to the
|
|
33
|
+
* CURRENT list, deliberately not recursing into a related list's fields
|
|
34
|
+
* nested inside a relation filter itself (that recursion is #916's job, not
|
|
35
|
+
* this module's — see below).
|
|
36
|
+
*
|
|
37
|
+
* #916 — the relation-filter counterpart — scopes a relation filter itself
|
|
38
|
+
* (`some`/`every`/`none`/`is`/`isNot`) by the RELATED list's own `query`
|
|
39
|
+
* access, folding it into the nested clause exactly like
|
|
40
|
+
* `buildAccessScopedInclude` folds it into `include` (`access-filter.ts`).
|
|
41
|
+
* It reuses this module's shape-recognition (`resolveQueryField`,
|
|
42
|
+
* `LOGICAL_OPERATORS`, `RELATION_QUANTIFIERS`, exported below) and calls
|
|
43
|
+
* `walkWhereReadAccess` once per hop — against the RELATED list's own
|
|
44
|
+
* config — for the field-read half of the same job this module's
|
|
45
|
+
* `validateQueryFieldReadAccess` already does for the CURRENT list. There is
|
|
46
|
+
* deliberately no second copy of either the shape-recognition or the
|
|
47
|
+
* field-read check: `access-filter.ts` supplies the RELATED list at each
|
|
48
|
+
* hop and calls back into the same primitives this module already owns.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
// Prisma's logical combinators for a WHERE clause — never field names.
|
|
52
|
+
export const LOGICAL_OPERATORS = new Set(['AND', 'OR', 'NOT'])
|
|
53
|
+
|
|
54
|
+
// Prisma's relation quantifiers. The value nested under one of these is itself a
|
|
55
|
+
// WHERE clause for the RELATED list, and is walked against that list's fields.
|
|
56
|
+
export const RELATION_QUANTIFIERS = new Set(['some', 'every', 'none', 'is', 'isNot'])
|
|
57
|
+
|
|
58
|
+
// Always present, never declared in a list's `fields` — the write path
|
|
59
|
+
// (`filterWritableFields`) excludes the same three names from `fieldConfigs`.
|
|
60
|
+
const SYSTEM_FIELDS = new Set(['id', 'createdAt', 'updatedAt'])
|
|
61
|
+
|
|
62
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- field configs are heterogeneous across field types
|
|
63
|
+
type FieldConfigMap = Record<string, any>
|
|
64
|
+
|
|
65
|
+
export interface ResolvedQueryField {
|
|
66
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- field configs are heterogeneous across field types
|
|
67
|
+
fieldConfig: any
|
|
68
|
+
isRelationship: boolean
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Resolve a `where`/`orderBy` key against a list's declared fields.
|
|
73
|
+
*
|
|
74
|
+
* A key is valid when it is:
|
|
75
|
+
* - a system field (`id`, `createdAt`, `updatedAt`) — always present, and never
|
|
76
|
+
* declared in `fields` (the write path excludes them from `fieldConfigs` the
|
|
77
|
+
* same way), or
|
|
78
|
+
* - a field declared directly in the list config, or
|
|
79
|
+
* - the foreign-key scalar a to-one `relationship` field implies (e.g. `authorId`
|
|
80
|
+
* for `author: relationship(...)`) — the config never names this column
|
|
81
|
+
* directly, but Prisma always generates it, and the write path
|
|
82
|
+
* (`filterWritableFields`) grants it the same pass, or
|
|
83
|
+
* - a raw per-part column a multi-column field's `splitColumns` contributes (e.g.
|
|
84
|
+
* storage `image()`/`file()` in Keystone-parity mode) — undeclared by design,
|
|
85
|
+
* mirroring the write path's `splitColumnOwners` allowance (#568/#789).
|
|
86
|
+
*
|
|
87
|
+
* Anything else — most importantly a Prisma-generated back-relation the config
|
|
88
|
+
* never declares — resolves to `undefined` and is rejected by the caller.
|
|
89
|
+
*/
|
|
90
|
+
export function resolveQueryField(
|
|
91
|
+
key: string,
|
|
92
|
+
fields: FieldConfigMap,
|
|
93
|
+
): ResolvedQueryField | undefined {
|
|
94
|
+
if (SYSTEM_FIELDS.has(key)) {
|
|
95
|
+
return { fieldConfig: undefined, isRelationship: false }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const fieldConfig = fields[key]
|
|
99
|
+
if (fieldConfig) {
|
|
100
|
+
return { fieldConfig, isRelationship: fieldConfig.type === 'relationship' }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (key.endsWith('Id')) {
|
|
104
|
+
const baseField = fields[key.slice(0, -2)]
|
|
105
|
+
if (baseField && baseField.type === 'relationship' && !baseField.many) {
|
|
106
|
+
return { fieldConfig: baseField, isRelationship: false }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
for (const [ownerName, owner] of Object.entries(fields)) {
|
|
111
|
+
if (owner && typeof owner.getColumnNames === 'function') {
|
|
112
|
+
const columns: string[] = owner.getColumnNames(ownerName)
|
|
113
|
+
if (columns.includes(key)) {
|
|
114
|
+
return { fieldConfig: owner, isRelationship: false }
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return undefined
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function rejectUndeclaredKey(listName: string, key: string, kind: 'where' | 'orderBy'): never {
|
|
123
|
+
throw new ValidationError([
|
|
124
|
+
`Cannot query "${listName}" — "${key}" is not a field of this list. ` +
|
|
125
|
+
`Undeclared ${kind} keys are rejected (use sudo to bypass).`,
|
|
126
|
+
])
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function walkWhere(
|
|
130
|
+
where: unknown,
|
|
131
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
132
|
+
listConfig: ListConfig<any>,
|
|
133
|
+
listName: string,
|
|
134
|
+
config: OpenSaasConfig,
|
|
135
|
+
isSudo: boolean,
|
|
136
|
+
): void {
|
|
137
|
+
if (where === null || typeof where !== 'object') return
|
|
138
|
+
|
|
139
|
+
if (Array.isArray(where)) {
|
|
140
|
+
for (const entry of where) walkWhere(entry, listConfig, listName, config, isSudo)
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
for (const [key, value] of Object.entries(where as Record<string, unknown>)) {
|
|
145
|
+
if (LOGICAL_OPERATORS.has(key)) {
|
|
146
|
+
walkWhere(value, listConfig, listName, config, isSudo)
|
|
147
|
+
continue
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const resolved = resolveQueryField(key, listConfig.fields)
|
|
151
|
+
if (!resolved) {
|
|
152
|
+
if (isSudo) continue
|
|
153
|
+
rejectUndeclaredKey(listName, key, 'where')
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Scalar field filters use Prisma's own operator vocabulary (`equals`,
|
|
157
|
+
// `contains`, `in`, …) and never nest another field name — trusted as-is,
|
|
158
|
+
// no further walk needed. Only a relationship field's filter nests a
|
|
159
|
+
// WHERE clause for another list.
|
|
160
|
+
if (
|
|
161
|
+
resolved.isRelationship &&
|
|
162
|
+
value !== null &&
|
|
163
|
+
typeof value === 'object' &&
|
|
164
|
+
!Array.isArray(value)
|
|
165
|
+
) {
|
|
166
|
+
const related = getRelatedListConfig(resolved.fieldConfig.ref, config)
|
|
167
|
+
if (!related) continue
|
|
168
|
+
|
|
169
|
+
const relationEntries = Object.entries(value as Record<string, unknown>)
|
|
170
|
+
const hasQuantifier = relationEntries.some(([k]) => RELATION_QUANTIFIERS.has(k))
|
|
171
|
+
|
|
172
|
+
if (hasQuantifier) {
|
|
173
|
+
// Wrapped form: `{ author: { is: {...} } }` / `{ posts: { some: {...} } }`.
|
|
174
|
+
// Only the quantifier's own value is a nested WHERE clause for the
|
|
175
|
+
// related list.
|
|
176
|
+
for (const [quantifier, quantifierValue] of relationEntries) {
|
|
177
|
+
if (RELATION_QUANTIFIERS.has(quantifier)) {
|
|
178
|
+
walkWhere(quantifierValue, related.listConfig, related.listName, config, isSudo)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
} else {
|
|
182
|
+
// Direct-nesting form: Prisma's documented default for a to-one
|
|
183
|
+
// relation filter nests the related list's own fields with no `is`
|
|
184
|
+
// wrapper at all (`{ author: { email: { contains: '...' } } }`). The
|
|
185
|
+
// whole value object IS the nested WHERE clause here — walk it
|
|
186
|
+
// directly, or an undeclared key reached exactly this way (one hop
|
|
187
|
+
// through a to-one relation) would pass through unchecked.
|
|
188
|
+
walkWhere(value, related.listConfig, related.listName, config, isSudo)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function walkOrderBy(
|
|
195
|
+
orderBy: unknown,
|
|
196
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
197
|
+
listConfig: ListConfig<any>,
|
|
198
|
+
listName: string,
|
|
199
|
+
config: OpenSaasConfig,
|
|
200
|
+
isSudo: boolean,
|
|
201
|
+
): void {
|
|
202
|
+
if (orderBy === null || typeof orderBy !== 'object') return
|
|
203
|
+
|
|
204
|
+
if (Array.isArray(orderBy)) {
|
|
205
|
+
for (const entry of orderBy) walkOrderBy(entry, listConfig, listName, config, isSudo)
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
for (const [key, value] of Object.entries(orderBy as Record<string, unknown>)) {
|
|
210
|
+
const resolved = resolveQueryField(key, listConfig.fields)
|
|
211
|
+
if (!resolved) {
|
|
212
|
+
if (isSudo) continue
|
|
213
|
+
rejectUndeclaredKey(listName, key, 'orderBy')
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (resolved.isRelationship && value !== null && typeof value === 'object') {
|
|
217
|
+
// `{ relation: { _count: 'asc' } }` orders by an aggregate — no nested
|
|
218
|
+
// field name to resolve. `{ relation: { name: 'asc' } }` orders by a
|
|
219
|
+
// field on a to-one related list — walk it against that list's fields.
|
|
220
|
+
if ('_count' in (value as Record<string, unknown>)) continue
|
|
221
|
+
|
|
222
|
+
const related = getRelatedListConfig(resolved.fieldConfig.ref, config)
|
|
223
|
+
if (related) walkOrderBy(value, related.listConfig, related.listName, config, isSudo)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Validate a caller-supplied `where`/`orderBy` against the list config,
|
|
230
|
+
* recursing into logical operators (`AND`/`OR`/`NOT`) and relation filters.
|
|
231
|
+
* Throws a `ValidationError` naming the list and the offending key on the
|
|
232
|
+
* first undeclared key found. `isSudo` bypasses the check entirely, matching
|
|
233
|
+
* the write path's `sudo` escape hatch.
|
|
234
|
+
*/
|
|
235
|
+
export function validateQueryKeys(args: {
|
|
236
|
+
where?: unknown
|
|
237
|
+
orderBy?: unknown
|
|
238
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
239
|
+
listConfig: ListConfig<any>
|
|
240
|
+
listName: string
|
|
241
|
+
config: OpenSaasConfig
|
|
242
|
+
isSudo: boolean
|
|
243
|
+
}): void {
|
|
244
|
+
const { where, orderBy, listConfig, listName, config, isSudo } = args
|
|
245
|
+
if (where !== undefined) walkWhere(where, listConfig, listName, config, isSudo)
|
|
246
|
+
if (orderBy !== undefined) walkOrderBy(orderBy, listConfig, listName, config, isSudo)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* #915 — the predicate-time counterpart to `checkFieldAccess`'s post-query
|
|
251
|
+
* check: reject a `where`/`orderBy` key that names a field the session cannot
|
|
252
|
+
* read, BEFORE the query runs. See this module's top doc comment for how this
|
|
253
|
+
* relates to `validateQueryKeys` (#912) and `isFieldReadableForPredicate`'s
|
|
254
|
+
* doc (in `field-access.ts`) for how a row-dependent `read` rule is handled.
|
|
255
|
+
*
|
|
256
|
+
* A key `resolveQueryField` cannot resolve is skipped here — #912 has already
|
|
257
|
+
* rejected it (or, under `sudo`, deliberately let it through) by the time
|
|
258
|
+
* this runs. A system field (`id`/`createdAt`/`updatedAt`) resolves with no
|
|
259
|
+
* `fieldConfig` and carries no field-level access control, so it is always
|
|
260
|
+
* readable and skipped too.
|
|
261
|
+
*/
|
|
262
|
+
async function checkKeyReadableOrThrow(
|
|
263
|
+
key: string,
|
|
264
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
265
|
+
listConfig: ListConfig<any>,
|
|
266
|
+
listName: string,
|
|
267
|
+
args: { session: Session | null; context: AccessContext & { _isSudo?: boolean } },
|
|
268
|
+
kind: 'where' | 'orderBy',
|
|
269
|
+
): Promise<void> {
|
|
270
|
+
const resolved = resolveQueryField(key, listConfig.fields)
|
|
271
|
+
if (!resolved || resolved.fieldConfig === undefined) return
|
|
272
|
+
|
|
273
|
+
const readable = await isFieldReadableForPredicate(resolved.fieldConfig.access, args)
|
|
274
|
+
if (!readable) {
|
|
275
|
+
throw new ValidationError([
|
|
276
|
+
`Cannot query "${listName}" — "${key}" is denied by field-level read access. ` +
|
|
277
|
+
`A field the session cannot read cannot be named in a ${kind} (use sudo to bypass).`,
|
|
278
|
+
])
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Check field-level `read` access for every key at ONE level of a `where`
|
|
284
|
+
* clause, recursing only into logical operators (`AND`/`OR`/`NOT`) — never
|
|
285
|
+
* into a relationship field's own nested value. Exported for `access-filter.ts`'s
|
|
286
|
+
* #916 reuse (see module doc comment above) — called once per hop against the
|
|
287
|
+
* RELATED list's own config.
|
|
288
|
+
*/
|
|
289
|
+
export async function walkWhereReadAccess(
|
|
290
|
+
where: unknown,
|
|
291
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
292
|
+
listConfig: ListConfig<any>,
|
|
293
|
+
listName: string,
|
|
294
|
+
args: { session: Session | null; context: AccessContext & { _isSudo?: boolean } },
|
|
295
|
+
): Promise<void> {
|
|
296
|
+
if (where === null || typeof where !== 'object') return
|
|
297
|
+
|
|
298
|
+
if (Array.isArray(where)) {
|
|
299
|
+
for (const entry of where) await walkWhereReadAccess(entry, listConfig, listName, args)
|
|
300
|
+
return
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
for (const [key, value] of Object.entries(where as Record<string, unknown>)) {
|
|
304
|
+
if (LOGICAL_OPERATORS.has(key)) {
|
|
305
|
+
await walkWhereReadAccess(value, listConfig, listName, args)
|
|
306
|
+
continue
|
|
307
|
+
}
|
|
308
|
+
// Deliberately does not recurse into a relationship field's own nested
|
|
309
|
+
// value: it checks whether THIS list's relationship field may be named
|
|
310
|
+
// (its own `read` access) — a field on the RELATED list nested inside it
|
|
311
|
+
// is checked by the CALLER re-invoking this function against the related
|
|
312
|
+
// list's config (see #916 in the module doc comment above).
|
|
313
|
+
await checkKeyReadableOrThrow(key, listConfig, listName, args, 'where')
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function walkOrderByReadAccess(
|
|
318
|
+
orderBy: unknown,
|
|
319
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
320
|
+
listConfig: ListConfig<any>,
|
|
321
|
+
listName: string,
|
|
322
|
+
args: { session: Session | null; context: AccessContext & { _isSudo?: boolean } },
|
|
323
|
+
): Promise<void> {
|
|
324
|
+
if (orderBy === null || typeof orderBy !== 'object') return
|
|
325
|
+
|
|
326
|
+
if (Array.isArray(orderBy)) {
|
|
327
|
+
for (const entry of orderBy) await walkOrderByReadAccess(entry, listConfig, listName, args)
|
|
328
|
+
return
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
for (const key of Object.keys(orderBy as Record<string, unknown>)) {
|
|
332
|
+
await checkKeyReadableOrThrow(key, listConfig, listName, args, 'orderBy')
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Validate a caller-supplied `where`/`orderBy` against field-level `read`
|
|
338
|
+
* access, recursing into logical operators (`AND`/`OR`/`NOT`) the same way
|
|
339
|
+
* `validateQueryKeys` does. Throws a `ValidationError` naming the list and
|
|
340
|
+
* the offending key on the first read-denied field found. `isSudo` bypasses
|
|
341
|
+
* the check entirely, matching `validateQueryKeys` and the write path's
|
|
342
|
+
* `sudo` escape hatch.
|
|
343
|
+
*/
|
|
344
|
+
export async function validateQueryFieldReadAccess(args: {
|
|
345
|
+
where?: unknown
|
|
346
|
+
orderBy?: unknown
|
|
347
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
348
|
+
listConfig: ListConfig<any>
|
|
349
|
+
listName: string
|
|
350
|
+
session: Session | null
|
|
351
|
+
context: AccessContext & { _isSudo?: boolean }
|
|
352
|
+
isSudo: boolean
|
|
353
|
+
}): Promise<void> {
|
|
354
|
+
const { where, orderBy, listConfig, listName, session, context, isSudo } = args
|
|
355
|
+
if (isSudo) return
|
|
356
|
+
const evalArgs = { session, context }
|
|
357
|
+
if (where !== undefined) await walkWhereReadAccess(where, listConfig, listName, evalArgs)
|
|
358
|
+
if (orderBy !== undefined) await walkOrderByReadAccess(orderBy, listConfig, listName, evalArgs)
|
|
359
|
+
}
|
|
@@ -15,7 +15,7 @@ import { getDbKey } from '../lib/case-utils.js'
|
|
|
15
15
|
* per-row query is issued and the count can never include rows the session
|
|
16
16
|
* cannot read. This module is the single place the related list's
|
|
17
17
|
* operation-level `query` access is folded into that `_count`, mirroring how
|
|
18
|
-
* `
|
|
18
|
+
* `buildAccessScopedInclude` folds it into relation includes.
|
|
19
19
|
*
|
|
20
20
|
* It also resolves the count Filter spec's markers: Prisma cannot compare a
|
|
21
21
|
* relation count in a `where`, so a to-many relationship's Filter spec emits a
|
|
@@ -99,7 +99,6 @@ export async function buildRelationshipCountSelect(
|
|
|
99
99
|
return Object.keys(select).length > 0 ? select : undefined
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
/** Read a to-many relationship's count off a fetched row's `_count` payload. */
|
|
103
102
|
function readRelationshipCount(row: Record<string, unknown>, fieldName: string): number {
|
|
104
103
|
const counts = row._count
|
|
105
104
|
if (counts && typeof counts === 'object') {
|
|
@@ -109,7 +108,6 @@ function readRelationshipCount(row: Record<string, unknown>, fieldName: string):
|
|
|
109
108
|
return 0
|
|
110
109
|
}
|
|
111
110
|
|
|
112
|
-
/** Whether a count satisfies a Filter operator/value comparison. */
|
|
113
111
|
function matchesCount(count: number, operator: FilterOperator, value: number): boolean {
|
|
114
112
|
switch (operator) {
|
|
115
113
|
case 'eq':
|
|
@@ -140,7 +138,6 @@ function asCountDelegate(value: unknown): CountFindManyDelegate | null {
|
|
|
140
138
|
return null
|
|
141
139
|
}
|
|
142
140
|
|
|
143
|
-
/** Extract the `RelationshipCountFilterMarker` from a condition value, if present. */
|
|
144
141
|
function readCountMarker(value: unknown): RelationshipCountFilterMarker | null {
|
|
145
142
|
if (!value || typeof value !== 'object') return null
|
|
146
143
|
const marker = (value as Record<string, unknown>)[RELATIONSHIP_COUNT_FILTER_KEY]
|
|
@@ -291,13 +288,8 @@ export async function resolveRelationshipCountFilters(
|
|
|
291
288
|
args,
|
|
292
289
|
config,
|
|
293
290
|
)
|
|
294
|
-
// Preserve any sibling conditions
|
|
295
|
-
//
|
|
296
|
-
// currently guarantees each AND-member (and the no-AND single object) carries
|
|
297
|
-
// exactly one field condition, so `siblings` is empty today and this equals the
|
|
298
|
-
// previous wholesale replacement — but if a future engine change ever merged
|
|
299
|
-
// multiple conditions into one member, spreading keeps the marker's siblings
|
|
300
|
-
// from being silently dropped.
|
|
291
|
+
// Preserve any sibling conditions rather than replacing the member
|
|
292
|
+
// wholesale — see `mergeResolvedMember` above for why.
|
|
301
293
|
const siblings: Record<string, unknown> = { ...member }
|
|
302
294
|
delete siblings[found.field]
|
|
303
295
|
resolvedMembers.push(mergeResolvedMember(siblings, resolved))
|
|
@@ -9,15 +9,16 @@ import {
|
|
|
9
9
|
} from './relationship-label-filter.js'
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
12
|
+
* `resolveRelationshipLabelFilters` used to fold the related list's `query`
|
|
13
|
+
* access into a to-one relationship label filter's nested `is` clause
|
|
14
|
+
* (issue #749). Since #916, the engine itself scopes every relation filter in
|
|
15
|
+
* `where` (`buildAccessScopedWhere`), including this exact `{ is: {...} } }`
|
|
16
|
+
* shape, so this resolver's own fold is redundant and has been removed — it
|
|
17
|
+
* is now a pass-through, kept exported only for API compatibility. These
|
|
18
|
+
* tests pin that pass-through behavior; `access-filter.test.ts` and
|
|
19
|
+
* `context.test.ts` cover the actual access-scoping this resolver used to do.
|
|
17
20
|
*/
|
|
18
21
|
|
|
19
|
-
// Post is fully open; User (the `author` relation target) is scoped so only
|
|
20
|
-
// active users are queryable; Widget ships fully closed (no access block).
|
|
21
22
|
function makeConfig(): OpenSaasConfig {
|
|
22
23
|
return {
|
|
23
24
|
db: { provider: 'sqlite', prismaClientConstructor: () => null as never },
|
|
@@ -63,8 +64,8 @@ describe('isToOneRelationshipField', () => {
|
|
|
63
64
|
})
|
|
64
65
|
})
|
|
65
66
|
|
|
66
|
-
describe('resolveRelationshipLabelFilters', () => {
|
|
67
|
-
it('returns
|
|
67
|
+
describe('resolveRelationshipLabelFilters (pass-through since #916)', () => {
|
|
68
|
+
it('returns a plain where unchanged', async () => {
|
|
68
69
|
const config = makeConfig()
|
|
69
70
|
const where = { title: { contains: 'hello' } }
|
|
70
71
|
const resolved = await resolveRelationshipLabelFilters(
|
|
@@ -76,7 +77,7 @@ describe('resolveRelationshipLabelFilters', () => {
|
|
|
76
77
|
expect(resolved).toBe(where)
|
|
77
78
|
})
|
|
78
79
|
|
|
79
|
-
it(
|
|
80
|
+
it('returns a to-one label-filter `is` clause unchanged — the engine scopes it now', async () => {
|
|
80
81
|
const config = makeConfig()
|
|
81
82
|
const where = { author: { is: { name: { contains: 'Ada' } } } }
|
|
82
83
|
const resolved = await resolveRelationshipLabelFilters(
|
|
@@ -85,31 +86,10 @@ describe('resolveRelationshipLabelFilters', () => {
|
|
|
85
86
|
{ session: null, context: makeContext() },
|
|
86
87
|
config,
|
|
87
88
|
)
|
|
88
|
-
expect(resolved).
|
|
89
|
-
author: { is: { AND: [{ active: { equals: 1 } }, { name: { contains: 'Ada' } }] } },
|
|
90
|
-
})
|
|
91
|
-
})
|
|
92
|
-
|
|
93
|
-
it('leaves the member unchanged when the related list is fully readable', async () => {
|
|
94
|
-
const config: OpenSaasConfig = {
|
|
95
|
-
db: { provider: 'sqlite', prismaClientConstructor: () => null as never },
|
|
96
|
-
lists: {
|
|
97
|
-
Tag: list({ fields: { name: text() }, access: { operation: { query: () => true } } }),
|
|
98
|
-
Post: list({ fields: { title: text(), tag: relationship({ ref: 'Tag' }) } }),
|
|
99
|
-
},
|
|
100
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
|
|
101
|
-
} as any
|
|
102
|
-
const where = { tag: { is: { name: { contains: 'news' } } } }
|
|
103
|
-
const resolved = await resolveRelationshipLabelFilters(
|
|
104
|
-
where,
|
|
105
|
-
config.lists.Post,
|
|
106
|
-
{ session: null, context: makeContext() },
|
|
107
|
-
config,
|
|
108
|
-
)
|
|
109
|
-
expect(resolved).toEqual(where)
|
|
89
|
+
expect(resolved).toBe(where)
|
|
110
90
|
})
|
|
111
91
|
|
|
112
|
-
it('
|
|
92
|
+
it('returns a label filter against a fully denied related list unchanged too', async () => {
|
|
113
93
|
const config = makeConfig()
|
|
114
94
|
const where = { widget: { is: { name: { contains: 'Gadget' } } } }
|
|
115
95
|
const resolved = await resolveRelationshipLabelFilters(
|
|
@@ -118,26 +98,10 @@ describe('resolveRelationshipLabelFilters', () => {
|
|
|
118
98
|
{ session: null, context: makeContext() },
|
|
119
99
|
config,
|
|
120
100
|
)
|
|
121
|
-
expect(resolved).
|
|
122
|
-
})
|
|
123
|
-
|
|
124
|
-
it('preserves sibling conditions, ANDing the resolved id constraint for a denied member', async () => {
|
|
125
|
-
const config = makeConfig()
|
|
126
|
-
const where = {
|
|
127
|
-
AND: [{ title: { contains: 'hello' } }, { widget: { is: { name: { contains: 'Gadget' } } } }],
|
|
128
|
-
}
|
|
129
|
-
const resolved = await resolveRelationshipLabelFilters(
|
|
130
|
-
where,
|
|
131
|
-
config.lists.Post,
|
|
132
|
-
{ session: null, context: makeContext() },
|
|
133
|
-
config,
|
|
134
|
-
)
|
|
135
|
-
expect(resolved).toEqual({
|
|
136
|
-
AND: [{ title: { contains: 'hello' } }, { id: { in: [] } }],
|
|
137
|
-
})
|
|
101
|
+
expect(resolved).toBe(where)
|
|
138
102
|
})
|
|
139
103
|
|
|
140
|
-
it('
|
|
104
|
+
it('returns a where with nested AND/label-filter members unchanged', async () => {
|
|
141
105
|
const config = makeConfig()
|
|
142
106
|
const where = {
|
|
143
107
|
AND: [{ title: { contains: 'hello' } }, { author: { is: { name: { contains: 'Ada' } } } }],
|
|
@@ -148,30 +112,17 @@ describe('resolveRelationshipLabelFilters', () => {
|
|
|
148
112
|
{ session: null, context: makeContext() },
|
|
149
113
|
config,
|
|
150
114
|
)
|
|
151
|
-
expect(resolved).
|
|
152
|
-
AND: [
|
|
153
|
-
{ title: { contains: 'hello' } },
|
|
154
|
-
{ author: { is: { AND: [{ active: { equals: 1 } }, { name: { contains: 'Ada' } }] } } },
|
|
155
|
-
],
|
|
156
|
-
})
|
|
115
|
+
expect(resolved).toBe(where)
|
|
157
116
|
})
|
|
158
117
|
|
|
159
|
-
it('
|
|
118
|
+
it('returns undefined unchanged when there is no where at all', async () => {
|
|
160
119
|
const config = makeConfig()
|
|
161
|
-
const where = {
|
|
162
|
-
AND: [{ author: { is: { name: { contains: 'Ada' } } } }, { views: { gt: 10 } }],
|
|
163
|
-
}
|
|
164
120
|
const resolved = await resolveRelationshipLabelFilters(
|
|
165
|
-
|
|
121
|
+
undefined,
|
|
166
122
|
config.lists.Post,
|
|
167
123
|
{ session: null, context: makeContext() },
|
|
168
124
|
config,
|
|
169
125
|
)
|
|
170
|
-
expect(resolved).
|
|
171
|
-
AND: [
|
|
172
|
-
{ author: { is: { AND: [{ active: { equals: 1 } }, { name: { contains: 'Ada' } }] } } },
|
|
173
|
-
{ views: { gt: 10 } },
|
|
174
|
-
],
|
|
175
|
-
})
|
|
126
|
+
expect(resolved).toBeUndefined()
|
|
176
127
|
})
|
|
177
128
|
})
|