@opensaas/stack-core 0.38.0 → 0.39.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 (204) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +123 -0
  3. package/CLAUDE.md +21 -0
  4. package/dist/access/access-filter.d.ts +46 -1
  5. package/dist/access/access-filter.d.ts.map +1 -1
  6. package/dist/access/access-filter.js +113 -4
  7. package/dist/access/access-filter.js.map +1 -1
  8. package/dist/access/declared-dependencies.d.ts.map +1 -1
  9. package/dist/access/declared-dependencies.js +4 -9
  10. package/dist/access/declared-dependencies.js.map +1 -1
  11. package/dist/access/engine.d.ts +0 -20
  12. package/dist/access/engine.d.ts.map +1 -1
  13. package/dist/access/engine.js +0 -25
  14. package/dist/access/engine.js.map +1 -1
  15. package/dist/access/errors.d.ts +49 -0
  16. package/dist/access/errors.d.ts.map +1 -1
  17. package/dist/access/errors.js +75 -0
  18. package/dist/access/errors.js.map +1 -1
  19. package/dist/access/field-access.d.ts +39 -1
  20. package/dist/access/field-access.d.ts.map +1 -1
  21. package/dist/access/field-access.js +108 -42
  22. package/dist/access/field-access.js.map +1 -1
  23. package/dist/access/field-access.test.js +229 -2
  24. package/dist/access/field-access.test.js.map +1 -1
  25. package/dist/access/field-transforms.d.ts +0 -27
  26. package/dist/access/field-transforms.d.ts.map +1 -1
  27. package/dist/access/field-visibility.d.ts +0 -4
  28. package/dist/access/field-visibility.d.ts.map +1 -1
  29. package/dist/access/field-visibility.js +13 -39
  30. package/dist/access/field-visibility.js.map +1 -1
  31. package/dist/access/index.d.ts +6 -2
  32. package/dist/access/index.d.ts.map +1 -1
  33. package/dist/access/index.js +12 -2
  34. package/dist/access/index.js.map +1 -1
  35. package/dist/access/query-validation.d.ts +121 -0
  36. package/dist/access/query-validation.d.ts.map +1 -0
  37. package/dist/access/query-validation.js +292 -0
  38. package/dist/access/query-validation.js.map +1 -0
  39. package/dist/access/relationship-count.d.ts.map +1 -1
  40. package/dist/access/relationship-count.js +2 -10
  41. package/dist/access/relationship-count.js.map +1 -1
  42. package/dist/access/relationship-label-filter.d.ts +23 -23
  43. package/dist/access/relationship-label-filter.d.ts.map +1 -1
  44. package/dist/access/relationship-label-filter.js +5 -76
  45. package/dist/access/relationship-label-filter.js.map +1 -1
  46. package/dist/access/relationship-label-filter.test.js +19 -56
  47. package/dist/access/relationship-label-filter.test.js.map +1 -1
  48. package/dist/access/transaction-registry.d.ts +45 -0
  49. package/dist/access/transaction-registry.d.ts.map +1 -0
  50. package/dist/access/transaction-registry.js +37 -0
  51. package/dist/access/transaction-registry.js.map +1 -0
  52. package/dist/access/types.d.ts +55 -59
  53. package/dist/access/types.d.ts.map +1 -1
  54. package/dist/config/index.d.ts +9 -7
  55. package/dist/config/index.d.ts.map +1 -1
  56. package/dist/config/index.js +10 -18
  57. package/dist/config/index.js.map +1 -1
  58. package/dist/config/label.d.ts +6 -13
  59. package/dist/config/label.d.ts.map +1 -1
  60. package/dist/config/label.js +6 -13
  61. package/dist/config/label.js.map +1 -1
  62. package/dist/config/nav-count.d.ts +11 -19
  63. package/dist/config/nav-count.d.ts.map +1 -1
  64. package/dist/config/nav-count.js +11 -19
  65. package/dist/config/nav-count.js.map +1 -1
  66. package/dist/config/plugin-engine.d.ts +0 -10
  67. package/dist/config/plugin-engine.d.ts.map +1 -1
  68. package/dist/config/plugin-engine.js +1 -37
  69. package/dist/config/plugin-engine.js.map +1 -1
  70. package/dist/config/types.d.ts +94 -109
  71. package/dist/config/types.d.ts.map +1 -1
  72. package/dist/context/apply-defaults.d.ts +8 -31
  73. package/dist/context/apply-defaults.d.ts.map +1 -1
  74. package/dist/context/apply-defaults.js +12 -41
  75. package/dist/context/apply-defaults.js.map +1 -1
  76. package/dist/context/hook-pipeline.d.ts +4 -29
  77. package/dist/context/hook-pipeline.d.ts.map +1 -1
  78. package/dist/context/hook-pipeline.js +8 -41
  79. package/dist/context/hook-pipeline.js.map +1 -1
  80. package/dist/context/index.d.ts +4 -15
  81. package/dist/context/index.d.ts.map +1 -1
  82. package/dist/context/index.js +190 -193
  83. package/dist/context/index.js.map +1 -1
  84. package/dist/context/nested-operations.d.ts +7 -26
  85. package/dist/context/nested-operations.d.ts.map +1 -1
  86. package/dist/context/nested-operations.js +73 -171
  87. package/dist/context/nested-operations.js.map +1 -1
  88. package/dist/context/transaction-boundary.d.ts +36 -47
  89. package/dist/context/transaction-boundary.d.ts.map +1 -1
  90. package/dist/context/transaction-boundary.js +92 -79
  91. package/dist/context/transaction-boundary.js.map +1 -1
  92. package/dist/context/write-pipeline.d.ts +25 -64
  93. package/dist/context/write-pipeline.d.ts.map +1 -1
  94. package/dist/context/write-pipeline.js +81 -111
  95. package/dist/context/write-pipeline.js.map +1 -1
  96. package/dist/fields/format-prisma-default.d.ts.map +1 -1
  97. package/dist/fields/format-prisma-default.js +4 -7
  98. package/dist/fields/format-prisma-default.js.map +1 -1
  99. package/dist/fields/index.d.ts +17 -2
  100. package/dist/fields/index.d.ts.map +1 -1
  101. package/dist/fields/index.js +136 -147
  102. package/dist/fields/index.js.map +1 -1
  103. package/dist/filter/collect.d.ts +26 -3
  104. package/dist/filter/collect.d.ts.map +1 -1
  105. package/dist/filter/collect.js +30 -12
  106. package/dist/filter/collect.js.map +1 -1
  107. package/dist/filter/filter.test.js +98 -15
  108. package/dist/filter/filter.test.js.map +1 -1
  109. package/dist/filter/index.d.ts +1 -0
  110. package/dist/filter/index.d.ts.map +1 -1
  111. package/dist/filter/index.js.map +1 -1
  112. package/dist/filter/map.d.ts.map +1 -1
  113. package/dist/filter/map.js +0 -5
  114. package/dist/filter/map.js.map +1 -1
  115. package/dist/filter/parse.d.ts.map +1 -1
  116. package/dist/filter/parse.js +2 -9
  117. package/dist/filter/parse.js.map +1 -1
  118. package/dist/filter/serialize.d.ts.map +1 -1
  119. package/dist/filter/serialize.js +2 -4
  120. package/dist/filter/serialize.js.map +1 -1
  121. package/dist/hooks/index.d.ts +4 -42
  122. package/dist/hooks/index.d.ts.map +1 -1
  123. package/dist/hooks/index.js +5 -59
  124. package/dist/hooks/index.js.map +1 -1
  125. package/dist/index.d.ts +3 -1
  126. package/dist/index.d.ts.map +1 -1
  127. package/dist/index.js +20 -4
  128. package/dist/index.js.map +1 -1
  129. package/dist/internal.d.ts +1 -0
  130. package/dist/internal.d.ts.map +1 -1
  131. package/dist/internal.js +4 -0
  132. package/dist/internal.js.map +1 -1
  133. package/dist/lib/case-utils.d.ts +0 -32
  134. package/dist/lib/case-utils.d.ts.map +1 -1
  135. package/dist/lib/case-utils.js +0 -32
  136. package/dist/lib/case-utils.js.map +1 -1
  137. package/dist/mcp/handler.d.ts +0 -4
  138. package/dist/mcp/handler.d.ts.map +1 -1
  139. package/dist/mcp/handler.js +12 -69
  140. package/dist/mcp/handler.js.map +1 -1
  141. package/dist/query/index.d.ts +26 -227
  142. package/dist/query/index.d.ts.map +1 -1
  143. package/dist/query/index.js +14 -117
  144. package/dist/query/index.js.map +1 -1
  145. package/dist/utils/password.d.ts +0 -68
  146. package/dist/utils/password.d.ts.map +1 -1
  147. package/dist/utils/password.js +7 -76
  148. package/dist/utils/password.js.map +1 -1
  149. package/dist/validation/field-config.d.ts.map +1 -1
  150. package/dist/validation/field-config.js +0 -6
  151. package/dist/validation/field-config.js.map +1 -1
  152. package/dist/validation/schema.d.ts +0 -7
  153. package/dist/validation/schema.d.ts.map +1 -1
  154. package/dist/validation/schema.js +0 -10
  155. package/dist/validation/schema.js.map +1 -1
  156. package/package.json +1 -1
  157. package/src/access/access-filter.ts +152 -5
  158. package/src/access/declared-dependencies.ts +4 -9
  159. package/src/access/engine.ts +0 -25
  160. package/src/access/errors.ts +81 -0
  161. package/src/access/field-access.test.ts +312 -2
  162. package/src/access/field-access.ts +118 -42
  163. package/src/access/field-transforms.ts +8 -39
  164. package/src/access/field-visibility.ts +16 -47
  165. package/src/access/index.ts +20 -2
  166. package/src/access/query-validation.ts +359 -0
  167. package/src/access/relationship-count.ts +2 -10
  168. package/src/access/relationship-label-filter.test.ts +19 -68
  169. package/src/access/relationship-label-filter.ts +27 -104
  170. package/src/access/transaction-registry.ts +58 -0
  171. package/src/access/types.ts +83 -86
  172. package/src/config/index.ts +10 -22
  173. package/src/config/label.ts +6 -13
  174. package/src/config/nav-count.ts +11 -19
  175. package/src/config/plugin-engine.ts +1 -37
  176. package/src/config/types.ts +95 -110
  177. package/src/context/apply-defaults.ts +12 -44
  178. package/src/context/hook-pipeline.ts +12 -67
  179. package/src/context/index.ts +263 -247
  180. package/src/context/nested-operations.ts +94 -226
  181. package/src/context/transaction-boundary.ts +113 -109
  182. package/src/context/write-pipeline.ts +99 -155
  183. package/src/fields/format-prisma-default.ts +4 -7
  184. package/src/fields/index.ts +155 -147
  185. package/src/filter/collect.ts +48 -12
  186. package/src/filter/filter.test.ts +153 -15
  187. package/src/filter/index.ts +1 -0
  188. package/src/filter/map.ts +0 -5
  189. package/src/filter/parse.ts +2 -9
  190. package/src/filter/serialize.ts +2 -4
  191. package/src/hooks/index.ts +5 -59
  192. package/src/index.ts +23 -4
  193. package/src/internal.ts +5 -0
  194. package/src/lib/case-utils.ts +0 -32
  195. package/src/mcp/handler.ts +13 -71
  196. package/src/query/index.ts +29 -235
  197. package/src/utils/password.ts +7 -76
  198. package/src/validation/field-config.ts +0 -6
  199. package/src/validation/schema.ts +0 -10
  200. package/tests/context.test.ts +700 -1
  201. package/tests/field-types.test.ts +222 -0
  202. package/tests/mcp-handler.test.ts +34 -0
  203. package/tests/transaction-boundary-outermost.test.ts +530 -0
  204. package/tsconfig.tsbuildinfo +1 -1
