@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
@@ -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
- async function serverAction(props: ServerActionProps): Promise<unknown> {
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
- if (props.action === 'create') {
226
- return await model.create({ data: props.data })
227
- } else if (props.action === 'update') {
228
- return await model.update({
229
- where: { id: props.id },
230
- data: props.data,
231
- })
232
- } else if (props.action === 'delete') {
233
- return await model.delete({
234
- where: { id: props.id },
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
- return null
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
- listConfig: ListConfig,
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
- listConfig: ListConfig,
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
- listConfig: ListConfig,
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
- listConfig: ListConfig,
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
- listConfig: ListConfig,
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
- listConfig: ListConfig,
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
- relatedListConfig: ListConfig,
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
- const createAccess = relatedListConfig.access?.operation?.create
74
- const accessResult = await checkAccess(createAccess, {
75
- session: context.session,
76
- context,
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
- if (accessResult === false) {
80
- throw new Error('Access denied: Cannot create related item')
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
- relatedListConfig: ListConfig,
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
- for (const connection of connectionsArray) {
162
- // Access Prisma model dynamically - required because model names are generated at runtime
163
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
164
- const model = (prisma as any)[getDbKey(relatedListName)]
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
- // Fetch the item to check access
167
- const item = await model.findUnique({
168
- where: connection,
169
- })
171
+ // Fetch the item to check access
172
+ const item = await model.findUnique({
173
+ where: connection,
174
+ })
170
175
 
171
- if (!item) {
172
- throw new Error(`Cannot connect: Item not found`)
173
- }
176
+ if (!item) {
177
+ throw new Error(`Cannot connect: Item not found`)
178
+ }
174
179
 
175
- // Check update access (connecting modifies the relationship)
176
- const updateAccess = relatedListConfig.access?.operation?.update
177
- const accessResult = await checkAccess(updateAccess, {
178
- session: context.session,
179
- item,
180
- context,
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
- if (accessResult === false) {
184
- throw new Error('Access denied: Cannot connect to this item')
185
- }
188
+ if (accessResult === false) {
189
+ throw new Error('Access denied: Cannot connect to this item')
190
+ }
186
191
 
187
- // If access returns a filter, check if item matches
188
- if (typeof accessResult === 'object') {
189
- // Simple field matching
190
- for (const [key, value] of Object.entries(accessResult)) {
191
- if (typeof value === 'object' && value !== null && 'equals' in value) {
192
- if (item[key] !== (value as Record<string, unknown>).equals) {
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
- relatedListConfig: ListConfig,
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
- const updateAccess = relatedListConfig.access?.operation?.update
236
- const accessResult = await checkAccess(updateAccess, {
237
- session: context.session,
238
- item,
239
- context,
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
- if (accessResult === false) {
243
- throw new Error('Access denied: Cannot update related item')
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
- relatedListConfig: ListConfig,
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
- try {
336
- // Access Prisma model dynamically - required because model names are generated at runtime
337
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
338
- const model = (prisma as any)[getDbKey(relatedListName)]
339
- const existingItem = await model.findUnique({
340
- where: opRecord.where,
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 (accessResult === false) {
353
- throw new Error('Access denied: Cannot connect to existing item')
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 {