@opensaas/stack-core 0.32.0 → 0.34.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 +61 -0
  3. package/dist/access/access-filter.d.ts +76 -20
  4. package/dist/access/access-filter.d.ts.map +1 -1
  5. package/dist/access/access-filter.js +102 -70
  6. package/dist/access/access-filter.js.map +1 -1
  7. package/dist/access/access-filter.test.js +175 -10
  8. package/dist/access/access-filter.test.js.map +1 -1
  9. package/dist/access/depth-limits.d.ts +26 -0
  10. package/dist/access/depth-limits.d.ts.map +1 -0
  11. package/dist/access/depth-limits.js +26 -0
  12. package/dist/access/depth-limits.js.map +1 -0
  13. package/dist/access/errors.d.ts +43 -0
  14. package/dist/access/errors.d.ts.map +1 -0
  15. package/dist/access/errors.js +55 -0
  16. package/dist/access/errors.js.map +1 -0
  17. package/dist/access/field-visibility.d.ts.map +1 -1
  18. package/dist/access/field-visibility.js +82 -20
  19. package/dist/access/field-visibility.js.map +1 -1
  20. package/dist/access/index.d.ts +4 -1
  21. package/dist/access/index.d.ts.map +1 -1
  22. package/dist/access/index.js +5 -1
  23. package/dist/access/index.js.map +1 -1
  24. package/dist/access/multi-column-read-write.test.js +1 -1
  25. package/dist/access/multi-column-read-write.test.js.map +1 -1
  26. package/dist/access/relationship-count.test.js +1 -1
  27. package/dist/access/relationship-count.test.js.map +1 -1
  28. package/dist/access/relationship-label-filter.test.js +1 -1
  29. package/dist/access/relationship-label-filter.test.js.map +1 -1
  30. package/dist/access/types.d.ts +15 -7
  31. package/dist/access/types.d.ts.map +1 -1
  32. package/dist/context/index.d.ts.map +1 -1
  33. package/dist/context/index.js +15 -9
  34. package/dist/context/index.js.map +1 -1
  35. package/dist/context/nested-operations.d.ts +1 -1
  36. package/dist/context/nested-operations.d.ts.map +1 -1
  37. package/dist/context/nested-operations.js +1 -5
  38. package/dist/context/nested-operations.js.map +1 -1
  39. package/dist/context/transaction-boundary.d.ts.map +1 -1
  40. package/dist/context/transaction-boundary.js +43 -6
  41. package/dist/context/transaction-boundary.js.map +1 -1
  42. package/dist/context/write-pipeline.d.ts.map +1 -1
  43. package/dist/context/write-pipeline.js +5 -4
  44. package/dist/context/write-pipeline.js.map +1 -1
  45. package/dist/index.d.ts +2 -0
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +9 -0
  48. package/dist/index.js.map +1 -1
  49. package/package.json +1 -1
  50. package/src/access/access-filter.test.ts +258 -7
  51. package/src/access/access-filter.ts +146 -72
  52. package/src/access/depth-limits.ts +26 -0
  53. package/src/access/errors.ts +62 -0
  54. package/src/access/field-visibility.ts +97 -21
  55. package/src/access/index.ts +6 -0
  56. package/src/access/multi-column-read-write.test.ts +1 -1
  57. package/src/access/relationship-count.test.ts +1 -1
  58. package/src/access/relationship-label-filter.test.ts +1 -1
  59. package/src/access/types.ts +12 -5
  60. package/src/context/index.ts +15 -6
  61. package/src/context/nested-operations.ts +0 -7
  62. package/src/context/transaction-boundary.ts +48 -7
  63. package/src/context/write-pipeline.ts +5 -4
  64. package/src/index.ts +11 -0
  65. package/tests/access-relationships.test.ts +78 -64
  66. package/tests/context.test.ts +106 -24
  67. package/tests/default-value-create.test.ts +1 -1
  68. package/tests/hook-pipeline.test.ts +1 -1
  69. package/tests/nav-count.test.ts +2 -2
  70. package/tests/resolve-chain.test.ts +394 -0
  71. package/tests/transaction-boundary-hooks.test.ts +246 -1
  72. package/tests/write-pipeline.test.ts +1 -1
  73. package/tsconfig.tsbuildinfo +1 -1
@@ -23,7 +23,7 @@ describe('Relationship Access Control', () => {
23
23
  },
24
24
  plugins: {},
25
25
  _isSudo: false,
26
- _resolveOutputCounter: { depth: 0 },
26
+ _resolveOutputChain: [],
27
27
  }
28
28
 
