@opensaas/stack-core 0.40.0 → 0.41.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 (86) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +39 -0
  3. package/CLAUDE.md +52 -0
  4. package/dist/access/access-filter.d.ts +88 -18
  5. package/dist/access/access-filter.d.ts.map +1 -1
  6. package/dist/access/access-filter.js +343 -36
  7. package/dist/access/access-filter.js.map +1 -1
  8. package/dist/access/access-filter.test.js +759 -6
  9. package/dist/access/access-filter.test.js.map +1 -1
  10. package/dist/access/denied-relation-visibility.test.d.ts +2 -0
  11. package/dist/access/denied-relation-visibility.test.d.ts.map +1 -0
  12. package/dist/access/denied-relation-visibility.test.js +114 -0
  13. package/dist/access/denied-relation-visibility.test.js.map +1 -0
  14. package/dist/access/engine.d.ts +11 -0
  15. package/dist/access/engine.d.ts.map +1 -1
  16. package/dist/access/engine.js +26 -0
  17. package/dist/access/engine.js.map +1 -1
  18. package/dist/access/errors.d.ts +34 -0
  19. package/dist/access/errors.d.ts.map +1 -1
  20. package/dist/access/errors.js +47 -0
  21. package/dist/access/errors.js.map +1 -1
  22. package/dist/access/field-visibility.d.ts +2 -2
  23. package/dist/access/field-visibility.d.ts.map +1 -1
  24. package/dist/access/field-visibility.js +73 -23
  25. package/dist/access/field-visibility.js.map +1 -1
  26. package/dist/access/index.d.ts +7 -3
  27. package/dist/access/index.d.ts.map +1 -1
  28. package/dist/access/index.js +9 -2
  29. package/dist/access/index.js.map +1 -1
  30. package/dist/access/multi-column-read-write.test.js.map +1 -1
  31. package/dist/access/query-validation.d.ts +31 -0
  32. package/dist/access/query-validation.d.ts.map +1 -1
  33. package/dist/access/query-validation.js +75 -16
  34. package/dist/access/query-validation.js.map +1 -1
  35. package/dist/access/relationship-count.d.ts +41 -3
  36. package/dist/access/relationship-count.d.ts.map +1 -1
  37. package/dist/access/relationship-count.js +44 -7
  38. package/dist/access/relationship-count.js.map +1 -1
  39. package/dist/access/relationship-count.test.js +62 -0
  40. package/dist/access/relationship-count.test.js.map +1 -1
  41. package/dist/access/synthetic-include-read.test.d.ts +2 -0
  42. package/dist/access/synthetic-include-read.test.d.ts.map +1 -0
  43. package/dist/access/synthetic-include-read.test.js +121 -0
  44. package/dist/access/synthetic-include-read.test.js.map +1 -0
  45. package/dist/config/types.d.ts +22 -22
  46. package/dist/config/types.d.ts.map +1 -1
  47. package/dist/context/hook-pipeline.d.ts +2 -2
  48. package/dist/context/hook-pipeline.d.ts.map +1 -1
  49. package/dist/context/hook-pipeline.js.map +1 -1
  50. package/dist/context/index.d.ts +16 -1
  51. package/dist/context/index.d.ts.map +1 -1
  52. package/dist/context/index.js +76 -26
  53. package/dist/context/index.js.map +1 -1
  54. package/dist/context/nested-operations.d.ts +2 -2
  55. package/dist/context/nested-operations.d.ts.map +1 -1
  56. package/dist/context/nested-operations.js +21 -3
  57. package/dist/context/nested-operations.js.map +1 -1
  58. package/dist/context/write-pipeline.d.ts.map +1 -1
  59. package/dist/context/write-pipeline.js +23 -29
  60. package/dist/context/write-pipeline.js.map +1 -1
  61. package/dist/hooks/index.d.ts +16 -15
  62. package/dist/hooks/index.d.ts.map +1 -1
  63. package/dist/hooks/index.js.map +1 -1
  64. package/package.json +2 -2
  65. package/src/access/access-filter.test.ts +1403 -159
  66. package/src/access/access-filter.ts +525 -47
  67. package/src/access/denied-relation-visibility.test.ts +173 -0
  68. package/src/access/engine.ts +28 -0
  69. package/src/access/errors.ts +55 -0
  70. package/src/access/field-visibility.ts +105 -24
  71. package/src/access/index.ts +11 -0
  72. package/src/access/multi-column-read-write.test.ts +3 -2
  73. package/src/access/query-validation.ts +136 -9
  74. package/src/access/relationship-count.test.ts +75 -0
  75. package/src/access/relationship-count.ts +53 -10
  76. package/src/access/synthetic-include-read.test.ts +179 -0
  77. package/src/config/types.ts +22 -22
  78. package/src/context/hook-pipeline.ts +4 -2
  79. package/src/context/index.ts +120 -39
  80. package/src/context/nested-operations.ts +35 -11
  81. package/src/context/write-pipeline.ts +52 -32
  82. package/src/hooks/index.ts +22 -15
  83. package/tests/context.test.ts +193 -5
  84. package/tests/hook-context-secured.test.ts +424 -0
  85. package/tests/nested-access-and-hooks.test.ts +324 -0
  86. package/tsconfig.tsbuildinfo +1 -1
