@opensaas/stack-core 0.40.0 → 0.42.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 +65 -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 +70 -58
  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 +76 -50
  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
@@ -1,6 +1,13 @@
1
1
  import type { Hooks } from '../config/types.js'
2
2
  import type { AccessContext } from '../access/types.js'
3
3
  import type { FieldConfig } from '../config/types.js'
4
+ // #1176: resolveInput/validate/beforeOperation/afterOperation (list AND field
5
+ // level) receive the full secured `StackContext` — sudo()/withSession()/
6
+ // transaction() bound to the write's own transaction client — not the plain
7
+ // `AccessContext`. Transaction-boundary hooks (beforeTransaction/
8
+ // afterTransaction) are unaffected: they stay on `AccessContext`/the base
9
+ // client by ADR-0028 design.
10
+ import type { StackContext } from '../context/index.js'
4
11
  import { validateWithZod } from '../validation/schema.js'
5
12
  import { checkFieldAccess } from '../access/field-access.js'
6
13
 
@@ -42,7 +49,7 @@ export async function executeResolveInput<
42
49
  inputData: TCreateInput
43
50
  resolvedData: TCreateInput
44
51
  item: undefined
45
- context: AccessContext
52
+ context: StackContext
46
53
  }
47
54
  | {
48
55
  listKey: string
@@ -50,7 +57,7 @@ export async function executeResolveInput<
50
57
  inputData: TUpdateInput
51
58
  resolvedData: TUpdateInput
52
59
  item: TOutput
53
- context: AccessContext
60
+ context: StackContext
54
61
  },
55
62
  ): Promise<TCreateInput | TUpdateInput> {
56
63
  if (!hooks?.resolveInput) {
@@ -75,7 +82,7 @@ export async function executeValidate<
75
82
  inputData: TCreateInput
76
83
  resolvedData: TCreateInput
77
84
  item: undefined
78
- context: AccessContext
85
+ context: StackContext
79
86
  }
80
87
  | {
81
88
  listKey: string
@@ -83,13 +90,13 @@ export async function executeValidate<
83
90
  inputData: TUpdateInput
84
91
  resolvedData: TUpdateInput
85
92
  item: TOutput
86
- context: AccessContext
93
+ context: StackContext
87
94
  }
88
95
  | {
89
96
  listKey: string
90
97
  operation: 'delete'
91
98
  item: TOutput
92
- context: AccessContext
99
+ context: StackContext
93
100
  },
94
101
  ): Promise<void> {
95
102
  const validateHook = hooks?.validate || hooks?.validateInput
@@ -131,7 +138,7 @@ export async function executeBeforeOperation<
131
138
  operation: 'create'
132
139
  inputData: TCreateInput
133
140
  resolvedData: TCreateInput
134
- context: AccessContext
141
+ context: StackContext
135
142
  }
136
143
  | {
137
144
  listKey: string
@@ -139,13 +146,13 @@ export async function executeBeforeOperation<
139
146
  inputData: TUpdateInput
140
147
  item: TOutput
141
148
  resolvedData: TUpdateInput
142
- context: AccessContext
149
+ context: StackContext
143
150
  }
144
151
  | {
145
152
  listKey: string
146
153
  operation: 'delete'
147
154
  item: TOutput
148
- context: AccessContext
155
+ context: StackContext
149
156
  },
