@opensaas/stack-core 0.24.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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +223 -0
- package/dist/access/access-filter.d.ts +39 -0
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +121 -0
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/field-access.d.ts +1 -0
- package/dist/access/field-access.d.ts.map +1 -1
- package/dist/access/field-access.js +79 -4
- package/dist/access/field-access.js.map +1 -1
- package/dist/access/field-access.test.js +213 -0
- package/dist/access/field-access.test.js.map +1 -1
- package/dist/access/index.d.ts +1 -1
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +1 -1
- package/dist/access/index.js.map +1 -1
- package/dist/access/types.d.ts +39 -0
- package/dist/access/types.d.ts.map +1 -1
- package/dist/config/types.d.ts +318 -0
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/index.d.ts +19 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +153 -26
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts +59 -3
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +552 -129
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/transaction-boundary.d.ts +91 -0
- package/dist/context/transaction-boundary.d.ts.map +1 -0
- package/dist/context/transaction-boundary.js +329 -0
- package/dist/context/transaction-boundary.js.map +1 -0
- package/dist/context/write-pipeline.d.ts +15 -1
- package/dist/context/write-pipeline.d.ts.map +1 -1
- package/dist/context/write-pipeline.js +173 -10
- package/dist/context/write-pipeline.js.map +1 -1
- package/dist/fields/calendar-day.test.d.ts +2 -0
- package/dist/fields/calendar-day.test.d.ts.map +1 -0
- package/dist/fields/calendar-day.test.js +120 -0
- package/dist/fields/calendar-day.test.js.map +1 -0
- package/dist/fields/index.d.ts +18 -2
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +93 -17
- package/dist/fields/index.js.map +1 -1
- package/dist/hooks/index.d.ts +116 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +154 -0
- package/dist/hooks/index.js.map +1 -1
- package/dist/validation/schema.test.js +222 -1
- package/dist/validation/schema.test.js.map +1 -1
- package/package.json +1 -1
- package/src/access/access-filter.ts +156 -0
- package/src/access/field-access.test.ts +255 -0
- package/src/access/field-access.ts +91 -5
- package/src/access/index.ts +1 -1
- package/src/access/types.ts +45 -0
- package/src/config/types.ts +364 -0
- package/src/context/index.ts +207 -37
- package/src/context/nested-operations.ts +969 -143
- package/src/context/transaction-boundary.ts +440 -0
- package/src/context/write-pipeline.ts +234 -13
- package/src/fields/calendar-day.test.ts +140 -0
- package/src/fields/index.ts +96 -16
- package/src/hooks/index.ts +265 -0
- package/src/validation/schema.test.ts +266 -1
- package/tests/access.test.ts +24 -16
- package/tests/context.test.ts +481 -0
- package/tests/field-types.test.ts +17 -3
- package/tests/nested-access-and-hooks.test.ts +1130 -54
- package/tests/nested-operation-registry.test.ts +28 -3
- package/tests/nested-write-hooks.test.ts +864 -0
- package/tests/transaction-boundary-hooks.test.ts +465 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/context/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
*/
|