@opensaas/stack-core 0.1.7 → 0.4.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 +352 -0
- package/CLAUDE.md +46 -1
- package/dist/access/engine.d.ts +7 -6
- package/dist/access/engine.d.ts.map +1 -1
- package/dist/access/engine.js +55 -0
- 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 +39 -9
- package/dist/access/types.d.ts.map +1 -1
- package/dist/config/index.d.ts +40 -20
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +34 -15
- package/dist/config/index.js.map +1 -1
- package/dist/config/plugin-engine.d.ts.map +1 -1
- package/dist/config/plugin-engine.js +9 -0
- package/dist/config/plugin-engine.js.map +1 -1
- package/dist/config/types.d.ts +277 -84
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/index.d.ts +5 -3
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +146 -20
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +88 -72
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/fields/index.d.ts +65 -9
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +98 -16
- 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/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp/handler.js +1 -0
- package/dist/mcp/handler.js.map +1 -1
- package/dist/validation/schema.d.ts.map +1 -1
- package/dist/validation/schema.js +4 -2
- package/dist/validation/schema.js.map +1 -1
- package/package.json +8 -9
- package/src/access/engine.test.ts +145 -0
- package/src/access/engine.ts +73 -9
- package/src/access/types.ts +38 -8
- package/src/config/index.ts +45 -23
- package/src/config/plugin-engine.ts +13 -3
- package/src/config/types.ts +347 -117
- package/src/context/index.ts +176 -23
- package/src/context/nested-operations.ts +83 -71
- package/src/fields/index.ts +132 -27
- package/src/hooks/index.ts +63 -20
- package/src/index.ts +9 -0
- package/src/mcp/handler.ts +2 -1
- package/src/validation/schema.ts +4 -2
- package/tests/context.test.ts +38 -6
- package/tests/field-types.test.ts +729 -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 +230 -2
- 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,70 @@ 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
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
139
|
+
function parsePrismaError(error: unknown, listConfig: ListConfig<any>): Error {
|
|
140
|
+
// Check if it's a Prisma error
|
|
141
|
+
if (
|
|
142
|
+
error &&
|
|
143
|
+
typeof error === 'object' &&
|
|
144
|
+
'code' in error &&
|
|
145
|
+
'meta' in error &&
|
|
146
|
+
typeof error.code === 'string'
|
|
147
|
+
) {
|
|
148
|
+
const prismaError = error as { code: string; meta?: { target?: string[] }; message?: string }
|
|
149
|
+
|
|
150
|
+
// Handle unique constraint violation
|
|
151
|
+
if (prismaError.code === 'P2002') {
|
|
152
|
+
const target = prismaError.meta?.target
|
|
153
|
+
const fieldErrors: Record<string, string> = {}
|
|
154
|
+
|
|
155
|
+
if (target && Array.isArray(target)) {
|
|
156
|
+
// Get field names from the constraint target
|
|
157
|
+
for (const fieldName of target) {
|
|
158
|
+
// Get the field config to get a better label
|
|
159
|
+
const fieldConfig = listConfig.fields[fieldName]
|
|
160
|
+
const label = fieldName.charAt(0).toUpperCase() + fieldName.slice(1)
|
|
161
|
+
|
|
162
|
+
if (fieldConfig) {
|
|
163
|
+
fieldErrors[fieldName] = `This ${label.toLowerCase()} is already in use`
|
|
164
|
+
} else {
|
|
165
|
+
fieldErrors[fieldName] = `This value is already in use`
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Create a user-friendly general message
|
|
170
|
+
const fieldLabels = target.map((f) => f.charAt(0).toUpperCase() + f.slice(1)).join(', ')
|
|
171
|
+
return new DatabaseError(
|
|
172
|
+
`${fieldLabels} must be unique. The value you entered is already in use.`,
|
|
173
|
+
fieldErrors,
|
|
174
|
+
prismaError.code,
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return new DatabaseError('A record with this value already exists', {}, prismaError.code)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Handle other Prisma errors - return generic message
|
|
182
|
+
return new DatabaseError(
|
|
183
|
+
prismaError.message || 'A database error occurred',
|
|
184
|
+
{},
|
|
185
|
+
prismaError.code,
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Not a Prisma error, return as-is if it's already an Error
|
|
190
|
+
if (error instanceof Error) {
|
|
191
|
+
return error
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Unknown error type
|
|
195
|
+
return new Error('An unknown error occurred')
|
|
196
|
+
}
|
|
197
|
+
|
|
133
198
|
/**
|
|
134
199
|
* Create an access-controlled context
|
|
135
200
|
*
|
|
@@ -144,21 +209,23 @@ export function getContext<
|
|
|
144
209
|
>(
|
|
145
210
|
config: TConfig,
|
|
146
211
|
prisma: TPrisma,
|
|
147
|
-
session: Session,
|
|
212
|
+
session: Session | null,
|
|
148
213
|
storage?: StorageUtils,
|
|
149
214
|
_isSudo: boolean = false,
|
|
150
215
|
): {
|
|
151
216
|
db: AccessControlledDB<TPrisma>
|
|
152
|
-
session: Session
|
|
217
|
+
session: Session | null
|
|
153
218
|
prisma: TPrisma
|
|
154
219
|
storage: StorageUtils
|
|
220
|
+
plugins: Record<string, unknown>
|
|
155
221
|
serverAction: (props: ServerActionProps) => Promise<unknown>
|
|
156
222
|
_isSudo: boolean
|
|
157
223
|
sudo: () => {
|
|
158
224
|
db: AccessControlledDB<TPrisma>
|
|
159
|
-
session: Session
|
|
225
|
+
session: Session | null
|
|
160
226
|
prisma: TPrisma
|
|
161
227
|
storage: StorageUtils
|
|
228
|
+
plugins: Record<string, unknown>
|
|
162
229
|
serverAction: (props: ServerActionProps) => Promise<unknown>
|
|
163
230
|
sudo: () => unknown
|
|
164
231
|
_isSudo: boolean
|
|
@@ -196,6 +263,7 @@ export function getContext<
|
|
|
196
263
|
)
|
|
197
264
|
},
|
|
198
265
|
},
|
|
266
|
+
plugins: {}, // Will be populated with plugin runtime services
|
|
199
267
|
_isSudo,
|
|
200
268
|
}
|
|
201
269
|
|
|
@@ -213,29 +281,107 @@ export function getContext<
|
|
|
213
281
|
}
|
|
214
282
|
}
|
|
215
283
|
|
|
284
|
+
// Execute plugin runtime functions and populate context.plugins
|
|
285
|
+
// Use _plugins (sorted by dependencies) if available, otherwise fall back to plugins array
|
|
286
|
+
const pluginsToExecute = config._plugins || config.plugins || []
|
|
287
|
+
for (const plugin of pluginsToExecute) {
|
|
288
|
+
if (plugin.runtime) {
|
|
289
|
+
try {
|
|
290
|
+
context.plugins[plugin.name] = plugin.runtime(context)
|
|
291
|
+
} catch (error) {
|
|
292
|
+
console.error(`Error executing runtime for plugin "${plugin.name}":`, error)
|
|
293
|
+
// Continue with other plugins even if one fails
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
216
298
|
// Generic server action handler with discriminated union for type safety
|
|
217
|
-
|
|
299
|
+
// Returns a result object instead of throwing to work properly in Next.js production
|
|
300
|
+
async function serverAction(
|
|
301
|
+
props: ServerActionProps,
|
|
302
|
+
): Promise<
|
|
303
|
+
| { success: true; data: unknown }
|
|
304
|
+
| { success: false; error: string; fieldErrors?: Record<string, string> }
|
|
305
|
+
> {
|
|
218
306
|
const dbKey = getDbKey(props.listKey)
|
|
307
|
+
const listConfig = config.lists[props.listKey]
|
|
308
|
+
|
|
309
|
+
if (!listConfig) {
|
|
310
|
+
return {
|
|
311
|
+
success: false,
|
|
312
|
+
error: `List "${props.listKey}" not found in configuration`,
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
219
316
|
const model = db[dbKey] as {
|
|
220
317
|
create: (args: { data: Record<string, unknown> }) => Promise<unknown>
|
|
221
318
|
update: (args: { where: { id: string }; data: Record<string, unknown> }) => Promise<unknown>
|
|
222
319
|
delete: (args: { where: { id: string } }) => Promise<unknown>
|
|
223
320
|
}
|
|
224
321
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
})
|
|
236
|
-
|
|
322
|
+
try {
|
|
323
|
+
let result: unknown = null
|
|
324
|
+
|
|
325
|
+
if (props.action === 'create') {
|
|
326
|
+
result = await model.create({ data: props.data })
|
|
327
|
+
} else if (props.action === 'update') {
|
|
328
|
+
result = await model.update({
|
|
329
|
+
where: { id: props.id },
|
|
330
|
+
data: props.data,
|
|
331
|
+
})
|
|
332
|
+
} else if (props.action === 'delete') {
|
|
333
|
+
result = await model.delete({
|
|
334
|
+
where: { id: props.id },
|
|
335
|
+
})
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Check for access denial (null return from access-controlled operations)
|
|
339
|
+
if (result === null) {
|
|
340
|
+
return {
|
|
341
|
+
success: false,
|
|
342
|
+
error: 'Access denied or operation failed',
|
|
343
|
+
}
|
|
344
|
+
}
|
|
237
345
|
|
|
238
|
-
|
|
346
|
+
return {
|
|
347
|
+
success: true,
|
|
348
|
+
data: result,
|
|
349
|
+
}
|
|
350
|
+
} catch (error) {
|
|
351
|
+
// Handle ValidationError (has fieldErrors)
|
|
352
|
+
if (error instanceof ValidationError) {
|
|
353
|
+
return {
|
|
354
|
+
success: false,
|
|
355
|
+
error: error.message,
|
|
356
|
+
fieldErrors: error.fieldErrors,
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Handle DatabaseError (has fieldErrors)
|
|
361
|
+
if (error instanceof DatabaseError) {
|
|
362
|
+
return {
|
|
363
|
+
success: false,
|
|
364
|
+
error: error.message,
|
|
365
|
+
fieldErrors: error.fieldErrors,
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Parse and convert Prisma errors to user-friendly DatabaseError
|
|
370
|
+
const dbError = parsePrismaError(error, listConfig)
|
|
371
|
+
if (dbError instanceof DatabaseError) {
|
|
372
|
+
return {
|
|
373
|
+
success: false,
|
|
374
|
+
error: dbError.message,
|
|
375
|
+
fieldErrors: dbError.fieldErrors,
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Generic error fallback
|
|
380
|
+
return {
|
|
381
|
+
success: false,
|
|
382
|
+
error: dbError.message,
|
|
383
|
+
}
|
|
384
|
+
}
|
|
239
385
|
}
|
|
240
386
|
|
|
241
387
|
// Sudo function - creates a new context that bypasses access control
|
|
@@ -249,6 +395,7 @@ export function getContext<
|
|
|
249
395
|
session,
|
|
250
396
|
prisma,
|
|
251
397
|
storage: context.storage,
|
|
398
|
+
plugins: context.plugins,
|
|
252
399
|
serverAction,
|
|
253
400
|
sudo,
|
|
254
401
|
_isSudo,
|
|
@@ -260,7 +407,8 @@ export function getContext<
|
|
|
260
407
|
*/
|
|
261
408
|
function createFindUnique<TPrisma extends PrismaClientLike>(
|
|
262
409
|
listName: string,
|
|
263
|
-
|
|
410
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
411
|
+
listConfig: ListConfig<any>,
|
|
264
412
|
prisma: TPrisma,
|
|
265
413
|
context: AccessContext<TPrisma>,
|
|
266
414
|
config: OpenSaasConfig,
|
|
@@ -346,7 +494,8 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
|
|
|
346
494
|
*/
|
|
347
495
|
function createFindMany<TPrisma extends PrismaClientLike>(
|
|
348
496
|
listName: string,
|
|
349
|
-
|
|
497
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
498
|
+
listConfig: ListConfig<any>,
|
|
350
499
|
prisma: TPrisma,
|
|
351
500
|
context: AccessContext<TPrisma>,
|
|
352
501
|
config: OpenSaasConfig,
|
|
@@ -443,7 +592,8 @@ function createFindMany<TPrisma extends PrismaClientLike>(
|
|
|
443
592
|
*/
|
|
444
593
|
function createCreate<TPrisma extends PrismaClientLike>(
|
|
445
594
|
listName: string,
|
|
446
|
-
|
|
595
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
596
|
+
listConfig: ListConfig<any>,
|
|
447
597
|
prisma: TPrisma,
|
|
448
598
|
context: AccessContext<TPrisma>,
|
|
449
599
|
config: OpenSaasConfig,
|
|
@@ -563,7 +713,8 @@ function createCreate<TPrisma extends PrismaClientLike>(
|
|
|
563
713
|
*/
|
|
564
714
|
function createUpdate<TPrisma extends PrismaClientLike>(
|
|
565
715
|
listName: string,
|
|
566
|
-
|
|
716
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
717
|
+
listConfig: ListConfig<any>,
|
|
567
718
|
prisma: TPrisma,
|
|
568
719
|
context: AccessContext<TPrisma>,
|
|
569
720
|
config: OpenSaasConfig,
|
|
@@ -717,7 +868,8 @@ function createUpdate<TPrisma extends PrismaClientLike>(
|
|
|
717
868
|
*/
|
|
718
869
|
function createDelete<TPrisma extends PrismaClientLike>(
|
|
719
870
|
listName: string,
|
|
720
|
-
|
|
871
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
872
|
+
listConfig: ListConfig<any>,
|
|
721
873
|
prisma: TPrisma,
|
|
722
874
|
context: AccessContext<TPrisma>,
|
|
723
875
|
) {
|
|
@@ -800,7 +952,8 @@ function createDelete<TPrisma extends PrismaClientLike>(
|
|
|
800
952
|
*/
|
|
801
953
|
function createCount<TPrisma extends PrismaClientLike>(
|
|
802
954
|
listName: string,
|
|
803
|
-
|
|
955
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
956
|
+
listConfig: ListConfig<any>,
|
|
804
957
|
prisma: TPrisma,
|
|
805
958
|
context: AccessContext<TPrisma>,
|
|
806
959
|
) {
|
|
@@ -61,7 +61,8 @@ function isRelationshipField(fieldConfig: FieldConfig | undefined): boolean {
|
|
|
61
61
|
*/
|
|
62
62
|
async function processNestedCreate(
|
|
63
63
|
items: Record<string, unknown> | Array<Record<string, unknown>>,
|
|
64
|
-
|
|
64
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
65
|
+
relatedListConfig: ListConfig<any>,
|
|
65
66
|
context: AccessContext,
|
|
66
67
|
config: OpenSaasConfig,
|
|
67
68
|
): Promise<Record<string, unknown> | Array<Record<string, unknown>>> {
|
|
@@ -69,15 +70,17 @@ async function processNestedCreate(
|
|
|
69
70
|
|
|
70
71
|
const processedItems = await Promise.all(
|
|
71
72
|
itemsArray.map(async (item) => {
|
|
72
|
-
// 1. Check create access
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
73
|
+
// 1. Check create access (skip if sudo mode)
|
|
74
|
+
if (!context._isSudo) {
|
|
75
|
+
const createAccess = relatedListConfig.access?.operation?.create
|
|
76
|
+
const accessResult = await checkAccess(createAccess, {
|
|
77
|
+
session: context.session,
|
|
78
|
+
context,
|
|
79
|
+
})
|
|
78
80
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
+
if (accessResult === false) {
|
|
82
|
+
throw new Error('Access denied: Cannot create related item')
|
|
83
|
+
}
|
|
81
84
|
}
|
|
82
85
|
|
|
83
86
|
// 2. Execute list-level resolveInput hook
|
|
@@ -151,49 +154,52 @@ async function processNestedCreate(
|
|
|
151
154
|
async function processNestedConnect(
|
|
152
155
|
connections: Record<string, unknown> | Array<Record<string, unknown>>,
|
|
153
156
|
relatedListName: string,
|
|
154
|
-
|
|
157
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
158
|
+
relatedListConfig: ListConfig<any>,
|
|
155
159
|
context: AccessContext,
|
|
156
160
|
prisma: unknown,
|
|
157
161
|
): Promise<Record<string, unknown> | Array<Record<string, unknown>>> {
|
|
158
162
|
const connectionsArray = Array.isArray(connections) ? connections : [connections]
|
|
159
163
|
|
|
160
|
-
// Check update access for each item being connected
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
164
|
+
// Check update access for each item being connected (skip if sudo mode)
|
|
165
|
+
if (!context._isSudo) {
|
|
166
|
+
for (const connection of connectionsArray) {
|
|
167
|
+
// Access Prisma model dynamically - required because model names are generated at runtime
|
|
168
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
169
|
+
const model = (prisma as any)[getDbKey(relatedListName)]
|
|
165
170
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
171
|
+
// Fetch the item to check access
|
|
172
|
+
const item = await model.findUnique({
|
|
173
|
+
where: connection,
|
|
174
|
+
})
|
|
170
175
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
176
|
+
if (!item) {
|
|
177
|
+
throw new Error(`Cannot connect: Item not found`)
|
|
178
|
+
}
|
|
174
179
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
180
|
+
// Check update access (connecting modifies the relationship)
|
|
181
|
+
const updateAccess = relatedListConfig.access?.operation?.update
|
|
182
|
+
const accessResult = await checkAccess(updateAccess, {
|
|
183
|
+
session: context.session,
|
|
184
|
+
item,
|
|
185
|
+
context,
|
|
186
|
+
})
|
|
182
187
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
188
|
+
if (accessResult === false) {
|
|
189
|
+
throw new Error('Access denied: Cannot connect to this item')
|
|
190
|
+
}
|
|
186
191
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
192
|
+
// If access returns a filter, check if item matches
|
|
193
|
+
if (typeof accessResult === 'object') {
|
|
194
|
+
// Simple field matching
|
|
195
|
+
for (const [key, value] of Object.entries(accessResult)) {
|
|
196
|
+
if (typeof value === 'object' && value !== null && 'equals' in value) {
|
|
197
|
+
if (item[key] !== (value as Record<string, unknown>).equals) {
|
|
198
|
+
throw new Error('Access denied: Cannot connect to this item')
|
|
199
|
+
}
|
|
200
|
+
} else if (item[key] !== value) {
|
|
193
201
|
throw new Error('Access denied: Cannot connect to this item')
|
|
194
202
|
}
|
|
195
|
-
} else if (item[key] !== value) {
|
|
196
|
-
throw new Error('Access denied: Cannot connect to this item')
|
|
197
203
|
}
|
|
198
204
|
}
|
|
199
205
|
}
|
|
@@ -209,7 +215,8 @@ async function processNestedConnect(
|
|
|
209
215
|
async function processNestedUpdate(
|
|
210
216
|
updates: Record<string, unknown> | Array<Record<string, unknown>>,
|
|
211
217
|
relatedListName: string,
|
|
212
|
-
|
|
218
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
219
|
+
relatedListConfig: ListConfig<any>,
|
|
213
220
|
context: AccessContext,
|
|
214
221
|
config: OpenSaasConfig,
|
|
215
222
|
prisma: unknown,
|
|
@@ -231,16 +238,18 @@ async function processNestedUpdate(
|
|
|
231
238
|
throw new Error('Cannot update: Item not found')
|
|
232
239
|
}
|
|
233
240
|
|
|
234
|
-
// Check update access
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
+
// Check update access (skip if sudo mode)
|
|
242
|
+
if (!context._isSudo) {
|
|
243
|
+
const updateAccess = relatedListConfig.access?.operation?.update
|
|
244
|
+
const accessResult = await checkAccess(updateAccess, {
|
|
245
|
+
session: context.session,
|
|
246
|
+
item,
|
|
247
|
+
context,
|
|
248
|
+
})
|
|
241
249
|
|
|
242
|
-
|
|
243
|
-
|
|
250
|
+
if (accessResult === false) {
|
|
251
|
+
throw new Error('Access denied: Cannot update related item')
|
|
252
|
+
}
|
|
244
253
|
}
|
|
245
254
|
|
|
246
255
|
// Execute list-level resolveInput hook
|
|
@@ -313,7 +322,8 @@ async function processNestedUpdate(
|
|
|
313
322
|
async function processNestedConnectOrCreate(
|
|
314
323
|
operations: Record<string, unknown> | Array<Record<string, unknown>>,
|
|
315
324
|
relatedListName: string,
|
|
316
|
-
|
|
325
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
326
|
+
relatedListConfig: ListConfig<any>,
|
|
317
327
|
context: AccessContext,
|
|
318
328
|
config: OpenSaasConfig,
|
|
319
329
|
prisma: unknown,
|
|
@@ -331,30 +341,32 @@ async function processNestedConnectOrCreate(
|
|
|
331
341
|
config,
|
|
332
342
|
)
|
|
333
343
|
|
|
334
|
-
// Check access for the connect portion (try to find existing item)
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
if (existingItem) {
|
|
344
|
-
// Check update access for connection
|
|
345
|
-
const updateAccess = relatedListConfig.access?.operation?.update
|
|
346
|
-
const accessResult = await checkAccess(updateAccess, {
|
|
347
|
-
session: context.session,
|
|
348
|
-
item: existingItem,
|
|
349
|
-
context,
|
|
344
|
+
// Check access for the connect portion (try to find existing item) (skip if sudo mode)
|
|
345
|
+
if (!context._isSudo) {
|
|
346
|
+
try {
|
|
347
|
+
// Access Prisma model dynamically - required because model names are generated at runtime
|
|
348
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
349
|
+
const model = (prisma as any)[getDbKey(relatedListName)]
|
|
350
|
+
const existingItem = await model.findUnique({
|
|
351
|
+
where: opRecord.where,
|
|
350
352
|
})
|
|
351
353
|
|
|
352
|
-
if (
|
|
353
|
-
|
|
354
|
+
if (existingItem) {
|
|
355
|
+
// Check update access for connection
|
|
356
|
+
const updateAccess = relatedListConfig.access?.operation?.update
|
|
357
|
+
const accessResult = await checkAccess(updateAccess, {
|
|
358
|
+
session: context.session,
|
|
359
|
+
item: existingItem,
|
|
360
|
+
context,
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
if (accessResult === false) {
|
|
364
|
+
throw new Error('Access denied: Cannot connect to existing item')
|
|
365
|
+
}
|
|
354
366
|
}
|
|
367
|
+
} catch {
|
|
368
|
+
// Item doesn't exist, will use create (already processed)
|
|
355
369
|
}
|
|
356
|
-
} catch {
|
|
357
|
-
// Item doesn't exist, will use create (already processed)
|
|
358
370
|
}
|
|
359
371
|
|
|
360
372
|
return {
|