@opensaas/stack-core 0.23.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +256 -0
  3. package/dist/access/access-filter.d.ts +39 -0
  4. package/dist/access/access-filter.d.ts.map +1 -1
  5. package/dist/access/access-filter.js +121 -0
  6. package/dist/access/access-filter.js.map +1 -1
  7. package/dist/access/field-access.d.ts +1 -0
  8. package/dist/access/field-access.d.ts.map +1 -1
  9. package/dist/access/field-access.js +79 -4
  10. package/dist/access/field-access.js.map +1 -1
  11. package/dist/access/field-access.test.js +213 -0
  12. package/dist/access/field-access.test.js.map +1 -1
  13. package/dist/access/index.d.ts +1 -1
  14. package/dist/access/index.d.ts.map +1 -1
  15. package/dist/access/index.js +1 -1
  16. package/dist/access/index.js.map +1 -1
  17. package/dist/access/types.d.ts +39 -0
  18. package/dist/access/types.d.ts.map +1 -1
  19. package/dist/config/index.d.ts +1 -1
  20. package/dist/config/index.d.ts.map +1 -1
  21. package/dist/config/types.d.ts +378 -0
  22. package/dist/config/types.d.ts.map +1 -1
  23. package/dist/context/index.d.ts +19 -1
  24. package/dist/context/index.d.ts.map +1 -1
  25. package/dist/context/index.js +153 -26
  26. package/dist/context/index.js.map +1 -1
  27. package/dist/context/nested-operations.d.ts +59 -3
  28. package/dist/context/nested-operations.d.ts.map +1 -1
  29. package/dist/context/nested-operations.js +552 -129
  30. package/dist/context/nested-operations.js.map +1 -1
  31. package/dist/context/transaction-boundary.d.ts +91 -0
  32. package/dist/context/transaction-boundary.d.ts.map +1 -0
  33. package/dist/context/transaction-boundary.js +329 -0
  34. package/dist/context/transaction-boundary.js.map +1 -0
  35. package/dist/context/write-pipeline.d.ts +15 -1
  36. package/dist/context/write-pipeline.d.ts.map +1 -1
  37. package/dist/context/write-pipeline.js +173 -10
  38. package/dist/context/write-pipeline.js.map +1 -1
  39. package/dist/fields/calendar-day.test.d.ts +2 -0
  40. package/dist/fields/calendar-day.test.d.ts.map +1 -0
  41. package/dist/fields/calendar-day.test.js +120 -0
  42. package/dist/fields/calendar-day.test.js.map +1 -0
  43. package/dist/fields/index.d.ts +18 -2
  44. package/dist/fields/index.d.ts.map +1 -1
  45. package/dist/fields/index.js +93 -17
  46. package/dist/fields/index.js.map +1 -1
  47. package/dist/hooks/index.d.ts +116 -0
  48. package/dist/hooks/index.d.ts.map +1 -1
  49. package/dist/hooks/index.js +154 -0
  50. package/dist/hooks/index.js.map +1 -1
  51. package/dist/validation/schema.test.js +222 -1
  52. package/dist/validation/schema.test.js.map +1 -1
  53. package/package.json +1 -1
  54. package/src/access/access-filter.ts +156 -0
  55. package/src/access/field-access.test.ts +255 -0
  56. package/src/access/field-access.ts +91 -5
  57. package/src/access/index.ts +1 -1
  58. package/src/access/types.ts +45 -0
  59. package/src/config/index.ts +2 -0
  60. package/src/config/types.ts +426 -0
  61. package/src/context/index.ts +207 -37
  62. package/src/context/nested-operations.ts +969 -143
  63. package/src/context/transaction-boundary.ts +440 -0
  64. package/src/context/write-pipeline.ts +234 -13
  65. package/src/fields/calendar-day.test.ts +140 -0
  66. package/src/fields/index.ts +96 -16
  67. package/src/hooks/index.ts +265 -0
  68. package/src/validation/schema.test.ts +266 -1
  69. package/tests/access.test.ts +24 -16
  70. package/tests/config.test.ts +30 -0
  71. package/tests/context.test.ts +481 -0
  72. package/tests/field-types.test.ts +17 -3
  73. package/tests/nested-access-and-hooks.test.ts +1130 -54
  74. package/tests/nested-operation-registry.test.ts +28 -3
  75. package/tests/nested-write-hooks.test.ts +864 -0
  76. package/tests/transaction-boundary-hooks.test.ts +465 -0
  77. package/tsconfig.tsbuildinfo +1 -1