29
29
  describe('getRelatedListConfig', () => {
@@ -319,9 +319,10 @@ describe('Relationship Access Control', () => {
319
319
  }
320
320
 
321
321
  // Test that buildIncludeWithAccessControl creates the right where clause
322
- const { buildIncludeWithAccessControl } = await import('../src/access/index.js')
322
+ const { buildIncludeWithAccessControl, toPrismaInclude } =
323
+ await import('../src/access/index.js')
323
324
 
324
- const include = await buildIncludeWithAccessControl(
325
+ const result = await buildIncludeWithAccessControl(
325
326
  config.lists.User.fields,
326
327
  {
327
328
  session: null,
@@ -329,6 +330,7 @@ describe('Relationship Access Control', () => {
329
330
  },
330
331
  config,
331
332
  )
333
+ const include = toPrismaInclude(result)
332
334
 
333
335
  // Should include posts with a where filter
334
336
  expect(include).toBeDefined()
@@ -486,9 +488,10 @@ describe('Relationship Access Control', () => {
486
488
  }
487
489
 
488
490
  // Test that buildIncludeWithAccessControl creates session-based where clause
489
- const { buildIncludeWithAccessControl } = await import('../src/access/index.js')
491
+ const { buildIncludeWithAccessControl, toPrismaInclude } =
492
+ await import('../src/access/index.js')
490
493
 
491
- const include = await buildIncludeWithAccessControl(
494
+ const result = await buildIncludeWithAccessControl(
492
495
  config.lists.User.fields,
493
496
  {
494
497
  session: { userId: '1' },
@@ -496,6 +499,7 @@ describe('Relationship Access Control', () => {
496
499
  },
497
500
  config,
498
501
  )
502
+ const include = toPrismaInclude(result)
499
503
 
500
504
  // Should include posts with session-based where filter
501
505
  expect(include).toBeDefined()
@@ -505,75 +509,85 @@ describe('Relationship Access Control', () => {
505
509
  })
506
510
  })
507
511
 
508
- describe('depth limiting', () => {
509
- it('should prevent infinite recursion with depth limit', async () => {
510
- const config: OpenSaasConfig = {
511
- db: {
512
- provider: 'postgresql',
513
- url: 'postgresql://localhost:5432/test',
514
- },
515
- lists: {
516
- User: {
517
- fields: {
518
- name: { type: 'text' },
519
- posts: {
520
- type: 'relationship',
521
- ref: 'Post.author',
522
- many: true,
523
- },
524
- },
525
- access: {
526
- operation: {
527
- query: () => true,
528
- },
529
- },
512
+ describe('no depth cap on an acyclic chain (issue #830)', () => {
513
+ /**
514
+ * `filterReadableFields` used to stop recursing into relationships past
515
+ * `MAX_DEPTH` (5), so a nested row past that depth was returned with its
516
+ * field-level `read` access never evaluated and its `resolveOutput` hooks
517
+ * never run — even though the row-scoping half of the pipeline
518
+ * (`buildIncludeWithAccessControl`) may have correctly scoped it. The fix
519
+ * removes this cap: by the time a result reaches this function it is
520
+ * already a finite, ACYCLIC tree bounded by whatever the (now fail-closed)
521
+ * pre-query phase permitted, so there is no infinite-recursion risk left
522
+ * to guard against. This test builds an 8-level-deep chain — deeper than
523
+ * the old cap — with a read-denied field and a resolveOutput hook on the
524
+ * deepest list, and asserts both are applied at every level.
525
+ */
526
+ it('applies field-level read access and resolveOutput at depth 6+', async () => {
527
+ const allowQuery = () => true
528
+ const chainLength = 8
529
+ const lists: OpenSaasConfig['lists'] = {}
530
+ for (let i = 0; i < chainLength; i++) {
531
+ const isLast = i === chainLength - 1
532
+ lists[`L${i}`] = {
533
+ fields: {
534
+ name: { type: 'text' },
535
+ ...(isLast
536
+ ? {
537
+ secret: {
538
+ type: 'text',
539
+ access: { read: () => false },
540
+ },
541
+ label: {
542
+ type: 'text',
543
+ hooks: {
544
+ resolveOutput: ({ value }: { value: unknown }) => `resolved:${value}`,
545
+ },
546
+ },
547
+ }
548
+ : {}),
549
+ ...(i < chainLength - 1
550
+ ? { next: { type: 'relationship', ref: `L${i + 1}.prev` } }
551
+ : {}),
552
+ ...(i > 0 ? { prev: { type: 'relationship', ref: `L${i - 1}.next` } } : {}),
530
553
  },
531
- Post: {
532
- fields: {
533
- title: { type: 'text' },
534
- author: {
535
- type: 'relationship',
536
- ref: 'User.posts',
537
- },
538
- },
539
- access: {
540
- operation: {
541
- query: () => true,
542
- },
543
- },
544
- },
545
- },
554
+ access: { operation: { query: allowQuery } },
555
+ }
546
556
  }
547
-
548
- // Create circular reference structure
549
- const user = {
550
- id: '1',
551
- name: 'John Doe',
552
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
553
- posts: [] as any[],
557
+ const config: OpenSaasConfig = {
558
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
559
+ lists,
554
560
  }
555
561
 
556
- const post = {
557
- id: '1',
558
- title: 'Test Post',
559
- author: user,
562
+ // Build a genuinely acyclic, materialized nested object — exactly what
563
+ // Prisma would return for this include shape (a fresh object per level,
564
+ // no shared/back references).
565
+ let deepest: Record<string, unknown> = {
566
+ id: `${chainLength - 1}`,
567
+ name: `L${chainLength - 1}`,
568
+ secret: 'TOP-SECRET',
569
+ label: 'raw-value',
570
+ }
571
+ for (let i = chainLength - 2; i >= 0; i--) {
572
+ deepest = { id: `${i}`, name: `L${i}`, next: deepest }
560
573
  }
561
-
562
- user.posts = [post]
563
574
 
564
575
  const result = await filterReadableFields(
565
- user,
566
- config.lists.User.fields,
567
- {
568
- session: null,
569
- context: mockContext,
570
- },
576
+ deepest,
577
+ config.lists.L0.fields,
578
+ { session: null, context: mockContext },
571
579
  config,
572
580
  )
573
581
 
574
- // Should not throw stack overflow error
575
- expect(result).toBeDefined()
576
- expect(result.posts).toBeDefined()
582
+ // Walk down to the deepest level in the filtered result.
583
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
584
+ let current: any = result
585
+ for (let i = 0; i < chainLength - 1; i++) {
586
+ current = current.next
587
+ }
588
+
589
+ expect(current.secret).toBeUndefined() // read-denied field stripped
590
+ expect(current.label).toBe('resolved:raw-value') // resolveOutput ran
577
591
  })
578
592
  })
579
593
 
@@ -2,6 +2,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
2
  import { getContext } from '../src/context/index.js'
3
3
  import { defineFragment } from '../src/query/index.js'
4
4
  import { virtual } from '../src/fields/index.js'
5
+ import { AccessScopeDepthExceededError } from '../src/access/index.js'
6
+ import { READ_INCLUDE_MAX_DEPTH } from '../src/access/depth-limits.js'
5
7
  import type { OpenSaasConfig } from '../src/config/types.js'
6
8
 
7
9
  describe('getContext', () => {
@@ -1425,20 +1427,19 @@ describe('getContext', () => {
1425
1427
  expect(call.include).toEqual({ posts: true })
1426
1428
  })
1427
1429
 
1428
- // Regression: when buildIncludeWithAccessControl returns `undefined` (a
1429
- // NON-denial outcome), the caller include must PASS THROUGH unchanged
1430
- // rather than every declared relation being silently dropped. The original
1431
- // #566 merge treated `undefined` as "all relations denied" (fail-closed
1432
- // data loss). `undefined` is returned in three non-denial cases:
1433
- // 1. inside a resolveOutput hook / virtual-field context,
1434
- // 2. at MAX_DEPTH,
1435
- // 3. when a list has no relationships.
1436
- describe('passes caller include through when no access include is computed', () => {
1430
+ // Regression for issue #830: a read issued from inside a `resolveOutput`
1431
+ // hook used to lose relation row scoping ENTIRELY — `buildIncludeWithAccessControl`
1432
+ // returned a whole-object `undefined` for the inner read (any
1433
+ // `_resolveOutputChain.length > 0`), which `mergeIncludeWithAccessControl`
1434
+ // treated as "nothing to merge against" and passed the caller's include
1435
+ // through completely unscoped. The fix scopes each immediate relation with
1436
+ // its own access `where` while still not auto-EXPANDING into that
1437
+ // relation's own nested relations (preserving the original loop-prevention
1438
+ // see the self-referential coverage in `access-filter.test.ts`).
1439
+ describe('scopes (without expanding) a caller include used inside a resolveOutput hook (#830)', () => {
1437
1440
  // Build an Author config with a virtual field whose resolveOutput issues a
1438
1441
  // read WITH an explicit include. While that hook runs,
1439
- // _resolveOutputCounter.depth > 0, so buildIncludeWithAccessControl
1440
- // returns undefined for the inner read — exercising the
1441
- // `accessControlledInclude === undefined` passthrough path.
1442
+ // _resolveOutputChain.length > 0.
1442
1443
  function configWithResolveOutputProbe(
1443
1444
  callerInclude: Record<string, unknown>,
1444
1445
  capture: (include: unknown) => void,
@@ -1467,7 +1468,7 @@ describe('getContext', () => {
1467
1468
  }
1468
1469
  }
1469
1470
 
1470
- it('findUnique inside a resolveOutput hook keeps the caller include (not dropped)', async () => {
1471
+ it('findUnique inside a resolveOutput hook row-scopes the relation instead of dropping the where', async () => {
1471
1472
  let innerIncludeSeen: unknown
1472
1473
  const hookConfig = configWithResolveOutputProbe({ post: true }, (include) => {
1473
1474
  innerIncludeSeen = include
@@ -1479,12 +1480,12 @@ describe('getContext', () => {
1479
1480
  const context = await getContext(hookConfig, relPrisma, null)
1480
1481
  await context.db.author.findUnique({ where: { id: 'a1' } })
1481
1482
 
1482
- // The caller include `{ post: true }` survives: the declared `post`
1483
- // relation is NOT dropped despite the access include being undefined here.
1484
- expect(innerIncludeSeen).toEqual({ post: true })
1483
+ // `post` is still fetched (not dropped) but now carries Post's own
1484
+ // query-access `where` it is no longer a bare, unscoped `true`.
1485
+ expect(innerIncludeSeen).toEqual({ post: { where: { status: { equals: 'published' } } } })
1485
1486
  })
1486
1487
 
1487
- it('findMany inside a resolveOutput hook passes a NESTED caller include through whole', async () => {
1488
+ it('findMany inside a resolveOutput hook scopes the relation but does not auto-expand its nested include', async () => {
1488
1489
  let innerIncludeSeen: unknown
1489
1490
  const hookConfig = configWithResolveOutputProbe(
1490
1491
  { post: { include: { author: true } } },
@@ -1499,15 +1500,96 @@ describe('getContext', () => {
1499
1500
  const context = await getContext(hookConfig, relPrisma, null)
1500
1501
  await context.db.author.findMany()
1501
1502
 
1502
- // The whole nested caller include passes through untouched (no access
1503
- // include exists to merge against in resolveOutput context).
1504
- expect(innerIncludeSeen).toEqual({ post: { include: { author: true } } })
1503
+ // The caller's own nested selection (`post.include.author`) is honoured
1504
+ // as-is (access control does not auto-descend further here), while
1505
+ // `post` itself picks up its access `where`.
1506
+ expect(innerIncludeSeen).toEqual({
1507
+ post: { where: { status: { equals: 'published' } }, include: { author: true } },
1508
+ })
1505
1509
  })
1510
+ })
1511
+
1512
+ // Regression for issue #830: a caller `include` nested deeper than the
1513
+ // Access Filter can scope used to be returned unscoped rather than
1514
+ // denied. This exercises the fix end-to-end through `context.db`,
1515
+ // matching the reproduction in the issue: a chain of lists deep enough
1516
+ // to cross `READ_INCLUDE_MAX_DEPTH`, read as a non-privileged session.
1517
+ describe('fail-closed at the read-include depth cap through context.db (#830)', () => {
1518
+ function chainListConfig(count: number): OpenSaasConfig['lists'] {
1519
+ const lists: OpenSaasConfig['lists'] = {}
1520
+ for (let i = 0; i < count; i++) {
1521
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
1522
+ const fields: Record<string, any> = { name: { type: 'text' } }
1523
+ if (i < count - 1) fields.next = { type: 'relationship', ref: `C${i + 1}.prev` }
1524
+ if (i > 0) fields.prev = { type: 'relationship', ref: `C${i - 1}.next` }
1525
+ lists[`C${i}`] = {
1526
+ fields,
1527
+ access: {
1528
+ operation: { query: () => (i === 0 ? true : { ownerId: { equals: `C${i}` } }) },
1529
+ },
1530
+ }
1531
+ }
1532
+ return lists
1533
+ }
1534
+
1535
+ // A caller include selecting `next` `hops` more times, ending bare.
1536
+ function nestedCallerInclude(hops: number): Record<string, unknown> {
1537
+ if (hops <= 0) return true as unknown as Record<string, unknown>
1538
+ return { include: { next: nestedCallerInclude(hops - 1) } }
1539
+ }
1540
+
1541
+ it('throws AccessScopeDepthExceededError for a caller include one hop past the cap', async () => {
1542
+ const chainLength = READ_INCLUDE_MAX_DEPTH + 2
1543
+ const chainConfig: OpenSaasConfig = {
1544
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
1545
+ lists: chainListConfig(chainLength),
1546
+ }
1547
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1548
+ const chainPrisma: any = {}
1549
+ for (let i = 0; i < chainLength; i++) {
1550
+ chainPrisma[`c${i}`] = { findMany: vi.fn(), findFirst: vi.fn(), findUnique: vi.fn() }
1551
+ }
1552
+
1553
+ const context = await getContext(chainConfig, chainPrisma, null)
1506
1554
 
1507
- // The MAX_DEPTH and no-relationships cases also make
1508
- // buildIncludeWithAccessControl return undefined; they flow through the
1509
- // identical `accessControlledInclude === undefined` branch verified above,
1510
- // so the resolveOutput probe covers all three non-denial cases.
1555
+ await expect(
1556
+ context.db.c0.findMany({
1557
+ include: { next: nestedCallerInclude(READ_INCLUDE_MAX_DEPTH) },
1558
+ }),
1559
+ ).rejects.toThrow(AccessScopeDepthExceededError)
1560
+
1561
+ // The database is never even queried — the denial happens before the
1562
+ // Prisma call, not as a post-hoc filter on returned data.
1563
+ expect(chainPrisma.c0.findMany).not.toHaveBeenCalled()
1564
+ })
1565
+
1566
+ it('still returns correctly row-scoped data for the same include one hop shallower', async () => {
1567
+ const chainLength = READ_INCLUDE_MAX_DEPTH + 1
1568
+ const chainConfig: OpenSaasConfig = {
1569
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
1570
+ lists: chainListConfig(chainLength),
1571
+ }
1572
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1573
+ const chainPrisma: any = {}
1574
+ for (let i = 0; i < chainLength; i++) {
1575
+ chainPrisma[`c${i}`] = { findMany: vi.fn(), findFirst: vi.fn(), findUnique: vi.fn() }
1576
+ }
1577
+ chainPrisma.c0.findMany.mockResolvedValue([])
1578
+
1579
+ const context = await getContext(chainConfig, chainPrisma, null)
1580
+
1581
+ await context.db.c0.findMany({
1582
+ include: { next: nestedCallerInclude(READ_INCLUDE_MAX_DEPTH - 1) },
1583
+ })
1584
+
1585
+ // Walk the built include down to the last list — it must carry that
1586
+ // list's own access `where`, proving row scoping, not just "no throw".
1587
+ let entry = chainPrisma.c0.findMany.mock.calls[0][0].include.next
1588
+ for (let i = 1; i < READ_INCLUDE_MAX_DEPTH; i++) {
1589
+ entry = entry.include.next
1590
+ }
1591
+ expect(entry.where).toEqual({ ownerId: { equals: `C${READ_INCLUDE_MAX_DEPTH}` } })
1592
+ })
1511
1593
  })
1512
1594
 
1513
1595
  // Regression for #628: a virtual field named in `include` used to be
@@ -33,7 +33,7 @@ function makeContext(): AccessContext {
33
33
  storage: {} as any,
34
34
  plugins: {},
35
35
  _isSudo: false,
36
- _resolveOutputCounter: { depth: 0 },
36
+ _resolveOutputChain: [],
37
37
  }
38
38
  }
39
39
 
@@ -37,7 +37,7 @@ function makeContext(): AccessContext {
37
37
  storage: {} as any,
38
38
  plugins: {},
39
39
  _isSudo: false,
40
- _resolveOutputCounter: { depth: 0 },
40
+ _resolveOutputChain: [],
41
41
  }
42
42
  }
43
43
 
@@ -43,7 +43,7 @@ function makeContext(counts: Record<string, number>): {
43
43
  storage: {},
44
44
  plugins: {},
45
45
  _isSudo: false,
46
- _resolveOutputCounter: { depth: 0 },
46
+ _resolveOutputChain: [],
47
47
  } as unknown as AccessContext
48
48
  return { context, spies }
49
49
  }
@@ -168,7 +168,7 @@ describe('resolveNavCounts', () => {
168
168
  storage: {},
169
169
  plugins: {},
170
170
  _isSudo: false,
171
- _resolveOutputCounter: { depth: 0 },
171
+ _resolveOutputChain: [],
172
172
  } as unknown as AccessContext
173
173
 
174
174
  const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})