@opensaas/stack-core 0.24.0 → 0.25.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 (73) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +223 -0
  3. package/dist/access/access-filter.d.ts +39 -0
  4. package/dist/access/access-filter.d.ts.map +1 -1
  5. package/dist/access/access-filter.js +121 -0
  6. package/dist/access/access-filter.js.map +1 -1
  7. package/dist/access/field-access.d.ts +1 -0
  8. package/dist/access/field-access.d.ts.map +1 -1
  9. package/dist/access/field-access.js +79 -4
  10. package/dist/access/field-access.js.map +1 -1
  11. package/dist/access/field-access.test.js +213 -0
  12. package/dist/access/field-access.test.js.map +1 -1
  13. package/dist/access/index.d.ts +1 -1
  14. package/dist/access/index.d.ts.map +1 -1
  15. package/dist/access/index.js +1 -1
  16. package/dist/access/index.js.map +1 -1
  17. package/dist/access/types.d.ts +39 -0
  18. package/dist/access/types.d.ts.map +1 -1
  19. package/dist/config/types.d.ts +318 -0
  20. package/dist/config/types.d.ts.map +1 -1
  21. package/dist/context/index.d.ts +19 -1
  22. package/dist/context/index.d.ts.map +1 -1
  23. package/dist/context/index.js +153 -26
  24. package/dist/context/index.js.map +1 -1
  25. package/dist/context/nested-operations.d.ts +59 -3
  26. package/dist/context/nested-operations.d.ts.map +1 -1
  27. package/dist/context/nested-operations.js +552 -129
  28. package/dist/context/nested-operations.js.map +1 -1
  29. package/dist/context/transaction-boundary.d.ts +91 -0
  30. package/dist/context/transaction-boundary.d.ts.map +1 -0
  31. package/dist/context/transaction-boundary.js +329 -0
  32. package/dist/context/transaction-boundary.js.map +1 -0
  33. package/dist/context/write-pipeline.d.ts +15 -1
  34. package/dist/context/write-pipeline.d.ts.map +1 -1
  35. package/dist/context/write-pipeline.js +173 -10
  36. package/dist/context/write-pipeline.js.map +1 -1
  37. package/dist/fields/calendar-day.test.d.ts +2 -0
  38. package/dist/fields/calendar-day.test.d.ts.map +1 -0
  39. package/dist/fields/calendar-day.test.js +120 -0
  40. package/dist/fields/calendar-day.test.js.map +1 -0
  41. package/dist/fields/index.d.ts +18 -2
  42. package/dist/fields/index.d.ts.map +1 -1
  43. package/dist/fields/index.js +93 -17
  44. package/dist/fields/index.js.map +1 -1
  45. package/dist/hooks/index.d.ts +116 -0
  46. package/dist/hooks/index.d.ts.map +1 -1
  47. package/dist/hooks/index.js +154 -0
  48. package/dist/hooks/index.js.map +1 -1
  49. package/dist/validation/schema.test.js +222 -1
  50. package/dist/validation/schema.test.js.map +1 -1
  51. package/package.json +1 -1
  52. package/src/access/access-filter.ts +156 -0
  53. package/src/access/field-access.test.ts +255 -0
  54. package/src/access/field-access.ts +91 -5
  55. package/src/access/index.ts +1 -1
  56. package/src/access/types.ts +45 -0
  57. package/src/config/types.ts +364 -0
  58. package/src/context/index.ts +207 -37
  59. package/src/context/nested-operations.ts +969 -143
  60. package/src/context/transaction-boundary.ts +440 -0
  61. package/src/context/write-pipeline.ts +234 -13
  62. package/src/fields/calendar-day.test.ts +140 -0
  63. package/src/fields/index.ts +96 -16
  64. package/src/hooks/index.ts +265 -0
  65. package/src/validation/schema.test.ts +266 -1
  66. package/tests/access.test.ts +24 -16
  67. package/tests/context.test.ts +481 -0
  68. package/tests/field-types.test.ts +17 -3
  69. package/tests/nested-access-and-hooks.test.ts +1130 -54
  70. package/tests/nested-operation-registry.test.ts +28 -3
  71. package/tests/nested-write-hooks.test.ts +864 -0
  72. package/tests/transaction-boundary-hooks.test.ts +465 -0
  73. package/tsconfig.tsbuildinfo +1 -1