150
157
  ): Promise<void> {
151
158
  if (!hooks?.beforeOperation) {
@@ -168,7 +175,7 @@ export async function executeAfterOperation<
168
175
  inputData: TCreateInput
169
176
  item: TOutput
170
177
  resolvedData: TCreateInput
171
- context: AccessContext
178
+ context: StackContext
172
179
  }
173
180
  | {
174
181
  listKey: string
@@ -177,13 +184,13 @@ export async function executeAfterOperation<
177
184
  originalItem: TOutput
178
185
  item: TOutput
179
186
  resolvedData: TUpdateInput
180
- context: AccessContext
187
+ context: StackContext
181
188
  }
182
189
  | {
183
190
  listKey: string
184
191
  operation: 'delete'
185
192
  originalItem: TOutput
186
- context: AccessContext
193
+ context: StackContext
187
194
  },
188
195
  ): Promise<void> {
189
196
  if (!hooks?.afterOperation) {
@@ -472,7 +479,7 @@ export async function executeFieldResolveInputHooks(
472
479
  resolvedData: Record<string, any>,
473
480
  fields: Record<string, FieldConfig>,
474
481
  operation: 'create' | 'update',
475
- context: AccessContext,
482
+ context: StackContext,
476
483
  listKey: string,
477
484
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
478
485
  item?: any,
@@ -575,7 +582,7 @@ export async function executeFieldValidateHooks(
575
582
  resolvedData: Record<string, any> | undefined,
576
583
  fields: Record<string, FieldConfig>,
577
584
  operation: 'create' | 'update' | 'delete',
578
- context: AccessContext,
585
+ context: StackContext,
579
586
  listKey: string,
580
587
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
581
588
  item?: any,
@@ -640,7 +647,7 @@ export async function executeFieldBeforeOperationHooks(
640
647
  resolvedData: Record<string, any>,
641
648
  fields: Record<string, FieldConfig>,
642
649
  operation: 'create' | 'update' | 'delete',
643
- context: AccessContext,
650
+ context: StackContext,
644
651
  listKey: string,
645
652
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
646
653
  item?: any,
@@ -687,7 +694,7 @@ export async function executeFieldAfterOperationHooks(
687
694
  resolvedData: Record<string, unknown> | undefined,
688
695
  fields: Record<string, FieldConfig>,
689
696
  operation: 'create' | 'update' | 'delete',
690
- context: AccessContext,
697
+ context: StackContext,
691
698
  listKey: string,
692
699
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
693
700
  originalItem?: any,
@@ -48,6 +48,7 @@ describe('getContext', () => {
48
48
  fields: {
49
49
  name: { type: 'text' },
50
50
  email: { type: 'text', isIndexed: 'unique' },
51
+ posts: { type: 'relationship', ref: 'Post.author', many: true },
51
52
  },
52
53
  access: {
53
54
  operation: {
@@ -2281,6 +2282,59 @@ describe('getContext', () => {
2281
2282
  expect(call.include.posts.where).toEqual({ status: { equals: 'published' } })
2282
2283
  })
2283
2284
 
2285
+ it('caller `_count` folds the relation access where into the select (#1087)', async () => {
2286
+ relPrisma.author.findMany.mockResolvedValue([
2287
+ { id: 'a1', name: 'Jo', _count: { posts: 3 } },
2288
+ ])
2289
+
2290
+ const context = await getContext(relConfig, relPrisma, null)
2291
+ const result = await context.db.author.findMany({
2292
+ include: { _count: { select: { posts: true } } },
2293
+ })
2294
+
2295
+ const call = relPrisma.author.findMany.mock.calls[0][0]
2296
+ expect(call.include._count).toEqual({
2297
+ select: { posts: { where: { status: { equals: 'published' } } } },
2298
+ })
2299
+ expect(result[0]._count).toEqual({ posts: 3 })
2300
+ })
2301
+
2302
+ it('caller `_count` on a fully-denied relation is omitted from the select and returns 0 (#1087)', async () => {
2303
+ relPrisma.author.findMany.mockResolvedValue([{ id: 'a1', name: 'Jo' }])
2304
+
2305
+ const context = await getContext(relConfig, relPrisma, null)
2306
+ const result = await context.db.author.findMany({
2307
+ include: { _count: { select: { posts: true, secrets: true } } },
2308
+ })
2309
+
2310
+ const call = relPrisma.author.findMany.mock.calls[0][0]
2311
+ // The denied `Secret` list is never asked to be counted...
2312
+ expect(call.include._count).toEqual({
2313
+ select: { posts: { where: { status: { equals: 'published' } } } },
2314
+ })
2315
+ // ...but the caller still sees an explicit `0`, never an absent key —
2316
+ // a count is session-relative, and `0` is what "no visible rows" means.
2317
+ expect(result[0]._count).toEqual({ secrets: 0 })
2318
+ })
2319
+
2320
+ it('sudo `_count` is used as-is, unscoped (behaviour preserved)', async () => {
2321
+ relPrisma.author.findMany.mockResolvedValue([
2322
+ { id: 'a1', name: 'Jo', _count: { posts: 3, secrets: 7 } },
2323
+ ])
2324
+
2325
+ const context = await getContext(relConfig, relPrisma, null).sudo()
2326
+ const result = await context.db.author.findMany({
2327
+ include: { _count: { select: { posts: true, secrets: true } } },
2328
+ })
2329
+
2330
+ expect(relPrisma.author.findMany).toHaveBeenCalledWith(
2331
+ expect.objectContaining({
2332
+ include: { _count: { select: { posts: true, secrets: true } } },
2333
+ }),
2334
+ )
2335
+ expect(result[0]._count).toEqual({ posts: 3, secrets: 7 })
2336
+ })
2337
+
2284
2338
  it('AND-combines a caller nested where with the relation access where', async () => {
2285
2339
  relPrisma.author.findMany.mockResolvedValue([{ id: 'a1', name: 'Jo', posts: [] }])
2286
2340
 
@@ -2322,7 +2376,7 @@ describe('getContext', () => {
2322
2376
  )
2323
2377
  })
2324
2378
 
2325
- it('query fragment path is unaffected by the merge (fragment include used, unfiltered)', async () => {
2379
+ it('query fragment path carries the access filter, same as the include: path (#1088)', async () => {
2326
2380
  relPrisma.author.findMany.mockResolvedValue([{ id: 'a1', name: 'Jo', posts: [] }])
2327
2381
 
2328
2382
  const postsFragment = defineFragment<{ id: string; title: string }>()({
@@ -2338,10 +2392,144 @@ describe('getContext', () => {
2338
2392
  await context.db.author.findMany({ query: fragment })
2339
2393
 
2340
2394
  const call = relPrisma.author.findMany.mock.calls[0][0]
2341
- // Fragment-built include is used as-is; the merge helper is NOT applied to
2342
- // the fragment path, so the relation carries no access `where` here. (The
2343
- // fragment posts-selection contains only scalars, so it builds to `true`.)
2344
- expect(call.include).toEqual({ posts: true })
2395
+ // The fragment-built include now runs through the same scoping walk as
2396
+ // an explicit caller `include`, so `posts` carries Post's access where
2397
+ // (matching the `include: { posts: true }` test above) instead of a
2398
+ // bare, unfiltered `true`.
2399
+ expect(call.include).toEqual({ posts: { where: { status: { equals: 'published' } } } })
2400
+ })
2401
+
2402
+ it('drops a relation whose query access is false when named in a query fragment', async () => {
2403
+ relPrisma.author.findMany.mockResolvedValue([{ id: 'a1', name: 'Jo' }])
2404
+
2405
+ const secretsFragment = defineFragment<{ id: string; value: string }>()({
2406
+ value: true,
2407
+ } as const)
2408
+ const fragment = defineFragment<{ id: string; name: string; secrets: unknown }>()({
2409
+ id: true,
2410
+ name: true,
2411
+ secrets: secretsFragment,
2412
+ } as const)
2413
+
2414
+ const context = await getContext(relConfig, relPrisma, null)
2415
+ await context.db.author.findMany({ query: fragment })
2416
+
2417
+ const call = relPrisma.author.findMany.mock.calls[0][0]
2418
+ // `Secret`'s query access is `() => false` — the denied relation is
2419
+ // dropped from the include entirely, same as the `include:` path.
2420
+ expect(call.include.secrets).toBeUndefined()
2421
+ })
2422
+
2423
+ it('a fragment nesting past the depth cap raises the same depth error a caller include does', async () => {
2424
+ const chainLength = READ_INCLUDE_MAX_DEPTH + 2
2425
+ const chainConfig: OpenSaasConfig = {
2426
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
2427
+ lists: {},
2428
+ }
2429
+ for (let i = 0; i < chainLength; i++) {
2430
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
2431
+ const fields: Record<string, any> = { name: { type: 'text' } }
2432
+ if (i < chainLength - 1) fields.next = { type: 'relationship', ref: `D${i + 1}.prev` }
2433
+ if (i > 0) fields.prev = { type: 'relationship', ref: `D${i - 1}.next` }
2434
+ chainConfig.lists[`D${i}`] = { fields, access: { operation: { query: () => true } } }
2435
+ }
2436
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2437
+ const chainPrisma: any = {}
2438
+ for (let i = 0; i < chainLength; i++) {
2439
+ chainPrisma[`d${i}`] = { findMany: vi.fn(), findFirst: vi.fn(), findUnique: vi.fn() }
2440
+ }
2441
+
2442
+ // A fragment selecting `next` recursively, `hops` levels deep.
2443
+ function nestedFragmentFields(hops: number): Record<string, unknown> {
2444
+ if (hops <= 0) return { name: true }
2445
+ return {
2446
+ name: true,
2447
+ next: defineFragment<Record<string, unknown>>()(nestedFragmentFields(hops - 1)),
2448
+ }
2449
+ }
2450
+ const deepFragment = defineFragment<Record<string, unknown>>()(
2451
+ nestedFragmentFields(READ_INCLUDE_MAX_DEPTH + 1),
2452
+ )
2453
+
2454
+ const context = await getContext(chainConfig, chainPrisma, null)
2455
+
2456
+ await expect(context.db.d0.findMany({ query: deepFragment })).rejects.toThrow(
2457
+ AccessScopeDepthExceededError,
2458
+ )
2459
+ expect(chainPrisma.d0.findMany).not.toHaveBeenCalled()
2460
+ })
2461
+
2462
+ it('sudo query fragment reads stay unscoped (behaviour preserved)', async () => {
2463
+ relPrisma.author.findMany.mockResolvedValue([{ id: 'a1', name: 'Jo' }])
2464
+
2465
+ const secretsFragment = defineFragment<{ id: string; value: string }>()({
2466
+ value: true,
2467
+ } as const)
2468
+ const fragment = defineFragment<{ id: string; name: string; secrets: unknown }>()({
2469
+ id: true,
2470
+ name: true,
2471
+ secrets: secretsFragment,
2472
+ } as const)
2473
+
2474
+ const context = await getContext(relConfig, relPrisma, null).sudo()
2475
+ await context.db.author.findMany({ query: fragment })
2476
+
2477
+ // Under sudo, the fragment-built include is used as-is: the denied
2478
+ // `Secret` relation is fetched unfiltered, matching sudo's existing
2479
+ // include: behaviour.
2480
+ expect(relPrisma.author.findMany).toHaveBeenCalledWith(
2481
+ expect.objectContaining({ include: { secrets: true } }),
2482
+ )
2483
+ })
2484
+
2485
+ it('a fragment read on a to-one relation nulls out a row its related list denies, matching include: (#974)', async () => {
2486
+ // `Comment.post` is a to-one relation onto `Post`, whose query access
2487
+ // is a row filter (`status: published`) rather than a plain boolean —
2488
+ // Prisma can't carry that as a `where` on a to-one include, so it's
2489
+ // resolved via the post-query existence check instead.
2490
+ const postFragment = defineFragment<{ id: string; title: string }>()({
2491
+ title: true,
2492
+ } as const)
2493
+ const fragment = defineFragment<{ id: string; body: string; post: unknown }>()({
2494
+ id: true,
2495
+ body: true,
2496
+ post: postFragment,
2497
+ } as const)
2498
+
2499
+ relPrisma.comment.findMany.mockResolvedValue([
2500
+ { id: 'c1', body: 'hi', post: { id: 'p1', title: 'Draft' } },
2501
+ ])
2502
+ // The batched existence check queries the raw Post model directly;
2503
+ // an empty result means `p1` does not satisfy Post's access filter.
2504
+ relPrisma.post.findMany.mockResolvedValue([])
2505
+
2506
+ const context = await getContext(relConfig, relPrisma, null)
2507
+ const result = await context.db.comment.findMany({ query: fragment })
2508
+
2509
+ expect(result[0].post).toBeNull()
2510
+ })
2511
+
2512
+ it('a fragment read and an equivalent include: read produce the same access-scoped include', async () => {
2513
+ relPrisma.author.findMany.mockResolvedValue([{ id: 'a1', name: 'Jo', posts: [] }])
2514
+
2515
+ const postsFragment = defineFragment<{ id: string; title: string }>()({
2516
+ title: true,
2517
+ } as const)
2518
+ const fragment = defineFragment<{ id: string; name: string; posts: unknown }>()({
2519
+ id: true,
2520
+ name: true,
2521
+ posts: postsFragment,
2522
+ } as const)
2523
+
2524
+ const context = await getContext(relConfig, relPrisma, null)
2525
+ await context.db.author.findMany({ query: fragment })
2526
+ const fragmentInclude = relPrisma.author.findMany.mock.calls[0][0].include
2527
+
2528
+ relPrisma.author.findMany.mockClear()
2529
+ await context.db.author.findMany({ include: { posts: true } })
2530
+ const callerInclude = relPrisma.author.findMany.mock.calls[0][0].include
2531
+
2532
+ expect(fragmentInclude).toEqual(callerInclude)
2345
2533
  })
2346
2534
 
2347
2535
  // Core new guarantee introduced by #852 / ADR-0026: naming one relation