@opensaas/stack-core 0.39.2 → 0.40.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 (130) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +150 -0
  3. package/CLAUDE.md +51 -0
  4. package/dist/access/access-filter.d.ts +83 -10
  5. package/dist/access/access-filter.d.ts.map +1 -1
  6. package/dist/access/access-filter.js +139 -18
  7. package/dist/access/access-filter.js.map +1 -1
  8. package/dist/access/access-filter.test.js +224 -12
  9. package/dist/access/access-filter.test.js.map +1 -1
  10. package/dist/access/engine.d.ts +43 -1
  11. package/dist/access/engine.d.ts.map +1 -1
  12. package/dist/access/engine.js +58 -0
  13. package/dist/access/engine.js.map +1 -1
  14. package/dist/access/engine.test.d.ts +2 -0
  15. package/dist/access/engine.test.d.ts.map +1 -0
  16. package/dist/access/engine.test.js +56 -0
  17. package/dist/access/engine.test.js.map +1 -0
  18. package/dist/access/errors.d.ts +28 -0
  19. package/dist/access/errors.d.ts.map +1 -1
  20. package/dist/access/errors.js +38 -2
  21. package/dist/access/errors.js.map +1 -1
  22. package/dist/access/field-access.d.ts +12 -0
  23. package/dist/access/field-access.d.ts.map +1 -1
  24. package/dist/access/field-access.js +22 -2
  25. package/dist/access/field-access.js.map +1 -1
  26. package/dist/access/field-access.test.js +66 -0
  27. package/dist/access/field-access.test.js.map +1 -1
  28. package/dist/access/field-visibility.d.ts +2 -1
  29. package/dist/access/field-visibility.d.ts.map +1 -1
  30. package/dist/access/field-visibility.js +45 -3
  31. package/dist/access/field-visibility.js.map +1 -1
  32. package/dist/access/index.d.ts +5 -2
  33. package/dist/access/index.d.ts.map +1 -1
  34. package/dist/access/index.js +4 -2
  35. package/dist/access/index.js.map +1 -1
  36. package/dist/config/types.d.ts +76 -10
  37. package/dist/config/types.d.ts.map +1 -1
  38. package/dist/context/index.d.ts +19 -1
  39. package/dist/context/index.d.ts.map +1 -1
  40. package/dist/context/index.js +61 -17
  41. package/dist/context/index.js.map +1 -1
  42. package/dist/context/nested-operations.d.ts.map +1 -1
  43. package/dist/context/nested-operations.js +54 -13
  44. package/dist/context/nested-operations.js.map +1 -1
  45. package/dist/context/write-pipeline.d.ts +3 -2
  46. package/dist/context/write-pipeline.d.ts.map +1 -1
  47. package/dist/context/write-pipeline.js +8 -5
  48. package/dist/context/write-pipeline.js.map +1 -1
  49. package/dist/fields/index.d.ts +13 -1
  50. package/dist/fields/index.d.ts.map +1 -1
  51. package/dist/fields/index.js +50 -5
  52. package/dist/fields/index.js.map +1 -1
  53. package/dist/fields/relationship-foreign-key.test.d.ts +2 -0
  54. package/dist/fields/relationship-foreign-key.test.d.ts.map +1 -0
  55. package/dist/fields/relationship-foreign-key.test.js +18 -0
  56. package/dist/fields/relationship-foreign-key.test.js.map +1 -0
  57. package/dist/index.d.ts +4 -1
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +14 -1
  60. package/dist/index.js.map +1 -1
  61. package/dist/lib/case-utils.d.ts +15 -0
  62. package/dist/lib/case-utils.d.ts.map +1 -1
  63. package/dist/lib/case-utils.js +21 -0
  64. package/dist/lib/case-utils.js.map +1 -1
  65. package/dist/lib/case-utils.test.js +23 -1
  66. package/dist/lib/case-utils.test.js.map +1 -1
  67. package/dist/lib/prisma-errors.d.ts +38 -0
  68. package/dist/lib/prisma-errors.d.ts.map +1 -0
  69. package/dist/lib/prisma-errors.js +53 -0
  70. package/dist/lib/prisma-errors.js.map +1 -0
  71. package/dist/mcp/constants.d.ts +23 -0
  72. package/dist/mcp/constants.d.ts.map +1 -0
  73. package/dist/mcp/constants.js +23 -0
  74. package/dist/mcp/constants.js.map +1 -0
  75. package/dist/mcp/field-schema.d.ts +8 -0
  76. package/dist/mcp/field-schema.d.ts.map +1 -0
  77. package/dist/mcp/field-schema.js +69 -0
  78. package/dist/mcp/field-schema.js.map +1 -0
  79. package/dist/mcp/handler.d.ts.map +1 -1
  80. package/dist/mcp/handler.js +76 -90
  81. package/dist/mcp/handler.js.map +1 -1
  82. package/dist/mcp/projection.d.ts +62 -0
  83. package/dist/mcp/projection.d.ts.map +1 -0
  84. package/dist/mcp/projection.js +390 -0
  85. package/dist/mcp/projection.js.map +1 -0
  86. package/dist/utils/password.d.ts +3 -1
  87. package/dist/utils/password.d.ts.map +1 -1
  88. package/dist/utils/password.js +4 -2
  89. package/dist/utils/password.js.map +1 -1
  90. package/package.json +1 -1
  91. package/src/access/access-filter.test.ts +278 -12
  92. package/src/access/access-filter.ts +207 -19
  93. package/src/access/engine.test.ts +67 -0
  94. package/src/access/engine.ts +81 -1
  95. package/src/access/errors.ts +42 -2
  96. package/src/access/field-access.test.ts +78 -0
  97. package/src/access/field-access.ts +34 -2
  98. package/src/access/field-visibility.ts +75 -10
  99. package/src/access/index.ts +14 -0
  100. package/src/config/types.ts +76 -10
  101. package/src/context/index.ts +95 -15
  102. package/src/context/nested-operations.ts +61 -16
  103. package/src/context/write-pipeline.ts +8 -4
  104. package/src/fields/index.ts +55 -5
  105. package/src/fields/relationship-foreign-key.test.ts +29 -0
  106. package/src/index.ts +17 -1
  107. package/src/lib/case-utils.test.ts +32 -0
  108. package/src/lib/case-utils.ts +26 -0
  109. package/src/lib/prisma-errors.ts +86 -0
  110. package/src/mcp/constants.ts +24 -0
  111. package/src/mcp/field-schema.ts +84 -0
  112. package/src/mcp/handler.ts +109 -107
  113. package/src/mcp/projection.ts +497 -0
  114. package/src/utils/password.ts +5 -3
  115. package/tests/access-relationships.test.ts +3 -3
  116. package/tests/access.test.ts +83 -0
  117. package/tests/context.test.ts +259 -12
  118. package/tests/field-types.test.ts +113 -0
  119. package/tests/mcp-fields-projection-access.test.ts +250 -0
  120. package/tests/mcp-handler.test.ts +695 -4
  121. package/tests/nested-access-and-hooks.test.ts +65 -0
  122. package/tests/nested-write-synthetic-relation.test.ts +437 -0
  123. package/tests/password-types.test.ts +28 -0
  124. package/tests/password.test.ts +16 -2
  125. package/tests/prisma-errors.test.ts +141 -0
  126. package/tests/singleton.test.ts +9 -1
  127. package/tests/to-one-include-access-filter.test.ts +172 -0
  128. package/tests/with-session.test.ts +243 -0
  129. package/tests/write-pipeline.test.ts +29 -0
  130. package/tsconfig.tsbuildinfo +1 -1