@@ -1,5 +1,25 @@
1
1
  import { describe, it, expect } from 'vitest'
2
2
  import { filterWritableFields } from './field-access.js'
3
+ import { ValidationError } from '../hooks/index.js'
4
+
5
+ // A non-sudo access context. The cast is localized to test setup (mirrors the
6
+ // existing sudo-context casts in this file): the runtime AccessContext carries
7
+ // Prisma plumbing the unit under test never touches.
8
+ function nonSudoContext() {
9
+ return {
10
+ session: null,
11
+ _isSudo: false,
12
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
13
+ } as any
14
+ }
15
+
16
+ function sudoContext() {
17
+ return {
18
+ session: null,
19
+ _isSudo: true,
20
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
21
+ } as any
22
+ }
3
23
 
4
24
  describe('filterWritableFields', () => {
5
25
  it('should filter out foreign key fields when their corresponding relationship field exists', async () => {
@@ -146,4 +166,239 @@ describe('filterWritableFields', () => {
146
166
  // authorId is a foreign key for author relationship, so it should be filtered
147
167
  expect(filtered).not.toHaveProperty('authorId')
148
168
  })
169
+
170
+ // ── #564: undeclared data keys must fail CLOSED (throw) for non-sudo writes ──
171
+
172
+ it('throws on an undeclared data key for a non-sudo create', async () => {
173
+ const fieldConfigs = { title: { type: 'text' } }
174
+ const data = {
175
+ title: 'Test',
176
+ // Not a declared field — e.g. a Prisma back-relation the config never
177
+ // exposed (`from_Enrolment_student`). Must be rejected, not passed through.
178
+ from_Enrolment_student: { disconnect: [{ id: 'e1' }] },
179
+ }
180
+
181
+ await expect(
182
+ filterWritableFields(data, fieldConfigs, 'create', {
183
+ session: null,
184
+ context: nonSudoContext(),
185
+ inputData: data,
186
+ }),
187
+ ).rejects.toThrow(ValidationError)
188
+ await expect(
189
+ filterWritableFields(data, fieldConfigs, 'create', {
190
+ session: null,
191
+ context: nonSudoContext(),
192
+ inputData: data,
193
+ }),
194
+ ).rejects.toThrow(/from_Enrolment_student/)
195
+ })
196
+
197
+ it('throws on an undeclared data key for a non-sudo update', async () => {
198
+ const fieldConfigs = { title: { type: 'text' } }
199
+ const data = {
200
+ title: 'Updated',
201
+ bogusKey: 'value',
202
+ }
203
+
204
+ await expect(
205
+ filterWritableFields(data, fieldConfigs, 'update', {
206
+ session: null,
207
+ item: { id: 'post-1' },
208
+ context: nonSudoContext(),
209
+ inputData: data,
210
+ }),
211
+ ).rejects.toThrow(/bogusKey/)
212
+ })
213
+
214
+ it('passes undeclared data keys through under sudo (the single trusted bypass)', async () => {
215
+ const fieldConfigs = { title: { type: 'text' } }
216
+ const data = {
217
+ title: 'Test',
218
+ from_Enrolment_student: { disconnect: [{ id: 'e1' }] },
219
+ }
220
+
221
+ const filtered = await filterWritableFields(data, fieldConfigs, 'create', {
222
+ session: null,
223
+ context: sudoContext(),
224
+ inputData: data,
225
+ })
226
+
227
+ expect(filtered).toHaveProperty('title', 'Test')
228
+ expect(filtered).toHaveProperty('from_Enrolment_student')
229
+ })
230
+
231
+ it('still skips system fields and relationship FK fields cleanly for a non-sudo write', async () => {
232
+ const fieldConfigs = {
233
+ title: { type: 'text' },
234
+ author: { type: 'relationship', many: false },
235
+ }
236
+ const data = {
237
+ id: 'post-1',
238
+ createdAt: new Date(),
239
+ updatedAt: new Date(),
240
+ title: 'Test',
241
+ authorId: 'user-1', // FK skipped, not rejected
242
+ }
243
+
244
+ const filtered = await filterWritableFields(data, fieldConfigs, 'create', {
245
+ session: null,
246
+ context: nonSudoContext(),
247
+ inputData: data,
248
+ })
249
+
250
+ expect(filtered).not.toHaveProperty('id')
251
+ expect(filtered).not.toHaveProperty('createdAt')
252
+ expect(filtered).not.toHaveProperty('updatedAt')
253
+ expect(filtered).not.toHaveProperty('authorId')
254
+ expect(filtered).toHaveProperty('title', 'Test')
255
+ })
256
+
257
+ it('passes through raw per-part columns from a multi-column field whose write access ALLOWS (non-sudo)', async () => {
258
+ // Multi-column fields inject raw columns (e.g. m_url/m_size) that are not
259
+ // declared in fieldConfigs; they must not trip the undeclared-key reject.
260
+ // With no field-level access (allow), they pass through.
261
+ const fieldConfigs = {
262
+ media: {
263
+ type: 'image',
264
+ getColumnNames: (fieldName: string) => [`${fieldName}_url`, `${fieldName}_size`],
265
+ },
266
+ }
267
+ const data = {
268
+ media_url: 'https://x/y.jpg',
269
+ media_size: 99,
270
+ }
271
+
272
+ const filtered = await filterWritableFields(data, fieldConfigs, 'create', {
273
+ session: null,
274
+ context: nonSudoContext(),
275
+ inputData: data,
276
+ })
277
+
278
+ expect(filtered).toHaveProperty('media_url', 'https://x/y.jpg')
279
+ expect(filtered).toHaveProperty('media_size', 99)
280
+ })
281
+
282
+ it('THROWS when raw split columns are supplied for a field whose write access is DENIED (non-sudo)', async () => {
283
+ // Security (#568): a non-sudo caller who supplies the raw per-part columns
284
+ // DIRECTLY must not bypass the owning field's write-access gate. The
285
+ // logical-key gate in the hooks layer never fires here (no `media` key), so
286
+ // this filter is the only enforcement point — it must throw.
287
+ const fieldConfigs = {
288
+ media: {
289
+ type: 'image',
290
+ access: { create: () => false, update: () => false },
291
+ getColumnNames: (fieldName: string) => [`${fieldName}_url`, `${fieldName}_size`],
292
+ },
293
+ }
294
+ const data = {
295
+ media_url: 'https://evil/x.jpg',
296
+ media_size: 1,
297
+ }
298
+
299
+ // Throws ValidationError, and the message names the owning field.
300
+ await expect(
301
+ filterWritableFields(data, fieldConfigs, 'create', {
302
+ session: null,
303
+ context: nonSudoContext(),
304
+ inputData: data,
305
+ }),
306
+ ).rejects.toThrow(ValidationError)
307
+ await expect(
308
+ filterWritableFields(data, fieldConfigs, 'update', {
309
+ session: null,
310
+ item: { id: 'item-1' },
311
+ context: nonSudoContext(),
312
+ inputData: data,
313
+ }),
314
+ ).rejects.toThrow(/media/)
315
+ })
316
+
317
+ it('passes raw split columns through under sudo regardless of denied owning-field access', async () => {
318
+ // sudo is the single trusted bypass; `checkFieldAccess` returns true under
319
+ // sudo, so even a would-be-denied multi-column field passes through.
320
+ const fieldConfigs = {
321
+ media: {
322
+ type: 'image',
323
+ access: { create: () => false, update: () => false },
324
+ getColumnNames: (fieldName: string) => [`${fieldName}_url`, `${fieldName}_size`],
325
+ },
326
+ }
327
+ const data = {
328
+ media_url: 'https://x/y.jpg',
329
+ media_size: 99,
330
+ }
331
+
332
+ const filtered = await filterWritableFields(data, fieldConfigs, 'create', {
333
+ session: null,
334
+ context: sudoContext(),
335
+ inputData: data,
336
+ })
337
+
338
+ expect(filtered).toHaveProperty('media_url', 'https://x/y.jpg')
339
+ expect(filtered).toHaveProperty('media_size', 99)
340
+ })
341
+
342
+ // ── #568: field-access-denied keys must THROW, not be silently stripped ──────
343
+
344
+ it('throws when a declared field is denied by field-level access (non-sudo)', async () => {
345
+ const fieldConfigs = {
346
+ title: { type: 'text' },
347
+ status: {
348
+ type: 'text',
349
+ access: { update: () => false, create: () => false },
350
+ },
351
+ }
352
+ const data = { title: 'Test', status: 'published' }
353
+
354
+ await expect(
355
+ filterWritableFields(data, fieldConfigs, 'update', {
356
+ session: null,
357
+ item: { id: 'post-1' },
358
+ context: nonSudoContext(),
359
+ inputData: data,
360
+ }),
361
+ ).rejects.toThrow(/status/)
362
+ })
363
+
364
+ it('does NOT throw on a would-be-denied field under sudo', async () => {
365
+ const fieldConfigs = {
366
+ title: { type: 'text' },
367
+ status: {
368
+ type: 'text',
369
+ access: { update: () => false, create: () => false },
370
+ },
371
+ }
372
+ const data = { title: 'Test', status: 'published' }
373
+
374
+ const filtered = await filterWritableFields(data, fieldConfigs, 'update', {
375
+ session: null,
376
+ item: { id: 'post-1' },
377
+ context: sudoContext(),
378
+ inputData: data,
379
+ })
380
+
381
+ expect(filtered).toHaveProperty('title', 'Test')
382
+ expect(filtered).toHaveProperty('status', 'published')
383
+ })
384
+
385
+ it('passes a declared relationship field through to nested operations (non-sudo)', async () => {
386
+ const fieldConfigs = {
387
+ title: { type: 'text' },
388
+ author: { type: 'relationship', many: false },
389
+ }
390
+ const data = {
391
+ title: 'Test',
392
+ author: { connect: { id: 'user-1' } },
393
+ }
394
+
395
+ const filtered = await filterWritableFields(data, fieldConfigs, 'create', {
396
+ session: null,
397
+ context: nonSudoContext(),
398
+ inputData: data,
399
+ })
400
+
401
+ expect(filtered).toHaveProperty('author')
402
+ expect(filtered.author).toEqual({ connect: { id: 'user-1' } })
403
+ })
149
404
  })
@@ -1,5 +1,9 @@
1
1
  import type { Session, AccessContext } from './types.js'
2
2
  import type { FieldAccess } from './types.js'
3
+ // `ValidationError` is referenced only inside function bodies (call-time), never
4
+ // at module-evaluation time, so the field-access ⇄ hooks import cycle is safe
5
+ // under ESM live bindings.
6
+ import { ValidationError } from '../hooks/index.js'
3
7
 
4
8
  /**
5
9
  * Shared field-level access evaluation.
@@ -100,7 +104,14 @@ function matchesFilter(item: Record<string, unknown>, filter: Record<string, unk
100
104
  */
101
105
  export async function filterWritableFields<T extends Record<string, unknown>>(
102
106
  data: T,
103
- fieldConfigs: Record<string, { access?: FieldAccess; type?: string }>,
107
+ fieldConfigs: Record<
108
+ string,
109
+ {
110
+ access?: FieldAccess
111
+ type?: string
112
+ getColumnNames?: (fieldName: string) => string[]
113
+ }
114
+ >,
104
115
  operation: 'create' | 'update',
105
116
  args: {
106
117
  session: Session | null
@@ -114,6 +125,23 @@ export async function filterWritableFields<T extends Record<string, unknown>>(
114
125
  // Build a set of foreign key field names to exclude
115
126
  // Foreign keys should not be in the data when using Prisma's relation syntax
116
127
  const foreignKeyFields = new Set<string>()
128
+ // Map each raw per-part column name contributed by a multi-column field
129
+ // (e.g. storage image()/file() in Keystone-parity mode) back to its OWNING
130
+ // declared field. These columns are injected into the write payload by the
131
+ // field's `splitColumns` AFTER resolveInput and are intentionally NOT declared
132
+ // as their own entries in `fieldConfigs`, so without this map they would trip
133
+ // the #564 undeclared-key reject below.
134
+ //
135
+ // SECURITY (#568): a raw column must NOT be blanket-passed through. The hooks
136
+ // layer (`executeFieldResolveInputHooks`) only gates the owning field when the
137
+ // LOGICAL key (e.g. `media`) is present, because it iterates declared fields,
138
+ // not data keys. A non-sudo caller who supplies the raw columns DIRECTLY
139
+ // (`data: { media_url, media_size }`) never produces that logical key, so that
140
+ // gate never fires. We therefore gate each raw column HERE by its owning
141
+ // field's write access — denied (non-sudo) throws, allowed (or sudo) passes
142
+ // through — so the legitimate multi-column write path is preserved while the
143
+ // direct-raw-column bypass is closed.
144
+ const splitColumnOwners = new Map<string, { fieldName: string; access?: FieldAccess }>()
117
145
  for (const [fieldName, fieldConfig] of Object.entries(fieldConfigs)) {
118
146
  if (fieldConfig.type === 'relationship') {
119
147
  // For non-many relationships, Prisma creates a foreign key field named `${fieldName}Id`
@@ -122,8 +150,15 @@ export async function filterWritableFields<T extends Record<string, unknown>>(
122
150
  foreignKeyFields.add(`${fieldName}Id`)
123
151
  }
124
152
  }
153
+ if (typeof fieldConfig.getColumnNames === 'function') {
154
+ for (const column of fieldConfig.getColumnNames(fieldName)) {
155
+ splitColumnOwners.set(column, { fieldName, access: fieldConfig.access })
156
+ }
157
+ }
125
158
  }
126
159
 
160
+ const isSudo = args.context._isSudo === true
161
+
127
162
  for (const [fieldName, value] of Object.entries(data)) {
128
163
  const fieldConfig = fieldConfigs[fieldName]
129
164
 
@@ -144,15 +179,66 @@ export async function filterWritableFields<T extends Record<string, unknown>>(
144
179
  continue
145
180
  }
146
181
 
147
- // Check field access (checkFieldAccess already handles sudo mode)
148
- const canWrite = await checkFieldAccess(fieldConfig?.access, operation, {
182
+ // Raw per-part columns produced by a multi-column field's `splitColumns`.
183
+ // They are undeclared by design, so they must not trip the #564 reject — but
184
+ // they must NOT be blanket-passed through either: gate each one by its
185
+ // OWNING field's write access (see the SECURITY note where the map is built).
186
+ // This is the real gate for callers who supply the raw columns directly,
187
+ // because the logical-key gate in `executeFieldResolveInputHooks` never fires
188
+ // for them. Denied (non-sudo) throws — same fail-loud behaviour as a denied
189
+ // declared field (#568); allowed (or sudo, via `checkFieldAccess`) passes
190
+ // through, preserving the legitimate multi-column write path.
191
+ const splitColumnOwner = splitColumnOwners.get(fieldName)
192
+ if (splitColumnOwner) {
193
+ const canWrite = await checkFieldAccess(splitColumnOwner.access, operation, {
194
+ ...args,
195
+ inputData: args.inputData,
196
+ })
197
+ if (!canWrite) {
198
+ throw new ValidationError([
199
+ `Cannot ${operation} "${splitColumnOwner.fieldName}" (via column "${fieldName}"): ` +
200
+ `field-level access denied.`,
201
+ ])
202
+ }
203
+ filtered[fieldName] = value
204
+ continue
205
+ }
206
+
207
+ // #564 — undeclared data keys must fail CLOSED.
208
+ // A key with no entry in `fieldConfigs` is not a field the list config
209
+ // exposes. The generated Prisma model has MORE fields than the config
210
+ // declares (e.g. back-relations like `from_Enrolment_student`), so allowing
211
+ // an undeclared key to pass through lets a non-sudo caller drive ungated
212
+ // nested writes on undeclared back-relations. Mirror Keystone's
213
+ // GraphQL-schema behaviour and reject it. `sudo` is the single trusted
214
+ // bypass, so undeclared keys still pass through under sudo.
215
+ if (!fieldConfig) {
216
+ if (isSudo) {
217
+ filtered[fieldName] = value
218
+ continue
219
+ }
220
+ throw new ValidationError([
221
+ `Cannot ${operation} "${fieldName}": it is not a field of this list. ` +
222
+ `Undeclared data keys are rejected (use sudo to bypass).`,
223
+ ])
224
+ }
225
+
226
+ // #568 — fields denied by field-level access must THROW, not be silently
227
+ // dropped. Keystone threw a GraphQL access error for the same situation;
228
+ // silently stripping the field lets a write "succeed" while doing less than
229
+ // asked (and skips any hook side effects gated on that field).
230
+ // `checkFieldAccess` already returns `true` under sudo, so sudo writes never
231
+ // reach the throw below — no parallel sudo path is needed here.
232
+ const canWrite = await checkFieldAccess(fieldConfig.access, operation, {
149
233
  ...args,
150
234
  inputData: args.inputData,
151
235
  })
152
236
 
153
- if (canWrite) {
154
- filtered[fieldName] = value
237
+ if (!canWrite) {
238
+ throw new ValidationError([`Cannot ${operation} "${fieldName}": field-level access denied.`])
155
239
  }
240
+
241
+ filtered[fieldName] = value
156
242
  }
157
243
 
158
244
  return filtered as Partial<T>
@@ -22,6 +22,6 @@ export {
22
22
  // Canonical field-level access evaluation (shared by read and write paths).
23
23
  export { checkFieldAccess, filterWritableFields } from './field-access.js'
24
24
  // Phase 1 — Access Filter (pre-query row/relation scoping).
25
- export { buildIncludeWithAccessControl } from './access-filter.js'
25
+ export { buildIncludeWithAccessControl, mergeIncludeWithAccessControl } from './access-filter.js'
26
26
  // Phase 2 — Field Visibility (post-query field stripping + resolveOutput).
27
27
  export { filterReadableFields } from './field-visibility.js'
@@ -110,6 +110,48 @@ export interface AugmentedFindMany<TOriginal extends (...args: any[]) => any> {
110
110
  (...args: Parameters<TOriginal>): ReturnType<TOriginal>
111
111
  }
112
112
 
113
+ /**
114
+ * Extra query arguments accepted when a `query` Fragment is provided alongside
115
+ * `context.db.<list>.findFirst({ query: myFragment, ... })`.
116
+ */
117
+ export type FindFirstQueryArgs = {
118
+ where?: Record<string, unknown>
119
+ orderBy?: Record<string, 'asc' | 'desc'> | Array<Record<string, 'asc' | 'desc'>>
120
+ skip?: number
121
+ }
122
+
123
+ /**
124
+ * Overloaded `findFirst` that accepts an optional `query` Fragment.
125
+ *
126
+ * `findFirst` is sugar over the access-controlled `findMany` (`take: 1`), so it
127
+ * applies the exact same query-access checks and access-controlled include
128
+ * building, then returns the first matching record or `null`.
129
+ *
130
+ * - **With `query`**: builds the Prisma `include` from the fragment, executes the
131
+ * query, applies access control, and returns a record shaped to `ResultOf<fragment>`
132
+ * or `null`.
133
+ * - **Without `query`**: behaves exactly like the original Prisma `findFirst`.
134
+ *
135
+ * @example
136
+ * ```ts
137
+ * const post = await context.db.post.findFirst({
138
+ * where: { published: true },
139
+ * orderBy: { createdAt: 'desc' },
140
+ * query: postFragment,
141
+ * })
142
+ * // post: ResultOf<typeof postFragment> | null
143
+ * ```
144
+ */
145
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
146
+ export interface AugmentedFindFirst<TOriginal extends (...args: any[]) => any> {
147
+ // Overload 1: with query fragment — return type narrows to ResultOf<fragment> | null
148
+ <TItem, TFields extends FieldSelection<TItem>>(
149
+ args: FindFirstQueryArgs & { query: Fragment<TItem, TFields> },
150
+ ): Promise<ResultOf<Fragment<TItem, TFields>> | null>
151
+ // Overload 2: original Prisma behaviour
152
+ (...args: Parameters<TOriginal>): ReturnType<TOriginal>
153
+ }
154
+
113
155
  /**
114
156
  * Overloaded `findUnique` that accepts an optional `query` Fragment.
115
157
  *
@@ -149,6 +191,8 @@ export type AccessControlledDB<TPrisma extends PrismaClientLike> = {
149
191
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
150
192
  findUnique: any
151
193
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
194
+ findFirst: any
195
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
152
196
  findMany: any
153
197
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
154
198
  create: any
@@ -161,6 +205,7 @@ export type AccessControlledDB<TPrisma extends PrismaClientLike> = {
161
205
  }
162
206
  ? {
163
207
  findUnique: AugmentedFindUnique<TPrisma[K]['findUnique']>
208
+ findFirst: AugmentedFindFirst<TPrisma[K]['findFirst']>
164
209
  findMany: AugmentedFindMany<TPrisma[K]['findMany']>
165
210
  create: TPrisma[K]['create']
166
211
  update: TPrisma[K]['update']