@@ -2,7 +2,10 @@ 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'
5
+ import {
6
+ AccessScopeDepthExceededError,
7
+ InvalidFieldAccessResultError,
8
+ } from '../src/access/index.js'
6
9
  import { READ_INCLUDE_MAX_DEPTH } from '../src/access/depth-limits.js'
7
10
  import type { OpenSaasConfig } from '../src/config/types.js'
8
11
 
@@ -1255,6 +1258,702 @@ describe('getContext', () => {
1255
1258
  })
1256
1259
  })
1257
1260
 
1261
+ describe('read-path key validation (#912)', () => {
1262
+ it('throws on an undeclared `where` key on findMany, naming the list and the key', async () => {
1263
+ const context = await getContext(config, mockPrisma, null)
1264
+
1265
+ await expect(context.db.post.findMany({ where: { bogusKey: 'x' } })).rejects.toThrow(/Post/)
1266
+ await expect(context.db.post.findMany({ where: { bogusKey: 'x' } })).rejects.toThrow(
1267
+ /bogusKey/,
1268
+ )
1269
+ expect(mockPrisma.post.findMany).not.toHaveBeenCalled()
1270
+ })
1271
+
1272
+ it('throws on an undeclared `where` key on count', async () => {
1273
+ const context = await getContext(config, mockPrisma, null)
1274
+
1275
+ await expect(context.db.user.count({ where: { bogusKey: 'x' } })).rejects.toThrow(
1276
+ /bogusKey/,
1277
+ )
1278
+ expect(mockPrisma.user.count).not.toHaveBeenCalled()
1279
+ })
1280
+
1281
+ it('throws on an undeclared `orderBy` key on findMany', async () => {
1282
+ const context = await getContext(config, mockPrisma, null)
1283
+
1284
+ await expect(context.db.post.findMany({ orderBy: { bogusKey: 'asc' } })).rejects.toThrow(
1285
+ /bogusKey/,
1286
+ )
1287
+ expect(mockPrisma.post.findMany).not.toHaveBeenCalled()
1288
+ })
1289
+
1290
+ it('throws on an undeclared key nested inside a logical operator (AND/OR/NOT)', async () => {
1291
+ const context = await getContext(config, mockPrisma, null)
1292
+
1293
+ await expect(
1294
+ context.db.post.findMany({
1295
+ where: { OR: [{ title: { contains: 'a' } }, { bogusKey: 'x' }] },
1296
+ }),
1297
+ ).rejects.toThrow(/bogusKey/)
1298
+
1299
+ await expect(
1300
+ context.db.post.findMany({
1301
+ where: { AND: [{ NOT: { bogusKey: 'x' } }] },
1302
+ }),
1303
+ ).rejects.toThrow(/bogusKey/)
1304
+ })
1305
+
1306
+ it('throws on an undeclared key nested inside a relation filter, naming the RELATED list', async () => {
1307
+ const context = await getContext(config, mockPrisma, null)
1308
+
1309
+ await expect(
1310
+ context.db.post.findMany({ where: { author: { is: { bogusKey: 'x' } } } }),
1311
+ ).rejects.toThrow(/User/)
1312
+ await expect(
1313
+ context.db.post.findMany({ where: { author: { is: { bogusKey: 'x' } } } }),
1314
+ ).rejects.toThrow(/bogusKey/)
1315
+ })
1316
+
1317
+ it("throws on an undeclared key reached via Prisma's direct-nesting to-one filter (no `is` wrapper)", async () => {
1318
+ // Prisma's documented default for filtering a to-one relation nests the
1319
+ // related list's fields directly, with no `is`/`isNot` wrapper at all —
1320
+ // e.g. `{ author: { email: { contains: '...' } } }`. An undeclared key
1321
+ // reached this way (one hop through a to-one relation, rather than at
1322
+ // the root) must be rejected exactly like the wrapped `is` form.
1323
+ const context = await getContext(config, mockPrisma, null)
1324
+
1325
+ await expect(
1326
+ context.db.post.findMany({ where: { author: { bogusKey: 'x' } } }),
1327
+ ).rejects.toThrow(/User/)
1328
+ await expect(
1329
+ context.db.post.findMany({ where: { author: { bogusKey: 'x' } } }),
1330
+ ).rejects.toThrow(/bogusKey/)
1331
+ expect(mockPrisma.post.findMany).not.toHaveBeenCalled()
1332
+ })
1333
+
1334
+ it('accepts a declared field on the direct-nesting to-one filter form', async () => {
1335
+ mockPrisma.post.findMany.mockResolvedValue([])
1336
+
1337
+ const context = await getContext(config, mockPrisma, null)
1338
+ const where = { author: { name: { equals: 'John' } } }
1339
+ await context.db.post.findMany({ where })
1340
+
1341
+ expect(mockPrisma.post.findMany).toHaveBeenCalledWith(expect.objectContaining({ where }))
1342
+ })
1343
+
1344
+ it('rejects a Prisma-generated back-relation the list config never declares (the regression that matters most)', async () => {
1345
+ // Organisation declares no relationship back to Document — mirrors a
1346
+ // list whose Prisma model gets a `from_Document_organisation`
1347
+ // back-relation purely because Document holds an FK pointing at it.
1348
+ const backRelationPrisma = {
1349
+ organisation: { findMany: vi.fn(), count: vi.fn() },
1350
+ }
1351
+ const backRelationConfig: OpenSaasConfig = {
1352
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
1353
+ lists: {
1354
+ Organisation: {
1355
+ fields: { name: { type: 'text' } },
1356
+ access: { operation: { query: () => true } },
1357
+ },
1358
+ },
1359
+ }
1360
+
1361
+ const context = await getContext(backRelationConfig, backRelationPrisma, null)
1362
+
1363
+ await expect(
1364
+ context.db.organisation.findMany({
1365
+ where: { from_Document_organisation: { some: {} } },
1366
+ }),
1367
+ ).rejects.toThrow(/from_Document_organisation/)
1368
+ await expect(
1369
+ context.db.organisation.count({
1370
+ where: { from_Document_organisation: { some: {} } },
1371
+ }),
1372
+ ).rejects.toThrow(/from_Document_organisation/)
1373
+
1374
+ expect(backRelationPrisma.organisation.findMany).not.toHaveBeenCalled()
1375
+ expect(backRelationPrisma.organisation.count).not.toHaveBeenCalled()
1376
+ })
1377
+
1378
+ it('never mistakes a Prisma filter operator (equals/contains/startsWith/in/is/isNot) for a field name', async () => {
1379
+ mockPrisma.post.findMany.mockResolvedValue([
1380
+ { id: '1', title: 'Test Post', content: 'x', authorId: 'u1' },
1381
+ ])
1382
+
1383
+ const context = await getContext(config, mockPrisma, null)
1384
+ const where = {
1385
+ title: { equals: 'Test Post', contains: 'Test', startsWith: 'T', in: ['Test Post'] },
1386
+ author: { is: { name: 'John' }, isNot: null },
1387
+ }
1388
+
1389
+ await context.db.post.findMany({ where })
1390
+
1391
+ expect(mockPrisma.post.findMany).toHaveBeenCalledWith(expect.objectContaining({ where }))
1392
+ })
1393
+
1394
+ it('passes all of the above through under sudo (the single trusted bypass)', async () => {
1395
+ mockPrisma.post.findMany.mockResolvedValue([])
1396
+
1397
+ const context = (await getContext(config, mockPrisma, null)).sudo()
1398
+ await context.db.post.findMany({
1399
+ where: { bogusKey: 'x', OR: [{ alsoBogus: 'y' }] },
1400
+ orderBy: { alsoOrderByBogus: 'asc' },
1401
+ })
1402
+
1403
+ expect(mockPrisma.post.findMany).toHaveBeenCalledWith(
1404
+ expect.objectContaining({
1405
+ where: { bogusKey: 'x', OR: [{ alsoBogus: 'y' }] },
1406
+ orderBy: { alsoOrderByBogus: 'asc' },
1407
+ }),
1408
+ )
1409
+ })
1410
+
1411
+ it('keeps an implied foreign-key scalar filterable (authorId for author: relationship(...))', async () => {
1412
+ mockPrisma.post.findMany.mockResolvedValue([])
1413
+
1414
+ const context = await getContext(config, mockPrisma, null)
1415
+ await context.db.post.findMany({ where: { authorId: 'user-1' } })
1416
+
1417
+ expect(mockPrisma.post.findMany).toHaveBeenCalledWith(
1418
+ expect.objectContaining({ where: { authorId: 'user-1' } }),
1419
+ )
1420
+ })
1421
+
1422
+ it('findFirst inherits the check through findMany, with no second copy', async () => {
1423
+ const context = await getContext(config, mockPrisma, null)
1424
+
1425
+ await expect(context.db.post.findFirst({ where: { bogusKey: 'x' } })).rejects.toThrow(
1426
+ /bogusKey/,
1427
+ )
1428
+ expect(mockPrisma.post.findMany).not.toHaveBeenCalled()
1429
+ })
1430
+
1431
+ it('updateMany inherits the check through findMany, with no second copy', async () => {
1432
+ const context = await getContext(config, mockPrisma, null)
1433
+
1434
+ await expect(
1435
+ context.db.user.updateMany({ where: { bogusKey: 'x' }, data: { name: 'y' } }),
1436
+ ).rejects.toThrow(/bogusKey/)
1437
+ expect(mockPrisma.user.findMany).not.toHaveBeenCalled()
1438
+ expect(mockPrisma.user.update).not.toHaveBeenCalled()
1439
+ })
1440
+
1441
+ it('an ordinary read using only declared keys is unaffected', async () => {
1442
+ const mockUsers = [{ id: '1', name: 'John', email: 'john@example.com' }]
1443
+ mockPrisma.user.findMany.mockResolvedValue(mockUsers)
1444
+
1445
+ const context = await getContext(config, mockPrisma, null)
1446
+ const result = await context.db.user.findMany({
1447
+ where: { name: 'John' },
1448
+ orderBy: { name: 'asc' },
1449
+ })
1450
+
1451
+ expect(result).toEqual(mockUsers)
1452
+ })
1453
+ })
1454
+
1455
+ describe('predicate-time field-read access (#915)', () => {
1456
+ // The exact shape from the issue: a public `query` gate with one
1457
+ // field-level `read` gate. Without this fix, `billingAddress`'s withheld
1458
+ // value can be recovered one character at a time via `count()`, and its
1459
+ // relative order leaked via `orderBy`, despite no session ever being
1460
+ // allowed to READ it.
1461
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1462
+ let orgPrisma: any
1463
+ let orgConfig: OpenSaasConfig
1464
+
1465
+ beforeEach(() => {
1466
+ orgPrisma = {
1467
+ organisation: { findMany: vi.fn(), count: vi.fn() },
1468
+ }
1469
+ orgConfig = {
1470
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
1471
+ lists: {
1472
+ Organisation: {
1473
+ fields: {
1474
+ name: { type: 'text' },
1475
+ billingAddress: {
1476
+ type: 'text',
1477
+ access: { read: () => false },
1478
+ },
1479
+ },
1480
+ access: { operation: { query: () => true } },
1481
+ },
1482
+ },
1483
+ }
1484
+ })
1485
+
1486
+ it('denies a `where` filter naming a read-denied field, on both findMany and count', async () => {
1487
+ const context = await getContext(orgConfig, orgPrisma, null)
1488
+
1489
+ await expect(
1490
+ context.db.organisation.findMany({
1491
+ where: { billingAddress: { startsWith: '12 ' } },
1492
+ }),
1493
+ ).rejects.toThrow(/billingAddress/)
1494
+ await expect(
1495
+ context.db.organisation.count({
1496
+ where: { billingAddress: { startsWith: '12 ' } },
1497
+ }),
1498
+ ).rejects.toThrow(/billingAddress/)
1499
+
1500
+ expect(orgPrisma.organisation.findMany).not.toHaveBeenCalled()
1501
+ expect(orgPrisma.organisation.count).not.toHaveBeenCalled()
1502
+ })
1503
+
1504
+ it('a count probe answers identically for a matching and a non-matching prefix (no oracle)', async () => {
1505
+ // Before the fix, these two counts would differ (1 vs 0), letting the
1506
+ // withheld value be recovered one character at a time. After the fix,
1507
+ // both throw the SAME denial — there is no distinguishing signal left.
1508
+ const context = await getContext(orgConfig, orgPrisma, null)
1509
+
1510
+ const matching = context.db.organisation
1511
+ .count({ where: { billingAddress: { startsWith: '12 ' } } })
1512
+ .catch((err: Error) => err.message)
1513
+ const nonMatching = context.db.organisation
1514
+ .count({ where: { billingAddress: { startsWith: '99 ' } } })
1515
+ .catch((err: Error) => err.message)
1516
+
1517
+ expect(await matching).toEqual(await nonMatching)
1518
+ expect(orgPrisma.organisation.count).not.toHaveBeenCalled()
1519
+ })
1520
+
1521
+ it('denies ordering by a read-denied field', async () => {
1522
+ const context = await getContext(orgConfig, orgPrisma, null)
1523
+
1524
+ await expect(
1525
+ context.db.organisation.findMany({ orderBy: { billingAddress: 'asc' } }),
1526
+ ).rejects.toThrow(/billingAddress/)
1527
+ expect(orgPrisma.organisation.findMany).not.toHaveBeenCalled()
1528
+ })
1529
+
1530
+ it('checks keys nested inside logical operators (AND/OR/NOT)', async () => {
1531
+ const context = await getContext(orgConfig, orgPrisma, null)
1532
+
1533
+ await expect(
1534
+ context.db.organisation.findMany({
1535
+ where: { OR: [{ name: { contains: 'a' } }, { billingAddress: { contains: 'x' } }] },
1536
+ }),
1537
+ ).rejects.toThrow(/billingAddress/)
1538
+ await expect(
1539
+ context.db.organisation.findMany({
1540
+ where: { AND: [{ NOT: { billingAddress: { contains: 'x' } } }] },
1541
+ }),
1542
+ ).rejects.toThrow(/billingAddress/)
1543
+ })
1544
+
1545
+ it('a row-dependent read rule resolves to a denial, not a skipped check', async () => {
1546
+ // `item` cannot be evaluated before the query runs — there is no row
1547
+ // yet — so a rule that depends on it (the shape `FieldAccess['read']`
1548
+ // documents as the norm) must deny here even though, post-query, the
1549
+ // very same rule might have allowed this session to read the field.
1550
+ const rowDependentConfig: OpenSaasConfig = {
1551
+ ...orgConfig,
1552
+ lists: {
1553
+ Organisation: {
1554
+ ...orgConfig.lists.Organisation,
1555
+ fields: {
1556
+ ...orgConfig.lists.Organisation.fields,
1557
+ billingAddress: {
1558
+ type: 'text',
1559
+ access: {
1560
+ read: ({ item, session }: { item: { ownerId: string }; session: unknown }) =>
1561
+ item.ownerId === (session as { userId?: string } | null)?.userId,
1562
+ },
1563
+ },
1564
+ },
1565
+ },
1566
+ },
1567
+ }
1568
+
1569
+ const context = await getContext(rowDependentConfig, orgPrisma, { userId: 'user-1' })
1570
+
1571
+ await expect(
1572
+ context.db.organisation.findMany({
1573
+ where: { billingAddress: { contains: 'x' } },
1574
+ }),
1575
+ ).rejects.toThrow(/billingAddress/)
1576
+ expect(orgPrisma.organisation.findMany).not.toHaveBeenCalled()
1577
+ })
1578
+
1579
+ it('propagates InvalidFieldAccessResultError (#913), never folding it into a plain denial', async () => {
1580
+ const filterReturningConfig: OpenSaasConfig = {
1581
+ ...orgConfig,
1582
+ lists: {
1583
+ Organisation: {
1584
+ ...orgConfig.lists.Organisation,
1585
+ fields: {
1586
+ ...orgConfig.lists.Organisation.fields,
1587
+ billingAddress: {
1588
+ type: 'text',
1589
+ // Bypasses the FieldAccessControl boolean-only type on purpose —
1590
+ // this is the exact misconfiguration #913 closed.
1591
+ access: { read: (() => ({ ownerId: 'x' })) as unknown as () => boolean },
1592
+ },
1593
+ },
1594
+ },
1595
+ },
1596
+ }
1597
+
1598
+ const context = await getContext(filterReturningConfig, orgPrisma, null)
1599
+
1600
+ await expect(
1601
+ context.db.organisation.findMany({
1602
+ where: { billingAddress: { contains: 'x' } },
1603
+ }),
1604
+ ).rejects.toThrow(InvalidFieldAccessResultError)
1605
+ })
1606
+
1607
+ it('leaves a readable field filterable and sortable (no regression)', async () => {
1608
+ orgPrisma.organisation.findMany.mockResolvedValue([])
1609
+
1610
+ const context = await getContext(orgConfig, orgPrisma, null)
1611
+ await context.db.organisation.findMany({
1612
+ where: { name: { contains: 'Acme' } },
1613
+ orderBy: { name: 'asc' },
1614
+ })
1615
+
1616
+ expect(orgPrisma.organisation.findMany).toHaveBeenCalledWith(
1617
+ expect.objectContaining({
1618
+ where: { name: { contains: 'Acme' } },
1619
+ orderBy: { name: 'asc' },
1620
+ }),
1621
+ )
1622
+ })
1623
+
1624
+ it('sudo bypasses the check entirely, matching #912', async () => {
1625
+ orgPrisma.organisation.findMany.mockResolvedValue([])
1626
+ orgPrisma.organisation.count.mockResolvedValue(3)
1627
+
1628
+ const context = (await getContext(orgConfig, orgPrisma, null)).sudo()
1629
+ await context.db.organisation.findMany({
1630
+ where: { billingAddress: { startsWith: '12 ' } },
1631
+ orderBy: { billingAddress: 'asc' },
1632
+ })
1633
+ await context.db.organisation.count({
1634
+ where: { billingAddress: { startsWith: '12 ' } },
1635
+ })
1636
+
1637
+ expect(orgPrisma.organisation.findMany).toHaveBeenCalledWith(
1638
+ expect.objectContaining({
1639
+ where: { billingAddress: { startsWith: '12 ' } },
1640
+ orderBy: { billingAddress: 'asc' },
1641
+ }),
1642
+ )
1643
+ expect(orgPrisma.organisation.count).toHaveBeenCalledWith(
1644
+ expect.objectContaining({ where: { billingAddress: { startsWith: '12 ' } } }),
1645
+ )
1646
+ })
1647
+
1648
+ it('a caller with zero list access gets the ordinary silent denial, not a validation error naming the field (found in review of #925)', async () => {
1649
+ // Operation-level `query` access is fully denied for this session.
1650
+ // The #912/#915 where/orderBy validation must never run in that case
1651
+ // — the thrown ValidationError names the offending key, which would
1652
+ // let a caller with NO access to the list at all learn a field's
1653
+ // name and its read-gating status purely from the error message.
1654
+ // That is itself the kind of oracle #915 exists to close, just
1655
+ // pointed at "does this field exist and is it read-gated" instead of
1656
+ // "what is this field's value" — so a fully denied caller must see
1657
+ // the SAME silent []/0 whether or not the where/orderBy names an
1658
+ // undeclared or read-denied key.
1659
+ const deniedConfig: OpenSaasConfig = {
1660
+ ...orgConfig,
1661
+ lists: {
1662
+ Organisation: {
1663
+ ...orgConfig.lists.Organisation,
1664
+ access: { operation: { query: () => false } },
1665
+ },
1666
+ },
1667
+ }
1668
+ const context = await getContext(deniedConfig, orgPrisma, null)
1669
+
1670
+ await expect(
1671
+ context.db.organisation.findMany({
1672
+ where: { billingAddress: { startsWith: '12 ' } },
1673
+ }),
1674
+ ).resolves.toEqual([])
1675
+ await expect(
1676
+ context.db.organisation.findMany({ where: { bogusKey: 'x' } }),
1677
+ ).resolves.toEqual([])
1678
+ await expect(
1679
+ context.db.organisation.count({
1680
+ where: { billingAddress: { startsWith: '12 ' } },
1681
+ }),
1682
+ ).resolves.toBe(0)
1683
+ await expect(context.db.organisation.count({ where: { bogusKey: 'x' } })).resolves.toBe(0)
1684
+
1685
+ expect(orgPrisma.organisation.findMany).not.toHaveBeenCalled()
1686
+ expect(orgPrisma.organisation.count).not.toHaveBeenCalled()
1687
+ })
1688
+
1689
+ it('#915 itself does not recurse into a related list nested inside a relation filter, but #916 now does', async () => {
1690
+ // #915 (`validateQueryFieldReadAccess`) checks whether THIS list's
1691
+ // relationship field may be named at all — it does not by itself
1692
+ // reject a field on the RELATED list reached through it. Once #916
1693
+ // landed, that gap is closed by a SEPARATE pass
1694
+ // (`buildAccessScopedWhere`) that scopes relation filters and applies
1695
+ // the related list's own field-read access to keys nested inside
1696
+ // them — so the read-denied `User.secret` field is still rejected
1697
+ // overall, just not by #915's own top-level-only check.
1698
+ const relPrisma = {
1699
+ post: { findMany: vi.fn(), count: vi.fn() },
1700
+ }
1701
+ const relConfig: OpenSaasConfig = {
1702
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
1703
+ lists: {
1704
+ User: {
1705
+ fields: {
1706
+ name: { type: 'text' },
1707
+ secret: { type: 'text', access: { read: () => false } },
1708
+ },
1709
+ access: { operation: { query: () => true } },
1710
+ },
1711
+ Post: {
1712
+ fields: {
1713
+ title: { type: 'text' },
1714
+ author: { type: 'relationship', ref: 'User.posts' },
1715
+ },
1716
+ access: { operation: { query: () => true } },
1717
+ },
1718
+ },
1719
+ }
1720
+ relPrisma.post.findMany.mockResolvedValue([])
1721
+
1722
+ const context = await getContext(relConfig, relPrisma, null)
1723
+ // Names a field on the RELATED list (User.secret) nested inside the
1724
+ // relation filter — not a key of Post itself, so #915's own check
1725
+ // (which only inspects Post's own fields) does not deny it, but
1726
+ // #916's relation-filter scoping (which checks the RELATED list's
1727
+ // field-read access) now does.
1728
+ await expect(
1729
+ context.db.post.findMany({ where: { author: { is: { secret: { contains: 'x' } } } } }),
1730
+ ).rejects.toThrow(/secret/)
1731
+
1732
+ expect(relPrisma.post.findMany).not.toHaveBeenCalled()
1733
+ })
1734
+ })
1735
+
1736
+ describe('relation filter access scoping (#916)', () => {
1737
+ // The exact shape from the issue: a publicly-queryable Organisation,
1738
+ // reachable Document/Membership/User lists each gated differently, and
1739
+ // a two-hop chain (Organisation -> members -> user -> email) that lets
1740
+ // an anonymous caller binary-search a field it could never read
1741
+ // directly, unless the relation filter itself is scoped.
1742
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1743
+ let orgPrisma: any
1744
+ let orgConfig: OpenSaasConfig
1745
+
1746
+ beforeEach(() => {
1747
+ orgPrisma = {
1748
+ organisation: { findMany: vi.fn(), count: vi.fn() },
1749
+ }
1750
+ orgConfig = {
1751
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
1752
+ lists: {
1753
+ Organisation: {
1754
+ fields: {
1755
+ name: { type: 'text' },
1756
+ documents: { type: 'relationship', ref: 'Document.organisation', many: true },
1757
+ members: { type: 'relationship', ref: 'Membership.organisation', many: true },
1758
+ },
1759
+ access: { operation: { query: () => true } },
1760
+ },
1761
+ // Membership-scoped: `query` denies everything for an anonymous
1762
+ // session (no session means no membership filter can match).
1763
+ Document: {
1764
+ fields: {
1765
+ organisation: { type: 'relationship', ref: 'Organisation.documents' },
1766
+ billingAddress: { type: 'text' },
1767
+ },
1768
+ access: { operation: { query: () => false } },
1769
+ },
1770
+ Membership: {
1771
+ fields: {
1772
+ organisation: { type: 'relationship', ref: 'Organisation.members' },
1773
+ user: { type: 'relationship', ref: 'User.memberships' },
1774
+ },
1775
+ // Readable by anyone — the hop itself isn't the leak, the
1776
+ // identity list at the end of the chain is.
1777
+ access: { operation: { query: () => true } },
1778
+ },
1779
+ User: {
1780
+ fields: {
1781
+ memberships: { type: 'relationship', ref: 'Membership.user', many: true },
1782
+ email: { type: 'text' },
1783
+ },
1784
+ // Not queryable at all by an anonymous session.
1785
+ access: { operation: { query: () => false } },
1786
+ },
1787
+ },
1788
+ }
1789
+ })
1790
+
1791
+ it('denies a single-hop relation filter through a fully denied related list, on both findMany and count', async () => {
1792
+ const context = await getContext(orgConfig, orgPrisma, null)
1793
+
1794
+ await expect(
1795
+ context.db.organisation.findMany({
1796
+ where: { documents: { some: { billingAddress: { startsWith: '12 ' } } } },
1797
+ }),
1798
+ ).rejects.toThrow(/Document/)
1799
+ await expect(
1800
+ context.db.organisation.count({
1801
+ where: { documents: { some: { billingAddress: { startsWith: '12 ' } } } },
1802
+ }),
1803
+ ).rejects.toThrow(/Document/)
1804
+
1805
+ expect(orgPrisma.organisation.findMany).not.toHaveBeenCalled()
1806
+ expect(orgPrisma.organisation.count).not.toHaveBeenCalled()
1807
+ })
1808
+
1809
+ it('a count probe through a denied relation answers identically for a matching and non-matching value (no oracle)', async () => {
1810
+ // Before the fix these counts could differ (1 vs 0), letting a caller
1811
+ // recover `billingAddress` one character at a time. After the fix
1812
+ // both throw the SAME denial.
1813
+ const context = await getContext(orgConfig, orgPrisma, null)
1814
+
1815
+ const matching = context.db.organisation
1816
+ .count({ where: { documents: { some: { billingAddress: { startsWith: '12 ' } } } } })
1817
+ .catch((err: Error) => err.message)
1818
+ const nonMatching = context.db.organisation
1819
+ .count({ where: { documents: { some: { billingAddress: { startsWith: '99 ' } } } } })
1820
+ .catch((err: Error) => err.message)
1821
+
1822
+ expect(await matching).toEqual(await nonMatching)
1823
+ expect(orgPrisma.organisation.count).not.toHaveBeenCalled()
1824
+ })
1825
+
1826
+ it('denies a two-hop chain reaching a denied identity list, the exact probe from the issue', async () => {
1827
+ const context = await getContext(orgConfig, orgPrisma, null)
1828
+
1829
+ await expect(
1830
+ context.db.organisation.count({
1831
+ where: {
1832
+ members: { some: { user: { is: { email: { equals: 'ada@example.com' } } } } },
1833
+ },
1834
+ }),
1835
+ ).rejects.toThrow(/User/)
1836
+
1837
+ expect(orgPrisma.organisation.count).not.toHaveBeenCalled()
1838
+ })
1839
+
1840
+ it('a two-hop count probe answers identically for a real and a fabricated email (no identity-confirmation oracle)', async () => {
1841
+ const context = await getContext(orgConfig, orgPrisma, null)
1842
+
1843
+ const real = context.db.organisation
1844
+ .count({
1845
+ where: {
1846
+ members: { some: { user: { is: { email: { equals: 'real@example.com' } } } } },
1847
+ },
1848
+ })
1849
+ .catch((err: Error) => err.message)
1850
+ const fabricated = context.db.organisation
1851
+ .count({
1852
+ where: {
1853
+ members: { some: { user: { is: { email: { equals: 'nobody@example.com' } } } } },
1854
+ },
1855
+ })
1856
+ .catch((err: Error) => err.message)
1857
+
1858
+ expect(await real).toEqual(await fabricated)
1859
+ expect(orgPrisma.organisation.count).not.toHaveBeenCalled()
1860
+ })
1861
+
1862
+ it("folds the related list's access filter into a relation filter rather than denying or passing it through unscoped", async () => {
1863
+ orgPrisma.organisation.findMany.mockResolvedValue([])
1864
+ const scopedConfig: OpenSaasConfig = {
1865
+ ...orgConfig,
1866
+ lists: {
1867
+ ...orgConfig.lists,
1868
+ Document: {
1869
+ ...orgConfig.lists.Document,
1870
+ // A filter, not a boolean: only this org's own published docs.
1871
+ access: { operation: { query: () => ({ published: { equals: true } }) } },
1872
+ },
1873
+ },
1874
+ }
1875
+
1876
+ const context = await getContext(scopedConfig, orgPrisma, null)
1877
+ await context.db.organisation.findMany({
1878
+ where: { documents: { some: { billingAddress: { startsWith: '12 ' } } } },
1879
+ })
1880
+
1881
+ expect(orgPrisma.organisation.findMany).toHaveBeenCalledWith(
1882
+ expect.objectContaining({
1883
+ where: {
1884
+ documents: {
1885
+ some: {
1886
+ AND: [{ published: { equals: true } }, { billingAddress: { startsWith: '12 ' } }],
1887
+ },
1888
+ },
1889
+ },
1890
+ }),
1891
+ )
1892
+ })
1893
+
1894
+ it('leaves a relation filter through a fully readable related list unwrapped (no perturbation)', async () => {
1895
+ orgPrisma.organisation.findMany.mockResolvedValue([])
1896
+ const openConfig: OpenSaasConfig = {
1897
+ ...orgConfig,
1898
+ lists: {
1899
+ ...orgConfig.lists,
1900
+ Membership: {
1901
+ ...orgConfig.lists.Membership,
1902
+ access: { operation: { query: () => true } },
1903
+ },
1904
+ },
1905
+ }
1906
+ const context = await getContext(openConfig, orgPrisma, null)
1907
+ const where = { members: { some: { organisation: { is: { name: { equals: 'Acme' } } } } } }
1908
+
1909
+ await context.db.organisation.findMany({ where })
1910
+
1911
+ expect(orgPrisma.organisation.findMany).toHaveBeenCalledWith(
1912
+ expect.objectContaining({ where }),
1913
+ )
1914
+ })
1915
+
1916
+ it('sudo bypasses relation-filter scoping entirely (#916 unaffected)', async () => {
1917
+ orgPrisma.organisation.findMany.mockResolvedValue([])
1918
+
1919
+ const context = (await getContext(orgConfig, orgPrisma, null)).sudo()
1920
+ const where = { documents: { some: { billingAddress: { startsWith: '12 ' } } } }
1921
+ await context.db.organisation.findMany({ where })
1922
+
1923
+ expect(orgPrisma.organisation.findMany).toHaveBeenCalledWith(
1924
+ expect.objectContaining({ where }),
1925
+ )
1926
+ })
1927
+
1928
+ it('a caller with zero list access gets the ordinary silent denial, never the relation-filter error', async () => {
1929
+ const deniedConfig: OpenSaasConfig = {
1930
+ ...orgConfig,
1931
+ lists: {
1932
+ ...orgConfig.lists,
1933
+ Organisation: {
1934
+ ...orgConfig.lists.Organisation,
1935
+ access: { operation: { query: () => false } },
1936
+ },
1937
+ },
1938
+ }
1939
+ const context = await getContext(deniedConfig, orgPrisma, null)
1940
+
1941
+ await expect(
1942
+ context.db.organisation.findMany({
1943
+ where: { documents: { some: { billingAddress: { startsWith: '12 ' } } } },
1944
+ }),
1945
+ ).resolves.toEqual([])
1946
+ await expect(
1947
+ context.db.organisation.count({
1948
+ where: { documents: { some: { billingAddress: { startsWith: '12 ' } } } },
1949
+ }),
1950
+ ).resolves.toBe(0)
1951
+
1952
+ expect(orgPrisma.organisation.findMany).not.toHaveBeenCalled()
1953
+ expect(orgPrisma.organisation.count).not.toHaveBeenCalled()
1954
+ })
1955
+ })
1956
+
1258
1957
  describe('explicit include merges with access control (#566)', () => {
1259
1958
  // Author has many Posts; Post.query access scopes to published posts only.
1260
1959
  // A caller-supplied `include` must NOT bypass that per-relation filter.