@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.
Files changed (66) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +352 -0
  3. package/CLAUDE.md +46 -1
  4. package/dist/access/engine.d.ts +7 -6
  5. package/dist/access/engine.d.ts.map +1 -1
  6. package/dist/access/engine.js +55 -0
  7. package/dist/access/engine.js.map +1 -1
  8. package/dist/access/engine.test.d.ts +2 -0
  9. package/dist/access/engine.test.d.ts.map +1 -0
  10. package/dist/access/engine.test.js +125 -0
  11. package/dist/access/engine.test.js.map +1 -0
  12. package/dist/access/types.d.ts +39 -9
  13. package/dist/access/types.d.ts.map +1 -1
  14. package/dist/config/index.d.ts +40 -20
  15. package/dist/config/index.d.ts.map +1 -1
  16. package/dist/config/index.js +34 -15
  17. package/dist/config/index.js.map +1 -1
  18. package/dist/config/plugin-engine.d.ts.map +1 -1
  19. package/dist/config/plugin-engine.js +9 -0
  20. package/dist/config/plugin-engine.js.map +1 -1
  21. package/dist/config/types.d.ts +277 -84
  22. package/dist/config/types.d.ts.map +1 -1
  23. package/dist/context/index.d.ts +5 -3
  24. package/dist/context/index.d.ts.map +1 -1
  25. package/dist/context/index.js +146 -20
  26. package/dist/context/index.js.map +1 -1
  27. package/dist/context/nested-operations.d.ts.map +1 -1
  28. package/dist/context/nested-operations.js +88 -72
  29. package/dist/context/nested-operations.js.map +1 -1
  30. package/dist/fields/index.d.ts +65 -9
  31. package/dist/fields/index.d.ts.map +1 -1
  32. package/dist/fields/index.js +98 -16
  33. package/dist/fields/index.js.map +1 -1
  34. package/dist/hooks/index.d.ts +28 -12
  35. package/dist/hooks/index.d.ts.map +1 -1
  36. package/dist/hooks/index.js +16 -0
  37. package/dist/hooks/index.js.map +1 -1
  38. package/dist/index.d.ts +1 -1
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js.map +1 -1
  41. package/dist/mcp/handler.js +1 -0
  42. package/dist/mcp/handler.js.map +1 -1
  43. package/dist/validation/schema.d.ts.map +1 -1
  44. package/dist/validation/schema.js +4 -2
  45. package/dist/validation/schema.js.map +1 -1
  46. package/package.json +8 -9
  47. package/src/access/engine.test.ts +145 -0
  48. package/src/access/engine.ts +73 -9
  49. package/src/access/types.ts +38 -8
  50. package/src/config/index.ts +45 -23
  51. package/src/config/plugin-engine.ts +13 -3
  52. package/src/config/types.ts +347 -117
  53. package/src/context/index.ts +176 -23
  54. package/src/context/nested-operations.ts +83 -71
  55. package/src/fields/index.ts +132 -27
  56. package/src/hooks/index.ts +63 -20
  57. package/src/index.ts +9 -0
  58. package/src/mcp/handler.ts +2 -1
  59. package/src/validation/schema.ts +4 -2
  60. package/tests/context.test.ts +38 -6
  61. package/tests/field-types.test.ts +729 -0
  62. package/tests/password-type-distribution.test.ts +0 -1
  63. package/tests/password-types.test.ts +0 -1
  64. package/tests/plugin-engine.test.ts +1102 -0
  65. package/tests/sudo.test.ts +230 -2
  66. package/tsconfig.tsbuildinfo +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opensaas/stack-core",
3
- "version": "0.1.7",
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.2",
48
- "zod": "^4.1.12"
47
+ "bcryptjs": "^3.0.3",
48
+ "zod": "^4.1.13"
49
49
  },
50
50
  "devDependencies": {
51
- "@prisma/client": "^6.17.1",
52
- "@types/bcryptjs": "^3.0.0",
53
- "@types/node": "^24.7.2",
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.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
+ })
@@ -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
- ): { listName: string; listConfig: ListConfig } | null {
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,
@@ -1,10 +1,39 @@
1
1
  /**
2
- * Session type - can be extended by users
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 type Session = {
5
- userId?: string
34
+ export interface Session {
6
35
  [key: string]: unknown
7
- } | null
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 type AccessContext<TPrisma extends PrismaClientLike = PrismaClientLike> = {
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>>
@@ -1,4 +1,4 @@
1
- import type { OpenSaasConfig, ListConfig, FieldConfig, OperationAccess, Hooks } from './types.js'
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 T
25
- * This enables proper typing in field hooks where item: T
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
- * import type { User } from './.opensaas/types'
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
- * User: list<User>({
32
- * fields: {
33
- * password: password({
34
- * hooks: {
35
- * resolveInput: async ({ inputValue, item }) => {
36
- * // item is typed as User | undefined
37
- * // inputValue is typed as string | undefined
38
- * return hashPassword(inputValue)
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
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
47
- export function list<T = any>(config: {
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<T>
69
+ operation?: OperationAccess<TTypeInfo['item']>
51
70
  }
52
- hooks?: Hooks<T>
71
+ hooks?: Hooks<TTypeInfo['item'], TTypeInfo['inputs']['create'], TTypeInfo['inputs']['update']>
53
72
  mcp?: import('./types.js').ListMcpConfig
54
- }): ListConfig<T> {
73
+ }): ListConfig<TTypeInfo> {
55
74
  // At runtime, field configs are unchanged
56
- // At type level, they're transformed to inject T as the item type
57
- return config as ListConfig<T>
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
- FieldsWithItemType,
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
- const fieldTypeRegistry = new Map<string, (options?: unknown) => BaseFieldConfig>()
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
- addList: (name: string, listConfig: ListConfig) => {
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
- registerFieldType: (type: string, builder: (options?: unknown) => BaseFieldConfig) => {
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