@open-mercato/shared 0.6.8-develop.6898.1.8b42ed5e41 → 0.6.8-develop.6903.1.0ec850a22b

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.
@@ -1,4 +1,4 @@
1
- const APP_VERSION = "0.6.8-develop.6898.1.8b42ed5e41";
1
+ const APP_VERSION = "0.6.8-develop.6903.1.0ec850a22b";
2
2
  const appVersion = APP_VERSION;
3
3
  export {
4
4
  APP_VERSION,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/version.ts"],
4
- "sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.8-develop.6898.1.8b42ed5e41';\nexport const appVersion = APP_VERSION;\n"],
4
+ "sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.8-develop.6903.1.0ec850a22b';\nexport const appVersion = APP_VERSION;\n"],
5
5
  "mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/shared",
3
- "version": "0.6.8-develop.6898.1.8b42ed5e41",
3
+ "version": "0.6.8-develop.6903.1.0ec850a22b",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -105,7 +105,7 @@
105
105
  "@mikro-orm/core": "^7.1.8",
106
106
  "@mikro-orm/decorators": "^7.1.8",
107
107
  "@mikro-orm/postgresql": "^7.1.8",
108
- "@open-mercato/cache": "0.6.8-develop.6898.1.8b42ed5e41",
108
+ "@open-mercato/cache": "0.6.8-develop.6903.1.0ec850a22b",
109
109
  "@types/sanitize-html": "^2.16.1",
110
110
  "dotenv": "^17.4.2",
111
111
  "pino": "^10.3.1",
@@ -4,11 +4,15 @@ import {
4
4
  registerCommand,
5
5
  registerCommandLoaders,
6
6
  CommandBus,
7
+ isCommandInterceptorError,
7
8
  } from '@open-mercato/shared/lib/commands'
9
+ import { registerCommandInterceptors } from '@open-mercato/shared/lib/commands/command-interceptor-store'
10
+ import type { CommandInterceptor } from '@open-mercato/shared/lib/commands/command-interceptor'
8
11
 