@@ -46,6 +46,20 @@ import { ValidationError } from '../hooks/index.js'
46
46
  * deliberately no second copy of either the shape-recognition or the
47
47
  * field-read check: `access-filter.ts` supplies the RELATED list at each
48
48
  * hop and calls back into the same primitives this module already owns.
49
+ *
50
+ * #1092 — `validateQueryKeys` is also the include-nested counterpart: a
51
+ * `where`/`orderBy` a caller nests inside an `include` entry gets the same
52
+ * #912 check, called by `buildAccessScopedInclude` against the RELATED list
53
+ * instead of the current one — no second key-existence walker for that
54
+ * position. `validateQueryFieldReadAccess` needs no equivalent call there:
55
+ * `checkKeyReadableOrThrow` already treats a key `resolveQueryField` cannot
56
+ * resolve as "already handled elsewhere" and skips it, which is exactly the
57
+ * synthetic-back-relation tolerance below needs — nothing to change. The one
58
+ * thing this position needs that the top-level `where`/`orderBy` never did:
59
+ * a key neither call resolves is tried against the optional
60
+ * `resolveSyntheticRelation` hook before being rejected, because a synthetic
61
+ * back-relation (#1082) is nameable in a nested predicate too. Every
62
+ * existing caller omits the hook, so top-level behavior is unchanged.
49
63
  */
50
64
 
51
65
  // Prisma's logical combinators for a WHERE clause — never field names.
@@ -68,6 +82,23 @@ export interface ResolvedQueryField {
68
82
  isRelationship: boolean
69
83
  }
70
84
 
85
+ /**
86
+ * Where an unresolved key's nested predicate should recurse when it turns out
87
+ * to name a synthetic back-relation (#1082) rather than a declared field —
88
+ * the source list the back-relation stands for, NOT `getRelatedListConfig`'s
89
+ * target (a synthetic field carries no `ref` of its own to follow).
90
+ */
91
+ export interface SyntheticRelationTarget {
92
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
93
+ listConfig: ListConfig<any>
94
+ listName: string
95
+ }
96
+
97
+ export type ResolveSyntheticRelation = (
98
+ key: string,
99
+ listName: string,
100
+ ) => SyntheticRelationTarget | null
101
+
71
102
  /**
72
103
  * Resolve a `where`/`orderBy` key against a list's declared fields.
73
104
  *
@@ -133,22 +164,61 @@ function walkWhere(
133
164
  listName: string,
134
165
  config: OpenSaasConfig,
135
166
  isSudo: boolean,
167
+ resolveSyntheticRelation?: ResolveSyntheticRelation,
136
168
  ): void {
137
169
  if (where === null || typeof where !== 'object') return
138
170
 
139
171
  if (Array.isArray(where)) {
140
- for (const entry of where) walkWhere(entry, listConfig, listName, config, isSudo)
172
+ for (const entry of where) {
173
+ walkWhere(entry, listConfig, listName, config, isSudo, resolveSyntheticRelation)
174
+ }
141
175
  return
142
176
  }
143
177
 
144
178
  for (const [key, value] of Object.entries(where as Record<string, unknown>)) {
145
179
  if (LOGICAL_OPERATORS.has(key)) {
146
- walkWhere(value, listConfig, listName, config, isSudo)
180
+ walkWhere(value, listConfig, listName, config, isSudo, resolveSyntheticRelation)
147
181
  continue
148
182
  }
149
183
 
150
184
  const resolved = resolveQueryField(key, listConfig.fields)
151
185
  if (!resolved) {
186
+ const synthetic = resolveSyntheticRelation?.(key, listName)
187
+ if (synthetic) {
188
+ // A synthetic back-relation is always to-many (#1082's own
189
+ // construction site has no arity branch), so its nested value takes
190
+ // the same two shapes a declared to-many relationship's filter does
191
+ // — walk it exactly like the resolved-relationship branch below,
192
+ // against the synthetic's source list instead of `getRelatedListConfig`.
193
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
194
+ const syntheticEntries = Object.entries(value as Record<string, unknown>)
195
+ const syntheticHasQuantifier = syntheticEntries.some(([k]) => RELATION_QUANTIFIERS.has(k))
196
+ if (syntheticHasQuantifier) {
197
+ for (const [quantifier, quantifierValue] of syntheticEntries) {
198
+ if (RELATION_QUANTIFIERS.has(quantifier)) {
199
+ walkWhere(
200
+ quantifierValue,
201
+ synthetic.listConfig,
202
+ synthetic.listName,
203
+ config,
204
+ isSudo,
205
+ resolveSyntheticRelation,
206
+ )
207
+ }
208
+ }
209
+ } else {
210
+ walkWhere(
211
+ value,
212
+ synthetic.listConfig,
213
+ synthetic.listName,
214
+ config,
215
+ isSudo,
216
+ resolveSyntheticRelation,
217
+ )
218
+ }
219
+ }
220
+ continue
221
+ }
152
222
  if (isSudo) continue
153
223
  rejectUndeclaredKey(listName, key, 'where')
154
224
  }
@@ -175,7 +245,14 @@ function walkWhere(
175
245
  // related list.
176
246
  for (const [quantifier, quantifierValue] of relationEntries) {
177
247
  if (RELATION_QUANTIFIERS.has(quantifier)) {
178
- walkWhere(quantifierValue, related.listConfig, related.listName, config, isSudo)
248
+ walkWhere(
249
+ quantifierValue,
250
+ related.listConfig,
251
+ related.listName,
252
+ config,
253
+ isSudo,
254
+ resolveSyntheticRelation,
255
+ )
179
256
  }
180
257
  }
181
258
  } else {
@@ -185,7 +262,14 @@ function walkWhere(
185
262
  // whole value object IS the nested WHERE clause here — walk it
186
263
  // directly, or an undeclared key reached exactly this way (one hop
187
264
  // through a to-one relation) would pass through unchecked.
188
- walkWhere(value, related.listConfig, related.listName, config, isSudo)
265
+ walkWhere(
266
+ value,
267
+ related.listConfig,
268
+ related.listName,
269
+ config,
270
+ isSudo,
271
+ resolveSyntheticRelation,
272
+ )
189
273
  }
190
274
  }
191
275
  }
@@ -198,17 +282,41 @@ function walkOrderBy(
198
282
  listName: string,
199
283
  config: OpenSaasConfig,
200
284
  isSudo: boolean,
285
+ resolveSyntheticRelation?: ResolveSyntheticRelation,
201
286
  ): void {
202
287
  if (orderBy === null || typeof orderBy !== 'object') return
203
288
 
204
289
  if (Array.isArray(orderBy)) {
205
- for (const entry of orderBy) walkOrderBy(entry, listConfig, listName, config, isSudo)
290
+ for (const entry of orderBy) {
291
+ walkOrderBy(entry, listConfig, listName, config, isSudo, resolveSyntheticRelation)
292
+ }
206
293
  return
207
294
  }
208
295
 
209
296
  for (const [key, value] of Object.entries(orderBy as Record<string, unknown>)) {
210
297
  const resolved = resolveQueryField(key, listConfig.fields)
211
298
  if (!resolved) {
299
+ const synthetic = resolveSyntheticRelation?.(key, listName)
300
+ if (synthetic) {
301
+ // `{ relation: { _count: 'asc' } }` orders by an aggregate — no
302
+ // nested field name to resolve, matching the declared-relationship
303
+ // branch below.
304
+ if (
305
+ value !== null &&
306
+ typeof value === 'object' &&
307
+ !('_count' in (value as Record<string, unknown>))
308
+ ) {
309
+ walkOrderBy(
310
+ value,
311
+ synthetic.listConfig,
312
+ synthetic.listName,
313
+ config,
314
+ isSudo,
315
+ resolveSyntheticRelation,
316
+ )
317
+ }
318
+ continue
319
+ }
212
320
  if (isSudo) continue
213
321
  rejectUndeclaredKey(listName, key, 'orderBy')
214
322
  }
@@ -220,7 +328,16 @@ function walkOrderBy(
220
328
  if ('_count' in (value as Record<string, unknown>)) continue
221
329
 
222
330
  const related = getRelatedListConfig(resolved.fieldConfig.ref, config)
223
- if (related) walkOrderBy(value, related.listConfig, related.listName, config, isSudo)
331
+ if (related) {
332
+ walkOrderBy(
333
+ value,
334
+ related.listConfig,
335
+ related.listName,
336
+ config,
337
+ isSudo,
338
+ resolveSyntheticRelation,
339
+ )
340
+ }
224
341
  }
225
342
  }
226
343
  }
@@ -231,6 +348,11 @@ function walkOrderBy(
231
348
  * Throws a `ValidationError` naming the list and the offending key on the
232
349
  * first undeclared key found. `isSudo` bypasses the check entirely, matching
233
350
  * the write path's `sudo` escape hatch.
351
+ *
352
+ * `resolveSyntheticRelation` is the include-nested position's own addition
353
+ * (#1092, see module doc comment) — omit it (every top-level `where`/`orderBy`
354
+ * caller does) and a key `resolveQueryField` can't resolve rejects exactly as
355
+ * before.
234
356
  */
235
357
  export function validateQueryKeys(args: {
236
358
  where?: unknown
@@ -240,10 +362,15 @@ export function validateQueryKeys(args: {
240
362
  listName: string
241
363
  config: OpenSaasConfig
242
364
  isSudo: boolean
365
+ resolveSyntheticRelation?: ResolveSyntheticRelation
243
366
  }): 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)
367
+ const { where, orderBy, listConfig, listName, config, isSudo, resolveSyntheticRelation } = args
368
+ if (where !== undefined) {
369
+ walkWhere(where, listConfig, listName, config, isSudo, resolveSyntheticRelation)
370
+ }
371
+ if (orderBy !== undefined) {
372
+ walkOrderBy(orderBy, listConfig, listName, config, isSudo, resolveSyntheticRelation)
373
+ }
247
374
  }
248
375
 
249
376
  /**
@@ -111,6 +111,41 @@ describe('buildRelationshipCountSelect', () => {
111
111
  )
112
112
  expect(select).toBeUndefined()
113
113
  })
114
+
115
+ // Issue #1111: a relationship hidden via field-level `read` access must not
116
+ // leak its true count, even when the related list's rows are otherwise
117
+ // fully readable — the same denial an ordinary `include` of that field
118
+ // already gets from `filterReadableFields`'s `checkFieldAccess` call.
119
+ it('omits a relation whose field-level `read` access denies it, even though the related list is fully readable', async () => {
120
+ const config: OpenSaasConfig = {
121
+ db: { provider: 'sqlite', prismaClientConstructor: () => null as never },
122
+ lists: {
123
+ User: list({
124
+ fields: {
125
+ name: text(),
126
+ posts: relationship({
127
+ ref: 'Post.author',
128
+ many: true,
129
+ access: { read: () => false },
130
+ }),
131
+ },
132
+ access: { operation: { query: () => true } },
133
+ }),
134
+ Post: list({
135
+ fields: { title: text(), author: relationship({ ref: 'User.posts' }) },
136
+ access: { operation: { query: () => true } },
137
+ }),
138
+ },
139
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
140
+ } as any
141
+
142
+ const select = await buildRelationshipCountSelect(
143
+ config.lists.User,
144
+ { session: null, context: makeContext() },
145
+ config,
146
+ )
147
+ expect(select).toBeUndefined()
148
+ })
114
149
  })
115
150
 
116
151
  describe('resolveRelationshipCountFilters', () => {
@@ -186,6 +221,46 @@ describe('resolveRelationshipCountFilters', () => {
186
221
  expect(findMany).not.toHaveBeenCalled()
187
222
  })
188
223
 
224
+ // Issue #1111: a relationship denied at the FIELD level (as opposed to the
225
+ // related list's operation-level `query` access, covered above) must also
226
+ // resolve to "always 0, no query needed" — the related list here (Post) is
227
+ // otherwise fully readable, so only the field-level check on `posts` itself
228
+ // can be what's denying it.
229
+ it('resolves a field-level-denied relationship without any query: count is always 0', async () => {
230
+ const config: OpenSaasConfig = {
231
+ db: { provider: 'sqlite', prismaClientConstructor: () => null as never },
232
+ lists: {
233
+ User: list({
234
+ fields: {
235
+ name: text(),
236
+ posts: relationship({
237
+ ref: 'Post.author',
238
+ many: true,
239
+ access: { read: () => false },
240
+ }),
241
+ },
242
+ access: { operation: { query: () => true } },
243
+ }),
244
+ Post: list({
245
+ fields: { title: text(), author: relationship({ ref: 'User.posts' }) },
246
+ access: { operation: { query: () => true } },
247
+ }),
248
+ },
249
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
250
+ } as any
251
+ const findMany = vi.fn(async () => [])
252
+ const where = { posts: { [RELATIONSHIP_COUNT_FILTER_KEY]: { operator: 'gt', value: 0 } } }
253
+ const resolved = await resolveRelationshipCountFilters(
254
+ where,
255
+ config.lists.User,
256
+ 'User',
257
+ { session: null, context: makeContext(findMany) },
258
+ config,
259
+ )
260
+ expect(resolved).toEqual({ id: { in: [] } })
261
+ expect(findMany).not.toHaveBeenCalled()
262
+ })
263
+
189
264
  it('preserves sibling conditions, ANDing the resolved id constraint', async () => {
190
265
  const config = makeConfig()
191
266
  const findMany = vi.fn(async () => [{ id: 'u2', _count: { posts: 7 } }])
@@ -1,8 +1,9 @@
1
- import type { Session, AccessContext, PrismaFilter } from './types.js'
1
+ import type { Session, AccessContext, PrismaFilter, FieldAccess } from './types.js'
2
2
  import type { OpenSaasConfig, ListConfig, FieldConfig } from '../config/types.js'
3
3
  import type { FilterOperator, RelationshipCountFilterMarker } from '../filter/types.js'
4
4
  import { RELATIONSHIP_COUNT_FILTER_KEY } from '../filter/types.js'
5
5
  import { checkAccess, getRelatedListConfig } from './engine.js'
6
+ import { isFieldReadableForPredicate } from './field-access.js'
6
7
  import { getDbKey } from '../lib/case-utils.js'
7
8
 
8
9
  /**
@@ -14,8 +15,10 @@ import { getDbKey } from '../lib/case-utils.js'
14
15
  * `_count` (`_count: { select: { orders: { where: <access filter> } } }`), so no
15
16
  * per-row query is issued and the count can never include rows the session
16
17
  * 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
- * `buildAccessScopedInclude` folds it into relation includes.
18
+ * operation-level `query` access, AND the counting list's own field-level
19
+ * `read` access on the relationship field being counted (issue #1111), are
20
+ * folded into that `_count`, mirroring how `buildAccessScopedInclude` folds
21
+ * both into relation includes.
19
22
  *
20
23
  * It also resolves the count Filter spec's markers: Prisma cannot compare a
21
24
  * relation count in a `where`, so a to-many relationship's Filter spec emits a
@@ -46,11 +49,56 @@ export function isToManyRelationshipField(field: FieldConfig | undefined): boole
46
49
  }
47
50
 
48
51
  /** The per-relation entry the count `_count.select` uses for one relationship. */
49
- type CountAccessEntry =
52
+ export type CountAccessEntry =
50
53
  | { kind: 'all' } // related list fully readable → count every related row
51
54
  | { kind: 'scoped'; where: PrismaFilter } // count only rows matching the access filter
52
55
  | { kind: 'denied' } // related list not readable at all → count is always 0
53
56
 
57
+ /**
58
+ * Resolve one related list's operation-level `query` access, and — when the
59
+ * counting list has a field of its own for the relationship being counted —
60
+ * that field's own field-level `read` access, into the entry its `_count`
61
+ * select needs. The shared decision both `relationshipCountAccessEntry` below
62
+ * (field → related list, for this module's own admin-list-view and
63
+ * count-filter callers) and `access-filter.ts`'s caller-`_count` scoping
64
+ * (issue #1087, which already has the related list resolved — including a
65
+ * synthetic back-relation's, which has no field of its own on the counting
66
+ * list) build on.
67
+ *
68
+ * `fieldAccess` is the COUNTING list's own relationship field access — not
69
+ * the related list's — mirroring the check `filterReadableFields`
70
+ * (`field-visibility.ts`) runs before returning an ordinary `include` of that
71
+ * same field (issue #1111: a `_count` used to leak the true count of a
72
+ * relationship whose field-level `read` access denied the relationship
73
+ * itself, even when the related list's own rows were otherwise fully
74
+ * readable). Evaluated via `isFieldReadableForPredicate` — the pre-query
75
+ * evaluator #915 already built for the identical problem on `where`/`orderBy`
76
+ * — since there is no fetched row yet at `_count`-select-build time; a rule
77
+ * that depends on one denies here, the same as it would there. Checked BEFORE
78
+ * the related list's `query` access so a field-level denial short-circuits
79
+ * without needing the related list's access rule at all. Omitted entirely
80
+ * (`undefined`) for a synthetic back-relation, which has no field of its own
81
+ * on the counting list to evaluate.
82
+ */
83
+ export async function resolveCountAccessEntryForList(
84
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
85
+ relatedListConfig: ListConfig<any>,
86
+ args: CountArgs,
87
+ fieldAccess?: FieldAccess,
88
+ ): Promise<CountAccessEntry> {
89
+ if (fieldAccess) {
90
+ const canReadField = await isFieldReadableForPredicate(fieldAccess, args)
91
+ if (!canReadField) return { kind: 'denied' }
92
+ }
93
+
94
+ const queryAccess = relatedListConfig.access?.operation?.query
95
+ const result = await checkAccess(queryAccess, { session: args.session, context: args.context })
96
+
97
+ if (result === false) return { kind: 'denied' }
98
+ if (typeof result === 'object') return { kind: 'scoped', where: result }
99
+ return { kind: 'all' }
100
+ }
101
+
54
102
  /**
55
103
  * Resolve the related list's operation-level `query` access for one to-many
56
104
  * relationship into the entry its `_count` select needs. Returns `null` when the
@@ -69,12 +117,7 @@ async function relationshipCountAccessEntry(
69
117
  const related = getRelatedListConfig(ref, config)
70
118
  if (!related) return null
71
119
 
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' }
120
+ return resolveCountAccessEntryForList(related.listConfig, args, field.access)
78
121
  }
79
122
 
80
123
  /**
@@ -0,0 +1,179 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { filterReadableFields } from './field-visibility.js'
3
+ import type { OpenSaasConfig, FieldConfig } from '../config/types.js'
4
+ import type { AccessContext } from './types.js'
5
+
6
+ /**
7
+ * Regression coverage for issue #1082, Field Visibility half.
8
+ *
9
+ * `access-filter.test.ts` covers the pre-query scoping of a synthetic
10
+ * back-relation (`buildAccessScopedInclude`); these tests cover what happens
11
+ * to the rows it fetches. Before this fix, `filterReadableFields` recognised
12
+ * only a DECLARED relationship field (`fieldConfig?.type === 'relationship'`)
13
+ * as something to recurse into — a synthetic key has no declared field on the
14
+ * list it's fetched through, so it fell to the generic non-relationship
15
+ * branch and was copied into the result verbatim: no field-level `read` gate
16
+ * on the related rows, and no virtual field computed on them.
17
+ */
18
+
19
+ // A relationship field pointing at another list.
20
+ function rel(ref: string, many = false): FieldConfig {
21
+ return { type: 'relationship', ref, many } as unknown as FieldConfig
22
+ }
23
+
24
+ // A virtual field computed via resolveOutput.
25
+ function virtualField(
26
+ resolveOutput: (args: { item: Record<string, unknown> }) => unknown,
27
+ ): FieldConfig {
28
+ return {
29
+ type: 'virtual',
30
+ virtual: true,
31
+ hooks: { resolveOutput },
32
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal field config for unit test
33
+ } as any as FieldConfig
34
+ }
35
+
36
+ function makeContext(): AccessContext {
37
+ return {
38
+ session: null,
39
+ _isSudo: false,
40
+ _resolveOutputChain: [],
41
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal context for unit test
42
+ } as any
43
+ }
44
+
45
+ // Term ← Bill.term (list-only ref, no field on Term) — schema generation
46
+ // synthesizes `from_Bill_term` on Term, which no list config declares.
47
+ function syntheticConfig(): OpenSaasConfig {
48
+ return {
49
+ db: { provider: 'sqlite' },
50
+ lists: {
51
+ Term: {
52
+ fields: { name: { type: 'text' } as FieldConfig },
53
+ access: { operation: { query: () => true } },
54
+ },
55
+ Bill: {
56
+ fields: {
57
+ amount: { type: 'integer' } as FieldConfig,
58
+ term: rel('Term'),
59
+ internalNotes: {
60
+ type: 'text',
61
+ access: { read: () => false },
62
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal field config for unit test
63
+ } as any as FieldConfig,
64
+ label: virtualField(({ item }) => `Bill #${item.amount}`),
65
+ },
66
+ access: { operation: { query: () => true } },
67
+ },
68
+ },
69
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
70
+ } as any as OpenSaasConfig
71
+ }
72
+
73
+ describe('filterReadableFields — synthetic back-relation (#1082)', () => {
74
+ it('applies the owning list field-level read access to rows fetched through a synthetic key', async () => {
75
+ const config = syntheticConfig()
76
+ const termRow = {
77
+ id: 't1',
78
+ name: 'Term 1',
79
+ from_Bill_term: [{ id: 'b1', amount: 5, internalNotes: 'secret' }],
80
+ }
81
+
82
+ const result = await filterReadableFields(
83
+ termRow,
84
+ config.lists.Term.fields,
85
+ { session: null, context: makeContext() },
86
+ config,
87
+ 0,
88
+ 'Term',
89
+ )
90
+
91
+ const bills = result.from_Bill_term as Record<string, unknown>[]
92
+ expect(bills).toHaveLength(1)
93
+ expect(bills[0].amount).toBe(5)
94
+ expect('internalNotes' in bills[0]).toBe(false)
95
+ })
96
+
97
+ it('computes a virtual field on rows fetched through a synthetic key', async () => {
98
+ const config = syntheticConfig()
99
+ const termRow = {
100
+ id: 't1',
101
+ name: 'Term 1',
102
+ from_Bill_term: [{ id: 'b1', amount: 5 }],
103
+ }
104
+
105
+ const result = await filterReadableFields(
106
+ termRow,
107
+ config.lists.Term.fields,
108
+ { session: null, context: makeContext() },
109
+ config,
110
+ 0,
111
+ 'Term',
112
+ )
113
+
114
+ const bills = result.from_Bill_term as Record<string, unknown>[]
115
+ expect(bills[0].label).toBe('Bill #5')
116
+ })
117
+
118
+ it('recurses through a to-one value fetched through a synthetic key the same as a to-many', async () => {
119
+ const config = syntheticConfig()
120
+ const termRow = {
121
+ id: 't1',
122
+ name: 'Term 1',
123
+ from_Bill_term: { id: 'b1', amount: 5, internalNotes: 'secret' },
124
+ }
125
+
126
+ const result = await filterReadableFields(
127
+ termRow,
128
+ config.lists.Term.fields,
129
+ { session: null, context: makeContext() },
130
+ config,
131
+ 0,
132
+ 'Term',
133
+ )
134
+
135
+ const bill = result.from_Bill_term as Record<string, unknown>
136
+ expect(bill.amount).toBe(5)
137
+ expect(bill.label).toBe('Bill #5')
138
+ expect('internalNotes' in bill).toBe(false)
139
+ })
140
+
141
+ it('leaves a declared, non-relationship field on the row untouched by the synthetic path', async () => {
142
+ // `name` is a declared scalar field on Term itself — the synthetic
143
+ // fallback only ever applies to a key ABSENT from `fieldConfigs`, so it
144
+ // must never interfere with the list's own ordinary fields.
145
+ const config = syntheticConfig()
146
+ const termRow = { id: 't1', name: 'Term 1' }
147
+
148
+ const result = await filterReadableFields(
149
+ termRow,
150
+ config.lists.Term.fields,
151
+ { session: null, context: makeContext() },
152
+ config,
153
+ 0,
154
+ 'Term',
155
+ )
156
+
157
+ expect(result).toEqual({ id: 't1', name: 'Term 1' })
158
+ })
159
+
160
+ it('leaves an undeclared, non-synthetic key untouched (no config supplied)', async () => {
161
+ // Mirrors the pre-#1082 narrow-unit-test contract: without `config`
162
+ // there is nothing to resolve a synthetic key against, so the field
163
+ // passes through as a plain value — the same fallback
164
+ // `filterWritableFields` documents for its own `config`-less callers.
165
+ const config = syntheticConfig()
166
+ const termRow = { id: 't1', name: 'Term 1', from_Bill_term: [{ id: 'b1', amount: 5 }] }
167
+
168
+ const result = await filterReadableFields(
169
+ termRow,
170
+ config.lists.Term.fields,
171
+ { session: null, context: makeContext() },
172
+ undefined,
173
+ 0,
174
+ 'Term',
175
+ )
176
+
177
+ expect(result.from_Bill_term).toEqual([{ id: 'b1', amount: 5 }])
178
+ })
179
+ })