@@ -9,6 +9,7 @@ import {
9
9
  resolveQueryField,
10
10
  walkWhereReadAccess,
11
11
  } from './query-validation.js'
12
+ import { getDbKey } from '../lib/case-utils.js'
12
13
 
13
14
  /**
14
15
  * Access Filter — phase 1 of the two-phase read (pre-query).
@@ -34,10 +35,36 @@ import {
34
35
  * named a nested `include` there too. A relation nobody named never has its
35
36
  * list's `query` access evaluated at all — there is no separate "build the
36
37
  * whole tree, then reconcile against what was asked for" pass to walk it.
38
+ *
39
+ * **To-one relations are scoped after the query, not inside it (issue #974).**
40
+ * Prisma accepts a nested `where` on an `include` entry only for a to-**many**
41
+ * relation; the same shape on a to-**one** relation raises
42
+ * `PrismaClientValidationError`. So for a to-one relation whose related
43
+ * list's `query` access resolves to a filter, `buildAccessScopedInclude`
44
+ * does NOT attach that filter as `where` — it fetches the row unscoped and
45
+ * records the filter in the returned `toOneAccessFilters` tree instead.
46
+ * `resolveToOneAccessVisibility` (below) turns that tree into the set of
47
+ * related ids the session may actually see, via ONE batched `id IN (...)`
48
+ * existence check per (relation, nesting level) across every row in the
49
+ * read — never a per-row query, and never a hand-rolled evaluation of the
50
+ * access filter (it is handed to Prisma exactly as `checkAccess` produced
51
+ * it). `field-visibility.ts`'s `filterReadableFields` is where the result
52
+ * actually becomes `null` for a row the check excludes — see its module doc.
53
+ * A to-one relation whose related list's `query` access is `true` (no
54
+ * filter) or `false` (denied outright) needs no existence check at all: the
55
+ * former is left as `true` in `include`, unchanged from before; the latter
56
+ * is recorded as `{ kind: 'denied' }`, and `field-visibility.ts` forces the
57
+ * key to `null` without ever asking Prisma for it.
37
58
  */
38
59
 
39
60
  /** The structured (object) form of a relation include entry — caller/fold-supplied or produced by this module. */
40
- type IncludeEntryObject = { where?: PrismaFilter; include?: Record<string, unknown>; take?: number }
61
+ type IncludeEntryObject = {
62
+ where?: PrismaFilter
63
+ include?: Record<string, unknown>
64
+ take?: number
65
+ orderBy?: PrismaFilter | PrismaFilter[]
66
+ skip?: number
67
+ }
41
68
 
