@opensaas/stack-core 0.1.5 → 0.1.7
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 +13 -0
- package/README.md +5 -5
- package/dist/access/engine.d.ts +9 -3
- package/dist/access/engine.d.ts.map +1 -1
- package/dist/access/engine.js +6 -2
- package/dist/access/engine.js.map +1 -1
- package/dist/access/types.d.ts +1 -0
- package/dist/access/types.d.ts.map +1 -1
- package/dist/config/index.d.ts +6 -2
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +12 -2
- package/dist/config/index.js.map +1 -1
- package/dist/config/plugin-engine.d.ts +25 -0
- package/dist/config/plugin-engine.d.ts.map +1 -0
- package/dist/config/plugin-engine.js +240 -0
- package/dist/config/plugin-engine.js.map +1 -0
- package/dist/config/types.d.ts +129 -0
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/index.d.ts +11 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +116 -86
- package/dist/context/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.d.ts +1 -1
- package/dist/mcp/handler.d.ts.map +1 -1
- package/dist/mcp/handler.js +2 -2
- package/dist/mcp/handler.js.map +1 -1
- package/package.json +5 -1
- package/src/access/engine.ts +10 -5
- package/src/access/types.ts +1 -0
- package/src/config/index.ts +17 -2
- package/src/config/plugin-engine.ts +302 -0
- package/src/config/types.ts +147 -0
- package/src/context/index.ts +137 -95
- package/src/index.ts +4 -0
- package/src/mcp/handler.ts +6 -6
- package/tests/context.test.ts +13 -13
- package/tests/nested-access-and-hooks.test.ts +13 -13
- package/tests/password-type-distribution.test.ts +3 -3
- package/tests/password-types.test.ts +5 -5
- package/tests/sudo.test.ts +406 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/config/types.ts
CHANGED
|
@@ -212,6 +212,27 @@ export type BaseFieldConfig<TInput = any, TOutput = TInput> = {
|
|
|
212
212
|
type: string
|
|
213
213
|
optional: boolean
|
|
214
214
|
}
|
|
215
|
+
/**
|
|
216
|
+
* Get TypeScript imports needed for this field's type
|
|
217
|
+
* @returns Array of import statements needed for the generated types file
|
|
218
|
+
*/
|
|
219
|
+
getTypeScriptImports?: () => Array<{
|
|
220
|
+
/**
|
|
221
|
+
* The type/value names to import
|
|
222
|
+
* e.g., ['StoredEmbedding', 'EmbeddingMetadata']
|
|
223
|
+
*/
|
|
224
|
+
names: string[]
|
|
225
|
+
/**
|
|
226
|
+
* The module to import from
|
|
227
|
+
* e.g., '@opensaas/stack-rag'
|
|
228
|
+
*/
|
|
229
|
+
from: string
|
|
230
|
+
/**
|
|
231
|
+
* Whether this is a type-only import
|
|
232
|
+
* @default true
|
|
233
|
+
*/
|
|
234
|
+
typeOnly?: boolean
|
|
235
|
+
}>
|
|
215
236
|
}
|
|
216
237
|
|
|
217
238
|
export type TextField = BaseFieldConfig<string, string> & {
|
|
@@ -712,6 +733,121 @@ export interface ImageTransformationResult {
|
|
|
712
733
|
|
|
713
734
|
export type StorageConfig = Record<string, { type: string; [key: string]: unknown }>
|
|
714
735
|
|
|
736
|
+
/**
|
|
737
|
+
* Plugin system types
|
|
738
|
+
*/
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Files generated by the core generators
|
|
742
|
+
* Plugins can modify these during afterGenerate hooks
|
|
743
|
+
*/
|
|
744
|
+
export type GeneratedFiles = {
|
|
745
|
+
prismaSchema: string
|
|
746
|
+
types: string
|
|
747
|
+
context: string
|
|
748
|
+
[key: string]: string // Allow plugins to add custom generated files
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* Context provided to plugins during initialization
|
|
753
|
+
* Provides helpers for safely modifying config
|
|
754
|
+
*/
|
|
755
|
+
export type PluginContext = {
|
|
756
|
+
/**
|
|
757
|
+
* Current config state (read-only)
|
|
758
|
+
* Plugins should use helper methods to modify config, not mutate directly
|
|
759
|
+
*/
|
|
760
|
+
readonly config: OpenSaasConfig
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* Add a new list to the config
|
|
764
|
+
* Throws error if list already exists (unless merge strategy used)
|
|
765
|
+
*/
|
|
766
|
+
addList: (name: string, listConfig: ListConfig) => void
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* Extend an existing list with additional fields, hooks, or access control
|
|
770
|
+
* Deep merges fields, hooks, and access control
|
|
771
|
+
* Throws error if list doesn't exist
|
|
772
|
+
*/
|
|
773
|
+
extendList: (
|
|
774
|
+
name: string,
|
|
775
|
+
extension: {
|
|
776
|
+
fields?: Record<string, FieldConfig>
|
|
777
|
+
hooks?: Hooks
|
|
778
|
+
access?: {
|
|
779
|
+
operation?: OperationAccess
|
|
780
|
+
}
|
|
781
|
+
mcp?: ListMcpConfig
|
|
782
|
+
},
|
|
783
|
+
) => void
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Register a field type globally
|
|
787
|
+
* Useful for third-party field packages
|
|
788
|
+
*/
|
|
789
|
+
registerFieldType?: (type: string, builder: (options?: unknown) => BaseFieldConfig) => void
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* Register a custom MCP tool
|
|
793
|
+
* Tools are added to the global MCP server
|
|
794
|
+
*/
|
|
795
|
+
registerMcpTool?: (tool: McpCustomTool) => void
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* Store plugin-specific data in config for runtime access
|
|
799
|
+
* Prefixed with plugin name to avoid conflicts
|
|
800
|
+
*/
|
|
801
|
+
setPluginData: <T>(pluginName: string, data: T) => void
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* Plugin definition
|
|
806
|
+
* Plugins can extend config, inject lists, add hooks, and participate in generation lifecycle
|
|
807
|
+
*/
|
|
808
|
+
export type Plugin = {
|
|
809
|
+
/**
|
|
810
|
+
* Unique plugin name (used for dependency resolution and data storage)
|
|
811
|
+
*/
|
|
812
|
+
name: string
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* Plugin version (semantic versioning)
|
|
816
|
+
*/
|
|
817
|
+
version?: string
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* Dependencies on other plugins (by name)
|
|
821
|
+
* Ensures plugins execute in correct order
|
|
822
|
+
*/
|
|
823
|
+
dependencies?: string[]
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* Main initialization hook
|
|
827
|
+
* Called during config processing to extend or modify configuration
|
|
828
|
+
*/
|
|
829
|
+
init: (context: PluginContext) => void | Promise<void>
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* Optional: Modify config before Prisma schema generation
|
|
833
|
+
* Useful for programmatic config transformations
|
|
834
|
+
*/
|
|
835
|
+
beforeGenerate?: (config: OpenSaasConfig) => OpenSaasConfig | Promise<OpenSaasConfig>
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Optional: Post-process generated files
|
|
839
|
+
* Allows plugins to modify Prisma schema, types, or add custom generated files
|
|
840
|
+
*/
|
|
841
|
+
afterGenerate?: (files: GeneratedFiles) => GeneratedFiles | Promise<GeneratedFiles>
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* Optional: Provide runtime services
|
|
845
|
+
* Called when creating context to provide plugin-specific services
|
|
846
|
+
* Return value is stored in context.plugins[pluginName]
|
|
847
|
+
*/
|
|
848
|
+
runtime?: (context: import('../access/types.js').AccessContext) => unknown
|
|
849
|
+
}
|
|
850
|
+
|
|
715
851
|
/**
|
|
716
852
|
* Main configuration type
|
|
717
853
|
*/
|
|
@@ -734,4 +870,15 @@ export type OpenSaasConfig = {
|
|
|
734
870
|
* @default ".opensaas"
|
|
735
871
|
*/
|
|
736
872
|
opensaasPath?: string
|
|
873
|
+
/**
|
|
874
|
+
* Plugins to extend the stack
|
|
875
|
+
* Executed in array order (or dependency order if dependencies specified)
|
|
876
|
+
*/
|
|
877
|
+
plugins?: Plugin[]
|
|
878
|
+
/**
|
|
879
|
+
* Plugin-specific data storage
|
|
880
|
+
* Keyed by plugin name, used for runtime configuration
|
|
881
|
+
* @internal
|
|
882
|
+
*/
|
|
883
|
+
_pluginData?: Record<string, unknown>
|
|
737
884
|
}
|
package/src/context/index.ts
CHANGED
|
@@ -146,12 +146,23 @@ export function getContext<
|
|
|
146
146
|
prisma: TPrisma,
|
|
147
147
|
session: Session,
|
|
148
148
|
storage?: StorageUtils,
|
|
149
|
+
_isSudo: boolean = false,
|
|
149
150
|
): {
|
|
150
151
|
db: AccessControlledDB<TPrisma>
|
|
151
152
|
session: Session
|
|
152
153
|
prisma: TPrisma
|
|
153
154
|
storage: StorageUtils
|
|
154
155
|
serverAction: (props: ServerActionProps) => Promise<unknown>
|
|
156
|
+
_isSudo: boolean
|
|
157
|
+
sudo: () => {
|
|
158
|
+
db: AccessControlledDB<TPrisma>
|
|
159
|
+
session: Session
|
|
160
|
+
prisma: TPrisma
|
|
161
|
+
storage: StorageUtils
|
|
162
|
+
serverAction: (props: ServerActionProps) => Promise<unknown>
|
|
163
|
+
sudo: () => unknown
|
|
164
|
+
_isSudo: boolean
|
|
165
|
+
}
|
|
155
166
|
} {
|
|
156
167
|
// Initialize db object - will be populated with access-controlled operations
|
|
157
168
|
// Type is intentionally broad to allow dynamic model access
|
|
@@ -185,6 +196,7 @@ export function getContext<
|
|
|
185
196
|
)
|
|
186
197
|
},
|
|
187
198
|
},
|
|
199
|
+
_isSudo,
|
|
188
200
|
}
|
|
189
201
|
|
|
190
202
|
// Create access-controlled operations for each list
|
|
@@ -226,12 +238,20 @@ export function getContext<
|
|
|
226
238
|
return null
|
|
227
239
|
}
|
|
228
240
|
|
|
241
|
+
// Sudo function - creates a new context that bypasses access control
|
|
242
|
+
// but still executes all hooks and validation
|
|
243
|
+
function sudo() {
|
|
244
|
+
return getContext(config, prisma, session, context.storage, true)
|
|
245
|
+
}
|
|
246
|
+
|
|
229
247
|
return {
|
|
230
248
|
db: db as AccessControlledDB<TPrisma>,
|
|
231
249
|
session,
|
|
232
250
|
prisma,
|
|
233
251
|
storage: context.storage,
|
|
234
252
|
serverAction,
|
|
253
|
+
sudo,
|
|
254
|
+
_isSudo,
|
|
235
255
|
}
|
|
236
256
|
}
|
|
237
257
|
|
|
@@ -242,25 +262,29 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
|
|
|
242
262
|
listName: string,
|
|
243
263
|
listConfig: ListConfig,
|
|
244
264
|
prisma: TPrisma,
|
|
245
|
-
context: AccessContext
|
|
265
|
+
context: AccessContext<TPrisma>,
|
|
246
266
|
config: OpenSaasConfig,
|
|
247
267
|
) {
|
|
248
268
|
return async (args: { where: { id: string }; include?: Record<string, unknown> }) => {
|
|
249
|
-
// Check query access
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
269
|
+
// Check query access (skip if sudo mode)
|
|
270
|
+
let where: Record<string, unknown> = args.where
|
|
271
|
+
if (!context._isSudo) {
|
|
272
|
+
const queryAccess = listConfig.access?.operation?.query
|
|
273
|
+
const accessResult = await checkAccess(queryAccess, {
|
|
274
|
+
session: context.session,
|
|
275
|
+
context,
|
|
276
|
+
})
|
|
255
277
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
278
|
+
if (accessResult === false) {
|
|
279
|
+
return null
|
|
280
|
+
}
|
|
259
281
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
282
|
+
// Merge access filter with where clause
|
|
283
|
+
const mergedWhere = mergeFilters(args.where, accessResult)
|
|
284
|
+
if (mergedWhere === null) {
|
|
285
|
+
return null
|
|
286
|
+
}
|
|
287
|
+
where = mergedWhere
|
|
264
288
|
}
|
|
265
289
|
|
|
266
290
|
// Build include with access control filters
|
|
@@ -290,12 +314,13 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
|
|
|
290
314
|
}
|
|
291
315
|
|
|
292
316
|
// Filter readable fields and apply resolveOutput hooks (including nested relationships)
|
|
317
|
+
// Pass sudo flag through context to skip field-level access checks
|
|
293
318
|
const filtered = await filterReadableFields(
|
|
294
319
|
item,
|
|
295
320
|
listConfig.fields,
|
|
296
321
|
{
|
|
297
322
|
session: context.session,
|
|
298
|
-
context,
|
|
323
|
+
context: { ...context, _isSudo: context._isSudo },
|
|
299
324
|
},
|
|
300
325
|
config,
|
|
301
326
|
0,
|
|
@@ -323,7 +348,7 @@ function createFindMany<TPrisma extends PrismaClientLike>(
|
|
|
323
348
|
listName: string,
|
|
324
349
|
listConfig: ListConfig,
|
|
325
350
|
prisma: TPrisma,
|
|
326
|
-
context: AccessContext
|
|
351
|
+
context: AccessContext<TPrisma>,
|
|
327
352
|
config: OpenSaasConfig,
|
|
328
353
|
) {
|
|
329
354
|
return async (args?: {
|
|
@@ -332,21 +357,25 @@ function createFindMany<TPrisma extends PrismaClientLike>(
|
|
|
332
357
|
skip?: number
|
|
333
358
|
include?: Record<string, unknown>
|
|
334
359
|
}) => {
|
|
335
|
-
// Check query access
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
360
|
+
// Check query access (skip if sudo mode)
|
|
361
|
+
let where: Record<string, unknown> | undefined = args?.where
|
|
362
|
+
if (!context._isSudo) {
|
|
363
|
+
const queryAccess = listConfig.access?.operation?.query
|
|
364
|
+
const accessResult = await checkAccess(queryAccess, {
|
|
365
|
+
session: context.session,
|
|
366
|
+
context,
|
|
367
|
+
})
|
|
341
368
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
369
|
+
if (accessResult === false) {
|
|
370
|
+
return []
|
|
371
|
+
}
|
|
345
372
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
373
|
+
// Merge access filter with where clause
|
|
374
|
+
const mergedWhere = mergeFilters(args?.where, accessResult)
|
|
375
|
+
if (mergedWhere === null) {
|
|
376
|
+
return []
|
|
377
|
+
}
|
|
378
|
+
where = mergedWhere
|
|
350
379
|
}
|
|
351
380
|
|
|
352
381
|
// Build include with access control filters
|
|
@@ -374,6 +403,7 @@ function createFindMany<TPrisma extends PrismaClientLike>(
|
|
|
374
403
|
})
|
|
375
404
|
|
|
376
405
|
// Filter readable fields for each item and apply resolveOutput hooks (including nested relationships)
|
|
406
|
+
// Pass sudo flag through context to skip field-level access checks
|
|
377
407
|
const filtered = await Promise.all(
|
|
378
408
|
items.map((item: Record<string, unknown>) =>
|
|
379
409
|
filterReadableFields(
|
|
@@ -381,7 +411,7 @@ function createFindMany<TPrisma extends PrismaClientLike>(
|
|
|
381
411
|
listConfig.fields,
|
|
382
412
|
{
|
|
383
413
|
session: context.session,
|
|
384
|
-
context,
|
|
414
|
+
context: { ...context, _isSudo: context._isSudo },
|
|
385
415
|
},
|
|
386
416
|
config,
|
|
387
417
|
0,
|
|
@@ -415,19 +445,21 @@ function createCreate<TPrisma extends PrismaClientLike>(
|
|
|
415
445
|
listName: string,
|
|
416
446
|
listConfig: ListConfig,
|
|
417
447
|
prisma: TPrisma,
|
|
418
|
-
context: AccessContext
|
|
448
|
+
context: AccessContext<TPrisma>,
|
|
419
449
|
config: OpenSaasConfig,
|
|
420
450
|
) {
|
|
421
451
|
return async (args: { data: Record<string, unknown> }) => {
|
|
422
|
-
// 1. Check create access
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
452
|
+
// 1. Check create access (skip if sudo mode)
|
|
453
|
+
if (!context._isSudo) {
|
|
454
|
+
const createAccess = listConfig.access?.operation?.create
|
|
455
|
+
const accessResult = await checkAccess(createAccess, {
|
|
456
|
+
session: context.session,
|
|
457
|
+
context,
|
|
458
|
+
})
|
|
428
459
|
|
|
429
|
-
|
|
430
|
-
|
|
460
|
+
if (accessResult === false) {
|
|
461
|
+
return null
|
|
462
|
+
}
|
|
431
463
|
}
|
|
432
464
|
|
|
433
465
|
// 2. Execute list-level resolveInput hook
|
|
@@ -459,10 +491,10 @@ function createCreate<TPrisma extends PrismaClientLike>(
|
|
|
459
491
|
throw new ValidationError(validation.errors, validation.fieldErrors)
|
|
460
492
|
}
|
|
461
493
|
|
|
462
|
-
// 5. Filter writable fields (field-level access control)
|
|
494
|
+
// 5. Filter writable fields (field-level access control, skip if sudo mode)
|
|
463
495
|
const filteredData = await filterWritableFields(resolvedData, listConfig.fields, 'create', {
|
|
464
496
|
session: context.session,
|
|
465
|
-
context,
|
|
497
|
+
context: { ...context, _isSudo: context._isSudo },
|
|
466
498
|
})
|
|
467
499
|
|
|
468
500
|
// 5.5. Process nested relationship operations
|
|
@@ -509,12 +541,13 @@ function createCreate<TPrisma extends PrismaClientLike>(
|
|
|
509
541
|
)
|
|
510
542
|
|
|
511
543
|
// 11. Filter readable fields and apply resolveOutput hooks (including nested relationships)
|
|
544
|
+
// Pass sudo flag through context to skip field-level access checks
|
|
512
545
|
const filtered = await filterReadableFields(
|
|
513
546
|
item,
|
|
514
547
|
listConfig.fields,
|
|
515
548
|
{
|
|
516
549
|
session: context.session,
|
|
517
|
-
context,
|
|
550
|
+
context: { ...context, _isSudo: context._isSudo },
|
|
518
551
|
},
|
|
519
552
|
config,
|
|
520
553
|
0,
|
|
@@ -532,7 +565,7 @@ function createUpdate<TPrisma extends PrismaClientLike>(
|
|
|
532
565
|
listName: string,
|
|
533
566
|
listConfig: ListConfig,
|
|
534
567
|
prisma: TPrisma,
|
|
535
|
-
context: AccessContext
|
|
568
|
+
context: AccessContext<TPrisma>,
|
|
536
569
|
config: OpenSaasConfig,
|
|
537
570
|
) {
|
|
538
571
|
return async (args: { where: { id: string }; data: Record<string, unknown> }) => {
|
|
@@ -548,27 +581,29 @@ function createUpdate<TPrisma extends PrismaClientLike>(
|
|
|
548
581
|
return null
|
|
549
582
|
}
|
|
550
583
|
|
|
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),
|
|
584
|
+
// 2. Check update access (skip if sudo mode)
|
|
585
|
+
if (!context._isSudo) {
|
|
586
|
+
const updateAccess = listConfig.access?.operation?.update
|
|
587
|
+
const accessResult = await checkAccess(updateAccess, {
|
|
588
|
+
session: context.session,
|
|
589
|
+
item,
|
|
590
|
+
context,
|
|
567
591
|
})
|
|
568
592
|
|
|
569
|
-
if (
|
|
593
|
+
if (accessResult === false) {
|
|
570
594
|
return null
|
|
571
595
|
}
|
|
596
|
+
|
|
597
|
+
// If access returns a filter, check if item matches
|
|
598
|
+
if (typeof accessResult === 'object') {
|
|
599
|
+
const matchesFilter = await model.findFirst({
|
|
600
|
+
where: mergeFilters(args.where, accessResult),
|
|
601
|
+
})
|
|
602
|
+
|
|
603
|
+
if (!matchesFilter) {
|
|
604
|
+
return null
|
|
605
|
+
}
|
|
606
|
+
}
|
|
572
607
|
}
|
|
573
608
|
|
|
574
609
|
// 3. Execute list-level resolveInput hook
|
|
@@ -603,11 +638,11 @@ function createUpdate<TPrisma extends PrismaClientLike>(
|
|
|
603
638
|
throw new ValidationError(validation.errors, validation.fieldErrors)
|
|
604
639
|
}
|
|
605
640
|
|
|
606
|
-
// 6. Filter writable fields (field-level access control)
|
|
641
|
+
// 6. Filter writable fields (field-level access control, skip if sudo mode)
|
|
607
642
|
const filteredData = await filterWritableFields(resolvedData, listConfig.fields, 'update', {
|
|
608
643
|
session: context.session,
|
|
609
644
|
item,
|
|
610
|
-
context,
|
|
645
|
+
context: { ...context, _isSudo: context._isSudo },
|
|
611
646
|
})
|
|
612
647
|
|
|
613
648
|
// 6.5. Process nested relationship operations
|
|
@@ -660,12 +695,13 @@ function createUpdate<TPrisma extends PrismaClientLike>(
|
|
|
660
695
|
)
|
|
661
696
|
|
|
662
697
|
// 12. Filter readable fields and apply resolveOutput hooks (including nested relationships)
|
|
698
|
+
// Pass sudo flag through context to skip field-level access checks
|
|
663
699
|
const filtered = await filterReadableFields(
|
|
664
700
|
updated,
|
|
665
701
|
listConfig.fields,
|
|
666
702
|
{
|
|
667
703
|
session: context.session,
|
|
668
|
-
context,
|
|
704
|
+
context: { ...context, _isSudo: context._isSudo },
|
|
669
705
|
},
|
|
670
706
|
config,
|
|
671
707
|
0,
|
|
@@ -683,7 +719,7 @@ function createDelete<TPrisma extends PrismaClientLike>(
|
|
|
683
719
|
listName: string,
|
|
684
720
|
listConfig: ListConfig,
|
|
685
721
|
prisma: TPrisma,
|
|
686
|
-
context: AccessContext
|
|
722
|
+
context: AccessContext<TPrisma>,
|
|
687
723
|
) {
|
|
688
724
|
return async (args: { where: { id: string } }) => {
|
|
689
725
|
// 1. Fetch the item to pass to access control and hooks
|
|
@@ -698,27 +734,29 @@ function createDelete<TPrisma extends PrismaClientLike>(
|
|
|
698
734
|
return null
|
|
699
735
|
}
|
|
700
736
|
|
|
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),
|
|
737
|
+
// 2. Check delete access (skip if sudo mode)
|
|
738
|
+
if (!context._isSudo) {
|
|
739
|
+
const deleteAccess = listConfig.access?.operation?.delete
|
|
740
|
+
const accessResult = await checkAccess(deleteAccess, {
|
|
741
|
+
session: context.session,
|
|
742
|
+
item,
|
|
743
|
+
context,
|
|
717
744
|
})
|
|
718
745
|
|
|
719
|
-
if (
|
|
746
|
+
if (accessResult === false) {
|
|
720
747
|
return null
|
|
721
748
|
}
|
|
749
|
+
|
|
750
|
+
// If access returns a filter, check if item matches
|
|
751
|
+
if (typeof accessResult === 'object') {
|
|
752
|
+
const matchesFilter = await model.findFirst({
|
|
753
|
+
where: mergeFilters(args.where, accessResult),
|
|
754
|
+
})
|
|
755
|
+
|
|
756
|
+
if (!matchesFilter) {
|
|
757
|
+
return null
|
|
758
|
+
}
|
|
759
|
+
}
|
|
722
760
|
}
|
|
723
761
|
|
|
724
762
|
// 3. Execute field-level beforeOperation hooks (side effects only)
|
|
@@ -764,24 +802,28 @@ function createCount<TPrisma extends PrismaClientLike>(
|
|
|
764
802
|
listName: string,
|
|
765
803
|
listConfig: ListConfig,
|
|
766
804
|
prisma: TPrisma,
|
|
767
|
-
context: AccessContext
|
|
805
|
+
context: AccessContext<TPrisma>,
|
|
768
806
|
) {
|
|
769
807
|
return async (args?: { where?: Record<string, unknown> }) => {
|
|
770
|
-
// Check query access
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
808
|
+
// Check query access (skip if sudo mode)
|
|
809
|
+
let where: Record<string, unknown> | undefined = args?.where
|
|
810
|
+
if (!context._isSudo) {
|
|
811
|
+
const queryAccess = listConfig.access?.operation?.query
|
|
812
|
+
const accessResult = await checkAccess(queryAccess, {
|
|
813
|
+
session: context.session,
|
|
814
|
+
context,
|
|
815
|
+
})
|
|
776
816
|
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
817
|
+
if (accessResult === false) {
|
|
818
|
+
return 0
|
|
819
|
+
}
|
|
780
820
|
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
821
|
+
// Merge access filter with where clause
|
|
822
|
+
const mergedWhere = mergeFilters(args?.where, accessResult)
|
|
823
|
+
if (mergedWhere === null) {
|
|
824
|
+
return 0
|
|
825
|
+
}
|
|
826
|
+
where = mergedWhere
|
|
785
827
|
}
|
|
786
828
|
|
|
787
829
|
// Execute count
|
package/src/index.ts
CHANGED
package/src/mcp/handler.ts
CHANGED
|
@@ -32,7 +32,7 @@ import type { McpSession, McpSessionProvider } from './types.js'
|
|
|
32
32
|
export function createMcpHandlers(options: {
|
|
33
33
|
config: OpenSaasConfig
|
|
34
34
|
getSession: McpSessionProvider
|
|
35
|
-
getContext: (session?: { userId: string }) => AccessContext
|
|
35
|
+
getContext: (session?: { userId: string }) => Promise<AccessContext>
|
|
36
36
|
}): {
|
|
37
37
|
GET: (req: Request) => Promise<Response>
|
|
38
38
|
POST: (req: Request) => Promise<Response>
|
|
@@ -403,7 +403,7 @@ async function handleToolsCall(
|
|
|
403
403
|
params: any,
|
|
404
404
|
session: McpSession,
|
|
405
405
|
config: OpenSaasConfig,
|
|
406
|
-
getContext: (session?: { userId: string }) => AccessContext
|
|
406
|
+
getContext: (session?: { userId: string }) => Promise<AccessContext>,
|
|
407
407
|
id?: number | string,
|
|
408
408
|
): Promise<Response> {
|
|
409
409
|
const toolName = params?.name
|
|
@@ -447,11 +447,11 @@ async function handleCrudTool(
|
|
|
447
447
|
args: any,
|
|
448
448
|
session: McpSession,
|
|
449
449
|
config: OpenSaasConfig,
|
|
450
|
-
getContext: (session?: { userId: string }) => AccessContext
|
|
450
|
+
getContext: (session?: { userId: string }) => Promise<AccessContext>,
|
|
451
451
|
id?: number | string,
|
|
452
452
|
): Promise<Response> {
|
|
453
453
|
// Create context with user session
|
|
454
|
-
const context = getContext({ userId: session.userId })
|
|
454
|
+
const context = await getContext({ userId: session.userId })
|
|
455
455
|
|
|
456
456
|
try {
|
|
457
457
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Result type varies by Prisma operation
|
|
@@ -530,7 +530,7 @@ async function handleCustomTool(
|
|
|
530
530
|
args: any,
|
|
531
531
|
session: McpSession,
|
|
532
532
|
config: OpenSaasConfig,
|
|
533
|
-
getContext: (session?: { userId: string }) => AccessContext
|
|
533
|
+
getContext: (session?: { userId: string }) => Promise<AccessContext>,
|
|
534
534
|
id?: number | string,
|
|
535
535
|
): Promise<Response> {
|
|
536
536
|
// Find custom tool in config
|
|
@@ -538,7 +538,7 @@ async function handleCustomTool(
|
|
|
538
538
|
const customTool = listConfig.mcp?.customTools?.find((t) => t.name === toolName)
|
|
539
539
|
|
|
540
540
|
if (customTool) {
|
|
541
|
-
const context = getContext({ userId: session.userId })
|
|
541
|
+
const context = await getContext({ userId: session.userId })
|
|
542
542
|
|
|
543
543
|
try {
|
|
544
544
|
const result = await customTool.handler({
|