@opensaas/stack-core 0.1.6 → 0.3.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 +208 -0
- package/CLAUDE.md +46 -1
- package/dist/access/engine.d.ts +15 -8
- package/dist/access/engine.d.ts.map +1 -1
- package/dist/access/engine.js +23 -2
- package/dist/access/engine.js.map +1 -1
- package/dist/access/engine.test.d.ts +2 -0
- package/dist/access/engine.test.d.ts.map +1 -0
- package/dist/access/engine.test.js +125 -0
- package/dist/access/engine.test.js.map +1 -0
- package/dist/access/types.d.ts +40 -9
- package/dist/access/types.d.ts.map +1 -1
- package/dist/config/index.d.ts +38 -18
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +34 -14
- package/dist/config/index.js.map +1 -1
- package/dist/config/plugin-engine.d.ts.map +1 -1
- package/dist/config/plugin-engine.js +6 -0
- package/dist/config/plugin-engine.js.map +1 -1
- package/dist/config/types.d.ts +128 -21
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/index.d.ts +14 -2
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +243 -100
- package/dist/context/index.js.map +1 -1
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +9 -8
- package/dist/fields/index.js.map +1 -1
- package/dist/hooks/index.d.ts +28 -12
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +16 -0
- package/dist/hooks/index.js.map +1 -1
- package/package.json +3 -4
- package/src/access/engine.test.ts +145 -0
- package/src/access/engine.ts +35 -11
- package/src/access/types.ts +39 -8
- package/src/config/index.ts +46 -19
- package/src/config/plugin-engine.ts +7 -0
- package/src/config/types.ts +149 -18
- package/src/context/index.ts +298 -110
- package/src/fields/index.ts +8 -7
- package/src/hooks/index.ts +63 -20
- package/tests/context.test.ts +38 -6
- package/tests/field-types.test.ts +728 -0
- package/tests/password-type-distribution.test.ts +0 -1
- package/tests/password-types.test.ts +0 -1
- package/tests/plugin-engine.test.ts +1102 -0
- package/tests/sudo.test.ts +405 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/context/index.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
executeAfterOperation,
|
|
15
15
|
validateFieldRules,
|
|
16
16
|
ValidationError,
|
|
17
|
+
DatabaseError,
|
|
17
18
|
} from '../hooks/index.js'
|
|
18
19
|
import { processNestedOperations } from './nested-operations.js'
|
|
19
20
|
import { getDbKey } from '../lib/case-utils.js'
|
|
@@ -130,6 +131,69 @@ export type ServerActionProps =
|
|
|
130
131
|
| { listKey: string; action: 'create'; data: Record<string, unknown> }
|
|
131
132
|
| { listKey: string; action: 'update'; id: string; data: Record<string, unknown> }
|
|
132
133
|
| { listKey: string; action: 'delete'; id: string }
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Parse Prisma error and convert to user-friendly DatabaseError
|
|
137
|
+
*/
|
|
138
|
+
function parsePrismaError(error: unknown, listConfig: ListConfig): Error {
|
|
139
|
+
// Check if it's a Prisma error
|
|
140
|
+
if (
|
|
141
|
+
error &&
|
|
142
|
+
typeof error === 'object' &&
|
|
143
|
+
'code' in error &&
|
|
144
|
+
'meta' in error &&
|
|
145
|
+
typeof error.code === 'string'
|
|
146
|
+
) {
|
|
147
|
+
const prismaError = error as { code: string; meta?: { target?: string[] }; message?: string }
|
|
148
|
+
|
|
149
|
+
// Handle unique constraint violation
|
|
150
|
+
if (prismaError.code === 'P2002') {
|
|
151
|
+
const target = prismaError.meta?.target
|
|
152
|
+
const fieldErrors: Record<string, string> = {}
|
|
153
|
+
|
|
154
|
+
if (target && Array.isArray(target)) {
|
|
155
|
+
// Get field names from the constraint target
|
|
156
|
+
for (const fieldName of target) {
|
|
157
|
+
// Get the field config to get a better label
|
|
158
|
+
const fieldConfig = listConfig.fields[fieldName]
|
|
159
|
+
const label = fieldName.charAt(0).toUpperCase() + fieldName.slice(1)
|
|
160
|
+
|
|
161
|
+
if (fieldConfig) {
|
|
162
|
+
fieldErrors[fieldName] = `This ${label.toLowerCase()} is already in use`
|
|
163
|
+
} else {
|
|
164
|
+
fieldErrors[fieldName] = `This value is already in use`
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Create a user-friendly general message
|
|
169
|
+
const fieldLabels = target.map((f) => f.charAt(0).toUpperCase() + f.slice(1)).join(', ')
|
|
170
|
+
return new DatabaseError(
|
|
171
|
+
`${fieldLabels} must be unique. The value you entered is already in use.`,
|
|
172
|
+
fieldErrors,
|
|
173
|
+
prismaError.code,
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return new DatabaseError('A record with this value already exists', {}, prismaError.code)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Handle other Prisma errors - return generic message
|
|
181
|
+
return new DatabaseError(
|
|
182
|
+
prismaError.message || 'A database error occurred',
|
|
183
|
+
{},
|
|
184
|
+
prismaError.code,
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Not a Prisma error, return as-is if it's already an Error
|
|
189
|
+
if (error instanceof Error) {
|
|
190
|
+
return error
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Unknown error type
|
|
194
|
+
return new Error('An unknown error occurred')
|
|
195
|
+
}
|
|
196
|
+
|
|
133
197
|
/**
|
|
134
198
|
* Create an access-controlled context
|
|
135
199
|
*
|
|
@@ -144,14 +208,27 @@ export function getContext<
|
|
|
144
208
|
>(
|
|
145
209
|
config: TConfig,
|
|
146
210
|
prisma: TPrisma,
|
|
147
|
-
session: Session,
|
|
211
|
+
session: Session | null,
|
|
148
212
|
storage?: StorageUtils,
|
|
213
|
+
_isSudo: boolean = false,
|
|
149
214
|
): {
|
|
150
215
|
db: AccessControlledDB<TPrisma>
|
|
151
|
-
session: Session
|
|
216
|
+
session: Session | null
|
|
152
217
|
prisma: TPrisma
|
|
153
218
|
storage: StorageUtils
|
|
219
|
+
plugins: Record<string, unknown>
|
|
154
220
|
serverAction: (props: ServerActionProps) => Promise<unknown>
|
|
221
|
+
_isSudo: boolean
|
|
222
|
+
sudo: () => {
|
|
223
|
+
db: AccessControlledDB<TPrisma>
|
|
224
|
+
session: Session | null
|
|
225
|
+
prisma: TPrisma
|
|
226
|
+
storage: StorageUtils
|
|
227
|
+
plugins: Record<string, unknown>
|
|
228
|
+
serverAction: (props: ServerActionProps) => Promise<unknown>
|
|
229
|
+
sudo: () => unknown
|
|
230
|
+
_isSudo: boolean
|
|
231
|
+
}
|
|
155
232
|
} {
|
|
156
233
|
// Initialize db object - will be populated with access-controlled operations
|
|
157
234
|
// Type is intentionally broad to allow dynamic model access
|
|
@@ -185,6 +262,8 @@ export function getContext<
|
|
|
185
262
|
)
|
|
186
263
|
},
|
|
187
264
|
},
|
|
265
|
+
plugins: {}, // Will be populated with plugin runtime services
|
|
266
|
+
_isSudo,
|
|
188
267
|
}
|
|
189
268
|
|
|
190
269
|
// Create access-controlled operations for each list
|
|
@@ -201,29 +280,113 @@ export function getContext<
|
|
|
201
280
|
}
|
|
202
281
|
}
|
|
203
282
|
|
|
283
|
+
// Execute plugin runtime functions and populate context.plugins
|
|
284
|
+
// Use _plugins (sorted by dependencies) if available, otherwise fall back to plugins array
|
|
285
|
+
const pluginsToExecute = config._plugins || config.plugins || []
|
|
286
|
+
for (const plugin of pluginsToExecute) {
|
|
287
|
+
if (plugin.runtime) {
|
|
288
|
+
try {
|
|
289
|
+
context.plugins[plugin.name] = plugin.runtime(context)
|
|
290
|
+
} catch (error) {
|
|
291
|
+
console.error(`Error executing runtime for plugin "${plugin.name}":`, error)
|
|
292
|
+
// Continue with other plugins even if one fails
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
204
297
|
// Generic server action handler with discriminated union for type safety
|
|
205
|
-
|
|
298
|
+
// Returns a result object instead of throwing to work properly in Next.js production
|
|
299
|
+
async function serverAction(
|
|
300
|
+
props: ServerActionProps,
|
|
301
|
+
): Promise<
|
|
302
|
+
| { success: true; data: unknown }
|
|
303
|
+
| { success: false; error: string; fieldErrors?: Record<string, string> }
|
|
304
|
+
> {
|
|
206
305
|
const dbKey = getDbKey(props.listKey)
|
|
306
|
+
const listConfig = config.lists[props.listKey]
|
|
307
|
+
|
|
308
|
+
if (!listConfig) {
|
|
309
|
+
return {
|
|
310
|
+
success: false,
|
|
311
|
+
error: `List "${props.listKey}" not found in configuration`,
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
207
315
|
const model = db[dbKey] as {
|
|
208
316
|
create: (args: { data: Record<string, unknown> }) => Promise<unknown>
|
|
209
317
|
update: (args: { where: { id: string }; data: Record<string, unknown> }) => Promise<unknown>
|
|
210
318
|
delete: (args: { where: { id: string } }) => Promise<unknown>
|
|
211
319
|
}
|
|
212
320
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
})
|
|
321
|
+
try {
|
|
322
|
+
let result: unknown = null
|
|
323
|
+
|
|
324
|
+
if (props.action === 'create') {
|
|
325
|
+
result = await model.create({ data: props.data })
|
|
326
|
+
} else if (props.action === 'update') {
|
|
327
|
+
result = await model.update({
|
|
328
|
+
where: { id: props.id },
|
|
329
|
+
data: props.data,
|
|
330
|
+
})
|
|
331
|
+
} else if (props.action === 'delete') {
|
|
332
|
+
result = await model.delete({
|
|
333
|
+
where: { id: props.id },
|
|
334
|
+
})
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Check for access denial (null return from access-controlled operations)
|
|
338
|
+
if (result === null) {
|
|
339
|
+
return {
|
|
340
|
+
success: false,
|
|
341
|
+
error: 'Access denied or operation failed',
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return {
|
|
346
|
+
success: true,
|
|
347
|
+
data: result,
|
|
348
|
+
}
|
|
349
|
+
} catch (error) {
|
|
350
|
+
// Handle ValidationError (has fieldErrors)
|
|
351
|
+
if (error instanceof ValidationError) {
|
|
352
|
+
return {
|
|
353
|
+
success: false,
|
|
354
|
+
error: error.message,
|
|
355
|
+
fieldErrors: error.fieldErrors,
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Handle DatabaseError (has fieldErrors)
|
|
360
|
+
if (error instanceof DatabaseError) {
|
|
361
|
+
return {
|
|
362
|
+
success: false,
|
|
363
|
+
error: error.message,
|
|
364
|
+
fieldErrors: error.fieldErrors,
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Parse and convert Prisma errors to user-friendly DatabaseError
|
|
369
|
+
const dbError = parsePrismaError(error, listConfig)
|
|
370
|
+
if (dbError instanceof DatabaseError) {
|
|
371
|
+
return {
|
|
372
|
+
success: false,
|
|
373
|
+
error: dbError.message,
|
|
374
|
+
fieldErrors: dbError.fieldErrors,
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Generic error fallback
|
|
379
|
+
return {
|
|
380
|
+
success: false,
|
|
381
|
+
error: dbError.message,
|
|
382
|
+
}
|
|
224
383
|
}
|
|
384
|
+
}
|
|
225
385
|
|
|
226
|
-
|
|
386
|
+
// Sudo function - creates a new context that bypasses access control
|
|
387
|
+
// but still executes all hooks and validation
|
|
388
|
+
function sudo() {
|
|
389
|
+
return getContext(config, prisma, session, context.storage, true)
|
|
227
390
|
}
|
|
228
391
|
|
|
229
392
|
return {
|
|
@@ -231,7 +394,10 @@ export function getContext<
|
|
|
231
394
|
session,
|
|
232
395
|
prisma,
|
|
233
396
|
storage: context.storage,
|
|
397
|
+
plugins: context.plugins,
|
|
234
398
|
serverAction,
|
|
399
|
+
sudo,
|
|
400
|
+
_isSudo,
|
|
235
401
|
}
|
|
236
402
|
}
|
|
237
403
|
|
|
@@ -242,25 +408,29 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
|
|
|
242
408
|
listName: string,
|
|
243
409
|
listConfig: ListConfig,
|
|
244
410
|
prisma: TPrisma,
|
|
245
|
-
context: AccessContext
|
|
411
|
+
context: AccessContext<TPrisma>,
|
|
246
412
|
config: OpenSaasConfig,
|
|
247
413
|
) {
|
|
248
414
|
return async (args: { where: { id: string }; include?: Record<string, unknown> }) => {
|
|
249
|
-
// Check query access
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
415
|
+
// Check query access (skip if sudo mode)
|
|
416
|
+
let where: Record<string, unknown> = args.where
|
|
417
|
+
if (!context._isSudo) {
|
|
418
|
+
const queryAccess = listConfig.access?.operation?.query
|
|
419
|
+
const accessResult = await checkAccess(queryAccess, {
|
|
420
|
+
session: context.session,
|
|
421
|
+
context,
|
|
422
|
+
})
|
|
255
423
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
424
|
+
if (accessResult === false) {
|
|
425
|
+
return null
|
|
426
|
+
}
|
|
259
427
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
428
|
+
// Merge access filter with where clause
|
|
429
|
+
const mergedWhere = mergeFilters(args.where, accessResult)
|
|
430
|
+
if (mergedWhere === null) {
|
|
431
|
+
return null
|
|
432
|
+
}
|
|
433
|
+
where = mergedWhere
|
|
264
434
|
}
|
|
265
435
|
|
|
266
436
|
// Build include with access control filters
|
|
@@ -290,12 +460,13 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
|
|
|
290
460
|
}
|
|
291
461
|
|
|
292
462
|
// Filter readable fields and apply resolveOutput hooks (including nested relationships)
|
|
463
|
+
// Pass sudo flag through context to skip field-level access checks
|
|
293
464
|
const filtered = await filterReadableFields(
|
|
294
465
|
item,
|
|
295
466
|
listConfig.fields,
|
|
296
467
|
{
|
|
297
468
|
session: context.session,
|
|
298
|
-
context,
|
|
469
|
+
context: { ...context, _isSudo: context._isSudo },
|
|
299
470
|
},
|
|
300
471
|
config,
|
|
301
472
|
0,
|
|
@@ -323,7 +494,7 @@ function createFindMany<TPrisma extends PrismaClientLike>(
|
|
|
323
494
|
listName: string,
|
|
324
495
|
listConfig: ListConfig,
|
|
325
496
|
prisma: TPrisma,
|
|
326
|
-
context: AccessContext
|
|
497
|
+
context: AccessContext<TPrisma>,
|
|
327
498
|
config: OpenSaasConfig,
|
|
328
499
|
) {
|
|
329
500
|
return async (args?: {
|
|
@@ -332,21 +503,25 @@ function createFindMany<TPrisma extends PrismaClientLike>(
|
|
|
332
503
|
skip?: number
|
|
333
504
|
include?: Record<string, unknown>
|
|
334
505
|
}) => {
|
|
335
|
-
// Check query access
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
506
|
+
// Check query access (skip if sudo mode)
|
|
507
|
+
let where: Record<string, unknown> | undefined = args?.where
|
|
508
|
+
if (!context._isSudo) {
|
|
509
|
+
const queryAccess = listConfig.access?.operation?.query
|
|
510
|
+
const accessResult = await checkAccess(queryAccess, {
|
|
511
|
+
session: context.session,
|
|
512
|
+
context,
|
|
513
|
+
})
|
|
341
514
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
515
|
+
if (accessResult === false) {
|
|
516
|
+
return []
|
|
517
|
+
}
|
|
345
518
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
519
|
+
// Merge access filter with where clause
|
|
520
|
+
const mergedWhere = mergeFilters(args?.where, accessResult)
|
|
521
|
+
if (mergedWhere === null) {
|
|
522
|
+
return []
|
|
523
|
+
}
|
|
524
|
+
where = mergedWhere
|
|
350
525
|
}
|
|
351
526
|
|
|
352
527
|
// Build include with access control filters
|
|
@@ -374,6 +549,7 @@ function createFindMany<TPrisma extends PrismaClientLike>(
|
|
|
374
549
|
})
|
|
375
550
|
|
|
376
551
|
// Filter readable fields for each item and apply resolveOutput hooks (including nested relationships)
|
|
552
|
+
// Pass sudo flag through context to skip field-level access checks
|
|
377
553
|
const filtered = await Promise.all(
|
|
378
554
|
items.map((item: Record<string, unknown>) =>
|
|
379
555
|
filterReadableFields(
|
|
@@ -381,7 +557,7 @@ function createFindMany<TPrisma extends PrismaClientLike>(
|
|
|
381
557
|
listConfig.fields,
|
|
382
558
|
{
|
|
383
559
|
session: context.session,
|
|
384
|
-
context,
|
|
560
|
+
context: { ...context, _isSudo: context._isSudo },
|
|
385
561
|
},
|
|
386
562
|
config,
|
|
387
563
|
0,
|
|
@@ -415,19 +591,21 @@ function createCreate<TPrisma extends PrismaClientLike>(
|
|
|
415
591
|
listName: string,
|
|
416
592
|
listConfig: ListConfig,
|
|
417
593
|
prisma: TPrisma,
|
|
418
|
-
context: AccessContext
|
|
594
|
+
context: AccessContext<TPrisma>,
|
|
419
595
|
config: OpenSaasConfig,
|
|
420
596
|
) {
|
|
421
597
|
return async (args: { data: Record<string, unknown> }) => {
|
|
422
|
-
// 1. Check create access
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
598
|
+
// 1. Check create access (skip if sudo mode)
|
|
599
|
+
if (!context._isSudo) {
|
|
600
|
+
const createAccess = listConfig.access?.operation?.create
|
|
601
|
+
const accessResult = await checkAccess(createAccess, {
|
|
602
|
+
session: context.session,
|
|
603
|
+
context,
|
|
604
|
+
})
|
|
428
605
|
|
|
429
|
-
|
|
430
|
-
|
|
606
|
+
if (accessResult === false) {
|
|
607
|
+
return null
|
|
608
|
+
}
|
|
431
609
|
}
|
|
432
610
|
|
|
433
611
|
// 2. Execute list-level resolveInput hook
|
|
@@ -459,10 +637,10 @@ function createCreate<TPrisma extends PrismaClientLike>(
|
|
|
459
637
|
throw new ValidationError(validation.errors, validation.fieldErrors)
|
|
460
638
|
}
|
|
461
639
|
|
|
462
|
-
// 5. Filter writable fields (field-level access control)
|
|
640
|
+
// 5. Filter writable fields (field-level access control, skip if sudo mode)
|
|
463
641
|
const filteredData = await filterWritableFields(resolvedData, listConfig.fields, 'create', {
|
|
464
642
|
session: context.session,
|
|
465
|
-
context,
|
|
643
|
+
context: { ...context, _isSudo: context._isSudo },
|
|
466
644
|
})
|
|
467
645
|
|
|
468
646
|
// 5.5. Process nested relationship operations
|
|
@@ -509,12 +687,13 @@ function createCreate<TPrisma extends PrismaClientLike>(
|
|
|
509
687
|
)
|
|
510
688
|
|
|
511
689
|
// 11. Filter readable fields and apply resolveOutput hooks (including nested relationships)
|
|
690
|
+
// Pass sudo flag through context to skip field-level access checks
|
|
512
691
|
const filtered = await filterReadableFields(
|
|
513
692
|
item,
|
|
514
693
|
listConfig.fields,
|
|
515
694
|
{
|
|
516
695
|
session: context.session,
|
|
517
|
-
context,
|
|
696
|
+
context: { ...context, _isSudo: context._isSudo },
|
|
518
697
|
},
|
|
519
698
|
config,
|
|
520
699
|
0,
|
|
@@ -532,7 +711,7 @@ function createUpdate<TPrisma extends PrismaClientLike>(
|
|
|
532
711
|
listName: string,
|
|
533
712
|
listConfig: ListConfig,
|
|
534
713
|
prisma: TPrisma,
|
|
535
|
-
context: AccessContext
|
|
714
|
+
context: AccessContext<TPrisma>,
|
|
536
715
|
config: OpenSaasConfig,
|
|
537
716
|
) {
|
|
538
717
|
return async (args: { where: { id: string }; data: Record<string, unknown> }) => {
|
|
@@ -548,27 +727,29 @@ function createUpdate<TPrisma extends PrismaClientLike>(
|
|
|
548
727
|
return null
|
|
549
728
|
}
|
|
550
729
|
|
|
551
|
-
// 2. Check update access
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
if (accessResult === false) {
|
|
560
|
-
return null
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
// If access returns a filter, check if item matches
|
|
564
|
-
if (typeof accessResult === 'object') {
|
|
565
|
-
const matchesFilter = await model.findFirst({
|
|
566
|
-
where: mergeFilters(args.where, accessResult),
|
|
730
|
+
// 2. Check update access (skip if sudo mode)
|
|
731
|
+
if (!context._isSudo) {
|
|
732
|
+
const updateAccess = listConfig.access?.operation?.update
|
|
733
|
+
const accessResult = await checkAccess(updateAccess, {
|
|
734
|
+
session: context.session,
|
|
735
|
+
item,
|
|
736
|
+
context,
|
|
567
737
|
})
|
|
568
738
|
|
|
569
|
-
if (
|
|
739
|
+
if (accessResult === false) {
|
|
570
740
|
return null
|
|
571
741
|
}
|
|
742
|
+
|
|
743
|
+
// If access returns a filter, check if item matches
|
|
744
|
+
if (typeof accessResult === 'object') {
|
|
745
|
+
const matchesFilter = await model.findFirst({
|
|
746
|
+
where: mergeFilters(args.where, accessResult),
|
|
747
|
+
})
|
|
748
|
+
|
|
749
|
+
if (!matchesFilter) {
|
|
750
|
+
return null
|
|
751
|
+
}
|
|
752
|
+
}
|
|
572
753
|
}
|
|
573
754
|
|
|
574
755
|
// 3. Execute list-level resolveInput hook
|
|
@@ -603,11 +784,11 @@ function createUpdate<TPrisma extends PrismaClientLike>(
|
|
|
603
784
|
throw new ValidationError(validation.errors, validation.fieldErrors)
|
|
604
785
|
}
|
|
605
786
|
|
|
606
|
-
// 6. Filter writable fields (field-level access control)
|
|
787
|
+
// 6. Filter writable fields (field-level access control, skip if sudo mode)
|
|
607
788
|
const filteredData = await filterWritableFields(resolvedData, listConfig.fields, 'update', {
|
|
608
789
|
session: context.session,
|
|
609
790
|
item,
|
|
610
|
-
context,
|
|
791
|
+
context: { ...context, _isSudo: context._isSudo },
|
|
611
792
|
})
|
|
612
793
|
|
|
613
794
|
// 6.5. Process nested relationship operations
|
|
@@ -660,12 +841,13 @@ function createUpdate<TPrisma extends PrismaClientLike>(
|
|
|
660
841
|
)
|
|
661
842
|
|
|
662
843
|
// 12. Filter readable fields and apply resolveOutput hooks (including nested relationships)
|
|
844
|
+
// Pass sudo flag through context to skip field-level access checks
|
|
663
845
|
const filtered = await filterReadableFields(
|
|
664
846
|
updated,
|
|
665
847
|
listConfig.fields,
|
|
666
848
|
{
|
|
667
849
|
session: context.session,
|
|
668
|
-
context,
|
|
850
|
+
context: { ...context, _isSudo: context._isSudo },
|
|
669
851
|
},
|
|
670
852
|
config,
|
|
671
853
|
0,
|
|
@@ -683,7 +865,7 @@ function createDelete<TPrisma extends PrismaClientLike>(
|
|
|
683
865
|
listName: string,
|
|
684
866
|
listConfig: ListConfig,
|
|
685
867
|
prisma: TPrisma,
|
|
686
|
-
context: AccessContext
|
|
868
|
+
context: AccessContext<TPrisma>,
|
|
687
869
|
) {
|
|
688
870
|
return async (args: { where: { id: string } }) => {
|
|
689
871
|
// 1. Fetch the item to pass to access control and hooks
|
|
@@ -698,27 +880,29 @@ function createDelete<TPrisma extends PrismaClientLike>(
|
|
|
698
880
|
return null
|
|
699
881
|
}
|
|
700
882
|
|
|
701
|
-
// 2. Check delete access
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
if (accessResult === false) {
|
|
710
|
-
return null
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
// If access returns a filter, check if item matches
|
|
714
|
-
if (typeof accessResult === 'object') {
|
|
715
|
-
const matchesFilter = await model.findFirst({
|
|
716
|
-
where: mergeFilters(args.where, accessResult),
|
|
883
|
+
// 2. Check delete access (skip if sudo mode)
|
|
884
|
+
if (!context._isSudo) {
|
|
885
|
+
const deleteAccess = listConfig.access?.operation?.delete
|
|
886
|
+
const accessResult = await checkAccess(deleteAccess, {
|
|
887
|
+
session: context.session,
|
|
888
|
+
item,
|
|
889
|
+
context,
|
|
717
890
|
})
|
|
718
891
|
|
|
719
|
-
if (
|
|
892
|
+
if (accessResult === false) {
|
|
720
893
|
return null
|
|
721
894
|
}
|
|
895
|
+
|
|
896
|
+
// If access returns a filter, check if item matches
|
|
897
|
+
if (typeof accessResult === 'object') {
|
|
898
|
+
const matchesFilter = await model.findFirst({
|
|
899
|
+
where: mergeFilters(args.where, accessResult),
|
|
900
|
+
})
|
|
901
|
+
|
|
902
|
+
if (!matchesFilter) {
|
|
903
|
+
return null
|
|
904
|
+
}
|
|
905
|
+
}
|
|
722
906
|
}
|
|
723
907
|
|
|
724
908
|
// 3. Execute field-level beforeOperation hooks (side effects only)
|
|
@@ -764,24 +948,28 @@ function createCount<TPrisma extends PrismaClientLike>(
|
|
|
764
948
|
listName: string,
|
|
765
949
|
listConfig: ListConfig,
|
|
766
950
|
prisma: TPrisma,
|
|
767
|
-
context: AccessContext
|
|
951
|
+
context: AccessContext<TPrisma>,
|
|
768
952
|
) {
|
|
769
953
|
return async (args?: { where?: Record<string, unknown> }) => {
|
|
770
|
-
// Check query access
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
954
|
+
// Check query access (skip if sudo mode)
|
|
955
|
+
let where: Record<string, unknown> | undefined = args?.where
|
|
956
|
+
if (!context._isSudo) {
|
|
957
|
+
const queryAccess = listConfig.access?.operation?.query
|
|
958
|
+
const accessResult = await checkAccess(queryAccess, {
|
|
959
|
+
session: context.session,
|
|
960
|
+
context,
|
|
961
|
+
})
|
|
776
962
|
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
963
|
+
if (accessResult === false) {
|
|
964
|
+
return 0
|
|
965
|
+
}
|
|
780
966
|
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
967
|
+
// Merge access filter with where clause
|
|
968
|
+
const mergedWhere = mergeFilters(args?.where, accessResult)
|
|
969
|
+
if (mergedWhere === null) {
|
|
970
|
+
return 0
|
|
971
|
+
}
|
|
972
|
+
where = mergedWhere
|
|
785
973
|
}
|
|
786
974
|
|
|
787
975
|
// Execute count
|
package/src/fields/index.ts
CHANGED
|
@@ -59,7 +59,7 @@ export function text(options?: Omit<TextField, 'type'>): TextField {
|
|
|
59
59
|
return z.union([withMax, z.undefined()])
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
return !isRequired ? withMax.optional() : withMax
|
|
62
|
+
return !isRequired ? withMax.optional().nullable() : withMax
|
|
63
63
|
},
|
|
64
64
|
getPrismaType: () => {
|
|
65
65
|
const validation = options?.validation
|
|
@@ -122,7 +122,7 @@ export function integer(options?: Omit<IntegerField, 'type'>): IntegerField {
|
|
|
122
122
|
: withMin
|
|
123
123
|
|
|
124
124
|
return !options?.validation?.isRequired || operation === 'update'
|
|
125
|
-
? withMax.optional()
|
|
125
|
+
? withMax.optional().nullable()
|
|
126
126
|
: withMax
|
|
127
127
|
},
|
|
128
128
|
getPrismaType: () => {
|
|
@@ -152,7 +152,7 @@ export function checkbox(options?: Omit<CheckboxField, 'type'>): CheckboxField {
|
|
|
152
152
|
type: 'checkbox',
|
|
153
153
|
...options,
|
|
154
154
|
getZodSchema: () => {
|
|
155
|
-
return z.boolean().optional()
|
|
155
|
+
return z.boolean().optional().nullable()
|
|
156
156
|
},
|
|
157
157
|
getPrismaType: () => {
|
|
158
158
|
const hasDefault = options?.defaultValue !== undefined
|
|
@@ -184,7 +184,7 @@ export function timestamp(options?: Omit<TimestampField, 'type'>): TimestampFiel
|
|
|
184
184
|
type: 'timestamp',
|
|
185
185
|
...options,
|
|
186
186
|
getZodSchema: () => {
|
|
187
|
-
return z.union([z.date(), z.iso.datetime()]).optional()
|
|
187
|
+
return z.union([z.date(), z.iso.datetime()]).optional().nullable()
|
|
188
188
|
},
|
|
189
189
|
getPrismaType: () => {
|
|
190
190
|
let modifiers = '?'
|
|
@@ -347,6 +347,7 @@ export function password(options?: Omit<PasswordField, 'type'>): PasswordField {
|
|
|
347
347
|
message: `${formatFieldName(fieldName)} must be text`,
|
|
348
348
|
})
|
|
349
349
|
.optional()
|
|
350
|
+
.nullable()
|
|
350
351
|
}
|
|
351
352
|
},
|
|
352
353
|
getPrismaType: () => {
|
|
@@ -386,7 +387,7 @@ export function select(options: Omit<SelectField, 'type'>): SelectField {
|
|
|
386
387
|
})
|
|
387
388
|
|
|
388
389
|
if (!options.validation?.isRequired || operation === 'update') {
|
|
389
|
-
schema = schema.optional()
|
|
390
|
+
schema = schema.optional().nullable()
|
|
390
391
|
}
|
|
391
392
|
|
|
392
393
|
return schema
|
|
@@ -499,8 +500,8 @@ export function json(options?: Omit<JsonField, 'type'>): JsonField {
|
|
|
499
500
|
// Required in update mode: can be undefined for partial updates
|
|
500
501
|
return z.union([baseSchema, z.undefined()])
|
|
501
502
|
} else {
|
|
502
|
-
// Not required: can be undefined
|
|
503
|
-
return baseSchema.optional()
|
|
503
|
+
// Not required: can be undefined or null
|
|
504
|
+
return baseSchema.optional().nullable()
|
|
504
505
|
}
|
|
505
506
|
},
|
|
506
507
|
getPrismaType: () => {
|