@open-mercato/shared 0.6.8-develop.6899.1.433952fd93 → 0.6.8-develop.6907.1.2968c89bdf
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/lib/commands/command-bus.js +4 -2
- package/dist/lib/commands/command-bus.js.map +2 -2
- package/dist/lib/commands/command-interceptor-runner.js +7 -2
- package/dist/lib/commands/command-interceptor-runner.js.map +2 -2
- package/dist/lib/commands/errors.js +25 -4
- package/dist/lib/commands/errors.js.map +2 -2
- package/dist/lib/commands/index.js +7 -1
- package/dist/lib/commands/index.js.map +2 -2
- package/dist/lib/crud/factory.js +5 -0
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +2 -2
- package/src/lib/commands/__tests__/command-bus.test.ts +63 -0
- package/src/lib/commands/__tests__/command-interceptor-error.test.ts +91 -1
- package/src/lib/commands/__tests__/command-interceptor-runner.test.ts +73 -0
- package/src/lib/commands/command-bus.ts +4 -2
- package/src/lib/commands/command-interceptor-runner.ts +25 -4
- package/src/lib/commands/command-interceptor.ts +8 -0
- package/src/lib/commands/errors.ts +67 -2
- package/src/lib/commands/index.ts +7 -1
- package/src/lib/crud/__tests__/crud-factory.test.ts +73 -0
- package/src/lib/crud/factory.ts +8 -0
package/dist/lib/version.js
CHANGED
package/dist/lib/version.js.map
CHANGED
|
@@ -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.
|
|
4
|
+
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.8-develop.6907.1.2968c89bdf';\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.
|
|
3
|
+
"version": "0.6.8-develop.6907.1.2968c89bdf",
|
|
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.
|
|
108
|
+
"@open-mercato/cache": "0.6.8-develop.6907.1.2968c89bdf",
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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?:
|
|
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:
|
|
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?:
|
|
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:
|
|
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
|
-
|
|
3
|
-
|
|
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 {
|
|
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 () => {})
|
package/src/lib/crud/factory.ts
CHANGED
|
@@ -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
|