@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.
Files changed (133) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +443 -0
  3. package/CLAUDE.md +4 -4
  4. package/dist/access/access-filter.d.ts.map +1 -1
  5. package/dist/access/access-filter.js +14 -1
  6. package/dist/access/access-filter.js.map +1 -1
  7. package/dist/access/access-filter.test.js +50 -0
  8. package/dist/access/access-filter.test.js.map +1 -1
  9. package/dist/access/field-access.js +6 -6
  10. package/dist/access/field-access.js.map +1 -1
  11. package/dist/access/multi-column-read-write.test.js +46 -12
  12. package/dist/access/multi-column-read-write.test.js.map +1 -1
  13. package/dist/access/relationship-count.d.ts +60 -0
  14. package/dist/access/relationship-count.d.ts.map +1 -0
  15. package/dist/access/relationship-count.js +233 -0
  16. package/dist/access/relationship-count.js.map +1 -0
  17. package/dist/access/relationship-count.test.d.ts +2 -0
  18. package/dist/access/relationship-count.test.d.ts.map +1 -0
  19. package/dist/access/relationship-count.test.js +185 -0
  20. package/dist/access/relationship-count.test.js.map +1 -0
  21. package/dist/access/relationship-label-filter.d.ts +45 -0
  22. package/dist/access/relationship-label-filter.d.ts.map +1 -0
  23. package/dist/access/relationship-label-filter.js +93 -0
  24. package/dist/access/relationship-label-filter.js.map +1 -0
  25. package/dist/access/relationship-label-filter.test.d.ts +2 -0
  26. package/dist/access/relationship-label-filter.test.d.ts.map +1 -0
  27. package/dist/access/relationship-label-filter.test.js +126 -0
  28. package/dist/access/relationship-label-filter.test.js.map +1 -0
  29. package/dist/config/index.d.ts +1 -1
  30. package/dist/config/index.d.ts.map +1 -1
  31. package/dist/config/nav-count.d.ts +34 -0
  32. package/dist/config/nav-count.d.ts.map +1 -0
  33. package/dist/config/nav-count.js +71 -0
  34. package/dist/config/nav-count.js.map +1 -0
  35. package/dist/config/types.d.ts +305 -4
  36. package/dist/config/types.d.ts.map +1 -1
  37. package/dist/context/hook-pipeline.d.ts.map +1 -1
  38. package/dist/context/hook-pipeline.js +11 -1
  39. package/dist/context/hook-pipeline.js.map +1 -1
  40. package/dist/context/index.d.ts +28 -0
  41. package/dist/context/index.d.ts.map +1 -1
  42. package/dist/context/index.js +206 -1
  43. package/dist/context/index.js.map +1 -1
  44. package/dist/context/nested-operations.d.ts.map +1 -1
  45. package/dist/context/nested-operations.js +7 -1
  46. package/dist/context/nested-operations.js.map +1 -1
  47. package/dist/extend.d.ts +1 -0
  48. package/dist/extend.d.ts.map +1 -1
  49. package/dist/fields/index.d.ts +1 -1
  50. package/dist/fields/index.d.ts.map +1 -1
  51. package/dist/fields/index.js +166 -0
  52. package/dist/fields/index.js.map +1 -1
  53. package/dist/filter/collect.d.ts +32 -0
  54. package/dist/filter/collect.d.ts.map +1 -0
  55. package/dist/filter/collect.js +60 -0
  56. package/dist/filter/collect.js.map +1 -0
  57. package/dist/filter/filter.test.d.ts +2 -0
  58. package/dist/filter/filter.test.d.ts.map +1 -0
  59. package/dist/filter/filter.test.js +348 -0
  60. package/dist/filter/filter.test.js.map +1 -0
  61. package/dist/filter/index.d.ts +7 -0
  62. package/dist/filter/index.d.ts.map +1 -0
  63. package/dist/filter/index.js +16 -0
  64. package/dist/filter/index.js.map +1 -0
  65. package/dist/filter/map.d.ts +18 -0
  66. package/dist/filter/map.d.ts.map +1 -0
  67. package/dist/filter/map.js +60 -0
  68. package/dist/filter/map.js.map +1 -0
  69. package/dist/filter/parse.d.ts +33 -0
  70. package/dist/filter/parse.d.ts.map +1 -0
  71. package/dist/filter/parse.js +103 -0
  72. package/dist/filter/parse.js.map +1 -0
  73. package/dist/filter/serialize.d.ts +25 -0
  74. package/dist/filter/serialize.d.ts.map +1 -0
  75. package/dist/filter/serialize.js +68 -0
  76. package/dist/filter/serialize.js.map +1 -0
  77. package/dist/filter/serialize.test.d.ts +2 -0
  78. package/dist/filter/serialize.test.d.ts.map +1 -0
  79. package/dist/filter/serialize.test.js +83 -0
  80. package/dist/filter/serialize.test.js.map +1 -0
  81. package/dist/filter/types.d.ts +122 -0
  82. package/dist/filter/types.d.ts.map +1 -0
  83. package/dist/filter/types.js +24 -0
  84. package/dist/filter/types.js.map +1 -0
  85. package/dist/hooks/index.d.ts +30 -0
  86. package/dist/hooks/index.d.ts.map +1 -1
  87. package/dist/hooks/index.js +79 -59
  88. package/dist/hooks/index.js.map +1 -1
  89. package/dist/index.d.ts +6 -1
  90. package/dist/index.d.ts.map +1 -1
  91. package/dist/index.js +19 -0
  92. package/dist/index.js.map +1 -1
  93. package/dist/internal.d.ts +1 -0
  94. package/dist/internal.d.ts.map +1 -1
  95. package/dist/internal.js +4 -0
  96. package/dist/internal.js.map +1 -1
  97. package/dist/mcp/handler.d.ts +11 -3
  98. package/dist/mcp/handler.d.ts.map +1 -1
  99. package/dist/mcp/handler.js +96 -20
  100. package/dist/mcp/handler.js.map +1 -1
  101. package/package.json +1 -1
  102. package/src/access/access-filter.test.ts +79 -0
  103. package/src/access/access-filter.ts +15 -4
  104. package/src/access/field-access.ts +6 -6
  105. package/src/access/multi-column-read-write.test.ts +68 -19
  106. package/src/access/relationship-count.test.ts +269 -0
  107. package/src/access/relationship-count.ts +311 -0
  108. package/src/access/relationship-label-filter.test.ts +177 -0
  109. package/src/access/relationship-label-filter.ts +140 -0
  110. package/src/config/index.ts +6 -0
  111. package/src/config/nav-count.ts +85 -0
  112. package/src/config/types.ts +319 -1
  113. package/src/context/hook-pipeline.ts +19 -0
  114. package/src/context/index.ts +286 -4
  115. package/src/context/nested-operations.ts +22 -0
  116. package/src/extend.ts +9 -0
  117. package/src/fields/index.ts +168 -0
  118. package/src/filter/collect.ts +72 -0
  119. package/src/filter/filter.test.ts +400 -0
  120. package/src/filter/index.ts +25 -0
  121. package/src/filter/map.ts +68 -0
  122. package/src/filter/parse.ts +106 -0
  123. package/src/filter/serialize.test.ts +115 -0
  124. package/src/filter/serialize.ts +75 -0
  125. package/src/filter/types.ts +120 -0
  126. package/src/hooks/index.ts +87 -57
  127. package/src/index.ts +54 -0
  128. package/src/internal.ts +5 -0
  129. package/src/mcp/handler.ts +118 -28
  130. package/tests/context.test.ts +690 -0
  131. package/tests/multi-column-validation-ordering.test.ts +305 -0
  132. package/tests/nav-count.test.ts +189 -0
  133. 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
+ }
@@ -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,