@opensaas/stack-core 0.30.0 → 0.31.1

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 (127) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +412 -0
  3. package/dist/access/access-filter.d.ts.map +1 -1
  4. package/dist/access/access-filter.js +14 -1
  5. package/dist/access/access-filter.js.map +1 -1
  6. package/dist/access/access-filter.test.js +50 -0
  7. package/dist/access/access-filter.test.js.map +1 -1
  8. package/dist/access/field-access.js +6 -6
  9. package/dist/access/field-access.js.map +1 -1
  10. package/dist/access/multi-column-read-write.test.js +46 -12
  11. package/dist/access/multi-column-read-write.test.js.map +1 -1
  12. package/dist/access/relationship-count.d.ts +60 -0
  13. package/dist/access/relationship-count.d.ts.map +1 -0
  14. package/dist/access/relationship-count.js +233 -0
  15. package/dist/access/relationship-count.js.map +1 -0
  16. package/dist/access/relationship-count.test.d.ts +2 -0
  17. package/dist/access/relationship-count.test.d.ts.map +1 -0
  18. package/dist/access/relationship-count.test.js +185 -0
  19. package/dist/access/relationship-count.test.js.map +1 -0
  20. package/dist/access/relationship-label-filter.d.ts +45 -0
  21. package/dist/access/relationship-label-filter.d.ts.map +1 -0
  22. package/dist/access/relationship-label-filter.js +93 -0
  23. package/dist/access/relationship-label-filter.js.map +1 -0
  24. package/dist/access/relationship-label-filter.test.d.ts +2 -0
  25. package/dist/access/relationship-label-filter.test.d.ts.map +1 -0
  26. package/dist/access/relationship-label-filter.test.js +126 -0
  27. package/dist/access/relationship-label-filter.test.js.map +1 -0
  28. package/dist/config/index.d.ts +1 -1
  29. package/dist/config/index.d.ts.map +1 -1
  30. package/dist/config/nav-count.d.ts +34 -0
  31. package/dist/config/nav-count.d.ts.map +1 -0
  32. package/dist/config/nav-count.js +71 -0
  33. package/dist/config/nav-count.js.map +1 -0
  34. package/dist/config/types.d.ts +305 -4
  35. package/dist/config/types.d.ts.map +1 -1
  36. package/dist/context/hook-pipeline.d.ts.map +1 -1
  37. package/dist/context/hook-pipeline.js +11 -1
  38. package/dist/context/hook-pipeline.js.map +1 -1
  39. package/dist/context/index.d.ts +28 -0
  40. package/dist/context/index.d.ts.map +1 -1
  41. package/dist/context/index.js +205 -0
  42. package/dist/context/index.js.map +1 -1
  43. package/dist/context/nested-operations.d.ts.map +1 -1
  44. package/dist/context/nested-operations.js +7 -1
  45. package/dist/context/nested-operations.js.map +1 -1
  46. package/dist/extend.d.ts +1 -0
  47. package/dist/extend.d.ts.map +1 -1
  48. package/dist/fields/index.d.ts +1 -1
  49. package/dist/fields/index.d.ts.map +1 -1
  50. package/dist/fields/index.js +166 -0
  51. package/dist/fields/index.js.map +1 -1
  52. package/dist/filter/collect.d.ts +32 -0
  53. package/dist/filter/collect.d.ts.map +1 -0
  54. package/dist/filter/collect.js +60 -0
  55. package/dist/filter/collect.js.map +1 -0
  56. package/dist/filter/filter.test.d.ts +2 -0
  57. package/dist/filter/filter.test.d.ts.map +1 -0
  58. package/dist/filter/filter.test.js +348 -0
  59. package/dist/filter/filter.test.js.map +1 -0
  60. package/dist/filter/index.d.ts +7 -0
  61. package/dist/filter/index.d.ts.map +1 -0
  62. package/dist/filter/index.js +16 -0
  63. package/dist/filter/index.js.map +1 -0
  64. package/dist/filter/map.d.ts +18 -0
  65. package/dist/filter/map.d.ts.map +1 -0
  66. package/dist/filter/map.js +60 -0
  67. package/dist/filter/map.js.map +1 -0
  68. package/dist/filter/parse.d.ts +33 -0
  69. package/dist/filter/parse.d.ts.map +1 -0
  70. package/dist/filter/parse.js +103 -0
  71. package/dist/filter/parse.js.map +1 -0
  72. package/dist/filter/serialize.d.ts +25 -0
  73. package/dist/filter/serialize.d.ts.map +1 -0
  74. package/dist/filter/serialize.js +68 -0
  75. package/dist/filter/serialize.js.map +1 -0
  76. package/dist/filter/serialize.test.d.ts +2 -0
  77. package/dist/filter/serialize.test.d.ts.map +1 -0
  78. package/dist/filter/serialize.test.js +83 -0
  79. package/dist/filter/serialize.test.js.map +1 -0
  80. package/dist/filter/types.d.ts +122 -0
  81. package/dist/filter/types.d.ts.map +1 -0
  82. package/dist/filter/types.js +24 -0
  83. package/dist/filter/types.js.map +1 -0
  84. package/dist/hooks/index.d.ts +30 -0
  85. package/dist/hooks/index.d.ts.map +1 -1
  86. package/dist/hooks/index.js +79 -59
  87. package/dist/hooks/index.js.map +1 -1
  88. package/dist/index.d.ts +6 -1
  89. package/dist/index.d.ts.map +1 -1
  90. package/dist/index.js +19 -0
  91. package/dist/index.js.map +1 -1
  92. package/dist/internal.d.ts +1 -0
  93. package/dist/internal.d.ts.map +1 -1
  94. package/dist/internal.js +4 -0
  95. package/dist/internal.js.map +1 -1
  96. package/package.json +1 -1
  97. package/src/access/access-filter.test.ts +79 -0
  98. package/src/access/access-filter.ts +15 -4
  99. package/src/access/field-access.ts +6 -6
  100. package/src/access/multi-column-read-write.test.ts +68 -19
  101. package/src/access/relationship-count.test.ts +269 -0
  102. package/src/access/relationship-count.ts +311 -0
  103. package/src/access/relationship-label-filter.test.ts +177 -0
  104. package/src/access/relationship-label-filter.ts +140 -0
  105. package/src/config/index.ts +6 -0
  106. package/src/config/nav-count.ts +85 -0
  107. package/src/config/types.ts +319 -1
  108. package/src/context/hook-pipeline.ts +19 -0
  109. package/src/context/index.ts +285 -3
  110. package/src/context/nested-operations.ts +22 -0
  111. package/src/extend.ts +9 -0
  112. package/src/fields/index.ts +168 -0
  113. package/src/filter/collect.ts +72 -0
  114. package/src/filter/filter.test.ts +400 -0
  115. package/src/filter/index.ts +25 -0
  116. package/src/filter/map.ts +68 -0
  117. package/src/filter/parse.ts +106 -0
  118. package/src/filter/serialize.test.ts +115 -0
  119. package/src/filter/serialize.ts +75 -0
  120. package/src/filter/types.ts +120 -0
  121. package/src/hooks/index.ts +87 -57
  122. package/src/index.ts +54 -0
  123. package/src/internal.ts +5 -0
  124. package/tests/context.test.ts +690 -0
  125. package/tests/multi-column-validation-ordering.test.ts +305 -0
  126. package/tests/nav-count.test.ts +189 -0
  127. package/tsconfig.tsbuildinfo +1 -1