9
12
  describe('CommandBus', () => {
10
13
  afterEach(() => {
11
14
  commandRegistry.clear()
15
+ registerCommandInterceptors([])
12
16
  })
13
17
 
14
18
  it('executes registered command and logs action metadata', async () => {
@@ -119,4 +123,63 @@ describe('CommandBus', () => {
119
123
  expect(result).toEqual({ ok: true })
120
124
  expect(execute).toHaveBeenCalledTimes(1)
121
125
  })
126
+
127
+ describe('interceptor rejections', () => {
128
+ const blockingInterceptor = (result: Record<string, unknown>): CommandInterceptor => ({
129
+ id: 'test.block',
130
+ targetCommand: 'test.*',
131
+ beforeExecute: async () => result,
132
+ })
133
+
134
+ const runBlockedCommand = async (interceptor: CommandInterceptor) => {
135
+ const execute = jest.fn(async () => ({ ok: true }))
136
+ registerCommand({ id: 'test.command.blocked', execute })
137
+ registerCommandInterceptors([{ moduleId: 'test', interceptors: [interceptor] }])
138
+
139
+ const container = createContainer({ injectionMode: InjectionMode.CLASSIC })
140
+ const bus = new CommandBus()
141
+ const ctx = {
142
+ container,
143
+ auth: { sub: 'user-4', tenantId: 'tenant-4', orgId: null },
144
+ organizationScope: null,
145
+ selectedOrganizationId: null,
146
+ organizationIds: null,
147
+ }
148
+
149
+ const error = await bus.execute('test.command.blocked', { input: {}, ctx }).catch((e: unknown) => e)
150
+ return { error, execute }
151
+ }
152
+
153
+ it('forwards the interceptor status and derived body onto the thrown error', async () => {
154
+ const { error, execute } = await runBlockedCommand(
155
+ blockingInterceptor({ ok: false, message: 'Missing required fields: VAT id', status: 422 }),
156
+ )
157
+
158
+ expect(isCommandInterceptorError(error)).toBe(true)
159
+ expect((error as { status?: number }).status).toBe(422)
160
+ expect((error as { body?: Record<string, unknown> }).body).toEqual({
161
+ error: 'Missing required fields: VAT id',
162
+ })
163
+ expect(execute).not.toHaveBeenCalled()
164
+ })
165
+
166
+ it('forwards an explicit interceptor body verbatim', async () => {
167
+ const body = { error: 'Blocked', missingFields: ['vatId'] }
168
+ const { error } = await runBlockedCommand(
169
+ blockingInterceptor({ ok: false, message: 'Blocked', status: 409, body }),
170
+ )
171
+
172
+ expect((error as { status?: number }).status).toBe(409)
173
+ expect((error as { body?: Record<string, unknown> }).body).toEqual(body)
174
+ })
175
+
176
+ it('leaves status and body undefined when the interceptor supplies no status', async () => {
177
+ const { error } = await runBlockedCommand(blockingInterceptor({ ok: false, message: 'Blocked' }))
178
+
179
+ expect(isCommandInterceptorError(error)).toBe(true)
180
+ expect((error as Error).message).toBe('Blocked')
181
+ expect((error as { status?: number }).status).toBeUndefined()
182
+ expect((error as { body?: unknown }).body).toBeUndefined()
183
+ })
184
+ })
122
185
  })
@@ -1,4 +1,8 @@
1
- import { CommandInterceptorError } from '../errors'
1
+ import {
2
+ CommandInterceptorError,
3
+ getCommandInterceptorHttpRejection,
4
+ isCommandInterceptorError,
5
+ } from '../errors'
2
6
 
3
7
  describe('CommandInterceptorError', () => {
4
8
  it('has the correct name', () => {
@@ -15,4 +19,90 @@ describe('CommandInterceptorError', () => {
15
19
  const error = new CommandInterceptorError('test')
16
20
  expect(error).toBeInstanceOf(Error)
17
21
  })
22
+
23
+ it('carries no transport information when constructed with a message alone', () => {
24
+ const error = new CommandInterceptorError('test')
25
+ expect(error.status).toBeUndefined()
26
+ expect(error.body).toBeUndefined()
27
+ })
28
+
29
+ it('carries the status and derives the body from the message', () => {
30
+ const error = new CommandInterceptorError('Missing required fields: VAT id', { status: 422 })
31
+ expect(error.status).toBe(422)
32
+ expect(error.body).toEqual({ error: 'Missing required fields: VAT id' })
33
+ })
34
+
35
+ it('keeps an explicit body verbatim', () => {
36
+ const body = { error: 'Blocked', missingFields: ['vatId'] }
37
+ const error = new CommandInterceptorError('Blocked', { status: 422, body })
38
+ expect(error.body).toEqual(body)
39
+ })
40
+
41
+ it('ignores a body supplied without a status, so status and body move together', () => {
42
+ const error = new CommandInterceptorError('Blocked', { body: { error: 'Blocked' } })
43
+ expect(error.status).toBeUndefined()
44
+ expect(error.body).toBeUndefined()
45
+ })
46
+
47
+ it('preserves the cause when one is supplied', () => {
48
+ const cause = new Error('underlying')
49
+ const error = new CommandInterceptorError('Blocked', { cause })
50
+ expect(error.cause).toBe(cause)
51
+ })
52
+
53
+ it('is detected by isCommandInterceptorError across module boundaries', () => {
54
+ expect(isCommandInterceptorError(new CommandInterceptorError('test'))).toBe(true)
55
+ // A structurally identical error from a duplicated bundle carries the same registered symbol.
56
+ const duplicated = Object.assign(new Error('test'), {
57
+ [Symbol.for('@open-mercato/CommandInterceptorError')]: true,
58
+ })
59
+ expect(isCommandInterceptorError(duplicated)).toBe(true)
60
+ })
61
+
62
+ it('does not treat unrelated values as interceptor errors', () => {
63
+ expect(isCommandInterceptorError(new Error('plain'))).toBe(false)
64
+ expect(isCommandInterceptorError(null)).toBe(false)
65
+ expect(isCommandInterceptorError('CommandInterceptorError')).toBe(false)
66
+ })
67
+ })
68
+
69
+ describe('getCommandInterceptorHttpRejection', () => {
70
+ it('returns the status and body of a status-carrying rejection', () => {
71
+ const error = new CommandInterceptorError('Missing required fields: VAT id', { status: 422 })
72
+ expect(getCommandInterceptorHttpRejection(error)).toEqual({
73
+ status: 422,
74
+ body: { error: 'Missing required fields: VAT id' },
75
+ })
76
+ })
77
+
78
+ it('returns the explicit body when the interceptor supplied one', () => {
79
+ const body = { error: 'Blocked', missingFields: ['vatId'] }
80
+ const error = new CommandInterceptorError('Blocked', { status: 409, body })
81
+ expect(getCommandInterceptorHttpRejection(error)).toEqual({ status: 409, body })
82
+ })
83
+
84
+ it('returns null for a rejection that carries no status', () => {
85
+ expect(getCommandInterceptorHttpRejection(new CommandInterceptorError('Blocked'))).toBeNull()
86
+ })
87
+
88
+ it('returns null for errors that are not interceptor rejections', () => {
89
+ expect(getCommandInterceptorHttpRejection(new Error('plain'))).toBeNull()
90
+ expect(getCommandInterceptorHttpRejection(null)).toBeNull()
91
+ })
92
+
93
+ it('rejects a status outside 4xx/5xx so the transport layer keeps its generic handling', () => {
94
+ // Statuses arrive as third-party interceptor data: outside 200-599 the Response constructor
95
+ // throws RangeError, and a 2xx would report a deliberate block as a success.
96
+ for (const status of [200, 302, 399, 600, 999, 0, -1]) {
97
+ const error = Object.assign(new CommandInterceptorError('Blocked'), { status })
98
+ expect(getCommandInterceptorHttpRejection(error)).toBeNull()
99
+ }
100
+ })
101
+
102
+ it('rejects a non-integer status', () => {
103
+ for (const status of [Number.NaN, 422.5, Number.POSITIVE_INFINITY]) {
104
+ const error = Object.assign(new CommandInterceptorError('Blocked'), { status })
105
+ expect(getCommandInterceptorHttpRejection(error)).toBeNull()
106
+ }
107
+ })
18
108
  })
@@ -112,6 +112,56 @@ describe('runCommandInterceptorsBefore', () => {
112
112
  expect(i2.beforeExecute).not.toHaveBeenCalled()
113
113
  })
114
114
 
115
+ // Issue #5045 — a rejection only carries transport information when the interceptor asks for it.
116
+ it('omits status and body when the rejection carries no status', async () => {
117
+ const interceptor = makeInterceptor({
118
+ id: 'i1',
119
+ beforeExecute: jest.fn().mockResolvedValue({ ok: false, message: 'Blocked' }),
120
+ })
121
+ const result = await runCommandInterceptorsBefore([interceptor], 'customers.create-person', {}, baseContext, [])
122
+ expect(result.error).toEqual({ message: 'Blocked' })
123
+ })
124
+
125
+ it('propagates the status and derives a body from the message', async () => {
126
+ const interceptor = makeInterceptor({
127
+ id: 'i1',
128
+ beforeExecute: jest.fn().mockResolvedValue({ ok: false, message: 'Missing required fields', status: 422 }),
129
+ })
130
+ const result = await runCommandInterceptorsBefore([interceptor], 'customers.create-person', {}, baseContext, [])
131
+ expect(result.error).toEqual({
132
+ message: 'Missing required fields',
133
+ status: 422,
134
+ body: { error: 'Missing required fields' },
135
+ })
136
+ })
137
+
138
+ it('propagates an explicit body alongside the status', async () => {
139
+ const interceptor = makeInterceptor({
140
+ id: 'i1',
141
+ beforeExecute: jest.fn().mockResolvedValue({
142
+ ok: false,
143
+ message: 'Blocked',
144
+ status: 422,
145
+ body: { error: 'Blocked', missingFields: ['vatId'] },
146
+ }),
147
+ })
148
+ const result = await runCommandInterceptorsBefore([interceptor], 'customers.create-person', {}, baseContext, [])
149
+ expect(result.error?.body).toEqual({ error: 'Blocked', missingFields: ['vatId'] })
150
+ })
151
+
152
+ it('falls back to the generated message when a status-carrying rejection has none', async () => {
153
+ const interceptor = makeInterceptor({
154
+ id: 'i1',
155
+ beforeExecute: jest.fn().mockResolvedValue({ ok: false, status: 422 }),
156
+ })
157
+ const result = await runCommandInterceptorsBefore([interceptor], 'customers.create-person', {}, baseContext, [])
158
+ expect(result.error).toEqual({
159
+ message: 'Blocked by command interceptor: i1',
160
+ status: 422,
161
+ body: { error: 'Blocked by command interceptor: i1' },
162
+ })
163
+ })
164
+
115
165
  it('accumulates modified input', async () => {
116
166
  const i1 = makeInterceptor({
117
167
  id: 'i1',
@@ -236,6 +286,29 @@ describe('runCommandInterceptorsBeforeUndo', () => {
236
286
  expect(result.ok).toBe(false)
237
287
  expect(result.error?.message).toBe('Cannot undo')
238
288
  })
289
+
290
+ // Issue #5045 — the undo path shares the before-result contract, so it carries a status too.
291
+ it('omits status and body when the undo rejection carries no status', async () => {
292
+ const interceptor = makeInterceptor({
293
+ id: 'i1',
294
+ beforeUndo: jest.fn().mockResolvedValue({ ok: false, message: 'Cannot undo' }),
295
+ })
296
+ const result = await runCommandInterceptorsBeforeUndo([interceptor], 'customers.create-person', undoContext, baseContext, [])
297
+ expect(result.error).toEqual({ message: 'Cannot undo' })
298
+ })
299
+
300
+ it('propagates the status and body of an undo rejection', async () => {
301
+ const interceptor = makeInterceptor({
302
+ id: 'i1',
303
+ beforeUndo: jest.fn().mockResolvedValue({ ok: false, message: 'Cannot undo a settled invoice', status: 409 }),
304
+ })
305
+ const result = await runCommandInterceptorsBeforeUndo([interceptor], 'customers.create-person', undoContext, baseContext, [])
306
+ expect(result.error).toEqual({
307
+ message: 'Cannot undo a settled invoice',
308
+ status: 409,
309
+ body: { error: 'Cannot undo a settled invoice' },
310
+ })
311
+ })
239
312
  })
240
313
 
241
314
  describe('runCommandInterceptorsAfterUndo', () => {
@@ -245,7 +245,8 @@ export class CommandBus {
245
245
  allInterceptors, commandId, options.input, interceptorCtx, userFeatures,
246
246
  )
247
247
  if (!beforeResult.ok) {
248
- throw new CommandInterceptorError(beforeResult.error!.message)
248
+ const blocked = beforeResult.error!
249
+ throw new CommandInterceptorError(blocked.message, { status: blocked.status, body: blocked.body })
249
250
  }
250
251
  interceptorMetadata = beforeResult.metadataByInterceptor
251
252
  if (beforeResult.modifiedInput) {
@@ -363,7 +364,8 @@ export class CommandBus {
363
364
  allInterceptors, log.commandId, undoCtx, interceptorCtx, userFeatures,
364
365
  )
365
366
  if (!beforeResult.ok) {
366
- throw new CommandInterceptorError(beforeResult.error!.message)
367
+ const blocked = beforeResult.error!
368
+ throw new CommandInterceptorError(blocked.message, { status: blocked.status, body: blocked.body })
367
369
  }
368
370
  undoInterceptorMetadata = beforeResult.metadataByInterceptor
369
371
  }
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  CommandInterceptor,
3
+ CommandInterceptorBeforeResult,
3
4
  CommandInterceptorContext,
4
5
  CommandInterceptorUndoContext,
5
6
  } from './command-interceptor'
@@ -8,6 +9,26 @@ import { createLogger } from '../logger'
8
9
 
9
10
  const logger = createLogger('shared').child({ component: 'commands' })
10
11
 
12
+ /**
13
+ * A blocking verdict from a before-hook, normalized for the command bus. `status`/`body` are
14
+ * present only when the interceptor supplied a status, so a rejection without one keeps the
15
+ * historical generic-500 handling downstream.
16
+ */
17
+ export type CommandInterceptorBlockedError = {
18
+ message: string
19
+ status?: number
20
+ body?: Record<string, unknown>
21
+ }
22
+
23
+ function buildBlockedError(
24
+ result: CommandInterceptorBeforeResult,
25
+ fallbackMessage: string,
26
+ ): CommandInterceptorBlockedError {
27
+ const message = result.message ?? fallbackMessage
28
+ if (typeof result.status !== 'number') return { message }
29
+ return { message, status: result.status, body: result.body ?? { error: message } }
30
+ }
31
+
11
32
  // ---------------------------------------------------------------------------
12
33
  // Command pattern matching
13
34
  // ---------------------------------------------------------------------------
@@ -49,7 +70,7 @@ export async function runCommandInterceptorsBefore(
49
70
  userFeatures: string[],
50
71
  ): Promise<{
51
72
  ok: boolean
52
- error?: { message: string }
73
+ error?: CommandInterceptorBlockedError
53
74
  modifiedInput?: Record<string, unknown>
54
75
  metadataByInterceptor: Map<string, Record<string, unknown>>
55
76
  }> {
@@ -65,7 +86,7 @@ export async function runCommandInterceptorsBefore(
65
86
  if (result?.ok === false) {
66
87
  return {
67
88
  ok: false,
68
- error: { message: result.message ?? `Blocked by command interceptor: ${interceptor.id}` },
89
+ error: buildBlockedError(result, `Blocked by command interceptor: ${interceptor.id}`),
69
90
  metadataByInterceptor,
70
91
  }
71
92
  }
@@ -139,7 +160,7 @@ export async function runCommandInterceptorsBeforeUndo(
139
160
  userFeatures: string[],
140
161
  ): Promise<{
141
162
  ok: boolean
142
- error?: { message: string }
163
+ error?: CommandInterceptorBlockedError
143
164
  metadataByInterceptor: Map<string, Record<string, unknown>>
144
165
  }> {
145
166
  const matching = collectMatching(interceptors, commandId, userFeatures)
@@ -153,7 +174,7 @@ export async function runCommandInterceptorsBeforeUndo(
153
174
  if (result?.ok === false) {
154
175
  return {
155
176
  ok: false,
156
- error: { message: result.message ?? `Undo blocked by command interceptor: ${interceptor.id}` },
177
+ error: buildBlockedError(result, `Undo blocked by command interceptor: ${interceptor.id}`),
157
178
  metadataByInterceptor,
158
179
  }
159
180
  }
@@ -67,6 +67,14 @@ export interface CommandInterceptorBeforeResult {
67
67
  ok?: boolean
68
68
  /** Error message when blocking */
69
69
  message?: string
70
+ /**
71
+ * HTTP status code when blocking. Omit to keep the historical behaviour, where a rejection
72
+ * without a status surfaces as a generic 500 — set it (e.g. 422) to have the CRUD transport
73
+ * layer answer with a deliberate business-rejection status instead.
74
+ */
75
+ status?: number
76
+ /** Error body when blocking (overrides message). Only used together with `status`. */
77
+ body?: Record<string, unknown>
70
78
  /** Modified input — shallow-merged into command input if ok:true */
71
79
  modifiedInput?: Record<string, unknown>
72
80
  /** Metadata passed to the corresponding after hook */
@@ -1,6 +1,71 @@
1
+ // Use Symbol.for so the marker survives module duplication across bundle boundaries
2
+ // (same behaviour as CrudHttpError and the globalThis-based DI registries)
3
+ const COMMAND_INTERCEPTOR_ERROR_MARKER = Symbol.for('@open-mercato/CommandInterceptorError')
4
+
5
+ export type CommandInterceptorErrorOptions = {
6
+ /** HTTP status the transport layer should answer with. Omit to keep the generic 500. */
7
+ status?: number
8
+ /**
9
+ * Response body the transport layer should answer with, defaulting to `{ error: message }`.
10
+ * Only meaningful together with `status` — a body on its own is ignored, mirroring the same
11
+ * rule on `CommandInterceptorBeforeResult`.
12
+ */
13
+ body?: Record<string, unknown>
14
+ cause?: unknown
15
+ }
16
+
1
17
  export class CommandInterceptorError extends Error {
2
- constructor(message: string) {
3
- super(message)
18
+ readonly [COMMAND_INTERCEPTOR_ERROR_MARKER] = true
19
+ /**
20
+ * HTTP status a deliberate interceptor rejection wants to surface. `undefined` when the
21
+ * interceptor supplied none, in which case the transport layer keeps its generic 500.
22
+ */
23
+ readonly status?: number
24
+ /**
25
+ * Response body for the rejection — `options.body`, else `{ error: message }`. Set only
26
+ * alongside `status`, so the two are always populated together or not at all.
27
+ */
28
+ readonly body?: Record<string, unknown>
29
+
30
+ constructor(message: string, options?: CommandInterceptorErrorOptions) {
31
+ super(message, options?.cause !== undefined ? { cause: options.cause } : undefined)
4
32
  this.name = 'CommandInterceptorError'
33
+ if (typeof options?.status === 'number') {
34
+ this.status = options.status
35
+ this.body = options.body ?? { error: message }
36
+ }
5
37
  }
6
38
  }
39
+
40
+ /**
41
+ * Type-safe check for CommandInterceptorError that works across module/bundle boundaries.
42
+ * Prefer this over `instanceof CommandInterceptorError` whenever the error may originate
43
+ * from a different module bundle (e.g. enterprise packages, dynamic imports).
44
+ */
45
+ export function isCommandInterceptorError(err: unknown): err is CommandInterceptorError {
46
+ return !!err && typeof err === 'object'
47
+ && (err as Record<symbol, unknown>)[COMMAND_INTERCEPTOR_ERROR_MARKER] === true
48
+ }
49
+
50
+ export type CommandInterceptorHttpRejection = {
51
+ status: number
52
+ body: Record<string, unknown>
53
+ }
54
+
55
+ const MIN_REJECTION_STATUS = 400
56
+ const MAX_REJECTION_STATUS = 599
57
+
58
+ /**
59
+ * Transport data for a deliberate interceptor rejection, or `null` when the error is not one or
60
+ * carries no usable status. The status is restricted to 4xx/5xx on purpose: interceptor data is
61
+ * third-party input travelling through the runner and the bus, `new Response(body, { status })`
62
+ * throws `RangeError` outside 200-599, and a rejection answered with a 2xx would report a block as
63
+ * a success. Anything else falls through to the caller's generic handling.
64
+ */
65
+ export function getCommandInterceptorHttpRejection(err: unknown): CommandInterceptorHttpRejection | null {
66
+ if (!isCommandInterceptorError(err)) return null
67
+ const status = err.status
68
+ if (typeof status !== 'number' || !Number.isInteger(status)) return null
69
+ if (status < MIN_REJECTION_STATUS || status > MAX_REJECTION_STATUS) return null
70
+ return { status, body: err.body ?? { error: err.message } }
71
+ }
@@ -4,7 +4,13 @@ export { CommandBus } from './command-bus'
4
4
  export * from './customFieldSnapshots'
5
5
  export * from './undo'
6
6
  export * from './redo'
7
- export { CommandInterceptorError } from './errors'
7
+ export {
8
+ CommandInterceptorError,
9
+ isCommandInterceptorError,
10
+ getCommandInterceptorHttpRejection,
11
+ type CommandInterceptorErrorOptions,
12
+ type CommandInterceptorHttpRejection,
13
+ } from './errors'
8
14
  export {
9
15
  runCrudCommandWrite,
10
16
  type RunCrudCommandWriteOptions,
@@ -11,6 +11,7 @@ import {
11
11
  } from '@open-mercato/shared/lib/crud/optimistic-lock-store'
12
12
  import { loadCustomFieldDefinitionIndex } from '@open-mercato/shared/lib/crud/custom-fields'
13
13
  import { registerMutationGuards } from '@open-mercato/shared/lib/crud/mutation-guard-store'
14
+ import { CommandInterceptorError } from '@open-mercato/shared/lib/commands/errors'
14
15
  import { z } from 'zod'
15
16
 
16
17
  // Keep the real custom-field helpers but spy on the definition loader so we can
@@ -983,6 +984,78 @@ describe('CRUD Factory', () => {
983
984
  }))
984
985
  })
985
986
 
987
+ // Issue #5045 — a deliberate interceptor rejection must not be laundered into a generic 500.
988
+ const interceptorErrorRoute = () => makeCrudRoute({
989
+ metadata: { POST: { requireAuth: true } },
990
+ orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
991
+ indexer: { entityType: 'example.todo' },
992
+ actions: {
993
+ create: {
994
+ commandId: 'example.todo.create',
995
+ schema: createSchema,
996
+ response: () => ({ ok: true }),
997
+ },
998
+ },
999
+ })
1000
+
1001
+ const postInterceptorErrorRequest = (route: ReturnType<typeof interceptorErrorRoute>) => route.POST(
1002
+ new Request('http://x/api/example/todos/command', {
1003
+ method: 'POST',
1004
+ body: JSON.stringify({ title: 'A' }),
1005
+ headers: { 'content-type': 'application/json' },
1006
+ }),
1007
+ )
1008
+
1009
+ it('POST command route keeps the generic 500 when an interceptor blocks without a status', async () => {
1010
+ commandBus.execute.mockRejectedValue(new CommandInterceptorError('Missing required fields: VAT id'))
1011
+
1012
+ const res = await postInterceptorErrorRequest(interceptorErrorRoute())
1013
+
1014
+ expect(res.status).toBe(500)
1015
+ await expect(res.json()).resolves.toEqual({
1016
+ error: 'Internal server error',
1017
+ message: 'Something went wrong. Please try again later.',
1018
+ })
1019
+ })
1020
+
1021
+ it('POST command route surfaces the interceptor status and message when the block carries a status', async () => {
1022
+ commandBus.execute.mockRejectedValue(
1023
+ new CommandInterceptorError('Missing required fields: VAT id', { status: 422 }),
1024
+ )
1025
+
1026
+ const res = await postInterceptorErrorRequest(interceptorErrorRoute())
1027
+
1028
+ expect(res.status).toBe(422)
1029
+ await expect(res.json()).resolves.toEqual({ error: 'Missing required fields: VAT id' })
1030
+ })
1031
+
1032
+ it('POST command route surfaces the interceptor body verbatim when one is supplied', async () => {
1033
+ commandBus.execute.mockRejectedValue(
1034
+ new CommandInterceptorError('Blocked', { status: 422, body: { error: 'Blocked', missingFields: ['vatId'] } }),
1035
+ )
1036
+
1037
+ const res = await postInterceptorErrorRequest(interceptorErrorRoute())
1038
+
1039
+ expect(res.status).toBe(422)
1040
+ await expect(res.json()).resolves.toEqual({ error: 'Blocked', missingFields: ['vatId'] })
1041
+ })
1042
+
1043
+ it('POST command route keeps the generic 500 when the interceptor status is outside 4xx/5xx', async () => {
1044
+ // A status the Response constructor would reject (or that would report a block as success)
1045
+ // must not escape handleError as a RangeError — it falls back to the generic 500 instead.
1046
+ commandBus.execute.mockRejectedValue(
1047
+ Object.assign(new CommandInterceptorError('Blocked'), { status: 600, body: { error: 'Blocked' } }),
1048
+ )
1049
+
1050
+ const res = await postInterceptorErrorRequest(interceptorErrorRoute())
1051
+
1052
+ expect(res.status).toBe(500)
1053
+ await expect(res.json()).resolves.toEqual({
1054
+ error: 'Internal server error',
1055
+ message: 'Something went wrong. Please try again later.',
1056
+ })
1057
+ })
1058
+
986
1059
  it('POST command route falls back to the response payload id for guard afterSuccess', async () => {
987
1060
  commandBus.execute.mockResolvedValue({ result: { lineId: 'line-42' }, logEntry: { id: 'log-1' } })
988
1061
  const guardAfterSuccess = jest.fn(async () => {})
@@ -8,6 +8,7 @@ import { SortDir } from '@open-mercato/shared/lib/query/types'
8
8
  import type { DataEngine } from '@open-mercato/shared/lib/data/engine'
9
9
  import { resolveOrganizationScopeForRequest, type OrganizationScope } from '@open-mercato/core/modules/directory/utils/organizationScope'
10
10
  import { serializeOperationMetadata } from '@open-mercato/shared/lib/commands/operationMetadata'
11
+ import { getCommandInterceptorHttpRejection } from '@open-mercato/shared/lib/commands/errors'
11
12
  import { parseBooleanToken } from '@open-mercato/shared/lib/boolean'
12
13
  import {
13
14
  runMutationGuards,
@@ -586,6 +587,13 @@ function attachOperationHeader(res: Response, logEntry: any) {
586
587
  function handleError(err: unknown): Response {
587
588
  if (err instanceof Response) return err
588
589
  if (isCrudHttpError(err)) return json(err.body, { status: err.status })
590
+ // A command interceptor that blocked with an explicit status is a deliberate business
591
+ // rejection, not a server fault — surface its status and message instead of a generic 500.
592
+ // Without a usable status the error falls through to the historical handling below (issue #5045).
593
+ const interceptorRejection = getCommandInterceptorHttpRejection(err)
594
+ if (interceptorRejection) {
595
+ return json(interceptorRejection.body, { status: interceptorRejection.status })
596
+ }
589
597
  if (err instanceof z.ZodError) return json({ error: 'Invalid input', details: err.issues }, { status: 400 })
590
598
  if (isTransientDbError(err)) {
591
599
  // Transient DB unavailability (pool exhausted, `max_connections` reached, DB