@open-mercato/shared 0.6.8-develop.7090.1.4dbca7b350 → 0.6.8-develop.7092.1.8bbdd53d2f

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.7090.1.4dbca7b350";
1
+ const APP_VERSION = "0.6.8-develop.7092.1.8bbdd53d2f";
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.7090.1.4dbca7b350';\nexport const appVersion = APP_VERSION;\n"],
4
+ "sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.8-develop.7092.1.8bbdd53d2f';\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.7090.1.4dbca7b350",
3
+ "version": "0.6.8-develop.7092.1.8bbdd53d2f",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -109,7 +109,7 @@
109
109
  "@mikro-orm/core": "^7.1.8",
110
110
  "@mikro-orm/decorators": "^7.1.8",
111
111
  "@mikro-orm/postgresql": "^7.1.8",
112
- "@open-mercato/cache": "0.6.8-develop.7090.1.4dbca7b350",
112
+ "@open-mercato/cache": "0.6.8-develop.7092.1.8bbdd53d2f",
113
113
  "@types/html-to-text": "^9.0.4",
114
114
  "@types/sanitize-html": "^2.16.1",
115
115
  "dotenv": "^17.4.2",
@@ -12,6 +12,16 @@ import {
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
14
  import { CommandInterceptorError } from '@open-mercato/shared/lib/commands/errors'
15
+ import {
16
+ registerLoggerExtension,
17
+ resetLoggerExtension,
18
+ type LoggerExtensionRecord,
19
+ } from '@open-mercato/shared/lib/logger'
20
+ import {
21
+ registerTelemetryRuntime,
22
+ resetTelemetryRuntime,
23
+ type TelemetryRuntime,
24
+ } from '@open-mercato/shared/lib/telemetry/runtime'
15
25
  import { z } from 'zod'
16
26
 
17
27
  // Keep the real custom-field helpers but spy on the definition loader so we can
@@ -1076,6 +1086,7 @@ describe('CRUD Factory', () => {
1076
1086
  await expect(res.json()).resolves.toEqual({
1077
1087
  error: 'Internal server error',
1078
1088
  message: 'Something went wrong. Please try again later.',
1089
+ requestId: expect.any(String),
1079
1090
  })
1080
1091
  })
1081
1092
 
@@ -1114,6 +1125,138 @@ describe('CRUD Factory', () => {
1114
1125
  await expect(res.json()).resolves.toEqual({
1115
1126
  error: 'Internal server error',
1116
1127
  message: 'Something went wrong. Please try again later.',
1128
+ requestId: expect.any(String),
1129
+ })
1130
+ })
1131
+
1132
+ // Issue #5608 — a generic 500 must carry a requestId the client/support can cite, and
1133
+ // that same id must appear on the server log line so the two can be correlated.
1134
+ describe('generic 500 requestId correlation', () => {
1135
+ const logRecords: LoggerExtensionRecord[] = []
1136
+ const reportError = jest.fn()
1137
+
1138
+ const postWithRequestId = (requestId: string) => interceptorErrorRoute().POST(
1139
+ new Request('http://x/api/example/todos/command', {
1140
+ method: 'POST',
1141
+ body: JSON.stringify({ title: 'A' }),
1142
+ headers: { 'content-type': 'application/json', 'x-request-id': requestId },
1143
+ }),
1144
+ )
1145
+
1146
+ beforeEach(() => {
1147
+ logRecords.length = 0
1148
+ reportError.mockClear()
1149
+ registerLoggerExtension({ emit: (record) => logRecords.push(record) })
1150
+ registerTelemetryRuntime({
1151
+ canUseGlobalTracePropagation: () => false,
1152
+ captureTraceContext: () => ({}),
1153
+ continueTrace: (_carrier, _name, fn) => fn(),
1154
+ recordHttpDuration: () => {},
1155
+ reportError,
1156
+ shutdown: async () => {},
1157
+ } satisfies TelemetryRuntime)
1158
+ })
1159
+
1160
+ afterEach(() => {
1161
+ resetLoggerExtension()
1162
+ resetTelemetryRuntime()
1163
+ })
1164
+
1165
+ it('includes a requestId in the body that matches the server log line', async () => {
1166
+ commandBus.execute.mockRejectedValue(new Error('boom'))
1167
+
1168
+ const res = await postInterceptorErrorRequest(interceptorErrorRoute())
1169
+ const body = await res.json()
1170
+
1171
+ expect(res.status).toBe(500)
1172
+ expect(typeof body.requestId).toBe('string')
1173
+ expect(body.requestId.length).toBeGreaterThan(0)
1174
+
1175
+ const logRecord = logRecords.find((record) => record.message === 'Unexpected CRUD error')
1176
+ expect(logRecord?.fields.requestId).toBe(body.requestId)
1177
+ })
1178
+
1179
+ it('echoes the requestId on an x-request-id response header', async () => {
1180
+ commandBus.execute.mockRejectedValue(new Error('boom'))
1181
+
1182
+ const res = await postInterceptorErrorRequest(interceptorErrorRoute())
1183
+ const body = await res.json()
1184
+
1185
+ expect(res.headers.get('x-request-id')).toBe(body.requestId)
1186
+ })
1187
+
1188
+ it('reuses an inbound x-request-id header instead of generating a new one', async () => {
1189
+ commandBus.execute.mockRejectedValue(new Error('boom'))
1190
+
1191
+ const res = await postWithRequestId('req-fixed-123')
1192
+ const body = await res.json()
1193
+
1194
+ expect(res.status).toBe(500)
1195
+ expect(body.requestId).toBe('req-fixed-123')
1196
+ const logRecord = logRecords.find((record) => record.message === 'Unexpected CRUD error')
1197
+ expect(logRecord?.fields.requestId).toBe('req-fixed-123')
1198
+ })
1199
+
1200
+ // `Headers.get()` returns '' for an empty or whitespace-only header, which a plain
1201
+ // `?? randomUUID()` would hand straight through as a blank correlation id.
1202
+ it.each([
1203
+ ['an empty inbound header', ''],
1204
+ ['a whitespace-only inbound header', ' '],
1205
+ ])('generates a fresh id for %s', async (_label, inbound) => {
1206
+ commandBus.execute.mockRejectedValue(new Error('boom'))
1207
+
1208
+ const res = await postWithRequestId(inbound)
1209
+ const body = await res.json()
1210
+
1211
+ expect(res.status).toBe(500)
1212
+ expect(typeof body.requestId).toBe('string')
1213
+ expect(body.requestId.length).toBeGreaterThan(0)
1214
+ const logRecord = logRecords.find((record) => record.message === 'Unexpected CRUD error')
1215
+ expect(logRecord?.fields.requestId).toBe(body.requestId)
1216
+ })
1217
+
1218
+ // A caller-controlled id lands verbatim in the unquoted `key=value` log line, so an
1219
+ // over-long one or one carrying spaces/`=` is discarded rather than echoed.
1220
+ it.each([
1221
+ ['a value carrying log-field separators', 'a=1 tenantId=victim'],
1222
+ ['an over-long value', 'x'.repeat(129)],
1223
+ ])('discards %s in favor of a generated id', async (_label, inbound) => {
1224
+ commandBus.execute.mockRejectedValue(new Error('boom'))
1225
+
1226
+ const res = await postWithRequestId(inbound)
1227
+ const body = await res.json()
1228
+
1229
+ expect(res.status).toBe(500)
1230
+ expect(body.requestId).not.toBe(inbound)
1231
+ expect(body.requestId).toMatch(/^[A-Za-z0-9-]{36}$/)
1232
+ })
1233
+
1234
+ it('reports the error to telemetry with the same requestId', async () => {
1235
+ commandBus.execute.mockRejectedValue(new Error('boom'))
1236
+
1237
+ const res = await postWithRequestId('req-fixed-123')
1238
+ const body = await res.json()
1239
+
1240
+ expect(body.requestId).toBe('req-fixed-123')
1241
+ expect(reportError).toHaveBeenCalledTimes(1)
1242
+ expect(reportError).toHaveBeenCalledWith(
1243
+ expect.any(Error),
1244
+ { module: 'crud', attributes: { requestId: 'req-fixed-123', errorName: 'Error' } },
1245
+ )
1246
+ })
1247
+
1248
+ // The 503/422 branches deliberately stay outside this change (issue #5608) — lock that
1249
+ // in so a later refactor cannot quietly widen the correlation id across every branch.
1250
+ it('leaves the interceptor-rejection branch without a requestId', async () => {
1251
+ commandBus.execute.mockRejectedValue(
1252
+ new CommandInterceptorError('Missing required fields: VAT id', { status: 422 }),
1253
+ )
1254
+
1255
+ const res = await postWithRequestId('req-fixed-123')
1256
+
1257
+ expect(res.status).toBe(422)
1258
+ await expect(res.json()).resolves.toEqual({ error: 'Missing required fields: VAT id' })
1259
+ expect(res.headers.get('x-request-id')).toBeNull()
1117
1260
  })
1118
1261
  })
1119
1262
 
@@ -75,6 +75,8 @@ import { createGenericOptimisticLockReader } from './optimistic-lock'
75
75
  import { registerOptimisticLockReaderIfAbsent } from './optimistic-lock-store'
76
76
  import { createLogger } from '../logger'
77
77
  import { isTransientDbError } from '../db/pg-errors'
78
+ import { getTelemetryRuntime } from '../telemetry/runtime'
79
+ import { randomUUID } from 'node:crypto'
78
80
 
79
81
  type RbacServiceLike = {
80
82
  getGrantedFeatures: (userId: string, opts: { tenantId: string | null; organizationId: string | null }) => Promise<string[]>
@@ -594,7 +596,19 @@ function attachOperationHeader(res: Response, logEntry: any) {
594
596
  return res
595
597
  }
596
598
 
597
- function handleError(err: unknown): Response {
599
+ // An inbound `x-request-id` is caller-controlled, so it is only reused when it still
600
+ // looks like an id. `Headers.get()` yields '' for an empty or whitespace-only header —
601
+ // which `??` would not replace — and an unbounded value carrying spaces or `=` would
602
+ // forge fields in the unquoted `key=value` log line this id exists to be read from.
603
+ const INBOUND_REQUEST_ID_PATTERN = /^[A-Za-z0-9._-]{1,128}$/
604
+
605
+ function resolveRequestId(request?: Request): string {
606
+ const inbound = request?.headers.get('x-request-id')?.trim()
607
+ if (inbound && INBOUND_REQUEST_ID_PATTERN.test(inbound)) return inbound
608
+ return randomUUID()
609
+ }
610
+
611
+ function handleError(err: unknown, request?: Request): Response {
598
612
  if (err instanceof Response) return err
599
613
  if (isCrudHttpError(err)) return json(err.body, { status: err.status })
600
614
  // A command interceptor that blocked with an explicit status is a deliberate business
@@ -618,14 +632,25 @@ function handleError(err: unknown): Response {
618
632
  )
619
633
  }
620
634
 
635
+ // Unexpected exceptions still collapse into a generic 500 for the client (no internal
636
+ // detail leaked), but a requestId ties that response to this log line and to whatever
637
+ // reaches APM, so a client/support ticket citing it can be correlated with server-side
638
+ // detail (issue #5608).
621
639
  const message = err instanceof Error ? err.message : undefined
622
640
  const stack = err instanceof Error ? err.stack : undefined
623
- logger.error('Unexpected CRUD error', { message, stack, err })
641
+ const errorName = err instanceof Error ? err.name : undefined
642
+ const requestId = resolveRequestId(request)
643
+ logger.error('Unexpected CRUD error', { message, stack, err, requestId })
644
+ getTelemetryRuntime()?.reportError(err, {
645
+ module: 'crud',
646
+ attributes: { requestId, errorName },
647
+ })
624
648
  const body: Record<string, unknown> = {
625
649
  error: 'Internal server error',
626
650
  message: 'Something went wrong. Please try again later.',
651
+ requestId,
627
652
  }
628
- return json(body, { status: 500 })
653
+ return json(body, { status: 500, headers: { 'x-request-id': requestId } })
629
654
  }
630
655
 
631
656
  const LIFECYCLE_ACTION_MAP: Record<string, { before: string; after: string }> = {
@@ -2163,7 +2188,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
2163
2188
  return response
2164
2189
  } catch (e) {
2165
2190
  finishProfile({ result: 'error' })
2166
- return handleError(e)
2191
+ return handleError(e, request)
2167
2192
  }
2168
2193
  }
2169
2194
 
@@ -2477,7 +2502,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
2477
2502
  payload = await enrichSingleRecord(payload, ctx)
2478
2503
  return json(payload, { status: 201 })
2479
2504
  } catch (e) {
2480
- return handleError(e)
2505
+ return handleError(e, request)
2481
2506
  }
2482
2507
  }
2483
2508
 
@@ -2815,7 +2840,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
2815
2840
  }
2816
2841
  return json(payload)
2817
2842
  } catch (e) {
2818
- return handleError(e)
2843
+ return handleError(e, request)
2819
2844
  }
2820
2845
  }
2821
2846
 
@@ -3104,7 +3129,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
3104
3129
  }
3105
3130
  return json(payload)
3106
3131
  } catch (e) {
3107
- return handleError(e)
3132
+ return handleError(e, request)
3108
3133
  }
3109
3134
  }
3110
3135