@open-mercato/shared 0.4.6-develop-af28b566dd → 0.4.6-develop-4d77832982

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.
@@ -51,6 +51,12 @@ import { createProfiler, shouldEnableProfiler, type Profiler } from '@open-merca
51
51
  import { getTranslationOverlayPlugin } from '@open-mercato/shared/lib/localization/overlay-plugin'
52
52
  import { applyResponseEnrichers, applyResponseEnricherToRecord } from './enricher-runner'
53
53
  import type { EnricherContext } from './response-enricher'
54
+ import type { ApiInterceptorMethod, InterceptorRequest, InterceptorResponse } from './api-interceptor'
55
+ import { runApiInterceptorsAfter, runApiInterceptorsBefore } from './interceptor-runner'
56
+
57
+ type RbacServiceLike = {
58
+ getGrantedFeatures: (userId: string, opts: { tenantId: string | null; organizationId: string | null }) => Promise<string[]>
59
+ }
54
60
 
55
61
  export type CrudHooks<TCreate, TUpdate, TList> = {
56
62
  beforeList?: (q: TList, ctx: CrudCtx) => Promise<void> | void
@@ -452,6 +458,32 @@ function handleError(err: unknown): Response {
452
458
  return json(body, { status: 500 })
453
459
  }
454
460
 
461
+ function normalizeInterceptorRoutePath(request: Request): string {
462
+ try {
463
+ const pathname = new URL(request.url).pathname
464
+ if (pathname.startsWith('/api/')) return pathname.slice(5)
465
+ if (pathname === '/api') return ''
466
+ return pathname.replace(/^\/+/, '')
467
+ } catch {
468
+ return ''
469
+ }
470
+ }
471
+
472
+ function toInterceptorHeaders(headers: Headers): Record<string, string> {
473
+ const output: Record<string, string> = {}
474
+ headers.forEach((value, key) => {
475
+ output[key] = value
476
+ })
477
+ return output
478
+ }
479
+
480
+ function cleanInterceptorObject(
481
+ value: Record<string, unknown> | undefined,
482
+ ): Record<string, unknown> | undefined {
483
+ if (!value || typeof value !== 'object') return undefined
484
+ return Object.fromEntries(Object.entries(value).filter(([, current]) => current !== undefined))
485
+ }
486
+
455
487
  function isUuid(v: any): v is string {
456
488
  return typeof v === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(v)
457
489
  }
@@ -783,7 +815,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
783
815
 
784
816
  let userFeatures: string[] | undefined
785
817
  try {
786
- const rbac = (ctx.container.resolve('rbacService') as any)
818
+ const rbac = ctx.container.resolve('rbacService') as RbacServiceLike | undefined
787
819
  if (rbac?.getGrantedFeatures) {
788
820
  userFeatures = await rbac.getGrantedFeatures(ctx.auth.sub, {
789
821
  tenantId: ctx.auth.tenantId,
@@ -804,6 +836,101 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
804
836
  }
805
837
  }
806
838
 
839
+ async function resolveUserFeatures(ctx: CrudCtx): Promise<string[] | undefined> {
840
+ if (!ctx.auth) return undefined
841
+ try {
842
+ const rbac = ctx.container.resolve('rbacService') as RbacServiceLike | undefined
843
+ if (rbac?.getGrantedFeatures) {
844
+ return await rbac.getGrantedFeatures(ctx.auth.sub, {
845
+ tenantId: ctx.auth.tenantId,
846
+ organizationId: ctx.selectedOrganizationId ?? ctx.auth.orgId,
847
+ })
848
+ }
849
+ } catch {
850
+ // rbacService is optional in some contexts
851
+ }
852
+ return undefined
853
+ }
854
+
855
+ const interceptorContextCache = new WeakMap<object, ReturnType<typeof buildInterceptorContextInner>>()
856
+
857
+ async function buildInterceptorContextInner(ctx: CrudCtx) {
858
+ if (!ctx.auth) return null
859
+ return {
860
+ userId: ctx.auth.sub,
861
+ organizationId: ctx.selectedOrganizationId ?? ctx.auth.orgId ?? '',
862
+ tenantId: ctx.auth.tenantId ?? '',
863
+ em: ctx.container.resolve('em'),
864
+ container: ctx.container,
865
+ userFeatures: await resolveUserFeatures(ctx),
866
+ }
867
+ }
868
+
869
+ function buildInterceptorContext(ctx: CrudCtx) {
870
+ const cached = interceptorContextCache.get(ctx)
871
+ if (cached) return cached
872
+ const promise = buildInterceptorContextInner(ctx)
873
+ interceptorContextCache.set(ctx, promise)
874
+ return promise
875
+ }
876
+
877
+ async function applyInterceptorsBefore(args: {
878
+ ctx: CrudCtx
879
+ request: Request
880
+ method: ApiInterceptorMethod
881
+ body?: Record<string, unknown>
882
+ query?: Record<string, unknown>
883
+ }): Promise<{ errorResponse: Response | null; requestPayload: InterceptorRequest; metadataByInterceptor: Record<string, Record<string, unknown> | undefined> }> {
884
+ const interceptorContext = await buildInterceptorContext(args.ctx)
885
+ const requestPayload: InterceptorRequest = {
886
+ method: args.method,
887
+ url: args.request.url,
888
+ body: cleanInterceptorObject(args.body),
889
+ query: cleanInterceptorObject(args.query),
890
+ headers: toInterceptorHeaders(args.request.headers),
891
+ }
892
+ if (!interceptorContext) {
893
+ return { errorResponse: null, requestPayload, metadataByInterceptor: {} }
894
+ }
895
+ const result = await runApiInterceptorsBefore({
896
+ routePath: normalizeInterceptorRoutePath(args.request),
897
+ method: args.method,
898
+ request: requestPayload,
899
+ context: interceptorContext,
900
+ })
901
+ if (!result.ok) {
902
+ return { errorResponse: json(result.body, { status: result.statusCode }), requestPayload, metadataByInterceptor: {} }
903
+ }
904
+ return { errorResponse: null, requestPayload: result.request, metadataByInterceptor: result.metadataByInterceptor }
905
+ }
906
+
907
+ async function applyInterceptorsAfter(args: {
908
+ ctx: CrudCtx
909
+ request: Request
910
+ method: ApiInterceptorMethod
911
+ requestPayload: InterceptorRequest
912
+ metadataByInterceptor: Record<string, Record<string, unknown> | undefined>
913
+ statusCode: number
914
+ body: Record<string, unknown>
915
+ headers?: Record<string, string>
916
+ }): Promise<{ ok: boolean; statusCode: number; body: Record<string, unknown>; headers: Record<string, string> } | null> {
917
+ const interceptorContext = await buildInterceptorContext(args.ctx)
918
+ if (!interceptorContext) return { ok: true, statusCode: args.statusCode, body: args.body, headers: args.headers ?? {} }
919
+ const result = await runApiInterceptorsAfter({
920
+ routePath: normalizeInterceptorRoutePath(args.request),
921
+ method: args.method,
922
+ request: args.requestPayload,
923
+ response: {
924
+ statusCode: args.statusCode,
925
+ body: args.body,
926
+ headers: args.headers ?? {},
927
+ } satisfies InterceptorResponse,
928
+ context: interceptorContext,
929
+ metadataByInterceptor: args.metadataByInterceptor,
930
+ })
931
+ return result
932
+ }
933
+
807
934
  /**
808
935
  * Apply response enrichers to list payload items.
809
936
  * Mutates payload.items and adds payload._meta.
@@ -927,11 +1054,31 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
927
1054
  return json({ error: 'Not implemented' }, { status: 501 })
928
1055
  }
929
1056
  const url = new URL(request.url)
930
- const queryParams = Object.fromEntries(url.searchParams.entries())
1057
+ const rawQueryParams = Object.fromEntries(url.searchParams.entries())
931
1058
  profiler.mark('query_parsed')
932
- const validated = opts.list.schema.parse(queryParams)
1059
+ let validated = opts.list.schema.parse(rawQueryParams)
933
1060
  profiler.mark('query_validated')
934
1061
 
1062
+ const beforeInterceptors = await applyInterceptorsBefore({
1063
+ ctx,
1064
+ request,
1065
+ method: 'GET',
1066
+ query: (validated as Record<string, unknown>),
1067
+ })
1068
+ if (beforeInterceptors.errorResponse) {
1069
+ finishProfile({ result: 'interceptor_before_blocked' })
1070
+ return beforeInterceptors.errorResponse
1071
+ }
1072
+ const interceptorRequest = beforeInterceptors.requestPayload
1073
+ const interceptorMetadata = beforeInterceptors.metadataByInterceptor
1074
+ if (interceptorRequest.query) {
1075
+ validated = opts.list.schema.parse(interceptorRequest.query)
1076
+ }
1077
+ const queryParams = {
1078
+ ...rawQueryParams,
1079
+ ...(interceptorRequest.query ?? {}),
1080
+ } as Record<string, unknown>
1081
+
935
1082
  await opts.hooks?.beforeList?.(validated as any, ctx)
936
1083
  profiler.mark('before_list_hook')
937
1084
 
@@ -971,6 +1118,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
971
1118
  const maybeStoreCrudCache = async (payload: any) => {
972
1119
  if (!cacheEnabled || !cache || !cacheKey) return
973
1120
  if (!payload || typeof payload !== 'object') return
1121
+ if (Array.isArray(payload)) return
974
1122
  const items = Array.isArray((payload as any).items) ? (payload as any).items : []
975
1123
  const tags = new Set<string>()
976
1124
  const scopeOrgIds = collectScopeOrganizationIds(ctx)
@@ -1040,6 +1188,24 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1040
1188
  if (cachedValue) {
1041
1189
  cacheStatus = 'hit'
1042
1190
  profiler.mark('cache_hit', { generatedAt: cachedValue.generatedAt ?? null })
1191
+ const payload = safeClone(cachedValue.payload)
1192
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload) || !Array.isArray((payload as any).items)) {
1193
+ cacheStatus = 'miss'
1194
+ profiler.mark('cache_payload_invalid', {
1195
+ payloadType: Array.isArray(payload) ? 'array' : typeof payload,
1196
+ })
1197
+ try {
1198
+ if (cache && cacheKey && typeof cache.delete === 'function') {
1199
+ await cache.delete(cacheKey)
1200
+ }
1201
+ } catch {
1202
+ // ignore cache eviction failure
1203
+ }
1204
+ cachedValue = null
1205
+ }
1206
+ }
1207
+
1208
+ if (cachedValue) {
1043
1209
  const payload = safeClone(cachedValue.payload)
1044
1210
  const items = Array.isArray((payload as any)?.items) ? (payload as any).items : []
1045
1211
  profiler.mark('cache_payload_ready', { itemCount: items.length })
@@ -1055,6 +1221,24 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1055
1221
  query: validated,
1056
1222
  })
1057
1223
  await opts.hooks?.afterList?.(payload, { ...ctx, query: validated as any })
1224
+ const cacheAfterInterceptors = await applyInterceptorsAfter({
1225
+ ctx,
1226
+ request,
1227
+ method: 'GET',
1228
+ requestPayload: interceptorRequest,
1229
+ metadataByInterceptor: interceptorMetadata,
1230
+ statusCode: 200,
1231
+ body: payload as Record<string, unknown>,
1232
+ })
1233
+ if (!cacheAfterInterceptors) {
1234
+ finishProfile({ result: 'interceptor_after_empty', cacheStatus })
1235
+ return json({ error: 'Internal interceptor error' }, { status: 500 })
1236
+ }
1237
+ if (!cacheAfterInterceptors.ok) {
1238
+ finishProfile({ result: 'interceptor_after_failed', cacheStatus })
1239
+ return json(cacheAfterInterceptors.body, { status: cacheAfterInterceptors.statusCode, headers: cacheAfterInterceptors.headers })
1240
+ }
1241
+ Object.assign(payload, cacheAfterInterceptors.body)
1058
1242
  await enrichListPayload(payload, ctx, profiler)
1059
1243
  logCacheOutcome('hit', items.length)
1060
1244
  const response = respondWithPayload(payload)
@@ -1091,6 +1275,24 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1091
1275
  })
1092
1276
  const emptyPayload = { items: [], total: 0, page: page.page, pageSize: page.pageSize, totalPages: 0 }
1093
1277
  await opts.hooks?.afterList?.(emptyPayload, { ...ctx, query: validated as any })
1278
+ const emptyAfterInterceptors = await applyInterceptorsAfter({
1279
+ ctx,
1280
+ request,
1281
+ method: 'GET',
1282
+ requestPayload: interceptorRequest,
1283
+ metadataByInterceptor: interceptorMetadata,
1284
+ statusCode: 200,
1285
+ body: emptyPayload as Record<string, unknown>,
1286
+ })
1287
+ if (!emptyAfterInterceptors) {
1288
+ finishProfile({ result: 'interceptor_after_empty', cacheStatus, itemCount: 0, total: 0 })
1289
+ return json({ error: 'Internal interceptor error' }, { status: 500 })
1290
+ }
1291
+ if (!emptyAfterInterceptors.ok) {
1292
+ finishProfile({ result: 'interceptor_after_failed', cacheStatus, itemCount: 0, total: 0 })
1293
+ return json(emptyAfterInterceptors.body, { status: emptyAfterInterceptors.statusCode, headers: emptyAfterInterceptors.headers })
1294
+ }
1295
+ Object.assign(emptyPayload, emptyAfterInterceptors.body)
1094
1296
  await maybeStoreCrudCache(emptyPayload)
1095
1297
  logCacheOutcome(cacheStatus, emptyPayload.items.length)
1096
1298
  const response = respondWithPayload(emptyPayload)
@@ -1237,6 +1439,24 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1237
1439
  }
1238
1440
  await opts.hooks?.afterList?.(payload, { ...ctx, query: validated as any })
1239
1441
  profiler.mark('after_list_hook')
1442
+ const afterInterceptors = await applyInterceptorsAfter({
1443
+ ctx,
1444
+ request,
1445
+ method: 'GET',
1446
+ requestPayload: interceptorRequest,
1447
+ metadataByInterceptor: interceptorMetadata,
1448
+ statusCode: 200,
1449
+ body: payload as Record<string, unknown>,
1450
+ })
1451
+ if (!afterInterceptors) {
1452
+ finishProfile({ result: 'interceptor_after_empty', cacheStatus })
1453
+ return json({ error: 'Internal interceptor error' }, { status: 500 })
1454
+ }
1455
+ if (!afterInterceptors.ok) {
1456
+ finishProfile({ result: 'interceptor_after_failed', cacheStatus })
1457
+ return json(afterInterceptors.body, { status: afterInterceptors.statusCode, headers: afterInterceptors.headers })
1458
+ }
1459
+ Object.assign(payload, afterInterceptors.body)
1240
1460
  await enrichListPayload(payload, ctx, profiler)
1241
1461
  await maybeStoreCrudCache(payload)
1242
1462
  profiler.mark('cache_store_attempt', { cacheEnabled })
@@ -1268,6 +1488,30 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1268
1488
  })
1269
1489
  const emptyPayload = { items: [], total: 0 }
1270
1490
  await opts.hooks?.afterList?.(emptyPayload, { ...ctx, query: validated as any })
1491
+ const fallbackEmptyAfterInterceptors = await applyInterceptorsAfter({
1492
+ ctx,
1493
+ request,
1494
+ method: 'GET',
1495
+ requestPayload: interceptorRequest,
1496
+ metadataByInterceptor: interceptorMetadata,
1497
+ statusCode: 200,
1498
+ body: emptyPayload as Record<string, unknown>,
1499
+ })
1500
+ if (!fallbackEmptyAfterInterceptors) {
1501
+ finishProfile({
1502
+ result: 'interceptor_after_empty',
1503
+ cacheStatus,
1504
+ itemCount: 0,
1505
+ total: 0,
1506
+ branch: 'fallback',
1507
+ })
1508
+ return json({ error: 'Internal interceptor error' }, { status: 500 })
1509
+ }
1510
+ if (!fallbackEmptyAfterInterceptors.ok) {
1511
+ finishProfile({ result: 'interceptor_after_failed', cacheStatus, itemCount: 0, total: 0, branch: 'fallback' })
1512
+ return json(fallbackEmptyAfterInterceptors.body, { status: fallbackEmptyAfterInterceptors.statusCode, headers: fallbackEmptyAfterInterceptors.headers })
1513
+ }
1514
+ Object.assign(emptyPayload, fallbackEmptyAfterInterceptors.body)
1271
1515
  await maybeStoreCrudCache(emptyPayload)
1272
1516
  logCacheOutcome(cacheStatus, emptyPayload.items.length)
1273
1517
  const response = respondWithPayload(emptyPayload)
@@ -1357,6 +1601,28 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1357
1601
  const payload = { items: list, total: list.length }
1358
1602
  await opts.hooks?.afterList?.(payload, { ...ctx, query: validated as any })
1359
1603
  profiler.mark('after_list_hook')
1604
+ const fallbackAfterInterceptors = await applyInterceptorsAfter({
1605
+ ctx,
1606
+ request,
1607
+ method: 'GET',
1608
+ requestPayload: interceptorRequest,
1609
+ metadataByInterceptor: interceptorMetadata,
1610
+ statusCode: 200,
1611
+ body: payload as Record<string, unknown>,
1612
+ })
1613
+ if (!fallbackAfterInterceptors) {
1614
+ finishProfile({
1615
+ result: 'interceptor_after_empty',
1616
+ cacheStatus,
1617
+ branch: 'fallback',
1618
+ })
1619
+ return json({ error: 'Internal interceptor error' }, { status: 500 })
1620
+ }
1621
+ if (!fallbackAfterInterceptors.ok) {
1622
+ finishProfile({ result: 'interceptor_after_failed', cacheStatus, branch: 'fallback' })
1623
+ return json(fallbackAfterInterceptors.body, { status: fallbackAfterInterceptors.statusCode, headers: fallbackAfterInterceptors.headers })
1624
+ }
1625
+ Object.assign(payload, fallbackAfterInterceptors.body)
1360
1626
  await enrichListPayload(payload, ctx, profiler)
1361
1627
  await maybeStoreCrudCache(payload)
1362
1628
  profiler.mark('cache_store_attempt', { cacheEnabled })
@@ -1394,13 +1660,26 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1394
1660
  return json({ error: 'Forbidden' }, { status: 403 })
1395
1661
  }
1396
1662
  const body = await request.json().catch(() => ({}))
1663
+ let interceptorRequestPayload: InterceptorRequest | null = null
1664
+ let interceptorMetadata: Record<string, Record<string, unknown> | undefined> = {}
1397
1665
 
1398
1666
  if (useCommand) {
1399
1667
  const commandBus = (ctx.container.resolve('commandBus') as CommandBus)
1400
1668
  const action = opts.actions!.create!
1401
1669
  const parsed = action.schema ? action.schema.parse(body) : body
1402
- const input = action.mapInput ? await action.mapInput({ parsed, raw: body, ctx }) : parsed
1403
- const userMetadata = action.metadata ? await action.metadata({ input, parsed, raw: body, ctx }) : null
1670
+ const beforeInterceptors = await applyInterceptorsBefore({
1671
+ ctx,
1672
+ request,
1673
+ method: 'POST',
1674
+ body: parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : undefined,
1675
+ })
1676
+ if (beforeInterceptors.errorResponse) return beforeInterceptors.errorResponse
1677
+ interceptorRequestPayload = beforeInterceptors.requestPayload
1678
+ interceptorMetadata = beforeInterceptors.metadataByInterceptor
1679
+ const interceptedBody = interceptorRequestPayload.body ?? {}
1680
+ const reparsed = action.schema ? action.schema.parse(interceptedBody) : interceptedBody
1681
+ const input = action.mapInput ? await action.mapInput({ parsed: reparsed, raw: interceptedBody, ctx }) : reparsed
1682
+ const userMetadata = action.metadata ? await action.metadata({ input, parsed: reparsed, raw: interceptedBody, ctx }) : null
1404
1683
  const baseMetadata: CommandLogMetadata = {
1405
1684
  tenantId: ctx.auth?.tenantId ?? null,
1406
1685
  organizationId: ctx.selectedOrganizationId ?? ctx.auth.orgId ?? null,
@@ -1410,7 +1689,22 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1410
1689
  const metadataToSend = mergeCommandMetadata(baseMetadata, userMetadata)
1411
1690
  const { result, logEntry } = await commandBus.execute(action.commandId, { input, ctx, metadata: metadataToSend })
1412
1691
  const payload = action.response ? action.response({ result, logEntry, ctx }) : result
1413
- const resolvedPayload = await Promise.resolve(payload)
1692
+ let resolvedPayload = await Promise.resolve(payload)
1693
+ if (interceptorRequestPayload && resolvedPayload && typeof resolvedPayload === 'object' && !Array.isArray(resolvedPayload)) {
1694
+ const afterInterceptors = await applyInterceptorsAfter({
1695
+ ctx,
1696
+ request,
1697
+ method: 'POST',
1698
+ requestPayload: interceptorRequestPayload,
1699
+ metadataByInterceptor: interceptorMetadata,
1700
+ statusCode: action.status ?? 201,
1701
+ body: resolvedPayload as Record<string, unknown>,
1702
+ })
1703
+ if (afterInterceptors && !afterInterceptors.ok) {
1704
+ return json(afterInterceptors.body, { status: afterInterceptors.statusCode, headers: afterInterceptors.headers })
1705
+ }
1706
+ if (afterInterceptors?.ok) resolvedPayload = afterInterceptors.body
1707
+ }
1414
1708
  const status = action.status ?? 201
1415
1709
  const response = json(resolvedPayload, { status })
1416
1710
  attachOperationHeader(response, logEntry)
@@ -1424,6 +1718,18 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1424
1718
  if (!createConfig) throw new Error('Create configuration missing')
1425
1719
 
1426
1720
  let input = createConfig.schema.parse(body)
1721
+ const beforeInterceptors = await applyInterceptorsBefore({
1722
+ ctx,
1723
+ request,
1724
+ method: 'POST',
1725
+ body: input && typeof input === 'object' ? (input as Record<string, unknown>) : undefined,
1726
+ })
1727
+ if (beforeInterceptors.errorResponse) return beforeInterceptors.errorResponse
1728
+ interceptorRequestPayload = beforeInterceptors.requestPayload
1729
+ interceptorMetadata = beforeInterceptors.metadataByInterceptor
1730
+ if (interceptorRequestPayload.body) {
1731
+ input = createConfig.schema.parse(interceptorRequestPayload.body)
1732
+ }
1427
1733
  const modified = await opts.hooks?.beforeCreate?.(input as any, ctx)
1428
1734
  if (modified) input = modified
1429
1735
  const de = (ctx.container.resolve('dataEngine') as DataEngine)
@@ -1472,6 +1778,21 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1472
1778
  await invalidateCrudCache(ctx.container, resourceKind, identifiers, ctx.auth.tenantId ?? null, 'created', resourceTargets)
1473
1779
 
1474
1780
  let payload = createConfig.response ? createConfig.response(entity) : { id: String((entity as any)[ormCfg.idField!]) }
1781
+ if (interceptorRequestPayload && payload && typeof payload === 'object' && !Array.isArray(payload)) {
1782
+ const afterInterceptors = await applyInterceptorsAfter({
1783
+ ctx,
1784
+ request,
1785
+ method: 'POST',
1786
+ requestPayload: interceptorRequestPayload,
1787
+ metadataByInterceptor: interceptorMetadata,
1788
+ statusCode: 201,
1789
+ body: payload as Record<string, unknown>,
1790
+ })
1791
+ if (afterInterceptors && !afterInterceptors.ok) {
1792
+ return json(afterInterceptors.body, { status: afterInterceptors.statusCode, headers: afterInterceptors.headers })
1793
+ }
1794
+ if (afterInterceptors?.ok) payload = afterInterceptors.body
1795
+ }
1475
1796
  payload = await enrichSingleRecord(payload, ctx)
1476
1797
  return json(payload, { status: 201 })
1477
1798
  } catch (e) {
@@ -1498,13 +1819,26 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1498
1819
  }
1499
1820
  const body = await request.json().catch(() => ({}))
1500
1821
  const scopeOrganizationId = ctx.selectedOrganizationId ?? ctx.auth.orgId ?? null
1822
+ let interceptorRequestPayload: InterceptorRequest | null = null
1823
+ let interceptorMetadata: Record<string, Record<string, unknown> | undefined> = {}
1501
1824
 
1502
1825
  if (useCommand) {
1503
1826
  const commandBus = (ctx.container.resolve('commandBus') as CommandBus)
1504
1827
  const action = opts.actions!.update!
1505
1828
  const parsed = action.schema ? action.schema.parse(body) : body
1506
- const input = action.mapInput ? await action.mapInput({ parsed, raw: body, ctx }) : parsed
1507
- const userMetadata = action.metadata ? await action.metadata({ input, parsed, raw: body, ctx }) : null
1829
+ const beforeInterceptors = await applyInterceptorsBefore({
1830
+ ctx,
1831
+ request,
1832
+ method: 'PUT',
1833
+ body: parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : undefined,
1834
+ })
1835
+ if (beforeInterceptors.errorResponse) return beforeInterceptors.errorResponse
1836
+ interceptorRequestPayload = beforeInterceptors.requestPayload
1837
+ interceptorMetadata = beforeInterceptors.metadataByInterceptor
1838
+ const interceptedBody = interceptorRequestPayload.body ?? {}
1839
+ const reparsed = action.schema ? action.schema.parse(interceptedBody) : interceptedBody
1840
+ const input = action.mapInput ? await action.mapInput({ parsed: reparsed, raw: interceptedBody, ctx }) : reparsed
1841
+ const userMetadata = action.metadata ? await action.metadata({ input, parsed: reparsed, raw: interceptedBody, ctx }) : null
1508
1842
  const candidateId = normalizeIdentifierValue((input as Record<string, unknown> | null | undefined)?.id)
1509
1843
  let mutationGuardValidation: CrudMutationGuardValidationResult | null = null
1510
1844
  if (ctx.auth.tenantId && candidateId) {
@@ -1536,7 +1870,22 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1536
1870
  const metadataToSend = mergeCommandMetadata(baseMetadata, userMetadata)
1537
1871
  const { result, logEntry } = await commandBus.execute(action.commandId, { input, ctx, metadata: metadataToSend })
1538
1872
  const payload = action.response ? action.response({ result, logEntry, ctx }) : result
1539
- const resolvedPayload = await Promise.resolve(payload)
1873
+ let resolvedPayload = await Promise.resolve(payload)
1874
+ if (interceptorRequestPayload && resolvedPayload && typeof resolvedPayload === 'object' && !Array.isArray(resolvedPayload)) {
1875
+ const afterInterceptors = await applyInterceptorsAfter({
1876
+ ctx,
1877
+ request,
1878
+ method: 'PUT',
1879
+ requestPayload: interceptorRequestPayload,
1880
+ metadataByInterceptor: interceptorMetadata,
1881
+ statusCode: action.status ?? 200,
1882
+ body: resolvedPayload as Record<string, unknown>,
1883
+ })
1884
+ if (afterInterceptors && !afterInterceptors.ok) {
1885
+ return json(afterInterceptors.body, { status: afterInterceptors.statusCode, headers: afterInterceptors.headers })
1886
+ }
1887
+ if (afterInterceptors?.ok) resolvedPayload = afterInterceptors.body
1888
+ }
1540
1889
  const status = action.status ?? 200
1541
1890
  const response = json(resolvedPayload, { status })
1542
1891
  attachOperationHeader(response, logEntry)
@@ -1568,6 +1917,18 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1568
1917
  if (!updateConfig) throw new Error('Update configuration missing')
1569
1918
 
1570
1919
  let input = updateConfig.schema.parse(body)
1920
+ const beforeInterceptors = await applyInterceptorsBefore({
1921
+ ctx,
1922
+ request,
1923
+ method: 'PUT',
1924
+ body: input && typeof input === 'object' ? (input as Record<string, unknown>) : undefined,
1925
+ })
1926
+ if (beforeInterceptors.errorResponse) return beforeInterceptors.errorResponse
1927
+ interceptorRequestPayload = beforeInterceptors.requestPayload
1928
+ interceptorMetadata = beforeInterceptors.metadataByInterceptor
1929
+ if (interceptorRequestPayload.body) {
1930
+ input = updateConfig.schema.parse(interceptorRequestPayload.body)
1931
+ }
1571
1932
  const modified = await opts.hooks?.beforeUpdate?.(input as any, ctx)
1572
1933
  if (modified) input = modified
1573
1934
 
@@ -1663,6 +2024,23 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1663
2024
  })
1664
2025
  }
1665
2026
  const payload = updateConfig.response ? updateConfig.response(entity) : { success: true }
2027
+ if (interceptorRequestPayload && payload && typeof payload === 'object' && !Array.isArray(payload)) {
2028
+ const afterInterceptors = await applyInterceptorsAfter({
2029
+ ctx,
2030
+ request,
2031
+ method: 'PUT',
2032
+ requestPayload: interceptorRequestPayload,
2033
+ metadataByInterceptor: interceptorMetadata,
2034
+ statusCode: 200,
2035
+ body: payload as Record<string, unknown>,
2036
+ })
2037
+ if (afterInterceptors && !afterInterceptors.ok) {
2038
+ return json(afterInterceptors.body, { status: afterInterceptors.statusCode, headers: afterInterceptors.headers })
2039
+ }
2040
+ if (afterInterceptors?.ok) {
2041
+ return json(afterInterceptors.body, { status: 200, headers: afterInterceptors.headers })
2042
+ }
2043
+ }
1666
2044
  return json(payload)
1667
2045
  } catch (e) {
1668
2046
  return handleError(e)
@@ -1687,14 +2065,35 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1687
2065
  const useCommand = !!opts.actions?.delete
1688
2066
  const url = new URL(request.url)
1689
2067
  const scopeOrganizationId = ctx.selectedOrganizationId ?? ctx.auth.orgId ?? null
2068
+ let interceptorRequestPayload: InterceptorRequest | null = null
2069
+ let interceptorMetadata: Record<string, Record<string, unknown> | undefined> = {}
1690
2070
 
1691
2071
  if (useCommand) {
1692
2072
  const action = opts.actions!.delete!
1693
2073
  const body = await request.json().catch(() => ({}))
1694
2074
  const raw = { body, query: Object.fromEntries(url.searchParams.entries()) }
1695
2075
  const parsed = action.schema ? action.schema.parse(raw) : raw
1696
- const input = action.mapInput ? await action.mapInput({ parsed, raw, ctx }) : parsed
1697
- const userMetadata = action.metadata ? await action.metadata({ input, parsed, raw, ctx }) : null
2076
+ const interceptorInput =
2077
+ parsed && typeof parsed === 'object' && (parsed as Record<string, unknown>).body && typeof (parsed as Record<string, unknown>).body === 'object'
2078
+ ? ((parsed as Record<string, unknown>).body as Record<string, unknown>)
2079
+ : body
2080
+ const beforeInterceptors = await applyInterceptorsBefore({
2081
+ ctx,
2082
+ request,
2083
+ method: 'DELETE',
2084
+ body: interceptorInput,
2085
+ })
2086
+ if (beforeInterceptors.errorResponse) return beforeInterceptors.errorResponse
2087
+ interceptorRequestPayload = beforeInterceptors.requestPayload
2088
+ interceptorMetadata = beforeInterceptors.metadataByInterceptor
2089
+ const interceptedBody = interceptorRequestPayload.body ?? {}
2090
+ const reparsedRaw = {
2091
+ body: interceptedBody,
2092
+ query: Object.fromEntries(url.searchParams.entries()),
2093
+ }
2094
+ const reparsed = action.schema ? action.schema.parse(reparsedRaw) : reparsedRaw
2095
+ const input = action.mapInput ? await action.mapInput({ parsed: reparsed, raw: reparsedRaw, ctx }) : reparsed
2096
+ const userMetadata = action.metadata ? await action.metadata({ input, parsed: reparsed, raw: reparsedRaw, ctx }) : null
1698
2097
  const commandBus = (ctx.container.resolve('commandBus') as CommandBus)
1699
2098
  const candidateId = normalizeIdentifierValue(
1700
2099
  (input as Record<string, unknown> | null | undefined)?.id
@@ -1728,7 +2127,22 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1728
2127
  const metadataToSend = mergeCommandMetadata(baseMetadata, userMetadata)
1729
2128
  const { result, logEntry } = await commandBus.execute(action.commandId, { input, ctx, metadata: metadataToSend })
1730
2129
  const payload = action.response ? action.response({ result, logEntry, ctx }) : result
1731
- const resolvedPayload = await Promise.resolve(payload)
2130
+ let resolvedPayload = await Promise.resolve(payload)
2131
+ if (interceptorRequestPayload && resolvedPayload && typeof resolvedPayload === 'object' && !Array.isArray(resolvedPayload)) {
2132
+ const afterInterceptors = await applyInterceptorsAfter({
2133
+ ctx,
2134
+ request,
2135
+ method: 'DELETE',
2136
+ requestPayload: interceptorRequestPayload,
2137
+ metadataByInterceptor: interceptorMetadata,
2138
+ statusCode: action.status ?? 200,
2139
+ body: resolvedPayload as Record<string, unknown>,
2140
+ })
2141
+ if (afterInterceptors && !afterInterceptors.ok) {
2142
+ return json(afterInterceptors.body, { status: afterInterceptors.statusCode, headers: afterInterceptors.headers })
2143
+ }
2144
+ if (afterInterceptors?.ok) resolvedPayload = afterInterceptors.body
2145
+ }
1732
2146
  const status = action.status ?? 200
1733
2147
  const response = json(resolvedPayload, { status })
1734
2148
  attachOperationHeader(response, logEntry)
@@ -1761,6 +2175,16 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1761
2175
  ? url.searchParams.get('id')
1762
2176
  : (await request.json().catch(() => ({}))).id
1763
2177
  if (!isUuid(id)) return json({ error: 'ID is required' }, { status: 400 })
2178
+ const beforeInterceptors = await applyInterceptorsBefore({
2179
+ ctx,
2180
+ request,
2181
+ method: 'DELETE',
2182
+ body: idFrom === 'query' ? undefined : ({ id } as Record<string, unknown>),
2183
+ query: idFrom === 'query' ? ({ id } as Record<string, unknown>) : undefined,
2184
+ })
2185
+ if (beforeInterceptors.errorResponse) return beforeInterceptors.errorResponse
2186
+ interceptorRequestPayload = beforeInterceptors.requestPayload
2187
+ interceptorMetadata = beforeInterceptors.metadataByInterceptor
1764
2188
 
1765
2189
  const mutationGuardValidation = ctx.auth.tenantId
1766
2190
  ? await validateCrudMutationGuard(ctx.container, {
@@ -1833,6 +2257,23 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1833
2257
  })
1834
2258
  }
1835
2259
  const payload = opts.del?.response ? opts.del.response(id) : { success: true }
2260
+ if (interceptorRequestPayload && payload && typeof payload === 'object' && !Array.isArray(payload)) {
2261
+ const afterInterceptors = await applyInterceptorsAfter({
2262
+ ctx,
2263
+ request,
2264
+ method: 'DELETE',
2265
+ requestPayload: interceptorRequestPayload,
2266
+ metadataByInterceptor: interceptorMetadata,
2267
+ statusCode: 200,
2268
+ body: payload as Record<string, unknown>,
2269
+ })
2270
+ if (afterInterceptors && !afterInterceptors.ok) {
2271
+ return json(afterInterceptors.body, { status: afterInterceptors.statusCode, headers: afterInterceptors.headers })
2272
+ }
2273
+ if (afterInterceptors?.ok) {
2274
+ return json(afterInterceptors.body, { status: 200, headers: afterInterceptors.headers })
2275
+ }
2276
+ }
1836
2277
  return json(payload)
1837
2278
  } catch (e) {
1838
2279
  return handleError(e)