@opensaas/stack-core 0.29.0 → 0.31.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 +443 -0
- package/CLAUDE.md +4 -4
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +14 -1
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/access-filter.test.js +50 -0
- package/dist/access/access-filter.test.js.map +1 -1
- package/dist/access/field-access.js +6 -6
- package/dist/access/field-access.js.map +1 -1
- package/dist/access/multi-column-read-write.test.js +46 -12
- package/dist/access/multi-column-read-write.test.js.map +1 -1
- package/dist/access/relationship-count.d.ts +60 -0
- package/dist/access/relationship-count.d.ts.map +1 -0
- package/dist/access/relationship-count.js +233 -0
- package/dist/access/relationship-count.js.map +1 -0
- package/dist/access/relationship-count.test.d.ts +2 -0
- package/dist/access/relationship-count.test.d.ts.map +1 -0
- package/dist/access/relationship-count.test.js +185 -0
- package/dist/access/relationship-count.test.js.map +1 -0
- package/dist/access/relationship-label-filter.d.ts +45 -0
- package/dist/access/relationship-label-filter.d.ts.map +1 -0
- package/dist/access/relationship-label-filter.js +93 -0
- package/dist/access/relationship-label-filter.js.map +1 -0
- package/dist/access/relationship-label-filter.test.d.ts +2 -0
- package/dist/access/relationship-label-filter.test.d.ts.map +1 -0
- package/dist/access/relationship-label-filter.test.js +126 -0
- package/dist/access/relationship-label-filter.test.js.map +1 -0
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/nav-count.d.ts +34 -0
- package/dist/config/nav-count.d.ts.map +1 -0
- package/dist/config/nav-count.js +71 -0
- package/dist/config/nav-count.js.map +1 -0
- package/dist/config/types.d.ts +305 -4
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/hook-pipeline.d.ts.map +1 -1
- package/dist/context/hook-pipeline.js +11 -1
- package/dist/context/hook-pipeline.js.map +1 -1
- package/dist/context/index.d.ts +28 -0
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +206 -1
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +7 -1
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/extend.d.ts +1 -0
- package/dist/extend.d.ts.map +1 -1
- package/dist/fields/index.d.ts +1 -1
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +166 -0
- package/dist/fields/index.js.map +1 -1
- package/dist/filter/collect.d.ts +32 -0
- package/dist/filter/collect.d.ts.map +1 -0
- package/dist/filter/collect.js +60 -0
- package/dist/filter/collect.js.map +1 -0
- package/dist/filter/filter.test.d.ts +2 -0
- package/dist/filter/filter.test.d.ts.map +1 -0
- package/dist/filter/filter.test.js +348 -0
- package/dist/filter/filter.test.js.map +1 -0
- package/dist/filter/index.d.ts +7 -0
- package/dist/filter/index.d.ts.map +1 -0
- package/dist/filter/index.js +16 -0
- package/dist/filter/index.js.map +1 -0
- package/dist/filter/map.d.ts +18 -0
- package/dist/filter/map.d.ts.map +1 -0
- package/dist/filter/map.js +60 -0
- package/dist/filter/map.js.map +1 -0
- package/dist/filter/parse.d.ts +33 -0
- package/dist/filter/parse.d.ts.map +1 -0
- package/dist/filter/parse.js +103 -0
- package/dist/filter/parse.js.map +1 -0
- package/dist/filter/serialize.d.ts +25 -0
- package/dist/filter/serialize.d.ts.map +1 -0
- package/dist/filter/serialize.js +68 -0
- package/dist/filter/serialize.js.map +1 -0
- package/dist/filter/serialize.test.d.ts +2 -0
- package/dist/filter/serialize.test.d.ts.map +1 -0
- package/dist/filter/serialize.test.js +83 -0
- package/dist/filter/serialize.test.js.map +1 -0
- package/dist/filter/types.d.ts +122 -0
- package/dist/filter/types.d.ts.map +1 -0
- package/dist/filter/types.js +24 -0
- package/dist/filter/types.js.map +1 -0
- package/dist/hooks/index.d.ts +30 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +79 -59
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -0
- 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/mcp/handler.d.ts +11 -3
- package/dist/mcp/handler.d.ts.map +1 -1
- package/dist/mcp/handler.js +96 -20
- package/dist/mcp/handler.js.map +1 -1
- package/package.json +1 -1
- package/src/access/access-filter.test.ts +79 -0
- package/src/access/access-filter.ts +15 -4
- package/src/access/field-access.ts +6 -6
- package/src/access/multi-column-read-write.test.ts +68 -19
- package/src/access/relationship-count.test.ts +269 -0
- package/src/access/relationship-count.ts +311 -0
- package/src/access/relationship-label-filter.test.ts +177 -0
- package/src/access/relationship-label-filter.ts +140 -0
- package/src/config/index.ts +6 -0
- package/src/config/nav-count.ts +85 -0
- package/src/config/types.ts +319 -1
- package/src/context/hook-pipeline.ts +19 -0
- package/src/context/index.ts +286 -4
- package/src/context/nested-operations.ts +22 -0
- package/src/extend.ts +9 -0
- package/src/fields/index.ts +168 -0
- package/src/filter/collect.ts +72 -0
- package/src/filter/filter.test.ts +400 -0
- package/src/filter/index.ts +25 -0
- package/src/filter/map.ts +68 -0
- package/src/filter/parse.ts +106 -0
- package/src/filter/serialize.test.ts +115 -0
- package/src/filter/serialize.ts +75 -0
- package/src/filter/types.ts +120 -0
- package/src/hooks/index.ts +87 -57
- package/src/index.ts +54 -0
- package/src/internal.ts +5 -0
- package/src/mcp/handler.ts +118 -28
- package/tests/context.test.ts +690 -0
- package/tests/multi-column-validation-ordering.test.ts +305 -0
- package/tests/nav-count.test.ts +189 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import type { Session, AccessContext, PrismaFilter } from './types.js'
|
|
2
|
+
import type { OpenSaasConfig, ListConfig, FieldConfig } from '../config/types.js'
|
|
3
|
+
import type { FilterOperator, RelationshipCountFilterMarker } from '../filter/types.js'
|
|
4
|
+
import { RELATIONSHIP_COUNT_FILTER_KEY } from '../filter/types.js'
|
|
5
|
+
import { checkAccess, getRelatedListConfig } from './engine.js'
|
|
6
|
+
import { getDbKey } from '../lib/case-utils.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Access-scoped to-many relationship counts for the admin list view (issue
|
|
10
|
+
* #732).
|
|
11
|
+
*
|
|
12
|
+
* A to-many relationship column shows the count of the related rows the session
|
|
13
|
+
* may see — fetched in the SAME query as the row via Prisma's filtered
|
|
14
|
+
* `_count` (`_count: { select: { orders: { where: <access filter> } } }`), so no
|
|
15
|
+
* per-row query is issued and the count can never include rows the session
|
|
16
|
+
* cannot read. This module is the single place the related list's
|
|
17
|
+
* operation-level `query` access is folded into that `_count`, mirroring how
|
|
18
|
+
* `buildIncludeWithAccessControl` folds it into relation includes.
|
|
19
|
+
*
|
|
20
|
+
* It also resolves the count Filter spec's markers: Prisma cannot compare a
|
|
21
|
+
* relation count in a `where`, so a to-many relationship's Filter spec emits a
|
|
22
|
+
* {@link RELATIONSHIP_COUNT_FILTER_KEY} marker that
|
|
23
|
+
* {@link resolveRelationshipCountFilters} turns into an access-scoped
|
|
24
|
+
* `{ id: { in } }` before the query runs — never leaking counts of related rows
|
|
25
|
+
* the session cannot see.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
type CountArgs = {
|
|
29
|
+
session: Session | null
|
|
30
|
+
context: AccessContext
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Whether a field is a to-many relationship — the only field kind that carries a
|
|
35
|
+
* relationship count (a to-one relationship has at most one related row).
|
|
36
|
+
*/
|
|
37
|
+
export function isToManyRelationshipField(field: FieldConfig | undefined): boolean {
|
|
38
|
+
return (
|
|
39
|
+
field?.type === 'relationship' &&
|
|
40
|
+
'many' in field &&
|
|
41
|
+
field.many === true &&
|
|
42
|
+
'ref' in field &&
|
|
43
|
+
typeof field.ref === 'string' &&
|
|
44
|
+
field.ref.length > 0
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The per-relation entry the count `_count.select` uses for one relationship. */
|
|
49
|
+
type CountAccessEntry =
|
|
50
|
+
| { kind: 'all' } // related list fully readable → count every related row
|
|
51
|
+
| { kind: 'scoped'; where: PrismaFilter } // count only rows matching the access filter
|
|
52
|
+
| { kind: 'denied' } // related list not readable at all → count is always 0
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Resolve the related list's operation-level `query` access for one to-many
|
|
56
|
+
* relationship into the entry its `_count` select needs. Returns `null` when the
|
|
57
|
+
* field is not a resolvable to-many relationship.
|
|
58
|
+
*/
|
|
59
|
+
async function relationshipCountAccessEntry(
|
|
60
|
+
field: FieldConfig | undefined,
|
|
61
|
+
args: CountArgs,
|
|
62
|
+
config: OpenSaasConfig,
|
|
63
|
+
): Promise<CountAccessEntry | null> {
|
|
64
|
+
if (!field || !isToManyRelationshipField(field) || !('ref' in field)) {
|
|
65
|
+
return null
|
|
66
|
+
}
|
|
67
|
+
const ref = field.ref
|
|
68
|
+
if (typeof ref !== 'string') return null
|
|
69
|
+
const related = getRelatedListConfig(ref, config)
|
|
70
|
+
if (!related) return null
|
|
71
|
+
|
|
72
|
+
const queryAccess = related.listConfig.access?.operation?.query
|
|
73
|
+
const result = await checkAccess(queryAccess, { session: args.session, context: args.context })
|
|
74
|
+
|
|
75
|
+
if (result === false) return { kind: 'denied' }
|
|
76
|
+
if (typeof result === 'object') return { kind: 'scoped', where: result }
|
|
77
|
+
return { kind: 'all' }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Build the object to place at `include._count.select` for a list's to-many
|
|
82
|
+
* relationships, with each related list's `query` access folded into the
|
|
83
|
+
* per-relation `where` so the returned counts are access-scoped. A relationship
|
|
84
|
+
* whose related list is fully denied is omitted (its count renders as 0). Returns
|
|
85
|
+
* `undefined` when the list has no countable to-many relationships.
|
|
86
|
+
*/
|
|
87
|
+
export async function buildRelationshipCountSelect(
|
|
88
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
89
|
+
listConfig: ListConfig<any>,
|
|
90
|
+
args: CountArgs,
|
|
91
|
+
config: OpenSaasConfig,
|
|
92
|
+
): Promise<Record<string, unknown> | undefined> {
|
|
93
|
+
const select: Record<string, unknown> = {}
|
|
94
|
+
for (const [fieldName, field] of Object.entries(listConfig.fields)) {
|
|
95
|
+
const entry = await relationshipCountAccessEntry(field, args, config)
|
|
96
|
+
if (entry === null || entry.kind === 'denied') continue
|
|
97
|
+
select[fieldName] = entry.kind === 'scoped' ? { where: entry.where } : true
|
|
98
|
+
}
|
|
99
|
+
return Object.keys(select).length > 0 ? select : undefined
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Read a to-many relationship's count off a fetched row's `_count` payload. */
|
|
103
|
+
function readRelationshipCount(row: Record<string, unknown>, fieldName: string): number {
|
|
104
|
+
const counts = row._count
|
|
105
|
+
if (counts && typeof counts === 'object') {
|
|
106
|
+
const value = (counts as Record<string, unknown>)[fieldName]
|
|
107
|
+
if (typeof value === 'number') return value
|
|
108
|
+
}
|
|
109
|
+
return 0
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Whether a count satisfies a Filter operator/value comparison. */
|
|
113
|
+
function matchesCount(count: number, operator: FilterOperator, value: number): boolean {
|
|
114
|
+
switch (operator) {
|
|
115
|
+
case 'eq':
|
|
116
|
+
return count === value
|
|
117
|
+
case 'gt':
|
|
118
|
+
return count > value
|
|
119
|
+
case 'gte':
|
|
120
|
+
return count >= value
|
|
121
|
+
case 'lt':
|
|
122
|
+
return count < value
|
|
123
|
+
case 'lte':
|
|
124
|
+
return count <= value
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Minimal shape the resolver needs off the secured `context.db` delegate. */
|
|
129
|
+
interface CountFindManyDelegate {
|
|
130
|
+
findMany: (args: { include: { _count: { select: Record<string, unknown> } } }) => Promise<unknown>
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function asCountDelegate(value: unknown): CountFindManyDelegate | null {
|
|
134
|
+
if (value && typeof value === 'object' && 'findMany' in value) {
|
|
135
|
+
const candidate = value as { findMany?: unknown }
|
|
136
|
+
if (typeof candidate.findMany === 'function') {
|
|
137
|
+
return value as CountFindManyDelegate
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return null
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Extract the `RelationshipCountFilterMarker` from a condition value, if present. */
|
|
144
|
+
function readCountMarker(value: unknown): RelationshipCountFilterMarker | null {
|
|
145
|
+
if (!value || typeof value !== 'object') return null
|
|
146
|
+
const marker = (value as Record<string, unknown>)[RELATIONSHIP_COUNT_FILTER_KEY]
|
|
147
|
+
if (!marker || typeof marker !== 'object') return null
|
|
148
|
+
const { operator, value: n } = marker as { operator?: unknown; value?: unknown }
|
|
149
|
+
if (
|
|
150
|
+
(operator === 'eq' ||
|
|
151
|
+
operator === 'gt' ||
|
|
152
|
+
operator === 'gte' ||
|
|
153
|
+
operator === 'lt' ||
|
|
154
|
+
operator === 'lte') &&
|
|
155
|
+
typeof n === 'number'
|
|
156
|
+
) {
|
|
157
|
+
return { operator, value: n }
|
|
158
|
+
}
|
|
159
|
+
return null
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Resolve one to-many relationship count-filter marker into a Prisma `where`
|
|
164
|
+
* fragment constraining the parent by id. Runs a single access-scoped read
|
|
165
|
+
* through the SECURED context — never a raw/unscoped query and never a per-row
|
|
166
|
+
* query — computing the access-visible count per parent and keeping the ids
|
|
167
|
+
* whose count satisfies the comparison. A fully-denied related list makes every
|
|
168
|
+
* count 0, resolved without any query.
|
|
169
|
+
*/
|
|
170
|
+
async function resolveOneCountFilter(
|
|
171
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
172
|
+
listConfig: ListConfig<any>,
|
|
173
|
+
listKey: string,
|
|
174
|
+
fieldName: string,
|
|
175
|
+
marker: RelationshipCountFilterMarker,
|
|
176
|
+
args: CountArgs,
|
|
177
|
+
config: OpenSaasConfig,
|
|
178
|
+
): Promise<PrismaFilter> {
|
|
179
|
+
const field = listConfig.fields[fieldName]
|
|
180
|
+
const entry = await relationshipCountAccessEntry(field, args, config)
|
|
181
|
+
|
|
182
|
+
// Not resolvable (shouldn't happen for a marker the spec emitted) → no-op.
|
|
183
|
+
if (entry === null) return {}
|
|
184
|
+
|
|
185
|
+
// Related list fully denied → every parent's access-visible count is 0.
|
|
186
|
+
if (entry.kind === 'denied') {
|
|
187
|
+
return matchesCount(0, marker.operator, marker.value) ? {} : { id: { in: [] } }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const delegate = asCountDelegate(args.context.db[getDbKey(listKey)])
|
|
191
|
+
if (!delegate) return {}
|
|
192
|
+
|
|
193
|
+
const countSelect = entry.kind === 'scoped' ? { where: entry.where } : true
|
|
194
|
+
// This over-fetches the parent's scalar columns — it reads only `id` + `_count`
|
|
195
|
+
// per row yet materialises every scalar. It is left un-narrowed on purpose: the
|
|
196
|
+
// secured `context.db` `findMany` does NOT honour Prisma `select` (it
|
|
197
|
+
// warns-and-ignores it and returns the full access-filtered record — see
|
|
198
|
+
// `warnIfSelectIgnored` in context/index.ts and the "Narrowing Reads" note in
|
|
199
|
+
// packages/core/CLAUDE.md). The only supported narrowing is `include`/fragment
|
|
200
|
+
// `query`, neither of which can drop scalar columns. So adding
|
|
201
|
+
// `select: { id: true, _count: {...} }` here would be a silent no-op that also
|
|
202
|
+
// trips the ignore-warning, not a real projection. The count stays access-scoped
|
|
203
|
+
// via the filtered `_count` include below regardless; trimming the projection
|
|
204
|
+
// would first require the read pipeline to honour `select`.
|
|
205
|
+
const rows = await delegate.findMany({
|
|
206
|
+
include: { _count: { select: { [fieldName]: countSelect } } },
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
const matchingIds: string[] = []
|
|
210
|
+
if (Array.isArray(rows)) {
|
|
211
|
+
for (const row of rows) {
|
|
212
|
+
if (!row || typeof row !== 'object') continue
|
|
213
|
+
const record = row as Record<string, unknown>
|
|
214
|
+
if (matchesCount(readRelationshipCount(record, fieldName), marker.operator, marker.value)) {
|
|
215
|
+
matchingIds.push(String(record.id))
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return { id: { in: matchingIds } }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Merge a filter-member's non-resolved sibling conditions with a resolved
|
|
224
|
+
* access-scoped fragment (e.g. a count marker's `{ id: { in } }`, or a to-one
|
|
225
|
+
* label filter's access-scoped `is`). With no siblings (the guaranteed case
|
|
226
|
+
* today) this is just the resolved fragment. When a sibling shares a key with
|
|
227
|
+
* the resolved fragment — a contrived case that cannot arise under the current
|
|
228
|
+
* one-condition-per-member invariant — both are ANDed so neither condition is
|
|
229
|
+
* silently lost. Shared by both relationship resolvers in this module and in
|
|
230
|
+
* `relationship-label-filter.ts`.
|
|
231
|
+
*/
|
|
232
|
+
export function mergeResolvedMember(
|
|
233
|
+
siblings: Record<string, unknown>,
|
|
234
|
+
resolved: Record<string, unknown>,
|
|
235
|
+
): Record<string, unknown> {
|
|
236
|
+
if (Object.keys(siblings).length === 0) return resolved
|
|
237
|
+
const collides = Object.keys(resolved).some((key) => key in siblings)
|
|
238
|
+
return collides ? { AND: [siblings, resolved] } : { ...siblings, ...resolved }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Replace any to-many relationship count-filter markers in a filter `where` with
|
|
243
|
+
* access-scoped `{ id: { in } }` fragments. Markers only ever appear as
|
|
244
|
+
* top-level AND members (the pure filter engine pushes each field condition into
|
|
245
|
+
* the top-level AND, and never nests a marker inside the free-text OR), so this
|
|
246
|
+
* walks only the top level. Returns the `where` unchanged when it contains no
|
|
247
|
+
* markers, so lists without count filters pay nothing.
|
|
248
|
+
*/
|
|
249
|
+
export async function resolveRelationshipCountFilters(
|
|
250
|
+
where: Record<string, unknown> | undefined,
|
|
251
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
252
|
+
listConfig: ListConfig<any>,
|
|
253
|
+
listKey: string,
|
|
254
|
+
args: CountArgs,
|
|
255
|
+
config: OpenSaasConfig,
|
|
256
|
+
): Promise<Record<string, unknown> | undefined> {
|
|
257
|
+
if (!where) return where
|
|
258
|
+
|
|
259
|
+
const andValue = where.AND
|
|
260
|
+
const members: Array<Record<string, unknown>> = Array.isArray(andValue)
|
|
261
|
+
? (andValue as Array<Record<string, unknown>>)
|
|
262
|
+
: [where]
|
|
263
|
+
|
|
264
|
+
const findMarker = (
|
|
265
|
+
member: Record<string, unknown>,
|
|
266
|
+
): { field: string; marker: RelationshipCountFilterMarker } | null => {
|
|
267
|
+
for (const key of Object.keys(member)) {
|
|
268
|
+
if (!isToManyRelationshipField(listConfig.fields[key])) continue
|
|
269
|
+
const marker = readCountMarker(member[key])
|
|
270
|
+
if (marker) return { field: key, marker }
|
|
271
|
+
}
|
|
272
|
+
return null
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (!members.some((member) => findMarker(member) !== null)) {
|
|
276
|
+
return where
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const resolvedMembers: Array<Record<string, unknown>> = []
|
|
280
|
+
for (const member of members) {
|
|
281
|
+
const found = findMarker(member)
|
|
282
|
+
if (!found) {
|
|
283
|
+
resolvedMembers.push(member)
|
|
284
|
+
continue
|
|
285
|
+
}
|
|
286
|
+
const resolved = await resolveOneCountFilter(
|
|
287
|
+
listConfig,
|
|
288
|
+
listKey,
|
|
289
|
+
found.field,
|
|
290
|
+
found.marker,
|
|
291
|
+
args,
|
|
292
|
+
config,
|
|
293
|
+
)
|
|
294
|
+
// Preserve any sibling conditions co-present on this member rather than
|
|
295
|
+
// replacing it wholesale with the resolved `{ id: { in } }`. The filter engine
|
|
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.
|
|
301
|
+
const siblings: Record<string, unknown> = { ...member }
|
|
302
|
+
delete siblings[found.field]
|
|
303
|
+
resolvedMembers.push(mergeResolvedMember(siblings, resolved))
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Drop no-op members (a fully-denied related list where 0 satisfies the
|
|
307
|
+
// comparison resolves to `{}` — matches everything, so it need not be ANDed).
|
|
308
|
+
const effective = resolvedMembers.filter((member) => Object.keys(member).length > 0)
|
|
309
|
+
if (effective.length === 0) return undefined
|
|
310
|
+
return effective.length === 1 ? effective[0] : { AND: effective }
|
|
311
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { list } from '../config/index.js'
|
|
3
|
+
import { text, relationship, integer } from '../fields/index.js'
|
|
4
|
+
import type { OpenSaasConfig } from '../config/types.js'
|
|
5
|
+
import type { AccessContext } from './types.js'
|
|
6
|
+
import {
|
|
7
|
+
resolveRelationshipLabelFilters,
|
|
8
|
+
isToOneRelationshipField,
|
|
9
|
+
} from './relationship-label-filter.js'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Access-scoped to-one relationship label filters (issue #749). Verifies that
|
|
13
|
+
* `author:Ada` → `{ author: { is: { name: { contains: 'Ada' } } } }` gets the
|
|
14
|
+
* related list's `query` access folded into the nested `is` clause, so a
|
|
15
|
+
* session can never distinguish parent rows by a related field it cannot
|
|
16
|
+
* itself read.
|
|
17
|
+
*/
|
|
18
|
+
|
|
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
|
+
function makeConfig(): OpenSaasConfig {
|
|
22
|
+
return {
|
|
23
|
+
db: { provider: 'sqlite', prismaClientConstructor: () => null as never },
|
|
24
|
+
lists: {
|
|
25
|
+
User: list({
|
|
26
|
+
fields: { name: text(), active: integer() },
|
|
27
|
+
access: { operation: { query: () => ({ active: { equals: 1 } }) } },
|
|
28
|
+
}),
|
|
29
|
+
Post: list({
|
|
30
|
+
fields: {
|
|
31
|
+
title: text(),
|
|
32
|
+
views: integer(),
|
|
33
|
+
author: relationship({ ref: 'User.posts' }),
|
|
34
|
+
widget: relationship({ ref: 'Widget' }),
|
|
35
|
+
},
|
|
36
|
+
access: { operation: { query: () => true } },
|
|
37
|
+
}),
|
|
38
|
+
// Widget ships closed (no access block) → query denied by default.
|
|
39
|
+
Widget: list({ fields: { name: text() } }),
|
|
40
|
+
},
|
|
41
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
|
|
42
|
+
} as any
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function makeContext(): AccessContext {
|
|
46
|
+
return {
|
|
47
|
+
session: null,
|
|
48
|
+
_isSudo: false,
|
|
49
|
+
_resolveOutputCounter: { depth: 0 },
|
|
50
|
+
db: {},
|
|
51
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal context for unit test
|
|
52
|
+
} as any
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe('isToOneRelationshipField', () => {
|
|
56
|
+
const config = makeConfig()
|
|
57
|
+
const postFields = config.lists.Post.fields
|
|
58
|
+
|
|
59
|
+
it('is true only for a to-one relationship', () => {
|
|
60
|
+
expect(isToOneRelationshipField(postFields.author)).toBe(true)
|
|
61
|
+
expect(isToOneRelationshipField(postFields.widget)).toBe(true)
|
|
62
|
+
expect(isToOneRelationshipField(postFields.title)).toBe(false)
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe('resolveRelationshipLabelFilters', () => {
|
|
67
|
+
it('returns the where unchanged when there are no label-filter members', async () => {
|
|
68
|
+
const config = makeConfig()
|
|
69
|
+
const where = { title: { contains: 'hello' } }
|
|
70
|
+
const resolved = await resolveRelationshipLabelFilters(
|
|
71
|
+
where,
|
|
72
|
+
config.lists.Post,
|
|
73
|
+
{ session: null, context: makeContext() },
|
|
74
|
+
config,
|
|
75
|
+
)
|
|
76
|
+
expect(resolved).toBe(where)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it("ANDs the related list's access filter into the nested `is` clause", async () => {
|
|
80
|
+
const config = makeConfig()
|
|
81
|
+
const where = { author: { is: { name: { contains: 'Ada' } } } }
|
|
82
|
+
const resolved = await resolveRelationshipLabelFilters(
|
|
83
|
+
where,
|
|
84
|
+
config.lists.Post,
|
|
85
|
+
{ session: null, context: makeContext() },
|
|
86
|
+
config,
|
|
87
|
+
)
|
|
88
|
+
expect(resolved).toEqual({
|
|
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)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('resolves a fully denied related list to a never-matching member (no leak)', async () => {
|
|
113
|
+
const config = makeConfig()
|
|
114
|
+
const where = { widget: { is: { name: { contains: 'Gadget' } } } }
|
|
115
|
+
const resolved = await resolveRelationshipLabelFilters(
|
|
116
|
+
where,
|
|
117
|
+
config.lists.Post,
|
|
118
|
+
{ session: null, context: makeContext() },
|
|
119
|
+
config,
|
|
120
|
+
)
|
|
121
|
+
expect(resolved).toEqual({ id: { in: [] } })
|
|
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
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('preserves sibling conditions alongside the access-scoped nested `is` clause', async () => {
|
|
141
|
+
const config = makeConfig()
|
|
142
|
+
const where = {
|
|
143
|
+
AND: [{ title: { contains: 'hello' } }, { author: { is: { name: { contains: 'Ada' } } } }],
|
|
144
|
+
}
|
|
145
|
+
const resolved = await resolveRelationshipLabelFilters(
|
|
146
|
+
where,
|
|
147
|
+
config.lists.Post,
|
|
148
|
+
{ session: null, context: makeContext() },
|
|
149
|
+
config,
|
|
150
|
+
)
|
|
151
|
+
expect(resolved).toEqual({
|
|
152
|
+
AND: [
|
|
153
|
+
{ title: { contains: 'hello' } },
|
|
154
|
+
{ author: { is: { AND: [{ active: { equals: 1 } }, { name: { contains: 'Ada' } }] } } },
|
|
155
|
+
],
|
|
156
|
+
})
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('composes with a to-many count marker member left untouched by this resolver', async () => {
|
|
160
|
+
const config = makeConfig()
|
|
161
|
+
const where = {
|
|
162
|
+
AND: [{ author: { is: { name: { contains: 'Ada' } } } }, { views: { gt: 10 } }],
|
|
163
|
+
}
|
|
164
|
+
const resolved = await resolveRelationshipLabelFilters(
|
|
165
|
+
where,
|
|
166
|
+
config.lists.Post,
|
|
167
|
+
{ session: null, context: makeContext() },
|
|
168
|
+
config,
|
|
169
|
+
)
|
|
170
|
+
expect(resolved).toEqual({
|
|
171
|
+
AND: [
|
|
172
|
+
{ author: { is: { AND: [{ active: { equals: 1 } }, { name: { contains: 'Ada' } }] } } },
|
|
173
|
+
{ views: { gt: 10 } },
|
|
174
|
+
],
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
})
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import type { Session, AccessContext } from './types.js'
|
|
2
|
+
import type { OpenSaasConfig, ListConfig, FieldConfig } from '../config/types.js'
|
|
3
|
+
import { checkAccess, getRelatedListConfig } from './engine.js'
|
|
4
|
+
import { mergeResolvedMember } from './relationship-count.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Access-scoped to-one relationship label filters for the admin list view
|
|
8
|
+
* (issue #749).
|
|
9
|
+
*
|
|
10
|
+
* A to-one relationship's Filter spec (`author:Ada` → `{ author: { is: { name:
|
|
11
|
+
* { contains: 'Ada' } } } }`) is a pure mapper (see `relationship()` in
|
|
12
|
+
* `fields/index.ts`) — it has no way to consult the related list's access
|
|
13
|
+
* control, so the nested `is` clause it emits runs as an unscoped Prisma
|
|
14
|
+
* sub-filter against the related table. The parent list's own access filter
|
|
15
|
+
* still scopes which parent rows are visible, but the nested condition itself
|
|
16
|
+
* is evaluated with no reference to the related list's `query` access — a
|
|
17
|
+
* session could distinguish parent rows by a related field it is not itself
|
|
18
|
+
* allowed to read (e.g. binary-searching `author:A`, `author:Ad`, `author:Ada`
|
|
19
|
+
* against a `User` list it cannot query).
|
|
20
|
+
*
|
|
21
|
+
* This module is the single place the related list's operation-level `query`
|
|
22
|
+
* access is folded into that nested `is` clause, mirroring how
|
|
23
|
+
* `relationship-count.ts` folds it into `_count` selects and count-filter
|
|
24
|
+
* markers. A fully denied related list makes the member never match — the
|
|
25
|
+
* token cannot be used to confirm or rule out a value on a field the session
|
|
26
|
+
* cannot read (narrowing-only, matching the count resolver's denied path).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
type LabelFilterArgs = {
|
|
30
|
+
session: Session | null
|
|
31
|
+
context: AccessContext
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Whether a field is a to-one relationship — the only field kind whose Filter
|
|
36
|
+
* spec emits a nested `{ is: {...} } }` condition against the related list's
|
|
37
|
+
* label field.
|
|
38
|
+
*/
|
|
39
|
+
export function isToOneRelationshipField(field: FieldConfig | undefined): boolean {
|
|
40
|
+
return (
|
|
41
|
+
field?.type === 'relationship' &&
|
|
42
|
+
!('many' in field && field.many === true) &&
|
|
43
|
+
'ref' in field &&
|
|
44
|
+
typeof field.ref === 'string' &&
|
|
45
|
+
field.ref.length > 0
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Narrow a filter member's field value to a `{ is: {...} } }` clause, if it is one. */
|
|
50
|
+
function readIsClause(value: unknown): Record<string, unknown> | null {
|
|
51
|
+
if (!value || typeof value !== 'object') return null
|
|
52
|
+
const isValue = (value as Record<string, unknown>).is
|
|
53
|
+
if (!isValue || typeof isValue !== 'object') return null
|
|
54
|
+
return isValue as Record<string, unknown>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Resolve one to-one relationship label-filter member into an access-scoped
|
|
59
|
+
* condition by ANDing the related list's `query` access filter into the nested
|
|
60
|
+
* `is` clause. Returns `{ id: { in: [] } }` (never matches) when the related
|
|
61
|
+
* list is fully denied, the original member unchanged when it is fully
|
|
62
|
+
* readable, and the `is` clause ANDed with the access filter otherwise.
|
|
63
|
+
*/
|
|
64
|
+
async function resolveOneLabelFilter(
|
|
65
|
+
field: FieldConfig,
|
|
66
|
+
fieldName: string,
|
|
67
|
+
isClause: Record<string, unknown>,
|
|
68
|
+
args: LabelFilterArgs,
|
|
69
|
+
config: OpenSaasConfig,
|
|
70
|
+
): Promise<Record<string, unknown>> {
|
|
71
|
+
if (!('ref' in field) || typeof field.ref !== 'string') {
|
|
72
|
+
return { [fieldName]: { is: isClause } }
|
|
73
|
+
}
|
|
74
|
+
const related = getRelatedListConfig(field.ref, config)
|
|
75
|
+
if (!related) return { [fieldName]: { is: isClause } }
|
|
76
|
+
|
|
77
|
+
const queryAccess = related.listConfig.access?.operation?.query
|
|
78
|
+
const result = await checkAccess(queryAccess, { session: args.session, context: args.context })
|
|
79
|
+
|
|
80
|
+
if (result === false) return { id: { in: [] } }
|
|
81
|
+
if (result === true) return { [fieldName]: { is: isClause } }
|
|
82
|
+
return { [fieldName]: { is: { AND: [result, isClause] } } }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Replace any to-one relationship label-filter members in a filter `where`
|
|
87
|
+
* with access-scoped equivalents. Label-filter members only ever appear as
|
|
88
|
+
* top-level AND members (the relationship Filter spec does not declare
|
|
89
|
+
* `freeText`, so it never participates in the free-text OR), so this walks
|
|
90
|
+
* only the top level. Returns the `where` unchanged when it contains no such
|
|
91
|
+
* members, so lists without to-one relationship filters pay nothing.
|
|
92
|
+
*/
|
|
93
|
+
export async function resolveRelationshipLabelFilters(
|
|
94
|
+
where: Record<string, unknown> | undefined,
|
|
95
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
96
|
+
listConfig: ListConfig<any>,
|
|
97
|
+
args: LabelFilterArgs,
|
|
98
|
+
config: OpenSaasConfig,
|
|
99
|
+
): Promise<Record<string, unknown> | undefined> {
|
|
100
|
+
if (!where) return where
|
|
101
|
+
|
|
102
|
+
const andValue = where.AND
|
|
103
|
+
const members: Array<Record<string, unknown>> = Array.isArray(andValue)
|
|
104
|
+
? (andValue as Array<Record<string, unknown>>)
|
|
105
|
+
: [where]
|
|
106
|
+
|
|
107
|
+
const findLabelFilter = (
|
|
108
|
+
member: Record<string, unknown>,
|
|
109
|
+
): { field: string; isClause: Record<string, unknown> } | null => {
|
|
110
|
+
for (const key of Object.keys(member)) {
|
|
111
|
+
const field = listConfig.fields[key]
|
|
112
|
+
if (!isToOneRelationshipField(field)) continue
|
|
113
|
+
const isClause = readIsClause(member[key])
|
|
114
|
+
if (!isClause) continue
|
|
115
|
+
return { field: key, isClause }
|
|
116
|
+
}
|
|
117
|
+
return null
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!members.some((member) => findLabelFilter(member) !== null)) {
|
|
121
|
+
return where
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const resolvedMembers: Array<Record<string, unknown>> = []
|
|
125
|
+
for (const member of members) {
|
|
126
|
+
const found = findLabelFilter(member)
|
|
127
|
+
if (!found) {
|
|
128
|
+
resolvedMembers.push(member)
|
|
129
|
+
continue
|
|
130
|
+
}
|
|
131
|
+
const field = listConfig.fields[found.field]
|
|
132
|
+
const resolved = await resolveOneLabelFilter(field, found.field, found.isClause, args, config)
|
|
133
|
+
|
|
134
|
+
const siblings: Record<string, unknown> = { ...member }
|
|
135
|
+
delete siblings[found.field]
|
|
136
|
+
resolvedMembers.push(mergeResolvedMember(siblings, resolved))
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return resolvedMembers.length === 1 ? resolvedMembers[0] : { AND: resolvedMembers }
|
|
140
|
+
}
|
package/src/config/index.ts
CHANGED
|
@@ -150,6 +150,12 @@ export type {
|
|
|
150
150
|
UIConfig,
|
|
151
151
|
ListUIConfig,
|
|
152
152
|
ListViewUIConfig,
|
|
153
|
+
BulkAction,
|
|
154
|
+
BulkActionContext,
|
|
155
|
+
BulkActionResult,
|
|
156
|
+
BulkActionVariant,
|
|
157
|
+
ItemViewUIConfig,
|
|
158
|
+
RelationshipItemViewConfig,
|
|
153
159
|
ThemeConfig,
|
|
154
160
|
ThemePreset,
|
|
155
161
|
ThemeColors,
|