@@ -202,6 +202,696 @@ describe('getContext', () => {
202
202
  })
203
203
  })
204
204
 
205
+ describe('removeRelated (relationship-table row removal)', () => {
206
+ it('unlinks a to-one back-reference via an update on the related list', async () => {
207
+ const existing = { id: 'p1', title: 'T', content: 'c', authorId: 'u1' }
208
+ mockPrisma.post.findUnique.mockResolvedValue(existing)
209
+ mockPrisma.post.update.mockResolvedValue({ id: 'p1', title: 'T', authorId: null })
210
+
211
+ const context = await getContext(config, mockPrisma, { userId: 'u1' })
212
+ const result = await context.serverAction({
213
+ listKey: 'Post',
214
+ action: 'removeRelated',
215
+ mode: 'disconnect',
216
+ id: 'p1',
217
+ field: 'author',
218
+ parentId: 'u1',
219
+ })
220
+
221
+ // Disconnect is an UPDATE on the related list nulling the back-reference
222
+ // (a to-one back-ref disconnects with `true`), never a delete. The
223
+ // distinct `{ removed }` shape avoids a redirect-on-success wrapper.
224
+ expect(mockPrisma.post.update).toHaveBeenCalled()
225
+ const updateArg = mockPrisma.post.update.mock.calls[0][0]
226
+ expect(updateArg.where).toEqual({ id: 'p1' })
227
+ expect(updateArg.data.author).toEqual({ disconnect: true })
228
+ expect(mockPrisma.post.delete).not.toHaveBeenCalled()
229
+ expect(result).toEqual({ removed: true })
230
+ })
231
+
232
+ it('deletes the related row when mode is delete', async () => {
233
+ mockPrisma.post.findUnique.mockResolvedValue({ id: 'p1', title: 'T', authorId: 'u1' })
234
+ mockPrisma.post.delete.mockResolvedValue({ id: 'p1', title: 'T' })
235
+
236
+ const context = await getContext(config, mockPrisma, { userId: 'u1' })
237
+ const result = await context.serverAction({
238
+ listKey: 'Post',
239
+ action: 'removeRelated',
240
+ mode: 'delete',
241
+ id: 'p1',
242
+ })
243
+
244
+ expect(mockPrisma.post.delete).toHaveBeenCalled()
245
+ expect(mockPrisma.post.update).not.toHaveBeenCalled()
246
+ expect(result).toEqual({ removed: true })
247
+ })
248
+
249
+ it('disconnects a to-many back-reference by parent id (many-to-many)', async () => {
250
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
251
+ const m2mPrisma: any = {
252
+ lesson: {
253
+ findUnique: vi.fn().mockResolvedValue({ id: 'l1', title: 'L' }),
254
+ update: vi.fn().mockResolvedValue({ id: 'l1', title: 'L' }),
255
+ delete: vi.fn(),
256
+ },
257
+ }
258
+ const m2mConfig: OpenSaasConfig = {
259
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
260
+ lists: {
261
+ Lesson: {
262
+ fields: {
263
+ title: { type: 'text' },
264
+ teachers: { type: 'relationship', ref: 'Teacher.lessons', many: true },
265
+ },
266
+ access: {
267
+ operation: {
268
+ query: () => true,
269
+ create: () => true,
270
+ update: () => true,
271
+ delete: () => true,
272
+ },
273
+ },
274
+ },
275
+ Teacher: {
276
+ fields: {
277
+ name: { type: 'text' },
278
+ lessons: { type: 'relationship', ref: 'Lesson.teachers', many: true },
279
+ },
280
+ access: {
281
+ operation: {
282
+ query: () => true,
283
+ create: () => true,
284
+ update: () => true,
285
+ delete: () => true,
286
+ },
287
+ },
288
+ },
289
+ },
290
+ }
291
+
292
+ const context = await getContext(m2mConfig, m2mPrisma, { userId: 'u1' })
293
+ await context.serverAction({
294
+ listKey: 'Lesson',
295
+ action: 'removeRelated',
296
+ mode: 'disconnect',
297
+ id: 'l1',
298
+ field: 'teachers',
299
+ parentId: 't1',
300
+ })
301
+
302
+ const updateArg = m2mPrisma.lesson.update.mock.calls[0][0]
303
+ // A to-many back-reference disconnects the specific parent by id.
304
+ expect(updateArg.data.teachers).toEqual({ disconnect: { id: 't1' } })
305
+ })
306
+
307
+ it('returns a generic error (Silent failure) when the update is access-denied', async () => {
308
+ const deniedConfig: OpenSaasConfig = {
309
+ ...config,
310
+ lists: {
311
+ ...config.lists,
312
+ Post: {
313
+ ...config.lists.Post,
314
+ access: {
315
+ operation: {
316
+ query: () => true,
317
+ create: () => true,
318
+ update: () => false, // no update access → disconnect denied
319
+ delete: () => true,
320
+ },
321
+ },
322
+ },
323
+ },
324
+ }
325
+
326
+ const context = await getContext(deniedConfig, mockPrisma, { userId: 'u1' })
327
+ const result = await context.serverAction({
328
+ listKey: 'Post',
329
+ action: 'removeRelated',
330
+ mode: 'disconnect',
331
+ id: 'p1',
332
+ field: 'author',
333
+ parentId: 'u1',
334
+ })
335
+
336
+ // Denied: reported as not removed with a generic reason (no leak).
337
+ expect(result).toEqual({ removed: false, error: 'Access denied or operation failed' })
338
+ })
339
+ })
340
+
341
+ describe('bulkAction (custom list bulk actions, #736)', () => {
342
+ // A config whose Post list declares a custom bulk action that runs each
343
+ // selected id through the SECURED context (`context.db.post.update`), so
344
+ // access control + hooks apply per id.
345
+ function configWithPublishAction(
346
+ overrides?: Partial<import('../src/config/types.js').BulkAction>,
347
+ ): OpenSaasConfig {
348
+ return {
349
+ ...config,
350
+ lists: {
351
+ ...config.lists,
352
+ Post: {
353
+ ...config.lists.Post,
354
+ fields: {
355
+ ...config.lists.Post.fields,
356
+ status: { type: 'text' },
357
+ },
358
+ ui: {
359
+ listView: {
360
+ bulkActions: [
361
+ {
362
+ key: 'publish',
363
+ label: 'Publish',
364
+ handler: async ({ ids, context }) => {
365
+ let n = 0
366
+ for (const id of ids) {
367
+ const r = await context.db.post.update({
368
+ where: { id },
369
+ data: { status: 'published' },
370
+ })
371
+ if (r) n++
372
+ }
373
+ return { message: `Published ${n} of ${ids.length}` }
374
+ },
375
+ ...overrides,
376
+ },
377
+ ],
378
+ },
379
+ },
380
+ },
381
+ },
382
+ }
383
+ }
384
+
385
+ it('runs the handler over the selected ids through the secured context', async () => {
386
+ mockPrisma.post.findUnique.mockImplementation(({ where }: { where: { id: string } }) =>
387
+ Promise.resolve({ id: where.id, title: 'T', content: 'c' }),
388
+ )
389
+ mockPrisma.post.update.mockImplementation(({ where }: { where: { id: string } }) =>
390
+ Promise.resolve({ id: where.id, status: 'published' }),
391
+ )
392
+
393
+ const context = await getContext(configWithPublishAction(), mockPrisma, { userId: 'u1' })
394
+ const result = await context.serverAction({
395
+ listKey: 'Post',
396
+ action: 'bulkAction',
397
+ key: 'publish',
398
+ ids: ['p1', 'p2'],
399
+ })
400
+
401
+ // Each id went through the secured update delegate, not a raw call.
402
+ expect(mockPrisma.post.update).toHaveBeenCalledTimes(2)
403
+ expect(result).toEqual({ bulkAction: true, message: 'Published 2 of 2' })
404
+ })
405
+
406
+ it('absorbs a per-id Silent failure into the handler count (no leak)', async () => {
407
+ // Update access scopes writes to id p1 only → p2 is denied and returns
408
+ // null through the secured delegate. The handler simply does not count
409
+ // it; the outcome never reveals which id was denied.
410
+ const scopedConfig = configWithPublishAction()
411
+ scopedConfig.lists.Post.access = {
412
+ operation: {
413
+ query: () => true,
414
+ create: () => true,
415
+ update: () => ({ id: { equals: 'p1' } }),
416
+ delete: () => true,
417
+ },
418
+ }
419
+ // The access filter merges into the existence pre-check: only p1 is
420
+ // visible, so p2's update is denied (Silent failure → null) before it
421
+ // ever reaches the update delegate.
422
+ mockPrisma.post.findUnique.mockImplementation(({ where }: { where: { id: string } }) =>
423
+ where.id === 'p1'
424
+ ? Promise.resolve({ id: 'p1', title: 'T', content: 'c' })
425
+ : Promise.resolve(null),
426
+ )
427
+ // The filter-match re-check (mergeFilters) only ever runs for the row
428
+ // that passed the existence pre-check (p1), so returning it is enough.
429
+ mockPrisma.post.findFirst.mockResolvedValue({ id: 'p1', title: 'T', content: 'c' })
430
+ mockPrisma.post.update.mockResolvedValue({ id: 'p1', status: 'published' })
431
+
432
+ const context = await getContext(scopedConfig, mockPrisma, { userId: 'u1' })
433
+ const result = await context.serverAction({
434
+ listKey: 'Post',
435
+ action: 'bulkAction',
436
+ key: 'publish',
437
+ ids: ['p1', 'p2'],
438
+ })
439
+
440
+ expect(result).toEqual({ bulkAction: true, message: 'Published 1 of 2' })
441
+ })
442
+
443
+ it('returns an error for an unknown action key', async () => {
444
+ const context = await getContext(configWithPublishAction(), mockPrisma, { userId: 'u1' })
445
+ const result = await context.serverAction({
446
+ listKey: 'Post',
447
+ action: 'bulkAction',
448
+ key: 'nope',
449
+ ids: ['p1'],
450
+ })
451
+
452
+ expect(result).toMatchObject({ bulkAction: false })
453
+ })
454
+
455
+ it('re-checks hasAccess server-side so a hidden action cannot be invoked', async () => {
456
+ const context = await getContext(
457
+ configWithPublishAction({ hasAccess: () => false }),
458
+ mockPrisma,
459
+ { userId: 'u1' },
460
+ )
461
+ const result = await context.serverAction({
462
+ listKey: 'Post',
463
+ action: 'bulkAction',
464
+ key: 'publish',
465
+ ids: ['p1', 'p2'],
466
+ })
467
+
468
+ expect(result).toEqual({ bulkAction: false, error: 'Access denied' })
469
+ // The handler never ran.
470
+ expect(mockPrisma.post.update).not.toHaveBeenCalled()
471
+ })
472
+
473
+ it('returns a generic message (not the internal text) and logs when the handler throws a plain Error', async () => {
474
+ // An unexpected, non-Prisma, non-known-error bug inside the handler must
475
+ // not surface its internal message to the client (#761).
476
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
477
+ try {
478
+ const context = await getContext(
479
+ configWithPublishAction({
480
+ handler: async () => {
481
+ throw new Error('secret internal detail: connection string leaked')
482
+ },
483
+ }),
484
+ mockPrisma,
485
+ { userId: 'u1' },
486
+ )
487
+ const result = await context.serverAction({
488
+ listKey: 'Post',
489
+ action: 'bulkAction',
490
+ key: 'publish',
491
+ ids: ['p1', 'p2'],
492
+ })
493
+
494
+ // Client sees the generic fallback — never the internal message.
495
+ expect(result).toEqual({ bulkAction: false, error: 'Action failed' })
496
+ if ('error' in result) {
497
+ expect(result.error).not.toContain('secret internal detail')
498
+ }
499
+ // The real error is logged server-side for the operator.
500
+ expect(consoleError).toHaveBeenCalled()
501
+ const loggedArgs = consoleError.mock.calls[0]
502
+ expect(loggedArgs.some((arg) => arg instanceof Error)).toBe(true)
503
+ } finally {
504
+ consoleError.mockRestore()
505
+ }
506
+ })
507
+ })
508
+
509
+ describe('updateRelated (relationship-table inline cell edit)', () => {
510
+ it('updates a single field on the related row through the secured context', async () => {
511
+ mockPrisma.post.findUnique.mockResolvedValue({
512
+ id: 'p1',
513
+ title: 'Old',
514
+ content: 'c',
515
+ authorId: 'u1',
516
+ })
517
+ mockPrisma.post.update.mockResolvedValue({
518
+ id: 'p1',
519
+ title: 'New',
520
+ content: 'c',
521
+ authorId: 'u1',
522
+ })
523
+
524
+ const context = await getContext(config, mockPrisma, { userId: 'u1' })
525
+ const result = await context.serverAction({
526
+ listKey: 'Post',
527
+ action: 'updateRelated',
528
+ id: 'p1',
529
+ field: 'title',
530
+ value: 'New',
531
+ })
532
+
533
+ // The edit is an UPDATE on the RELATED list, one field only. The distinct
534
+ // `{ updated }` shape avoids a redirect-on-success wrapper.
535
+ expect(mockPrisma.post.update).toHaveBeenCalled()
536
+ const updateArg = mockPrisma.post.update.mock.calls[0][0]
537
+ expect(updateArg.where).toEqual({ id: 'p1' })
538
+ expect(updateArg.data.title).toBe('New')
539
+ expect(mockPrisma.post.delete).not.toHaveBeenCalled()
540
+ expect(result).toEqual({ updated: true })
541
+ })
542
+
543
+ it('returns a generic error (Silent failure) when the update is access-denied', async () => {
544
+ const deniedConfig: OpenSaasConfig = {
545
+ ...config,
546
+ lists: {
547
+ ...config.lists,
548
+ Post: {
549
+ ...config.lists.Post,
550
+ access: {
551
+ operation: {
552
+ query: () => true,
553
+ create: () => true,
554
+ update: () => false, // no update access → inline edit denied
555
+ delete: () => true,
556
+ },
557
+ },
558
+ },
559
+ },
560
+ }
561
+
562
+ const context = await getContext(deniedConfig, mockPrisma, { userId: 'u1' })
563
+ const result = await context.serverAction({
564
+ listKey: 'Post',
565
+ action: 'updateRelated',
566
+ id: 'p1',
567
+ field: 'title',
568
+ value: 'New',
569
+ })
570
+
571
+ // Denied: reported as not updated with a generic reason (no denied-vs-absent leak).
572
+ expect(result).toEqual({ updated: false, error: 'Access denied or operation failed' })
573
+ })
574
+
575
+ it('surfaces a validation error so the cell can revert with a reason', async () => {
576
+ const validationConfig: OpenSaasConfig = {
577
+ ...config,
578
+ lists: {
579
+ ...config.lists,
580
+ Post: {
581
+ ...config.lists.Post,
582
+ hooks: {
583
+ validateInput: async (args) => {
584
+ if (args.operation === 'delete') return
585
+ const { resolvedData, addValidationError } = args
586
+ if (resolvedData.title === 'spam') {
587
+ addValidationError('Title cannot contain the word "spam"')
588
+ }
589
+ },
590
+ },
591
+ },
592
+ },
593
+ }
594
+ mockPrisma.post.findUnique.mockResolvedValue({
595
+ id: 'p1',
596
+ title: 'Old',
597
+ content: 'c',
598
+ authorId: 'u1',
599
+ })
600
+
601
+ const context = await getContext(validationConfig, mockPrisma, { userId: 'u1' })
602
+ const result = await context.serverAction({
603
+ listKey: 'Post',
604
+ action: 'updateRelated',
605
+ id: 'p1',
606
+ field: 'title',
607
+ value: 'spam',
608
+ })
609
+
610
+ // A validation error becomes { updated: false } with the reason; the write
611
+ // never reaches the database.
612
+ expect(mockPrisma.post.update).not.toHaveBeenCalled()
613
+ expect(result).toMatchObject({ updated: false })
614
+ const failure = result as { updated: boolean; error?: string }
615
+ expect(failure.error).toContain('spam')
616
+ })
617
+ })
618
+
619
+ describe('createRelated (relationship-table pre-linked create)', () => {
620
+ it('creates a row on the related list with the to-one back-reference preset to the parent', async () => {
621
+ const created = { id: 'p1', title: 'New', content: 'c', authorId: 'u1' }
622
+ mockPrisma.post.create.mockResolvedValue(created)
623
+ // The connect target must be reachable (the related list's read access).
624
+ mockPrisma.user.findUnique.mockResolvedValue({ id: 'u1' })
625
+
626
+ const context = await getContext(config, mockPrisma, { userId: 'u1' })
627
+ const result = await context.serverAction({
628
+ listKey: 'Post',
629
+ action: 'createRelated',
630
+ data: { title: 'New', content: 'c' },
631
+ field: 'author',
632
+ parentId: 'u1',
633
+ })
634
+
635
+ // The back-reference is set on the SERVER (a to-one back-ref connects a
636
+ // single parent), so the new row links to exactly the parent being edited.
637
+ expect(mockPrisma.post.create).toHaveBeenCalled()
638
+ const createArg = mockPrisma.post.create.mock.calls[0][0]
639
+ expect(createArg.data.title).toBe('New')
640
+ expect(createArg.data.author).toEqual({ connect: { id: 'u1' } })
641
+ // Distinct `{ created }` shape (never a single-op `success`).
642
+ expect(result).toEqual({ created: true, id: 'p1' })
643
+ })
644
+
645
+ it('connects a to-many back-reference by parent id (many-to-many)', async () => {
646
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
647
+ const m2mPrisma: any = {
648
+ lesson: {
649
+ findUnique: vi.fn(),
650
+ update: vi.fn(),
651
+ delete: vi.fn(),
652
+ create: vi.fn().mockResolvedValue({ id: 'l1', title: 'L' }),
653
+ },
654
+ teacher: {
655
+ findUnique: vi.fn().mockResolvedValue({ id: 't1' }),
656
+ update: vi.fn(),
657
+ delete: vi.fn(),
658
+ create: vi.fn(),
659
+ },
660
+ }
661
+ const m2mConfig: OpenSaasConfig = {
662
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
663
+ lists: {
664
+ Lesson: {
665
+ fields: {
666
+ title: { type: 'text' },
667
+ teachers: { type: 'relationship', ref: 'Teacher.lessons', many: true },
668
+ },
669
+ access: {
670
+ operation: {
671
+ query: () => true,
672
+ create: () => true,
673
+ update: () => true,
674
+ delete: () => true,
675
+ },
676
+ },
677
+ },
678
+ Teacher: {
679
+ fields: {
680
+ name: { type: 'text' },
681
+ lessons: { type: 'relationship', ref: 'Lesson.teachers', many: true },
682
+ },
683
+ access: {
684
+ operation: {
685
+ query: () => true,
686
+ create: () => true,
687
+ update: () => true,
688
+ delete: () => true,
689
+ },
690
+ },
691
+ },
692
+ },
693
+ }
694
+
695
+ const context = await getContext(m2mConfig, m2mPrisma, { userId: 'u1' })
696
+ await context.serverAction({
697
+ listKey: 'Lesson',
698
+ action: 'createRelated',
699
+ data: { title: 'L' },
700
+ field: 'teachers',
701
+ parentId: 't1',
702
+ })
703
+
704
+ const createArg = m2mPrisma.lesson.create.mock.calls[0][0]
705
+ // A to-many back-reference connects the parent by id.
706
+ expect(createArg.data.teachers).toEqual({ connect: [{ id: 't1' }] })
707
+ })
708
+
709
+ // Security-critical invariant (#758): the server sets the back-reference by
710
+ // spreading client `data` and THEN overwriting `data[field]` from the
711
+ // trusted `parentId`, so a HOSTILE client-supplied `data[field]` can never
712
+ // re-target the link to a different parent. These lock that overwrite for
713
+ // both the to-one and the to-many back-reference shapes.
714
+ it('overwrites a hostile client-supplied to-one back-reference with the trusted parentId', async () => {
715
+ const created = { id: 'p1', title: 'New', authorId: 'u1' }
716
+ mockPrisma.post.create.mockResolvedValue(created)
717
+ // The connect target must be reachable (the related list's read access).
718
+ mockPrisma.user.findUnique.mockResolvedValue({ id: 'u1' })
719
+
720
+ const context = await getContext(config, mockPrisma, { userId: 'u1' })
721
+ await context.serverAction({
722
+ listKey: 'Post',
723
+ action: 'createRelated',
724
+ // Hostile: the client tries to re-target the link to a different parent
725
+ // by supplying the back-reference field itself.
726
+ data: { title: 'New', author: 'evil-id' },
727
+ field: 'author',
728
+ parentId: 'u1',
729
+ })
730
+
731
+ // The server OVERWRITES the spread-in hostile value with a connect to the
732
+ // trusted parentId — the evil id never reaches the secured create.
733
+ const createArg = mockPrisma.post.create.mock.calls[0][0]
734
+ expect(createArg.data.author).toEqual({ connect: { id: 'u1' } })
735
+ expect(createArg.data.author).not.toBe('evil-id')
736
+ })
737
+
738
+ it('overwrites a hostile client-supplied to-many back-reference with the trusted parentId', async () => {
739
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
740
+ const m2mPrisma: any = {
741
+ lesson: {
742
+ findUnique: vi.fn(),
743
+ update: vi.fn(),
744
+ delete: vi.fn(),
745
+ create: vi.fn().mockResolvedValue({ id: 'l1', title: 'L' }),
746
+ },
747
+ teacher: {
748
+ findUnique: vi.fn().mockResolvedValue({ id: 't1' }),
749
+ update: vi.fn(),
750
+ delete: vi.fn(),
751
+ create: vi.fn(),
752
+ },
753
+ }
754
+ const m2mConfig: OpenSaasConfig = {
755
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
756
+ lists: {
757
+ Lesson: {
758
+ fields: {
759
+ title: { type: 'text' },
760
+ teachers: { type: 'relationship', ref: 'Teacher.lessons', many: true },
761
+ },
762
+ access: {
763
+ operation: {
764
+ query: () => true,
765
+ create: () => true,
766
+ update: () => true,
767
+ delete: () => true,
768
+ },
769
+ },
770
+ },
771
+ Teacher: {
772
+ fields: {
773
+ name: { type: 'text' },
774
+ lessons: { type: 'relationship', ref: 'Lesson.teachers', many: true },
775
+ },
776
+ access: {
777
+ operation: {
778
+ query: () => true,
779
+ create: () => true,
780
+ update: () => true,
781
+ delete: () => true,
782
+ },
783
+ },
784
+ },
785
+ },
786
+ }
787
+
788
+ const context = await getContext(m2mConfig, m2mPrisma, { userId: 'u1' })
789
+ await context.serverAction({
790
+ listKey: 'Lesson',
791
+ action: 'createRelated',
792
+ // Hostile: the client tries to connect a different teacher.
793
+ data: { title: 'L', teachers: { connect: [{ id: 'evil-id' }] } },
794
+ field: 'teachers',
795
+ parentId: 't1',
796
+ })
797
+
798
+ // The to-many back-reference is overwritten to connect exactly the trusted
799
+ // parent by id — the hostile connect is discarded.
800
+ const createArg = m2mPrisma.lesson.create.mock.calls[0][0]
801
+ expect(createArg.data.teachers).toEqual({ connect: [{ id: 't1' }] })
802
+ })
803
+
804
+ // Defensive guard (#758): malformed direct calls (unreachable from the
805
+ // drawer, which always sends a valid relationship back-reference plus both
806
+ // field and parentId) are rejected rather than degrading to an unguarded
807
+ // create.
808
+ it('rejects a malformed call supplying only one of field/parentId (defensive guard)', async () => {
809
+ const context = await getContext(config, mockPrisma, { userId: 'u1' })
810
+ const result = await context.serverAction({
811
+ listKey: 'Post',
812
+ action: 'createRelated',
813
+ data: { title: 'New', author: 'evil-id' },
814
+ field: 'author',
815
+ // parentId omitted — a lone back-reference field is malformed.
816
+ })
817
+
818
+ expect(result).toEqual({
819
+ created: false,
820
+ error: 'createRelated requires both field and parentId, or neither',
821
+ })
822
+ // The unguarded create is never attempted.
823
+ expect(mockPrisma.post.create).not.toHaveBeenCalled()
824
+ })
825
+
826
+ it('rejects a back-reference that does not name a relationship field (defensive guard)', async () => {
827
+ const context = await getContext(config, mockPrisma, { userId: 'u1' })
828
+ const result = await context.serverAction({
829
+ listKey: 'Post',
830
+ action: 'createRelated',
831
+ data: { title: 'New' },
832
+ field: 'title', // a scalar field, not a relationship
833
+ parentId: 'u1',
834
+ })
835
+
836
+ expect(result).toMatchObject({ created: false })
837
+ expect(mockPrisma.post.create).not.toHaveBeenCalled()
838
+ })
839
+
840
+ it('returns a generic error (Silent failure) when the create is access-denied', async () => {
841
+ const deniedConfig: OpenSaasConfig = {
842
+ ...config,
843
+ lists: {
844
+ ...config.lists,
845
+ Post: {
846
+ ...config.lists.Post,
847
+ access: {
848
+ operation: {
849
+ query: () => true,
850
+ create: () => false, // no create access → create denied
851
+ update: () => true,
852
+ delete: () => true,
853
+ },
854
+ },
855
+ },
856
+ },
857
+ }
858
+
859
+ const context = await getContext(deniedConfig, mockPrisma, { userId: 'u1' })
860
+ const result = await context.serverAction({
861
+ listKey: 'Post',
862
+ action: 'createRelated',
863
+ data: { title: 'New' },
864
+ field: 'author',
865
+ parentId: 'u1',
866
+ })
867
+
868
+ // Denied: reported as not created with a generic reason (no leak). The
869
+ // create is never attempted at the Prisma layer.
870
+ expect(result).toEqual({ created: false, error: 'Access denied or operation failed' })
871
+ expect(mockPrisma.post.create).not.toHaveBeenCalled()
872
+ })
873
+
874
+ it('surfaces field errors from the create (e.g. a unique constraint) in the drawer shape', async () => {
875
+ mockPrisma.post.create.mockRejectedValue({ code: 'P2002', meta: { target: ['title'] } })
876
+ mockPrisma.user.findUnique.mockResolvedValue({ id: 'u1' })
877
+
878
+ const context = await getContext(config, mockPrisma, { userId: 'u1' })
879
+ const result = await context.serverAction({
880
+ listKey: 'Post',
881
+ action: 'createRelated',
882
+ data: { title: 'Dup' },
883
+ field: 'author',
884
+ parentId: 'u1',
885
+ })
886
+
887
+ // Parsed to a distinct { created: false } with per-field errors the drawer
888
+ // renders — never a single-op `success` envelope.
889
+ expect(result).toMatchObject({ created: false })
890
+ const created = result as { created: boolean; fieldErrors?: Record<string, string> }
891
+ expect(created.fieldErrors?.title).toBeDefined()
892
+ })
893
+ })
894
+
205
895
  describe('relationshipOptions', () => {
206
896
  it('returns { id, label }[] for the related list, unfiltered/unincluded', async () => {
207
897
  mockPrisma.user.findMany.mockResolvedValue([