@@ -5,6 +5,7 @@ import {
5
5
  mergeFilters,
6
6
  filterReadableFields,
7
7
  buildIncludeWithAccessControl,
8
+ mergeIncludeWithAccessControl,
8
9
  } from '../access/index.js'
9
10
  import { ValidationError, DatabaseError } from '../hooks/index.js'
10
11
  import { getDbKey } from '../lib/case-utils.js'
@@ -65,6 +66,72 @@ function isSingletonList(listConfig: ListConfig<any>): boolean {
65
66
  return !!listConfig.isSingleton
66
67
  }
67
68
 
69
+ /**
70
+ * Compute the set of single-field unique selectors a `findUnique` `where` may be
71
+ * keyed by, derived from what the list config exposes at runtime.
72
+ *
73
+ * The set is:
74
+ * - `id` — always a unique identifier on every list.
75
+ * - Any field declared `isIndexed: 'unique'` in the config (e.g. `text({ isIndexed: 'unique' })`).
76
+ * - For a `relationship` field declared `isIndexed: 'unique'`, the foreign-key
77
+ * column name (`<field>Id`) — that is the column Prisma marks `@unique`, so the
78
+ * unique `where` is keyed by `<field>Id`, not the relation field itself.
79
+ *
80
+ * Chosen rule (documented intentionally): the config does NOT expose compound
81
+ * (`@@unique`) keys at runtime — there is no list-level unique declaration in the
82
+ * config API — so we cannot validate compound `<Model>_<a>_<b>` selectors. We
83
+ * therefore enforce the tractable subset: `where` must contain EXACTLY ONE
84
+ * recognised single-field unique key and NO other keys. This rejects non-unique
85
+ * filters (the bug in #567) and rejects extra non-unique keys alongside a unique
86
+ * one, while never falsely rejecting a valid single-field unique lookup. If a
87
+ * project legitimately needs a compound-unique lookup, that path is not covered
88
+ * here and would need explicit config support; the safe escape hatch for any
89
+ * non-unique single-row lookup is `findFirst` (see #565).
90
+ */
91
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
92
+ function getUniqueWhereKeys(listConfig: ListConfig<any>): Set<string> {
93
+ const keys = new Set<string>(['id'])
94
+
95
+ for (const [fieldKey, fieldConfig] of Object.entries(listConfig.fields)) {
96
+ if (!fieldConfig || typeof fieldConfig !== 'object') continue
97
+ if (!('isIndexed' in fieldConfig) || fieldConfig.isIndexed !== 'unique') continue
98
+
99
+ if (fieldConfig.type === 'relationship') {
100
+ // A unique relationship's `@unique` lives on the FK column `<field>Id`.
101
+ keys.add(`${fieldKey}Id`)
102
+ } else {
103
+ keys.add(fieldKey)
104
+ }
105
+ }
106
+
107
+ return keys
108
+ }
109
+
110
+ /**
111
+ * Enforce Keystone `findOne` semantics for `findUnique`: the caller-supplied
112
+ * `where` must be a valid unique selector. A non-unique `where` is a caller-shape
113
+ * error (not an access denial), so this THROWS rather than silently returning
114
+ * `null` — consistent with the fail-loud-on-misuse stance of PRD #581. A
115
+ * non-unique single-row lookup should use `findFirst` instead (see #565).
116
+ */
117
+ function assertUniqueWhere(
118
+ where: Record<string, unknown> | undefined,
119
+ uniqueKeys: Set<string>,
120
+ listName: string,
121
+ ): void {
122
+ const keys = where ? Object.keys(where) : []
123
+
124
+ const message =
125
+ `findUnique on "${listName}" requires a unique \`where\` (a single unique key such as ` +
126
+ `${Array.from(uniqueKeys).join(', ')}). ` +
127
+ `Received: ${keys.length === 0 ? '{}' : `{ ${keys.join(', ')} }`}. ` +
128
+ `Use \`findFirst\` for a non-unique single-row lookup.`
129
+
130
+ if (keys.length !== 1 || !uniqueKeys.has(keys[0])) {
131
+ throw new ValidationError([message], {})
132
+ }
133
+ }
134
+
68
135
  /**
69
136
  * Check if auto-create is enabled for a singleton list
70
137
  * Defaults to true if not explicitly set to false
@@ -236,40 +303,8 @@ export function getContext<
236
303
  _resolveOutputCounter: { depth: 0 },
237
304
  }
238
305
 
239
- // Create access-controlled operations for each list
240
- for (const [listName, listConfig] of Object.entries(config.lists)) {
241
- const dbKey = getDbKey(listName)
242
-
243
- // Create base operations
244
- const createOp = createCreate(listName, listConfig, prisma, context, config)
245
- const findManyOp = createFindMany(listName, listConfig, prisma, context, config)
246
- const updateOp = createUpdate(listName, listConfig, prisma, context, config)
247
- const operations: Record<string, unknown> = {
248
- findUnique: createFindUnique(listName, listConfig, prisma, context, config),
249
- findMany: findManyOp,
250
- create: createOp,
251
- update: updateOp,
252
- delete: createDelete(listName, listConfig, prisma, context, config),
253
- count: createCount(listName, listConfig, prisma, context),
254
- createMany: createCreateMany(listName, listConfig, prisma, context, config, createOp),
255
- updateMany: createUpdateMany(
256
- listName,
257
- listConfig,
258
- prisma,
259
- context,
260
- config,
261
- findManyOp,
262
- updateOp,
263
- ),
264
- }
265
-
266
- // Add get() method for singleton lists
267
- if (isSingletonList(listConfig)) {
268
- operations.get = createGet(listName, listConfig, prisma, context, config, createOp)
269
- }
270
-
271
- db[dbKey] = operations
272
- }
306
+ // Create access-controlled operations for each list, populating `db` in place.
307
+ populateDbDelegate(db, config, prisma, context)
273
308
 
274
309
  // Execute plugin runtime functions and populate context.plugins
275
310
  // Use _plugins (sorted by dependencies) if available, otherwise fall back to plugins array
@@ -392,6 +427,74 @@ export function getContext<
392
427
  }
393
428
  }
394
429
 
430
+ /**
431
+ * Populate `target` with the access-controlled CRUD operations for every list,
432
+ * each bound to `prisma` and `context`. Used both by {@link getContext} (at
433
+ * request setup) and by the Write Pipeline to rebuild a `db` delegate against a
434
+ * transaction client (ADR-0010), so a hook's `context.db` write participates in
435
+ * the same transaction.
436
+ *
437
+ * The operations capture `prisma` at construction, so rebinding to a different
438
+ * client (e.g. a transaction `tx`) requires rebuilding the delegate — which is
439
+ * exactly what this function enables.
440
+ */
441
+ export function populateDbDelegate<TPrisma extends PrismaClientLike>(
442
+ target: Record<string, unknown>,
443
+ config: OpenSaasConfig,
444
+ prisma: TPrisma,
445
+ context: AccessContext<TPrisma>,
446
+ ): void {
447
+ for (const [listName, listConfig] of Object.entries(config.lists)) {
448
+ const dbKey = getDbKey(listName)
449
+
450
+ // Create base operations
451
+ const createOp = createCreate(listName, listConfig, prisma, context, config)
452
+ const findManyOp = createFindMany(listName, listConfig, prisma, context, config)
453
+ const updateOp = createUpdate(listName, listConfig, prisma, context, config)
454
+ const operations: Record<string, unknown> = {
455
+ findUnique: createFindUnique(listName, listConfig, prisma, context, config),
456
+ findMany: findManyOp,
457
+ findFirst: createFindFirst(findManyOp),
458
+ create: createOp,
459
+ update: updateOp,
460
+ delete: createDelete(listName, listConfig, prisma, context, config),
461
+ count: createCount(listName, listConfig, prisma, context),
462
+ createMany: createCreateMany(listName, listConfig, prisma, context, config, createOp),
463
+ updateMany: createUpdateMany(
464
+ listName,
465
+ listConfig,
466
+ prisma,
467
+ context,
468
+ config,
469
+ findManyOp,
470
+ updateOp,
471
+ ),
472
+ }
473
+
474
+ // Add get() method for singleton lists
475
+ if (isSingletonList(listConfig)) {
476
+ operations.get = createGet(listName, listConfig, prisma, context, config, createOp)
477
+ }
478
+
479
+ target[dbKey] = operations
480
+ }
481
+ }
482
+
483
+ /**
484
+ * Build a fresh access-controlled `db` delegate bound to `prisma` and `context`.
485
+ * Convenience wrapper over {@link populateDbDelegate} returning a new object,
486
+ * used by the Write Pipeline to rebind `db` to a transaction client.
487
+ */
488
+ export function buildDbDelegate<TPrisma extends PrismaClientLike>(
489
+ config: OpenSaasConfig,
490
+ prisma: TPrisma,
491
+ context: AccessContext<TPrisma>,
492
+ ): AccessControlledDB<TPrisma> {
493
+ const db: Record<string, unknown> = {}
494
+ populateDbDelegate(db, config, prisma, context)
495
+ return db as AccessControlledDB<TPrisma>
496
+ }
497
+
395
498
  /**
396
499
  * Create findUnique operation with access control
397
500
  */