42
69
  /** A plain object — excludes `null` and arrays, which `typeof x === 'object'` alone would admit. */
43
70
  function isPlainObject(value: unknown): value is Record<string, unknown> {
@@ -51,16 +78,22 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
51
78
  * own type before trusting it, rather than casting the whole value wholesale.
52
79
  *
53
80
  * A numeric `take` on a to-many relation include (a caller-supplied row bound,
54
- * issue #752) is carried through: it only ever NARROWS the fetched rows and can
55
- * never widen past the access `where`, so preserving it is access-neutral.
81
+ * issue #752), and a caller-supplied `orderBy`/`skip` (#851), are carried
82
+ * through the same way: none of the three can ever widen the result past the
83
+ * access `where` — they only narrow or reorder rows the access filter already
84
+ * admits — so preserving them is access-neutral.
56
85
  */
57
86
  function asEntryObject(value: unknown): IncludeEntryObject | null {
58
87
  if (!isPlainObject(value)) return null
59
- const { where, include, take } = value
88
+ const { where, include, take, orderBy, skip } = value
60
89
  const entry: IncludeEntryObject = {}
61
90
  if (isPlainObject(where)) entry.where = where
62
91
  if (isPlainObject(include)) entry.include = include
63
92
  if (typeof take === 'number') entry.take = take
93
+ if (isPlainObject(orderBy) || Array.isArray(orderBy)) {
94
+ entry.orderBy = orderBy as PrismaFilter | PrismaFilter[]
95
+ }
96
+ if (typeof skip === 'number') entry.skip = skip
64
97
  return entry
65
98
  }
66
99
 
@@ -81,6 +114,38 @@ function andWhere(
81
114
  return accessWhere ?? callerWhere
82
115
  }
83
116
 
117
+ /** One to-one relation's recorded access filter, or an outright denial — see the module doc's "To-one relations" section. */
118
+ export type ToOneAccessFilterEntry =
119
+ { kind: 'scoped'; relatedListName: string; accessWhere: PrismaFilter } | { kind: 'denied' }
120
+
121
+ /**
122
+ * Which to-one relations, at which nesting level of an `include`, need a
123
+ * post-query existence check rather than a Prisma-side `where` — because
124
+ * their related list's `query` access resolved to a filter (`kind: 'scoped'`)
125
+ * or a denial (`kind: 'denied'`) and Prisma cannot express either as a nested
126
+ * `where` on a to-one include. `resolveToOneAccessVisibility` consumes this
127
+ * tree; `filterReadableFields` (`field-visibility.ts`) applies its result.
128
+ */
129
+ export type ToOneAccessFilterTree = {
130
+ /** To-one relation keys at THIS level needing a post-query check. */
131
+ filters: Record<string, ToOneAccessFilterEntry>
132
+ /** Per-key trees for relations present in the include for other reasons, whose own nested include may contain further to-one filters. */
133
+ nested: Record<string, ToOneAccessFilterTree>
134
+ }
135
+
136
+ export function emptyToOneAccessFilterTree(): ToOneAccessFilterTree {
137
+ return { filters: {}, nested: {} }
138
+ }
139
+
140
+ function isToOneAccessFilterTreeEmpty(tree: ToOneAccessFilterTree): boolean {
141
+ return Object.keys(tree.filters).length === 0 && Object.keys(tree.nested).length === 0
142
+ }
143
+
144
+ /** Whether a relationship field is to-one (at most one related row) rather than to-many. */
145
+ function isToOneRelationship(fieldConfig: FieldConfig): boolean {
146
+ return !('many' in fieldConfig && fieldConfig.many === true)
147
+ }
148
+
84
149
  /**
85
150
  * Build the access-scoped `include` for exactly the relations a read
86
151
  * requested, recursing only into branches `requestedInclude` itself names.
@@ -92,14 +157,21 @@ function andWhere(
92
157
  * - A declared relationship whose related list's `query` access denies it
93
158
  * (`=== false`) → dropped entirely, no matter what the request asked for
94
159
  * nested beneath it (#566): the caller chooses *which* relations, access
95
- * control chooses *whether* and *with what filter*.
96
- * - Otherwise the access `where` is AND-combined with any caller-supplied
97
- * nested `where` (never replaced the other half of #566), a
98
- * caller-supplied `take` rides through unchanged (#752), and the "One
99
- * hop" rule (ADR-0026) nested relations are scoped ONLY if
100
- * `requestedInclude` itself named a nested `include` here. A bare relation
101
- * (or one with no nested `include`) fetches its own columns and stops: no
102
- * recursive call, no access evaluation on anything beneath it.
160
+ * control chooses *whether* and *with what filter*. For a to-one relation
161
+ * this denial is also recorded in `toOneAccessFilters` (`kind: 'denied'`),
162
+ * so `filterReadableFields` can still surface an explicit `null` for it
163
+ * (issue #974) rather than an absent key.
164
+ * - Otherwise, for a to-**many** relation the access `where` is
165
+ * AND-combined with any caller-supplied nested `where` (never replaced
166
+ * the other half of #566), and a caller-supplied `take` rides through
167
+ * unchanged (#752). For a to-**one** relation the access filter (if any)
168
+ * is recorded in `toOneAccessFilters` instead of attached as `where`,
169
+ * because Prisma only accepts a nested `where` on a to-many include
170
+ * (issue #974) — the entry itself never carries a `where` for a to-one key.
171
+ * - Either way — the "One hop" rule (ADR-0026) — nested relations are scoped
172
+ * ONLY if `requestedInclude` itself named a nested `include` here. A bare
173
+ * relation (or one with no nested `include`) fetches its own columns and
174
+ * stops: no recursive call, no access evaluation on anything beneath it.
103
175
  *
104
176
  * **Depth is a cost limit, not a cycle guard (ADR-0026).** A `requestedInclude`
105
177
  * is always a finite literal — the caller's own object, or
@@ -120,13 +192,14 @@ export async function buildAccessScopedInclude(
120
192
  config: OpenSaasConfig,
121
193
  listKey: string,
122
194
  depth: number = 0,
123
- ): Promise<Record<string, unknown>> {
195
+ ): Promise<{ include: Record<string, unknown>; toOneAccessFilters: ToOneAccessFilterTree }> {
124
196
  const requestedKeys = Object.keys(requestedInclude)
125
197
  if (depth >= READ_INCLUDE_MAX_DEPTH && requestedKeys.length > 0) {
126
198
  throw new AccessScopeDepthExceededError(listKey, requestedKeys[0], depth)
127
199
  }
128
200
 
129
201
  const result: Record<string, unknown> = {}
202
+ const toOneAccessFilters = emptyToOneAccessFilterTree()
130
203
 
131
204
  for (const [relationName, requestedValue] of Object.entries(requestedInclude)) {
132
205
  const fieldConfig = fieldConfigs[relationName]
@@ -141,6 +214,8 @@ export async function buildAccessScopedInclude(
141
214
  const relatedConfig = getRelatedListConfig(fieldConfig.ref as string, config)
142
215
  if (!relatedConfig) continue
143
216
 
217
+ const isToOne = isToOneRelationship(fieldConfig)
218
+
144
219
  const queryAccess = relatedConfig.listConfig.access?.operation?.query
145
220
  const accessResult = await checkAccess(queryAccess, {
146
221
  session: args.session,
@@ -148,16 +223,19 @@ export async function buildAccessScopedInclude(
148
223
  })
149
224
 
150
225
  if (accessResult === false) {
226
+ if (isToOne) {
227
+ toOneAccessFilters.filters[relationName] = { kind: 'denied' }
228
+ }
151
229
  continue
152
230
  }
153
231
 
154
232
  const accessWhere = typeof accessResult === 'object' ? accessResult : undefined
155
233
  const requestedEntry = asEntryObject(requestedValue)
156
- const mergedWhere = andWhere(accessWhere, requestedEntry?.where)
157
234
 
158
235
  let nestedInclude: Record<string, unknown> | undefined
236
+ let nestedToOneFilters: ToOneAccessFilterTree | undefined
159
237
  if (requestedEntry?.include) {
160
- nestedInclude = await buildAccessScopedInclude(
238
+ const nested = await buildAccessScopedInclude(
161
239
  requestedEntry.include,
162
240
  relatedConfig.listConfig.fields,
163
241
  args,
@@ -165,17 +243,127 @@ export async function buildAccessScopedInclude(
165
243
  relatedConfig.listName,
166
244
  depth + 1,
167
245
  )
246
+ nestedInclude = nested.include
247
+ nestedToOneFilters = nested.toOneAccessFilters
168
248
  }
169
249
 
170
- const entry: { where?: PrismaFilter; include?: Record<string, unknown>; take?: number } = {}
171
- if (mergedWhere) entry.where = mergedWhere
250
+ const entry: IncludeEntryObject = {}
251
+ if (isToOne) {
252
+ if (accessWhere) {
253
+ toOneAccessFilters.filters[relationName] = {
254
+ kind: 'scoped',
255
+ relatedListName: relatedConfig.listName,
256
+ accessWhere,
257
+ }
258
+ }
259
+ } else {
260
+ const mergedWhere = andWhere(accessWhere, requestedEntry?.where)
261
+ if (mergedWhere) entry.where = mergedWhere
262
+ if (requestedEntry?.take !== undefined) entry.take = requestedEntry.take
263
+ if (requestedEntry?.orderBy !== undefined) entry.orderBy = requestedEntry.orderBy
264
+ if (requestedEntry?.skip !== undefined) entry.skip = requestedEntry.skip
265
+ }
172
266
  if (nestedInclude && Object.keys(nestedInclude).length > 0) entry.include = nestedInclude
173
- if (requestedEntry?.take !== undefined) entry.take = requestedEntry.take
267
+ if (nestedToOneFilters && !isToOneAccessFilterTreeEmpty(nestedToOneFilters)) {
268
+ toOneAccessFilters.nested[relationName] = nestedToOneFilters
269
+ }
174
270
 
175
271
  result[relationName] = Object.keys(entry).length > 0 ? entry : true
176
272
  }
177
273
 
178
- return result
274
+ return { include: result, toOneAccessFilters }
275
+ }
276
+
277
+ /** One to-one relation's resolved post-query visibility — see `resolveToOneAccessVisibility`. */
278
+ export type ToOneVisibility = { kind: 'denied' } | { kind: 'visible'; ids: ReadonlySet<string> }
279
+
280
+ /** The resolved counterpart to {@link ToOneAccessFilterTree}, produced by `resolveToOneAccessVisibility`. */
281
+ export type ToOneAccessVisibilityTree = {
282
+ filters: Record<string, ToOneVisibility>
283
+ nested: Record<string, ToOneAccessVisibilityTree>
284
+ }
285
+
286
+ export function emptyToOneAccessVisibilityTree(): ToOneAccessVisibilityTree {
287
+ return { filters: {}, nested: {} }
288
+ }
289
+
290
+ /**
291
+ * Resolve a `ToOneAccessFilterTree` against the RAW rows Prisma already
292
+ * fetched (unscoped for the flagged to-one relations — see
293
+ * `buildAccessScopedInclude`) into the set of related ids the session may
294
+ * actually see, one batched `id IN (...)` existence check per (relation,
295
+ * nesting level) across every row in `items` — never once per row.
296
+ *
297
+ * For each `filters` entry at a level:
298
+ * - `kind: 'denied'` → carried straight through; no query, nothing to check.
299
+ * - `kind: 'scoped'` → every id present at this key across ALL of `items` is
300
+ * collected first (an empty set skips the query entirely — nothing to
301
+ * check), then ONE `findMany` through the RAW `prisma` client (not
302
+ * `context.db`, which would re-evaluate the same access-control function a
303
+ * second time) asks which of those ids also satisfy `accessWhere` — the
304
+ * exact `PrismaFilter` `checkAccess` already produced, handed to Prisma
305
+ * unmodified rather than interpreted by hand.
306
+ *
307
+ * Recurses into `nested` by flattening the related items reached through
308
+ * each key across every row in `items` (a to-many hop contributes every one
309
+ * of its rows; a to-one hop contributes its single row, if any) into the
310
+ * next level's own `items` array, so a to-one relation nested arbitrarily
311
+ * deep is still resolved with one batched query per node, not per parent row.
312
+ */
313
+ export async function resolveToOneAccessVisibility(
314
+ items: readonly unknown[],
315
+ tree: ToOneAccessFilterTree,
316
+ args: {
317
+ session: Session | null
318
+ context: AccessContext
319
+ },
320
+ ): Promise<ToOneAccessVisibilityTree> {
321
+ const resolved = emptyToOneAccessVisibilityTree()
322
+
323
+ for (const [key, entry] of Object.entries(tree.filters)) {
324
+ if (entry.kind === 'denied') {
325
+ resolved.filters[key] = { kind: 'denied' }
326
+ continue
327
+ }
328
+
329
+ const ids = new Set<string>()
330
+ for (const item of items) {
331
+ if (!item || typeof item !== 'object') continue
332
+ const value = (item as Record<string, unknown>)[key]
333
+ if (value && typeof value === 'object' && 'id' in value) {
334
+ ids.add(String((value as Record<string, unknown>).id))
335
+ }
336
+ }
337
+
338
+ if (ids.size === 0) {
339
+ resolved.filters[key] = { kind: 'visible', ids: new Set() }
340
+ continue
341
+ }
342
+
343
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- dynamic model access by list name, mirroring the rest of the read pipeline
344
+ const model = (args.context.prisma as any)[getDbKey(entry.relatedListName)]
345
+ const visibleRows = await model.findMany({
346
+ where: { AND: [entry.accessWhere, { id: { in: [...ids] } }] },
347
+ select: { id: true },
348
+ })
349
+ const visibleIds = new Set<string>(
350
+ Array.isArray(visibleRows) ? visibleRows.map((row: { id: unknown }) => String(row.id)) : [],
351
+ )
352
+ resolved.filters[key] = { kind: 'visible', ids: visibleIds }
353
+ }
354
+
355
+ for (const [key, nestedTree] of Object.entries(tree.nested)) {
356
+ const nestedItems: unknown[] = []
357
+ for (const item of items) {
358
+ if (!item || typeof item !== 'object') continue
359
+ const value = (item as Record<string, unknown>)[key]
360
+ if (Array.isArray(value)) nestedItems.push(...value)
361
+ else if (value && typeof value === 'object') nestedItems.push(value)
362
+ }
363
+ resolved.nested[key] = await resolveToOneAccessVisibility(nestedItems, nestedTree, args)
364
+ }
365
+
366
+ return resolved
179
367
  }
180
368
 
181
369
  /**
@@ -0,0 +1,67 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { resolveSyntheticReverseRelation } from './engine.js'
3
+ import type { OpenSaasConfig, FieldConfig } from '../config/types.js'
4
+
5
+ // A relationship field pointing at another list.
6
+ function rel(ref: string, many = false): FieldConfig {
7
+ return { type: 'relationship', ref, many } as unknown as FieldConfig
8
+ }
9
+
10
+ /**
11
+ * #978: a list-only `ref` (`ref: 'ListName'`, no target field) generates a
12
+ * synthetic back-relation on its target — `from_<SourceList>_<sourceField>`
13
+ * (see `getSyntheticFieldName` in fields/index.ts). `resolveSyntheticReverseRelation`
14
+ * is the runtime counterpart that recognises the same name and resolves it
15
+ * back to the declared field that owns it, so a nested write addressed to it
16
+ * gets the same hook pipeline a declared relationship field gets.
17
+ */
18
+ describe('resolveSyntheticReverseRelation', () => {
19
+ const config: OpenSaasConfig = {
20
+ db: { provider: 'sqlite', url: 'file:./dev.db' },
21
+ lists: {
22
+ Account: { fields: { name: { type: 'text' } as unknown as FieldConfig } },
23
+ ChargeRequest: {
24
+ fields: {
25
+ kind: { type: 'text' } as unknown as FieldConfig,
26
+ // List-only ref — no field named on Account, so a back-relation is
27
+ // synthesized on it as `from_ChargeRequest_account`.
28
+ account: rel('Account'),
29
+ },
30
+ },
31
+ // A bidirectional relationship — Post.author names User.posts directly,
32
+ // so no back-relation is synthesized anywhere for it.
33
+ User: { fields: { posts: rel('Post.author', true) } },
34
+ Post: { fields: { author: rel('User.posts') } },
35
+ },
36
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
37
+ } as any
38
+
39
+ it('resolves the synthetic back-relation name to its owning field', () => {
40
+ const result = resolveSyntheticReverseRelation('from_ChargeRequest_account', 'Account', config)
41
+ expect(result).not.toBeNull()
42
+ expect(result?.sourceListName).toBe('ChargeRequest')
43
+ expect(result?.sourceFieldName).toBe('account')
44
+ expect(result?.sourceFieldConfig.ref).toBe('Account')
45
+ })
46
+
47
+ it('returns null for a name that does not match the synthetic construction', () => {
48
+ expect(
49
+ resolveSyntheticReverseRelation('from_ChargeRequest_amount', 'Account', config),
50
+ ).toBeNull()
51
+ expect(resolveSyntheticReverseRelation('chargeRequests', 'Account', config)).toBeNull()
52
+ })
53
+
54
+ it('returns null on the wrong parent list', () => {
55
+ expect(
56
+ resolveSyntheticReverseRelation('from_ChargeRequest_account', 'ChargeRequest', config),
57
+ ).toBeNull()
58
+ })
59
+
60
+ it('returns null for a bidirectional ref — its other side is a real declared field, not a synthetic one', () => {
61
+ expect(resolveSyntheticReverseRelation('from_Post_author', 'User', config)).toBeNull()
62
+ })
63
+
64
+ it('returns null for a genuinely unknown key', () => {
65
+ expect(resolveSyntheticReverseRelation('totallyBogusKey', 'Account', config)).toBeNull()
66
+ })
67
+ })
@@ -1,5 +1,7 @@
1
1
  import type { AccessControl, Session, AccessContext, PrismaFilter } from './types.js'
2
- import type { OpenSaasConfig, ListConfig } from '../config/types.js'
2
+ import type { OpenSaasConfig, ListConfig, RelationshipField } from '../config/types.js'
3
+ import { getSyntheticFieldName } from '../fields/index.js'
4
+ import { InvalidCreateAccessResultError } from './errors.js'
3
5
 
4
6
  /**
5
7
  * Access engine — operation-level access control and shared helpers.
@@ -45,6 +47,53 @@ export function getRelatedListConfig(
45
47
  return { listName, listConfig }
46
48
  }
47
49
 
50
+ /** A synthetic reverse relation, resolved back to the declared field that owns it. */
51
+ export interface SyntheticReverseRelation {
52
+ sourceListName: string
53
+ sourceFieldName: string
54
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- RelationshipField must accept any TypeInfo
55
+ sourceFieldConfig: RelationshipField<any>
56
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
57
+ sourceListConfig: ListConfig<any>
58
+ }
59
+
60
+ /**
61
+ * Resolve a candidate data key as the synthetic back-relation a list-only
62
+ * `ref` (`ref: 'ListName'`, no target field) generates on its target model —
63
+ * Prisma requires an opposite field there, but the config never declares one,
64
+ * so it never appears in `parentListName`'s own `fields`. Reuses
65
+ * `getSyntheticFieldName` (the same construction `getPrismaRelation` emits the
66
+ * schema with) rather than re-deriving the `from_<List>_<field>` format by
67
+ * string parsing, so the two cannot drift (#978).
68
+ *
69
+ * Returns the declared relationship field that owns the relation — the write
70
+ * pipeline treats a resolved synthetic key exactly like a nested write through
71
+ * that field, so it runs the same hooks/access/recovery machinery a declared
72
+ * relationship field gets. Returns `null` when `fieldName` isn't one of these
73
+ * on `parentListName` (a genuinely unknown key, or a bidirectional relation's
74
+ * ref, which never synthesizes a back-relation).
75
+ */
76
+ export function resolveSyntheticReverseRelation(
77
+ fieldName: string,
78
+ parentListName: string,
79
+ config: OpenSaasConfig,
80
+ ): SyntheticReverseRelation | null {
81
+ for (const [sourceListName, sourceListConfig] of Object.entries(config.lists)) {
82
+ for (const [sourceFieldName, sourceFieldConfig] of Object.entries(sourceListConfig.fields)) {
83
+ if (sourceFieldConfig.type !== 'relationship') continue
84
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- RelationshipField must accept any TypeInfo
85
+ const rel = sourceFieldConfig as RelationshipField<any>
86
+ // Only a list-only ref ('ListName', no '.fieldName') synthesizes a
87
+ // back-relation — a bidirectional ref's other side is a real field.
88
+ const refParts = rel.ref.split('.')
89
+ if (refParts.length !== 1 || refParts[0] !== parentListName) continue
90
+ if (getSyntheticFieldName(sourceListName, sourceFieldName) !== fieldName) continue
91
+ return { sourceListName, sourceFieldName, sourceFieldConfig: rel, sourceListConfig }
92
+ }
93
+ }
94
+ return null
95
+ }
96
+
48
97
  export async function checkAccess<T = Record<string, unknown>>(
49
98
  accessControl: AccessControl<T> | undefined,
50
99
  args: {
@@ -63,6 +112,37 @@ export async function checkAccess<T = Record<string, unknown>>(
63
112
  return result
64
113
  }
65
114
 
115
+ /**
116
+ * Evaluate operation-level `create` access. The single evaluator shared by the
117
+ * write pipeline's top-level create and the nested-create path — both must
118
+ * reject the same way, or the two drift again (#1009).
119
+ *
120
+ * Unlike `checkAccess` (which `query`/`update`/`delete` call directly and which
121
+ * legitimately returns a filter for them to re-check against a row), create
122
+ * has no existing row and no way to test a filter against input data. A rule
123
+ * that returns anything other than a strict boolean — most notably a filter,
124
+ * which type-checks against the shared `AccessControl` signature and reads as
125
+ * though it scopes the create — throws `InvalidCreateAccessResultError`
126
+ * rather than being silently treated as an allow. See that error's doc,
127
+ * ADR-0022, and ADR-0030.
128
+ */
129
+ export async function checkCreateAccess<T = Record<string, unknown>>(
130
+ listKey: string,
131
+ accessControl: AccessControl<T> | undefined,
132
+ args: {
133
+ session: Session | null
134
+ context: AccessContext
135
+ },
136
+ ): Promise<boolean> {
137
+ const result = await checkAccess(accessControl, args)
138
+
139
+ if (isBoolean(result)) {
140
+ return result
141
+ }
142
+
143
+ throw new InvalidCreateAccessResultError(listKey, result)
144
+ }
145
+
66
146
  export function mergeFilters(
67
147
  userFilter: PrismaFilter | undefined,
68
148
  accessFilter: boolean | PrismaFilter,
@@ -99,7 +99,7 @@ export class RelationFilterAccessDeniedError extends Error {
99
99
  }
100
100
  }
101
101
 
102
- function describeFieldAccessResult(result: unknown): string {
102
+ function describeAccessResult(result: unknown): string {
103
103
  if (result === null) return 'null'
104
104
  if (result === undefined) return 'undefined'
105
105
  if (typeof result === 'object') return 'an object (e.g. a Prisma filter)'
@@ -135,7 +135,7 @@ export class InvalidFieldAccessResultError extends Error {
135
135
  constructor(operation: 'read' | 'create' | 'update', result: unknown) {
136
136
  super(
137
137
  `Field-level access control for operation "${operation}" returned ` +
138
- `${describeFieldAccessResult(result)}, not a boolean. Field access is a per-field ` +
138
+ `${describeAccessResult(result)}, not a boolean. Field access is a per-field ` +
139
139
  `visibility decision — it must return true or false, and (unlike operation-level access) ` +
140
140
  `cannot scope which rows are affected. If you meant to restrict access based on the row or ` +
141
141
  `the write payload, evaluate the condition yourself and return a boolean, e.g. ` +
@@ -145,3 +145,43 @@ export class InvalidFieldAccessResultError extends Error {
145
145
  this.operation = operation
146
146
  }
147
147
  }
148
+
149
+ /**
150
+ * Thrown when operation-level `create` access control returns anything other
151
+ * than a strict `boolean`. `OperationAccess['create']` is typed as
152
+ * `AccessControl`, which also accepts a `PrismaFilter` — the shape
153
+ * `query`/`update`/`delete` legitimately use to scope which rows an
154
+ * operation may touch (see ADR-0001). Create has none of that to scope: there
155
+ * is no existing row, and — unlike update/delete, which re-check a returned
156
+ * filter against the target via `findFirst` — no equivalent re-check is
157
+ * possible against data that doesn't exist in the database yet.
158
+ *
159
+ * A filter-returning `create` rule type-checks (the shared `AccessControl`
160
+ * type admits it) and reads as though it scopes the create. Before this it
161
+ * fell through a check that only tested `=== false` and was silently treated
162
+ * as a full allow. This is the same defect shape ADR-0030 closed for
163
+ * field-level access, resolved the same way per ADR-0022 (an engine that
164
+ * cannot compute a scope must deny, never pass through) — a loud, distinct
165
+ * failure rather than a wider `null`/`[]` Silent failure, because this is a
166
+ * config bug, not an access denial.
167
+ *
168
+ * Deliberately does not expose the offending result as a public field, for
169
+ * the same reason as `InvalidFieldAccessResultError`: there is no
170
+ * concretely-typed shape to give it, and the root CLAUDE.md forbids exposing
171
+ * `unknown`/`any` as part of a package's external API.
172
+ */
173
+ export class InvalidCreateAccessResultError extends Error {
174
+ public listKey: string
175
+
176
+ constructor(listKey: string, result: unknown) {
177
+ super(
178
+ `Operation-level "create" access control for "${listKey}" returned ` +
179
+ `${describeAccessResult(result)}, not a boolean. Create cannot be row-scoped — there is no ` +
180
+ `existing row, and no input data is available to test a filter against here. Return a ` +
181
+ `boolean from create access, or move the ownership check into a \`resolveInput\` or ` +
182
+ `\`validate\` hook, where the input data is in scope.`,
183
+ )
184
+ this.name = 'InvalidCreateAccessResultError'
185
+ this.listKey = listKey
186
+ }
187
+ }
@@ -712,3 +712,81 @@ describe('filterWritableFields', () => {
712
712
  expect(filtered.author).toEqual({ connect: { id: 'user-1' } })
713
713
  })
714
714
  })
715
+
716
+ // ── #978: under sudo, only a synthetic reverse-relation key is a recognised
717
+ // undeclared key — anything else is refused, even under sudo ──────────────
718
+
719
+ describe('filterWritableFields — #978 tightened sudo undeclared-key guard', () => {
720
+ // A minimal config: Account is the target of ChargeRequest's list-only ref
721
+ // (`ref: 'Account'`, no field named on Account), so the generator
722
+ // synthesizes `from_ChargeRequest_account` on Account.
723
+ const config = {
724
+ db: { provider: 'sqlite', url: 'file:./dev.db' },
725
+ lists: {
726
+ Account: { fields: { name: { type: 'text' } } },
727
+ ChargeRequest: {
728
+ fields: {
729
+ kind: { type: 'text' },
730
+ account: { type: 'relationship', ref: 'Account' },
731
+ },
732
+ },
733
+ },
734
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
735
+ } as any
736
+
737
+ it('passes a synthetic reverse-relation key through under sudo, when config/listName are supplied', async () => {
738
+ const fieldConfigs = { name: { type: 'text' } }
739
+ const data = {
740
+ name: 'Acme',
741
+ from_ChargeRequest_account: { create: { kind: 'DEPOSIT' } },
742
+ }
743
+
744
+ const filtered = await filterWritableFields(data, fieldConfigs, 'update', {
745
+ session: null,
746
+ item: { id: 'a1' },
747
+ context: sudoContext(),
748
+ inputData: data,
749
+ listName: 'Account',
750
+ config,
751
+ })
752
+
753
+ expect(filtered).toHaveProperty('name', 'Acme')
754
+ expect(filtered).toHaveProperty('from_ChargeRequest_account')
755
+ })
756
+
757
+ it('throws on a genuinely unknown key under sudo, when config/listName are supplied', async () => {
758
+ const fieldConfigs = { name: { type: 'text' } }
759
+ const data = {
760
+ name: 'Acme',
761
+ totallyBogusKey: 'value',
762
+ }
763
+
764
+ await expect(
765
+ filterWritableFields(data, fieldConfigs, 'update', {
766
+ session: null,
767
+ item: { id: 'a1' },
768
+ context: sudoContext(),
769
+ inputData: data,
770
+ listName: 'Account',
771
+ config,
772
+ }),
773
+ ).rejects.toThrow(/totallyBogusKey/)
774
+ })
775
+
776
+ it('keeps the pre-#978 blanket sudo passthrough when config/listName are omitted', async () => {
777
+ // Pins the fallback direct unit tests above (e.g. "passes undeclared data
778
+ // keys through under sudo") rely on: with no config to resolve a synthetic
779
+ // key against, any undeclared key still passes through under sudo.
780
+ const fieldConfigs = { name: { type: 'text' } }
781
+ const data = { name: 'Acme', totallyBogusKey: 'value' }
782
+
783
+ const filtered = await filterWritableFields(data, fieldConfigs, 'update', {
784
+ session: null,
785
+ item: { id: 'a1' },
786
+ context: sudoContext(),
787
+ inputData: data,
788
+ })
789
+
790
+ expect(filtered).toHaveProperty('totallyBogusKey', 'value')
791
+ })
792
+ })