@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opensaas/stack-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Core stack for OpenSaas - schema definition, access control, and runtime utilities",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -41,19 +41,18 @@
|
|
|
41
41
|
"url": "https://github.com/OpenSaasAU/stack/issues"
|
|
42
42
|
},
|
|
43
43
|
"peerDependencies": {
|
|
44
|
-
"@prisma/client": "^6.17.0"
|
|
44
|
+
"@prisma/client": "^6.17.0 || ^7.0.0"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"bcryptjs": "^3.0.
|
|
48
|
-
"zod": "^4.1.
|
|
47
|
+
"bcryptjs": "^3.0.3",
|
|
48
|
+
"zod": "^4.1.13"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"@prisma/client": "^
|
|
52
|
-
"@types/
|
|
53
|
-
"@
|
|
54
|
-
"@vitest/coverage-v8": "^4.0.4",
|
|
51
|
+
"@prisma/client": "^7.1.0",
|
|
52
|
+
"@types/node": "^24.10.1",
|
|
53
|
+
"@vitest/coverage-v8": "^4.0.15",
|
|
55
54
|
"typescript": "^5.9.3",
|
|
56
|
-
"vitest": "^4.0.
|
|
55
|
+
"vitest": "^4.0.15"
|
|
57
56
|
},
|
|
58
57
|
"scripts": {
|
|
59
58
|
"build": "tsc",
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { filterWritableFields } from './engine.js'
|
|
3
|
+
|
|
4
|
+
describe('filterWritableFields', () => {
|
|
5
|
+
it('should filter out foreign key fields when their corresponding relationship field exists', async () => {
|
|
6
|
+
// Setup: Define field configs with a relationship field
|
|
7
|
+
const fieldConfigs = {
|
|
8
|
+
title: {
|
|
9
|
+
type: 'text',
|
|
10
|
+
},
|
|
11
|
+
author: {
|
|
12
|
+
type: 'relationship',
|
|
13
|
+
many: false,
|
|
14
|
+
},
|
|
15
|
+
tags: {
|
|
16
|
+
type: 'relationship',
|
|
17
|
+
many: true, // Many-to-many relationships don't have foreign keys
|
|
18
|
+
},
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Data that includes both the foreign key (authorId) and other fields
|
|
22
|
+
const data = {
|
|
23
|
+
title: 'Test Post',
|
|
24
|
+
authorId: 'user-123', // This should be filtered out
|
|
25
|
+
tagsId: 'tag-456', // This should NOT be filtered (tags is many:true)
|
|
26
|
+
author: {
|
|
27
|
+
connect: { id: 'user-123' },
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const filtered = await filterWritableFields(data, fieldConfigs, 'create', {
|
|
32
|
+
session: null,
|
|
33
|
+
context: {
|
|
34
|
+
session: null,
|
|
35
|
+
_isSudo: true, // Use sudo to bypass access control checks
|
|
36
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
37
|
+
} as any,
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
// authorId should be filtered out
|
|
41
|
+
expect(filtered).not.toHaveProperty('authorId')
|
|
42
|
+
|
|
43
|
+
// title should remain
|
|
44
|
+
expect(filtered).toHaveProperty('title', 'Test Post')
|
|
45
|
+
|
|
46
|
+
// author relationship should remain
|
|
47
|
+
expect(filtered).toHaveProperty('author')
|
|
48
|
+
expect(filtered.author).toEqual({ connect: { id: 'user-123' } })
|
|
49
|
+
|
|
50
|
+
// tagsId should remain (tags is many:true, so no foreign key is created)
|
|
51
|
+
expect(filtered).toHaveProperty('tagsId', 'tag-456')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('should filter out system fields', async () => {
|
|
55
|
+
const fieldConfigs = {
|
|
56
|
+
title: { type: 'text' },
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const data = {
|
|
60
|
+
id: 'post-123',
|
|
61
|
+
title: 'Test',
|
|
62
|
+
createdAt: new Date(),
|
|
63
|
+
updatedAt: new Date(),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const filtered = await filterWritableFields(data, fieldConfigs, 'create', {
|
|
67
|
+
session: null,
|
|
68
|
+
context: {
|
|
69
|
+
session: null,
|
|
70
|
+
_isSudo: true,
|
|
71
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
72
|
+
} as any,
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
// System fields should be filtered out
|
|
76
|
+
expect(filtered).not.toHaveProperty('id')
|
|
77
|
+
expect(filtered).not.toHaveProperty('createdAt')
|
|
78
|
+
expect(filtered).not.toHaveProperty('updatedAt')
|
|
79
|
+
|
|
80
|
+
// Regular fields should remain
|
|
81
|
+
expect(filtered).toHaveProperty('title', 'Test')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('should handle update operation', async () => {
|
|
85
|
+
const fieldConfigs = {
|
|
86
|
+
title: { type: 'text' },
|
|
87
|
+
author: {
|
|
88
|
+
type: 'relationship',
|
|
89
|
+
many: false,
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const data = {
|
|
94
|
+
title: 'Updated Title',
|
|
95
|
+
authorId: 'user-456', // Should be filtered out
|
|
96
|
+
author: {
|
|
97
|
+
connect: { id: 'user-456' },
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const filtered = await filterWritableFields(data, fieldConfigs, 'update', {
|
|
102
|
+
session: null,
|
|
103
|
+
item: { id: 'post-123' },
|
|
104
|
+
context: {
|
|
105
|
+
session: null,
|
|
106
|
+
_isSudo: true,
|
|
107
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
108
|
+
} as any,
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
expect(filtered).not.toHaveProperty('authorId')
|
|
112
|
+
expect(filtered).toHaveProperty('title', 'Updated Title')
|
|
113
|
+
expect(filtered).toHaveProperty('author')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('should not filter fields that happen to end with "Id" but are not foreign keys', async () => {
|
|
117
|
+
const fieldConfigs = {
|
|
118
|
+
trackingId: { type: 'text' }, // Regular field that happens to end with "Id"
|
|
119
|
+
author: {
|
|
120
|
+
type: 'relationship',
|
|
121
|
+
many: false,
|
|
122
|
+
},
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const data = {
|
|
126
|
+
trackingId: 'track-123', // Should NOT be filtered (it's a regular field)
|
|
127
|
+
authorId: 'user-456', // SHOULD be filtered (it's a foreign key)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const filtered = await filterWritableFields(data, fieldConfigs, 'create', {
|
|
131
|
+
session: null,
|
|
132
|
+
context: {
|
|
133
|
+
session: null,
|
|
134
|
+
_isSudo: true,
|
|
135
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
136
|
+
} as any,
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
// trackingId is a defined field, so it should remain
|
|
140
|
+
expect(filtered).toHaveProperty('trackingId', 'track-123')
|
|
141
|
+
|
|
142
|
+
// authorId is a foreign key for author relationship, so it should be filtered
|
|
143
|
+
expect(filtered).not.toHaveProperty('authorId')
|
|
144
|
+
})
|
|
145
|
+
})
|
package/src/access/engine.ts
CHANGED
|
@@ -40,7 +40,8 @@ export function isPrismaFilter(value: unknown): value is PrismaFilter {
|
|
|
40
40
|
export function getRelatedListConfig(
|
|
41
41
|
relationshipRef: string,
|
|
42
42
|
config: OpenSaasConfig,
|
|
43
|
-
|
|
43
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
44
|
+
): { listName: string; listConfig: ListConfig<any> } | null {
|
|
44
45
|
// Parse ref format: "ListName.fieldName"
|
|
45
46
|
const parts = relationshipRef.split('.')
|
|
46
47
|
if (parts.length !== 2) {
|
|
@@ -63,7 +64,7 @@ export function getRelatedListConfig(
|
|
|
63
64
|
export async function checkAccess<T = Record<string, unknown>>(
|
|
64
65
|
accessControl: AccessControl<T> | undefined,
|
|
65
66
|
args: {
|
|
66
|
-
session: Session
|
|
67
|
+
session: Session | null
|
|
67
68
|
item?: T
|
|
68
69
|
context: AccessContext
|
|
69
70
|
},
|
|
@@ -114,7 +115,7 @@ export async function checkFieldAccess(
|
|
|
114
115
|
fieldAccess: FieldAccess | undefined,
|
|
115
116
|
operation: 'read' | 'create' | 'update',
|
|
116
117
|
args: {
|
|
117
|
-
session: Session
|
|
118
|
+
session: Session | null
|
|
118
119
|
item?: Record<string, unknown>
|
|
119
120
|
context: AccessContext & { _isSudo?: boolean }
|
|
120
121
|
},
|
|
@@ -190,7 +191,7 @@ function matchesFilter(item: Record<string, unknown>, filter: Record<string, unk
|
|
|
190
191
|
export async function buildIncludeWithAccessControl(
|
|
191
192
|
fieldConfigs: Record<string, FieldConfig>,
|
|
192
193
|
args: {
|
|
193
|
-
session: Session
|
|
194
|
+
session: Session | null
|
|
194
195
|
context: AccessContext
|
|
195
196
|
},
|
|
196
197
|
config: OpenSaasConfig,
|
|
@@ -209,7 +210,7 @@ export async function buildIncludeWithAccessControl(
|
|
|
209
210
|
for (const [fieldName, fieldConfig] of Object.entries(fieldConfigs)) {
|
|
210
211
|
if (fieldConfig?.type === 'relationship' && 'ref' in fieldConfig && fieldConfig.ref) {
|
|
211
212
|
hasRelationships = true
|
|
212
|
-
const relatedConfig = getRelatedListConfig(fieldConfig.ref, config)
|
|
213
|
+
const relatedConfig = getRelatedListConfig(fieldConfig.ref as string, config)
|
|
213
214
|
|
|
214
215
|
if (relatedConfig) {
|
|
215
216
|
// Check query access for the related list
|
|
@@ -261,7 +262,7 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
261
262
|
item: T,
|
|
262
263
|
fieldConfigs: Record<string, FieldConfig>,
|
|
263
264
|
args: {
|
|
264
|
-
session: Session
|
|
265
|
+
session: Session | null
|
|
265
266
|
context: AccessContext & { _isSudo?: boolean }
|
|
266
267
|
},
|
|
267
268
|
config?: OpenSaasConfig,
|
|
@@ -271,6 +272,7 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
271
272
|
const filtered: Record<string, unknown> = {}
|
|
272
273
|
const MAX_DEPTH = 5 // Prevent infinite recursion
|
|
273
274
|
|
|
275
|
+
// Process existing fields from the database result
|
|
274
276
|
for (const [fieldName, value] of Object.entries(item)) {
|
|
275
277
|
const fieldConfig = fieldConfigs[fieldName]
|
|
276
278
|
|
|
@@ -302,7 +304,7 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
302
304
|
value !== undefined &&
|
|
303
305
|
depth < MAX_DEPTH
|
|
304
306
|
) {
|
|
305
|
-
const relatedConfig = getRelatedListConfig(fieldConfig.ref, config)
|
|
307
|
+
const relatedConfig = getRelatedListConfig(fieldConfig.ref as string, config)
|
|
306
308
|
|
|
307
309
|
if (relatedConfig) {
|
|
308
310
|
// For many relationships (arrays) - recursively filter fields in each item
|
|
@@ -357,6 +359,43 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
357
359
|
}
|
|
358
360
|
}
|
|
359
361
|
|
|
362
|
+
// Process virtual fields - compute values from other fields
|
|
363
|
+
// Virtual fields don't exist in the database result, so we need to compute them separately
|
|
364
|
+
for (const [fieldName, fieldConfig] of Object.entries(fieldConfigs)) {
|
|
365
|
+
// Skip if already processed (from database result)
|
|
366
|
+
if (fieldName in filtered) {
|
|
367
|
+
continue
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Only process virtual fields
|
|
371
|
+
if (!fieldConfig.virtual) {
|
|
372
|
+
continue
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Check field access
|
|
376
|
+
const canRead = await checkFieldAccess(fieldConfig.access, 'read', {
|
|
377
|
+
...args,
|
|
378
|
+
item,
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
if (!canRead) {
|
|
382
|
+
continue
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Virtual fields must have resolveOutput hook to compute their value
|
|
386
|
+
if (fieldConfig.hooks?.resolveOutput && listKey) {
|
|
387
|
+
const hook = fieldConfig.hooks.resolveOutput as unknown as ResolveOutputHookRuntime
|
|
388
|
+
filtered[fieldName] = hook({
|
|
389
|
+
value: undefined, // Virtual fields don't have a database value
|
|
390
|
+
operation: 'query',
|
|
391
|
+
fieldName,
|
|
392
|
+
listKey,
|
|
393
|
+
item: filtered, // Pass filtered item so virtual field can access other fields
|
|
394
|
+
context: args.context,
|
|
395
|
+
})
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
360
399
|
return filtered as Partial<T>
|
|
361
400
|
}
|
|
362
401
|
|
|
@@ -365,16 +404,29 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
365
404
|
*/
|
|
366
405
|
export async function filterWritableFields<T extends Record<string, unknown>>(
|
|
367
406
|
data: T,
|
|
368
|
-
fieldConfigs: Record<string, { access?: FieldAccess }>,
|
|
407
|
+
fieldConfigs: Record<string, { access?: FieldAccess; type?: string }>,
|
|
369
408
|
operation: 'create' | 'update',
|
|
370
409
|
args: {
|
|
371
|
-
session: Session
|
|
410
|
+
session: Session | null
|
|
372
411
|
item?: Record<string, unknown>
|
|
373
412
|
context: AccessContext & { _isSudo?: boolean }
|
|
374
413
|
},
|
|
375
414
|
): Promise<Partial<T>> {
|
|
376
415
|
const filtered: Record<string, unknown> = {}
|
|
377
416
|
|
|
417
|
+
// Build a set of foreign key field names to exclude
|
|
418
|
+
// Foreign keys should not be in the data when using Prisma's relation syntax
|
|
419
|
+
const foreignKeyFields = new Set<string>()
|
|
420
|
+
for (const [fieldName, fieldConfig] of Object.entries(fieldConfigs)) {
|
|
421
|
+
if (fieldConfig.type === 'relationship') {
|
|
422
|
+
// For non-many relationships, Prisma creates a foreign key field named `${fieldName}Id`
|
|
423
|
+
const relConfig = fieldConfig as { many?: boolean }
|
|
424
|
+
if (!relConfig.many) {
|
|
425
|
+
foreignKeyFields.add(`${fieldName}Id`)
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
378
430
|
for (const [fieldName, value] of Object.entries(data)) {
|
|
379
431
|
const fieldConfig = fieldConfigs[fieldName]
|
|
380
432
|
|
|
@@ -383,6 +435,18 @@ export async function filterWritableFields<T extends Record<string, unknown>>(
|
|
|
383
435
|
continue
|
|
384
436
|
}
|
|
385
437
|
|
|
438
|
+
// Skip virtual fields - they don't store in database
|
|
439
|
+
// Virtual fields with resolveInput hooks handle side effects separately
|
|
440
|
+
if (fieldConfig && 'virtual' in fieldConfig && fieldConfig.virtual) {
|
|
441
|
+
continue
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Skip foreign key fields (e.g., authorId) when their corresponding relationship field exists
|
|
445
|
+
// This prevents conflicts when using Prisma's relation syntax (e.g., author: { connect: { id } })
|
|
446
|
+
if (foreignKeyFields.has(fieldName)) {
|
|
447
|
+
continue
|
|
448
|
+
}
|
|
449
|
+
|
|
386
450
|
// Check field access (checkFieldAccess already handles sudo mode)
|
|
387
451
|
const canWrite = await checkFieldAccess(fieldConfig?.access, operation, {
|
|
388
452
|
...args,
|
package/src/access/types.ts
CHANGED
|
@@ -1,10 +1,39 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Session
|
|
2
|
+
* Session interface - can be augmented by developers to add custom fields
|
|
3
|
+
*
|
|
4
|
+
* By default, Session is a permissive object that can contain any properties.
|
|
5
|
+
* To get type safety and autocomplete, use module augmentation:
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```typescript
|
|
9
|
+
* // types/session.d.ts
|
|
10
|
+
* import '@opensaas/stack-core'
|
|
11
|
+
*
|
|
12
|
+
* declare module '@opensaas/stack-core' {
|
|
13
|
+
* interface Session {
|
|
14
|
+
* userId: string
|
|
15
|
+
* email: string
|
|
16
|
+
* role: 'admin' | 'user'
|
|
17
|
+
* }
|
|
18
|
+
* }
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* After augmentation, session will be fully typed everywhere:
|
|
22
|
+
* - Access control functions
|
|
23
|
+
* - Hooks (resolveInput, validateInput, etc.)
|
|
24
|
+
* - Context object
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```typescript
|
|
28
|
+
* // With augmentation, this is fully typed:
|
|
29
|
+
* const isAdmin: AccessControl = ({ session }) => {
|
|
30
|
+
* return session?.role === 'admin' // ✅ Autocomplete works
|
|
31
|
+
* }
|
|
32
|
+
* ```
|
|
3
33
|
*/
|
|
4
|
-
export
|
|
5
|
-
userId?: string
|
|
34
|
+
export interface Session {
|
|
6
35
|
[key: string]: unknown
|
|
7
|
-
}
|
|
36
|
+
}
|
|
8
37
|
|
|
9
38
|
/**
|
|
10
39
|
* Generic Prisma model delegate type
|
|
@@ -113,14 +142,15 @@ export type StorageUtils = {
|
|
|
113
142
|
|
|
114
143
|
/**
|
|
115
144
|
* Context type (simplified for access control)
|
|
145
|
+
* Using interface instead of type to allow module augmentation
|
|
116
146
|
*/
|
|
117
|
-
export
|
|
118
|
-
session: Session
|
|
147
|
+
export interface AccessContext<TPrisma extends PrismaClientLike = PrismaClientLike> {
|
|
148
|
+
session: Session | null
|
|
119
149
|
prisma: TPrisma
|
|
120
150
|
db: AccessControlledDB<TPrisma>
|
|
121
151
|
storage: StorageUtils
|
|
152
|
+
plugins: Record<string, unknown>
|
|
122
153
|
_isSudo: boolean
|
|
123
|
-
[key: string]: unknown
|
|
124
154
|
}
|
|
125
155
|
|
|
126
156
|
/**
|
|
@@ -136,7 +166,7 @@ export type PrismaFilter<T = Record<string, unknown>> = Partial<Record<keyof T,
|
|
|
136
166
|
* - PrismaFilter: Prisma where clause to filter results
|
|
137
167
|
*/
|
|
138
168
|
export type AccessControl<T = Record<string, unknown>> = (args: {
|
|
139
|
-
session: Session
|
|
169
|
+
session: Session | null
|
|
140
170
|
item?: T // Present for update/delete operations
|
|
141
171
|
context: AccessContext
|
|
142
172
|
}) => boolean | PrismaFilter<T> | Promise<boolean | PrismaFilter<T>>
|
package/src/config/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { OpenSaasConfig, ListConfig,
|
|
1
|
+
import type { OpenSaasConfig, ListConfig, OperationAccess, Hooks } from './types.js'
|
|
2
2
|
import { executePlugins } from './plugin-engine.js'
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -21,40 +21,59 @@ export function config(userConfig: OpenSaasConfig): OpenSaasConfig | Promise<Ope
|
|
|
21
21
|
/**
|
|
22
22
|
* Helper function to define a list with type safety
|
|
23
23
|
*
|
|
24
|
-
* Accepts raw field configs and transforms them to inject the item type
|
|
25
|
-
* This enables proper typing in field hooks where item
|
|
24
|
+
* Accepts raw field configs and transforms them to inject the item type
|
|
25
|
+
* This enables proper typing in field hooks where item is typed correctly
|
|
26
26
|
*
|
|
27
27
|
* @example
|
|
28
28
|
* ```typescript
|
|
29
|
-
*
|
|
29
|
+
* // Basic usage (before generation)
|
|
30
|
+
* Post: list({
|
|
31
|
+
* fields: { title: text() },
|
|
32
|
+
* hooks: {
|
|
33
|
+
* resolveInput: async ({ resolvedData }) => {
|
|
34
|
+
* // resolvedData: Record<string, unknown>
|
|
35
|
+
* return resolvedData
|
|
36
|
+
* }
|
|
37
|
+
* }
|
|
38
|
+
* })
|
|
39
|
+
*
|
|
40
|
+
* // With TypeInfo (after generation)
|
|
41
|
+
* import type { Lists } from './.opensaas/lists'
|
|
30
42
|
*
|
|
31
|
-
*
|
|
32
|
-
* fields: {
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
43
|
+
* Post: list<Lists.Post.TypeInfo>({
|
|
44
|
+
* fields: { title: text() },
|
|
45
|
+
* hooks: {
|
|
46
|
+
* resolveInput: async ({ operation, resolvedData, item }) => {
|
|
47
|
+
* if (operation === 'create') {
|
|
48
|
+
* // resolvedData: Prisma.PostCreateInput
|
|
49
|
+
* // item: undefined
|
|
50
|
+
* } else {
|
|
51
|
+
* // resolvedData: Prisma.PostUpdateInput
|
|
52
|
+
* // item: Post
|
|
40
53
|
* }
|
|
41
|
-
*
|
|
54
|
+
* return resolvedData
|
|
55
|
+
* }
|
|
42
56
|
* }
|
|
43
57
|
* })
|
|
58
|
+
*
|
|
59
|
+
* // Or as a typed constant
|
|
60
|
+
* const Post: Lists.Post = list({
|
|
61
|
+
* fields: { title: text() },
|
|
62
|
+
* hooks: { ... }
|
|
63
|
+
* })
|
|
44
64
|
* ```
|
|
45
65
|
*/
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
fields: Record<string, FieldConfig>
|
|
66
|
+
export function list<TTypeInfo extends import('./types.js').TypeInfo>(config: {
|
|
67
|
+
fields: import('./types.js').FieldsWithTypeInfo<TTypeInfo>
|
|
49
68
|
access?: {
|
|
50
|
-
operation?: OperationAccess<
|
|
69
|
+
operation?: OperationAccess<TTypeInfo['item']>
|
|
51
70
|
}
|
|
52
|
-
hooks?: Hooks<
|
|
71
|
+
hooks?: Hooks<TTypeInfo['item'], TTypeInfo['inputs']['create'], TTypeInfo['inputs']['update']>
|
|
53
72
|
mcp?: import('./types.js').ListMcpConfig
|
|
54
|
-
}): ListConfig<
|
|
73
|
+
}): ListConfig<TTypeInfo> {
|
|
55
74
|
// At runtime, field configs are unchanged
|
|
56
|
-
// At type level, they're transformed to inject
|
|
57
|
-
return config as ListConfig<
|
|
75
|
+
// At type level, they're transformed to inject TypeInfo types
|
|
76
|
+
return config as ListConfig<TTypeInfo>
|
|
58
77
|
}
|
|
59
78
|
|
|
60
79
|
// Re-export all types
|
|
@@ -70,10 +89,13 @@ export type {
|
|
|
70
89
|
PasswordField,
|
|
71
90
|
SelectField,
|
|
72
91
|
RelationshipField,
|
|
92
|
+
JsonField,
|
|
93
|
+
VirtualField,
|
|
94
|
+
TypeInfo,
|
|
73
95
|
OperationAccess,
|
|
74
96
|
Hooks,
|
|
75
97
|
FieldHooks,
|
|
76
|
-
|
|
98
|
+
FieldsWithTypeInfo,
|
|
77
99
|
DatabaseConfig,
|
|
78
100
|
SessionConfig,
|
|
79
101
|
UIConfig,
|
|
@@ -168,7 +168,8 @@ export async function executePlugins(config: OpenSaasConfig): Promise<OpenSaasCo
|
|
|
168
168
|
}
|
|
169
169
|
|
|
170
170
|
// Field type registry (for third-party fields)
|
|
171
|
-
|
|
171
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Registry must accept any field config builder
|
|
172
|
+
const fieldTypeRegistry = new Map<string, (options?: unknown) => BaseFieldConfig<any>>()
|
|
172
173
|
|
|
173
174
|
// MCP tools registry
|
|
174
175
|
const mcpToolsRegistry: McpCustomTool[] = []
|
|
@@ -178,7 +179,8 @@ export async function executePlugins(config: OpenSaasConfig): Promise<OpenSaasCo
|
|
|
178
179
|
const context: PluginContext = {
|
|
179
180
|
config: currentConfig,
|
|
180
181
|
|
|
181
|
-
|
|
182
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Plugin context must accept any list config
|
|
183
|
+
addList: (name: string, listConfig: ListConfig<any>) => {
|
|
182
184
|
if (currentConfig.lists[name]) {
|
|
183
185
|
throw new Error(
|
|
184
186
|
`Plugin "${plugin.name}" tried to add list "${name}" but it already exists. Use extendList() to modify existing lists.`,
|
|
@@ -224,7 +226,8 @@ export async function executePlugins(config: OpenSaasConfig): Promise<OpenSaasCo
|
|
|
224
226
|
}
|
|
225
227
|
},
|
|
226
228
|
|
|
227
|
-
|
|
229
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Field type registry must accept any field config
|
|
230
|
+
registerFieldType: (type: string, builder: (options?: unknown) => BaseFieldConfig<any>) => {
|
|
228
231
|
if (fieldTypeRegistry.has(type)) {
|
|
229
232
|
throw new Error(
|
|
230
233
|
`Plugin "${plugin.name}" tried to register field type "${type}" but it's already registered`,
|
|
@@ -257,6 +260,13 @@ export async function executePlugins(config: OpenSaasConfig): Promise<OpenSaasCo
|
|
|
257
260
|
currentConfig._pluginData.__mcpTools = mcpToolsRegistry
|
|
258
261
|
}
|
|
259
262
|
|
|
263
|
+
// Store plugin instances in config for runtime access
|
|
264
|
+
// This allows context creation to call plugin.runtime() functions
|
|
265
|
+
if (!currentConfig._plugins) {
|
|
266
|
+
currentConfig._plugins = []
|
|
267
|
+
}
|
|
268
|
+
currentConfig._plugins = sortedPlugins
|
|
269
|
+
|
|
260
270
|
return currentConfig
|
|
261
271
|
}
|
|
262
272
|
|