@@ -404,7 +507,10 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
404
507
  config: OpenSaasConfig,
405
508
  ) {
406
509
  return async (args: {
407
- where: { id: string }
510
+ // Accepts any unique selector at the delegate level (the generated
511
+ // `<List>FindUniqueArgs` type narrows `where` to Prisma's `WhereUniqueInput`).
512
+ // The runtime guard below rejects non-unique shapes.
513
+ where: Record<string, unknown>
408
514
  include?: Record<string, unknown>
409
515
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
410
516
  query?: any
@@ -414,6 +520,15 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
414
520
  // `select` is a visible no-op: warn, then proceed with include/query narrowing.
415
521
  warnIfSelectIgnored(args, listName, 'findUnique')
416
522
 
523
+ // Enforce unique-`where` (Keystone `findOne` parity). This is a caller-shape
524
+ // check independent of access, so it runs first and THROWS on misuse — it is
525
+ // not an access denial and must not be masked as a silent `null`. The
526
+ // type-level constraint already lives on the generated delegate: the custom
527
+ // `<List>FindUniqueArgs` only Omits `select`/`include` from
528
+ // `Prisma.<List>FindUniqueArgs`, so its `where` stays Prisma's
529
+ // `<List>WhereUniqueInput` — this runtime guard backstops untyped callers.
530
+ assertUniqueWhere(args.where, getUniqueWhereKeys(listConfig), listName)
531
+
417
532
  // Check query access (skip if sudo mode)
418
533
  let where: Record<string, unknown> = args.where
419
534
  if (!context._isSudo) {
@@ -443,6 +558,10 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
443
558
 
444
559
  if (fragment) {
445
560
  include = buildInclude(fragment._fields) ?? undefined
561
+ } else if (context._isSudo) {
562
+ // Sudo bypasses access control entirely — the caller's include is trusted
563
+ // and used as-is (matching the prior behaviour); no per-relation filtering.
564
+ include = args.include
446
565
  } else {
447
566
  // Build include with access control filters
448
567
  const accessControlledInclude = await buildIncludeWithAccessControl(
@@ -453,7 +572,18 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
453
572
  },
454
573
  config,
455
574
  )
456
- include = args.include || accessControlledInclude
575
+ // MERGE (not replace) a caller-supplied include with the access-controlled
576
+ // include: the caller selects WHICH relations to fetch, access control
577
+ // decides WHETHER and WITH WHAT filter (#566). A bare auto-include (no
578
+ // caller include) still uses the access-controlled include directly.
579
+ include = args.include
580
+ ? mergeIncludeWithAccessControl(
581
+ args.include,
582
+ accessControlledInclude,
583
+ listConfig.fields,
584
+ config,
585
+ )
586
+ : accessControlledInclude
457
587
  }
458
588
 
459
589
  // Execute query with optimized includes
@@ -551,6 +681,10 @@ function createFindMany<TPrisma extends PrismaClientLike>(
551
681
  let include: Record<string, unknown> | undefined
552
682
  if (fragment) {
553
683
  include = buildInclude(fragment._fields) ?? undefined
684
+ } else if (context._isSudo) {
685
+ // Sudo bypasses access control entirely — the caller's include is trusted
686
+ // and used as-is (matching the prior behaviour); no per-relation filtering.
687
+ include = args?.include
554
688
  } else {
555
689
  // Build include with access control filters
556
690
  const accessControlledInclude = await buildIncludeWithAccessControl(
@@ -561,7 +695,18 @@ function createFindMany<TPrisma extends PrismaClientLike>(
561
695
  },
562
696
  config,
563
697
  )
564
- include = args?.include || accessControlledInclude
698
+ // MERGE (not replace) a caller-supplied include with the access-controlled
699
+ // include: the caller selects WHICH relations to fetch, access control
700
+ // decides WHETHER and WITH WHAT filter (#566). A bare auto-include (no
701
+ // caller include) still uses the access-controlled include directly.
702
+ include = args?.include
703
+ ? mergeIncludeWithAccessControl(
704
+ args.include,
705
+ accessControlledInclude,
706
+ listConfig.fields,
707
+ config,
708
+ )
709
+ : accessControlledInclude
565
710
  }
566
711
 
567
712
  // Execute query with optimized includes
@@ -603,6 +748,31 @@ function createFindMany<TPrisma extends PrismaClientLike>(
603
748
  }
604
749
  }
605
750
 
751
+ /**
752
+ * Create findFirst operation with access control.
753
+ *
754
+ * findFirst is sugar over the access-controlled findMany: it runs the exact same
755
+ * query-access checks and access-controlled include building as findMany, then
756
+ * returns the first matching row (or null when nothing matches). This introduces
757
+ * no new access surface — it inherits findMany's silent-failure contract (an
758
+ * access-denied query yields `[]`, which becomes `null` here).
759
+ */
760
+ function createFindFirst(findManyOp: ReturnType<typeof createFindMany>) {
761
+ return async (args?: {
762
+ where?: Record<string, unknown>
763
+ orderBy?: Record<string, 'asc' | 'desc'> | Array<Record<string, 'asc' | 'desc'>>
764
+ skip?: number
765
+ include?: Record<string, unknown>
766
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
767
+ query?: any
768
+ // `select` is not honoured — accepted only so the no-op can be made visible.
769
+ select?: Record<string, unknown>
770
+ }) => {
771
+ const result = await findManyOp({ ...args, take: 1 })
772
+ return result[0] ?? null
773
+ }
774
+ }
775
+
606
776
  /**
607
777
  * Create create operation with access control and hooks
608
778
  */