@open-mercato/shared 0.7.0 → 0.7.1-develop.7103.1.41ff100d93

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +12 -1
  3. package/dist/lib/auth/jwt.js +6 -0
  4. package/dist/lib/auth/jwt.js.map +2 -2
  5. package/dist/lib/auth/mfaPendingAccess.js +42 -0
  6. package/dist/lib/auth/mfaPendingAccess.js.map +7 -0
  7. package/dist/lib/auth/organizationAccess.js +7 -4
  8. package/dist/lib/auth/organizationAccess.js.map +2 -2
  9. package/dist/lib/auth/principal-service.js +1 -0
  10. package/dist/lib/auth/principal-service.js.map +7 -0
  11. package/dist/lib/auth/server.js +38 -7
  12. package/dist/lib/auth/server.js.map +2 -2
  13. package/dist/lib/commands/command-bus.js +6 -1
  14. package/dist/lib/commands/command-bus.js.map +2 -2
  15. package/dist/lib/crud/factory.js +29 -12
  16. package/dist/lib/crud/factory.js.map +2 -2
  17. package/dist/lib/crud/ids.js +6 -3
  18. package/dist/lib/crud/ids.js.map +2 -2
  19. package/dist/lib/crud/query-params.js +33 -0
  20. package/dist/lib/crud/query-params.js.map +7 -0
  21. package/dist/lib/data/engine.js +8 -2
  22. package/dist/lib/data/engine.js.map +2 -2
  23. package/dist/lib/html/htmlToPlainText.js +16 -0
  24. package/dist/lib/html/htmlToPlainText.js.map +7 -0
  25. package/dist/lib/location/countries.js +12 -0
  26. package/dist/lib/location/countries.js.map +2 -2
  27. package/dist/lib/openapi/crud.js +4 -1
  28. package/dist/lib/openapi/crud.js.map +2 -2
  29. package/dist/lib/query/count-cap.js +11 -0
  30. package/dist/lib/query/count-cap.js.map +7 -0
  31. package/dist/lib/query/engine.js +270 -34
  32. package/dist/lib/query/engine.js.map +3 -3
  33. package/dist/lib/query/types.js.map +1 -1
  34. package/dist/lib/queue/dispatchOrigin.js +20 -0
  35. package/dist/lib/queue/dispatchOrigin.js.map +7 -0
  36. package/dist/lib/search/config.js +1 -0
  37. package/dist/lib/search/config.js.map +2 -2
  38. package/dist/lib/search/entityAccess.js +44 -0
  39. package/dist/lib/search/entityAccess.js.map +7 -0
  40. package/dist/lib/version.js +1 -1
  41. package/dist/lib/version.js.map +1 -1
  42. package/dist/modules/events/factory.js +69 -15
  43. package/dist/modules/events/factory.js.map +2 -2
  44. package/dist/modules/registry.js +15 -0
  45. package/dist/modules/registry.js.map +2 -2
  46. package/dist/modules/widgets/component-registry.js.map +2 -2
  47. package/package.json +10 -3
  48. package/src/lib/auth/__tests__/jwt.test.ts +13 -0
  49. package/src/lib/auth/__tests__/mfaPendingAccess.test.ts +69 -0
  50. package/src/lib/auth/__tests__/organizationAccess.test.ts +36 -1
  51. package/src/lib/auth/__tests__/principalServiceExport.test.ts +67 -0
  52. package/src/lib/auth/__tests__/server.apiKeyCache.test.ts +324 -0
  53. package/src/lib/auth/__tests__/server.test.ts +104 -0
  54. package/src/lib/auth/jwt.ts +17 -0
  55. package/src/lib/auth/mfaPendingAccess.ts +70 -0
  56. package/src/lib/auth/organizationAccess.ts +11 -3
  57. package/src/lib/auth/principal-service.ts +110 -0
  58. package/src/lib/auth/server.ts +78 -8
  59. package/src/lib/commands/__tests__/command-bus.test.ts +31 -0
  60. package/src/lib/commands/command-bus.ts +8 -1
  61. package/src/lib/crud/__tests__/crud-factory.test.ts +236 -0
  62. package/src/lib/crud/__tests__/ids.test.ts +29 -0
  63. package/src/lib/crud/__tests__/query-params.test.ts +98 -0
  64. package/src/lib/crud/factory.ts +38 -11
  65. package/src/lib/crud/ids.ts +11 -8
  66. package/src/lib/crud/query-params.ts +75 -0
  67. package/src/lib/data/__tests__/engine.event-validation.test.ts +9 -1
  68. package/src/lib/data/engine.ts +7 -1
  69. package/src/lib/html/__tests__/htmlToPlainText.test.ts +59 -0
  70. package/src/lib/html/htmlToPlainText.ts +17 -0
  71. package/src/lib/location/__tests__/countries.test.ts +15 -0
  72. package/src/lib/location/countries.ts +17 -0
  73. package/src/lib/openapi/crud.ts +3 -0
  74. package/src/lib/query/__tests__/count-cap-plan.test.ts +240 -0
  75. package/src/lib/query/__tests__/count-cap.test.ts +41 -0
  76. package/src/lib/query/__tests__/engine.count-distinct.test.ts +162 -15
  77. package/src/lib/query/__tests__/engine.scope-and-or.test.ts +11 -1
  78. package/src/lib/query/__tests__/engine.test.ts +445 -7
  79. package/src/lib/query/count-cap.ts +19 -0
  80. package/src/lib/query/engine.ts +434 -54
  81. package/src/lib/query/types.ts +15 -0
  82. package/src/lib/queue/dispatchOrigin.ts +35 -0
  83. package/src/lib/search/config.ts +10 -0
  84. package/src/lib/search/entityAccess.ts +132 -0
  85. package/src/modules/events/__tests__/factory.test.ts +88 -0
  86. package/src/modules/events/factory.ts +111 -19
  87. package/src/modules/events/types.ts +17 -0
  88. package/src/modules/registry.ts +40 -0
  89. package/src/modules/widgets/component-registry.ts +14 -0
@@ -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
@@ -268,6 +278,28 @@ describe('CRUD Factory', () => {
268
278
  }))
269
279
  })
270
280
 
281
+ it('GET spreads totalIsCapped only when the engine reports a capped count', async () => {
282
+ queryEngine.query.mockResolvedValueOnce({
283
+ items: [{ id: 'id-1', title: 'A', is_done: false }],
284
+ total: 10_000,
285
+ page: 1,
286
+ pageSize: 10,
287
+ meta: { listCountCapWarning: { entity: 'example.todo', cap: 10_000 } },
288
+ })
289
+ const res = await route.GET(new Request('http://x/api/example/todos?page=1&pageSize=10&sortField=id&sortDir=asc'))
290
+ expect(res.status).toBe(200)
291
+ const body = await res.json()
292
+ expect(body.total).toBe(10_000)
293
+ expect(body.totalIsCapped).toBe(true)
294
+ expect(body.meta.listCountCapWarning).toEqual({ entity: 'example.todo', cap: 10_000 })
295
+ })
296
+
297
+ it('GET omits totalIsCapped entirely for exact totals', async () => {
298
+ const res = await route.GET(new Request('http://x/api/example/todos?page=1&pageSize=10&sortField=id&sortDir=asc'))
299
+ const body = await res.json()
300
+ expect('totalIsCapped' in body).toBe(false)
301
+ })
302
+
271
303
  const makeDecoratedRoute = () => makeCrudRoute({
272
304
  metadata: { GET: { requireAuth: true } },
273
305
  orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
@@ -338,6 +370,77 @@ describe('CRUD Factory', () => {
338
370
  })
339
371
  })
340
372
 
373
+ describe('repeated query parameters (#5548)', () => {
374
+ const makeFilterRoute = () => {
375
+ const seen: { status?: string | string[]; search?: string | string[] }[] = []
376
+ const route = makeCrudRoute({
377
+ metadata: { GET: { requireAuth: true } },
378
+ orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
379
+ indexer: { entityType: 'example.todo' },
380
+ list: {
381
+ schema: querySchema.extend({
382
+ status: z.union([z.string(), z.array(z.string())]).optional(),
383
+ search: z.string().optional(),
384
+ }),
385
+ entityId: 'example.todo',
386
+ fields: ['id', 'title'],
387
+ buildFilters: (query) => {
388
+ seen.push({ status: (query as any).status, search: (query as any).search })
389
+ return {} as any
390
+ },
391
+ },
392
+ })
393
+ return { route, seen }
394
+ }
395
+
396
+ it('hands the list schema every value of a repeated key', async () => {
397
+ const { route, seen } = makeFilterRoute()
398
+ await route.GET(new Request('http://x/api/example/todos?status=win&status=loose'))
399
+ expect(seen.at(-1)?.status).toEqual(['win', 'loose'])
400
+ })
401
+
402
+ it('still hands a plain string to a key that occurs once', async () => {
403
+ const { route, seen } = makeFilterRoute()
404
+ await route.GET(new Request('http://x/api/example/todos?status=win'))
405
+ expect(seen.at(-1)?.status).toBe('win')
406
+ })
407
+
408
+ it('leaves a comma-bearing scalar untouched so free-text filters survive', async () => {
409
+ const { route, seen } = makeFilterRoute()
410
+ await route.GET(new Request(`http://x/api/example/todos?search=${encodeURIComponent('Smith, John')}&status=win`))
411
+ expect(seen.at(-1)?.search).toBe('Smith, John')
412
+ expect(seen.at(-1)?.status).toBe('win')
413
+ })
414
+
415
+ it('rejects a repeated occurrence of a single-valued param with 400 instead of silently keeping one value', async () => {
416
+ const { route, seen } = makeFilterRoute()
417
+ const res = await route.GET(new Request('http://x/api/example/todos?search=Smith&search=John'))
418
+ expect(res.status).toBe(400)
419
+ const body = await res.json()
420
+ expect(body.error).toBe('Invalid input')
421
+ expect(
422
+ (body.details as { path: (string | number)[] }[]).some((issue) => issue.path.includes('search')),
423
+ ).toBe(true)
424
+ expect(seen).toHaveLength(0)
425
+ })
426
+
427
+ it('resolves each ordering of the same repeated filter to the values that ordering sent', async () => {
428
+ const { route, seen } = makeFilterRoute()
429
+ await route.GET(new Request('http://x/api/example/todos?status=win&status=loose'))
430
+ await route.GET(new Request('http://x/api/example/todos?status=loose&status=win'))
431
+ expect(seen.at(-2)?.status).toEqual(['win', 'loose'])
432
+ expect(seen.at(-1)?.status).toEqual(['loose', 'win'])
433
+ })
434
+
435
+ it('keeps a repeated ids filter instead of dropping it entirely', async () => {
436
+ const idA = '550e8400-e29b-41d4-a716-446655440001'
437
+ const idB = '550e8400-e29b-41d4-a716-446655440002'
438
+ await route.GET(new Request(`http://x/api/example/todos?ids=${idA}&ids=${idB}`))
439
+ const queryArgs = queryEngine.query.mock.calls.at(-1)?.[1]
440
+ expect(queryArgs?.filters).toEqual({ id: { $in: [idA, idB] } })
441
+ })
442
+ })
443
+
341
444
  it('GET resolves a function-form list.fields projection per request (#2233)', async () => {
342
445
  const fieldsResolver = jest.fn((query: any) =>
343
446
  query?.id ? ['id', 'title', 'is_done', 'snapshot'] : ['id', 'title'],
@@ -1054,6 +1157,7 @@ describe('CRUD Factory', () => {
1054
1157
  await expect(res.json()).resolves.toEqual({
1055
1158
  error: 'Internal server error',
1056
1159
  message: 'Something went wrong. Please try again later.',
1160
+ requestId: expect.any(String),
1057
1161
  })
1058
1162
  })
1059
1163
 
@@ -1092,6 +1196,138 @@ describe('CRUD Factory', () => {
1092
1196
  await expect(res.json()).resolves.toEqual({
1093
1197
  error: 'Internal server error',
1094
1198
  message: 'Something went wrong. Please try again later.',
1199
+ requestId: expect.any(String),
1200
+ })
1201
+ })
1202
+
1203
+ // Issue #5608 — a generic 500 must carry a requestId the client/support can cite, and
1204
+ // that same id must appear on the server log line so the two can be correlated.
1205
+ describe('generic 500 requestId correlation', () => {
1206
+ const logRecords: LoggerExtensionRecord[] = []
1207
+ const reportError = jest.fn()
1208
+
1209
+ const postWithRequestId = (requestId: string) => interceptorErrorRoute().POST(
1210
+ new Request('http://x/api/example/todos/command', {
1211
+ method: 'POST',
1212
+ body: JSON.stringify({ title: 'A' }),
1213
+ headers: { 'content-type': 'application/json', 'x-request-id': requestId },
1214
+ }),
1215
+ )
1216
+
1217
+ beforeEach(() => {
1218
+ logRecords.length = 0
1219
+ reportError.mockClear()
1220
+ registerLoggerExtension({ emit: (record) => logRecords.push(record) })
1221
+ registerTelemetryRuntime({
1222
+ canUseGlobalTracePropagation: () => false,
1223
+ captureTraceContext: () => ({}),
1224
+ continueTrace: (_carrier, _name, fn) => fn(),
1225
+ recordHttpDuration: () => {},
1226
+ reportError,
1227
+ shutdown: async () => {},
1228
+ } satisfies TelemetryRuntime)
1229
+ })
1230
+
1231
+ afterEach(() => {
1232
+ resetLoggerExtension()
1233
+ resetTelemetryRuntime()
1234
+ })
1235
+
1236
+ it('includes a requestId in the body that matches the server log line', async () => {
1237
+ commandBus.execute.mockRejectedValue(new Error('boom'))
1238
+
1239
+ const res = await postInterceptorErrorRequest(interceptorErrorRoute())
1240
+ const body = await res.json()
1241
+
1242
+ expect(res.status).toBe(500)
1243
+ expect(typeof body.requestId).toBe('string')
1244
+ expect(body.requestId.length).toBeGreaterThan(0)
1245
+
1246
+ const logRecord = logRecords.find((record) => record.message === 'Unexpected CRUD error')
1247
+ expect(logRecord?.fields.requestId).toBe(body.requestId)
1248
+ })
1249
+
1250
+ it('echoes the requestId on an x-request-id response header', async () => {
1251
+ commandBus.execute.mockRejectedValue(new Error('boom'))
1252
+
1253
+ const res = await postInterceptorErrorRequest(interceptorErrorRoute())
1254
+ const body = await res.json()
1255
+
1256
+ expect(res.headers.get('x-request-id')).toBe(body.requestId)
1257
+ })
1258
+
1259
+ it('reuses an inbound x-request-id header instead of generating a new one', async () => {
1260
+ commandBus.execute.mockRejectedValue(new Error('boom'))
1261
+
1262
+ const res = await postWithRequestId('req-fixed-123')
1263
+ const body = await res.json()
1264
+
1265
+ expect(res.status).toBe(500)
1266
+ expect(body.requestId).toBe('req-fixed-123')
1267
+ const logRecord = logRecords.find((record) => record.message === 'Unexpected CRUD error')
1268
+ expect(logRecord?.fields.requestId).toBe('req-fixed-123')
1269
+ })
1270
+
1271
+ // `Headers.get()` returns '' for an empty or whitespace-only header, which a plain
1272
+ // `?? randomUUID()` would hand straight through as a blank correlation id.
1273
+ it.each([
1274
+ ['an empty inbound header', ''],
1275
+ ['a whitespace-only inbound header', ' '],
1276
+ ])('generates a fresh id for %s', async (_label, inbound) => {
1277
+ commandBus.execute.mockRejectedValue(new Error('boom'))
1278
+
1279
+ const res = await postWithRequestId(inbound)
1280
+ const body = await res.json()
1281
+
1282
+ expect(res.status).toBe(500)
1283
+ expect(typeof body.requestId).toBe('string')
1284
+ expect(body.requestId.length).toBeGreaterThan(0)
1285
+ const logRecord = logRecords.find((record) => record.message === 'Unexpected CRUD error')
1286
+ expect(logRecord?.fields.requestId).toBe(body.requestId)
1287
+ })
1288
+
1289
+ // A caller-controlled id lands verbatim in the unquoted `key=value` log line, so an
1290
+ // over-long one or one carrying spaces/`=` is discarded rather than echoed.
1291
+ it.each([
1292
+ ['a value carrying log-field separators', 'a=1 tenantId=victim'],
1293
+ ['an over-long value', 'x'.repeat(129)],
1294
+ ])('discards %s in favor of a generated id', async (_label, inbound) => {
1295
+ commandBus.execute.mockRejectedValue(new Error('boom'))
1296
+
1297
+ const res = await postWithRequestId(inbound)
1298
+ const body = await res.json()
1299
+
1300
+ expect(res.status).toBe(500)
1301
+ expect(body.requestId).not.toBe(inbound)
1302
+ expect(body.requestId).toMatch(/^[A-Za-z0-9-]{36}$/)
1303
+ })
1304
+
1305
+ it('reports the error to telemetry with the same requestId', async () => {
1306
+ commandBus.execute.mockRejectedValue(new Error('boom'))
1307
+
1308
+ const res = await postWithRequestId('req-fixed-123')
1309
+ const body = await res.json()
1310
+
1311
+ expect(body.requestId).toBe('req-fixed-123')
1312
+ expect(reportError).toHaveBeenCalledTimes(1)
1313
+ expect(reportError).toHaveBeenCalledWith(
1314
+ expect.any(Error),
1315
+ { module: 'crud', attributes: { requestId: 'req-fixed-123', errorName: 'Error' } },
1316
+ )
1317
+ })
1318
+
1319
+ // The 503/422 branches deliberately stay outside this change (issue #5608) — lock that
1320
+ // in so a later refactor cannot quietly widen the correlation id across every branch.
1321
+ it('leaves the interceptor-rejection branch without a requestId', async () => {
1322
+ commandBus.execute.mockRejectedValue(
1323
+ new CommandInterceptorError('Missing required fields: VAT id', { status: 422 }),
1324
+ )
1325
+
1326
+ const res = await postWithRequestId('req-fixed-123')
1327
+
1328
+ expect(res.status).toBe(422)
1329
+ await expect(res.json()).resolves.toEqual({ error: 'Missing required fields: VAT id' })
1330
+ expect(res.headers.get('x-request-id')).toBeNull()
1095
1331
  })
1096
1332
  })
1097
1333
 
@@ -122,6 +122,35 @@ describe('crud ids helpers', () => {
122
122
  expect(isIdsParamProvided(null)).toBe(false)
123
123
  })
124
124
 
125
+ // #5548: once the factory groups repeated params, `?ids=a&ids=b` reaches these
126
+ // helpers as an array. Treating that as "not supplied" would silently drop the
127
+ // filter and return the full list — the same side channel #4143 closed.
128
+ it('parseIdsParam accepts the repeated-parameter form', () => {
129
+ expect(parseIdsParam([idA, idB])).toEqual([idA, idB])
130
+ expect(parseIdsParam([`${idA},${idB}`, idC])).toEqual([idA, idB, idC])
131
+ expect(parseIdsParam([idA, idA])).toEqual([idA])
132
+ expect(parseIdsParam([idA, idB, idC], 2)).toEqual([idA, idB])
133
+ expect(parseIdsParam([])).toEqual([])
134
+ expect(parseIdsParam(['invalid', 'also-invalid'])).toEqual([])
135
+ })
136
+
137
+ it('isIdsParamProvided recognizes the repeated-parameter form', () => {
138
+ expect(isIdsParamProvided([idA, idB])).toBe(true)
139
+ expect(isIdsParamProvided(['not-a-uuid'])).toBe(true)
140
+ expect(isIdsParamProvided([])).toBe(false)
141
+ expect(isIdsParamProvided(['', ' '])).toBe(false)
142
+ })
143
+
144
+ // "Supplied" is about the raw occurrence, not about what survives parsing:
145
+ // `?ids=,,,` carries no usable value but was still requested, so it must match
146
+ // nothing rather than fall back to the unfiltered list.
147
+ it('isIdsParamProvided treats a value that parses to nothing as still supplied', () => {
148
+ expect(isIdsParamProvided(',,,')).toBe(true)
149
+ expect(parseIdsParam(',,,')).toEqual([])
150
+ expect(isIdsParamProvided([',', ','])).toBe(true)
151
+ expect(parseIdsParam([',', ','])).toEqual([])
152
+ })
153
+
125
154
  it('mergeIdFilter matches nothing when ids param was provided but all invalid', () => {
126
155
  // Malformed input: parseIdsParam yields [], but the param WAS provided.
127
156
  expect(mergeIdFilter({}, parseIdsParam('not-a-uuid'), { idsParamProvided: true })).toEqual({
@@ -0,0 +1,98 @@
1
+ import { buildQueryParams, readQueryParamList, toQueryValueList } from '@open-mercato/shared/lib/crud/query-params'
2
+
3
+ describe('buildQueryParams', () => {
4
+ it('keeps a key that occurs once as a plain string', () => {
5
+ const params = new URLSearchParams('status=win&page=2')
6
+ expect(buildQueryParams(params)).toEqual({ status: 'win', page: '2' })
7
+ })
8
+
9
+ it('keeps every value of a repeated key instead of the last one (#5548)', () => {
10
+ const params = new URLSearchParams('status=win&status=loose')
11
+ expect(buildQueryParams(params)).toEqual({ status: ['win', 'loose'] })
12
+ })
13
+
14
+ it('preserves the order the values were supplied in', () => {
15
+ expect(buildQueryParams(new URLSearchParams('status=loose&status=win'))).toEqual({
16
+ status: ['loose', 'win'],
17
+ })
18
+ })
19
+
20
+ it('does not split a single value on commas, so comma contracts stay intact', () => {
21
+ const params = new URLSearchParams('ids=a,b&search=Smith, John')
22
+ expect(buildQueryParams(params)).toEqual({ ids: 'a,b', search: 'Smith, John' })
23
+ })
24
+
25
+ it('returns an empty object for an empty query string', () => {
26
+ expect(buildQueryParams(new URLSearchParams(''))).toEqual({})
27
+ })
28
+
29
+ it('keeps an empty repeated value so the caller can decide what it means', () => {
30
+ expect(buildQueryParams(new URLSearchParams('status=&status=win'))).toEqual({
31
+ status: ['', 'win'],
32
+ })
33
+ })
34
+
35
+ // A plain `out[key] = value` assignment runs the `__proto__` setter, which
36
+ // would swap the returned object's prototype for the array and drop the key.
37
+ // `Object.fromEntries` defines own data properties, matching what the parse
38
+ // site did before this change.
39
+ it('carries a __proto__ key as an own property instead of touching the prototype', () => {
40
+ const repeated = buildQueryParams(new URLSearchParams('__proto__=a&__proto__=b'))
41
+ expect(Object.getPrototypeOf(repeated)).toBe(Object.prototype)
42
+ expect(Object.prototype.hasOwnProperty.call(repeated, '__proto__')).toBe(true)
43
+ expect(Object.getOwnPropertyDescriptor(repeated, '__proto__')?.value).toEqual(['a', 'b'])
44
+
45
+ const single = buildQueryParams(new URLSearchParams('__proto__=a'))
46
+ expect(Object.getPrototypeOf(single)).toBe(Object.prototype)
47
+ expect(Object.getOwnPropertyDescriptor(single, '__proto__')?.value).toBe('a')
48
+ })
49
+
50
+ it('rejects the Object.fromEntries shape this replaced', () => {
51
+ // Regression guard: reverting the parse site to
52
+ // `Object.fromEntries(url.searchParams.entries())` makes this fail.
53
+ const params = new URLSearchParams('status=win&status=loose')
54
+ expect(buildQueryParams(params)).not.toEqual(Object.fromEntries(params.entries()))
55
+ })
56
+ })
57
+
58
+ describe('toQueryValueList', () => {
59
+ it('turns a single string into a one-entry list', () => {
60
+ expect(toQueryValueList('win')).toEqual(['win'])
61
+ })
62
+
63
+ it('splits the comma form', () => {
64
+ expect(toQueryValueList('win,loose')).toEqual(['win', 'loose'])
65
+ })
66
+
67
+ it('flattens repeated values', () => {
68
+ expect(toQueryValueList(['win', 'loose'])).toEqual(['win', 'loose'])
69
+ })
70
+
71
+ it('treats the mixed form as one flat list', () => {
72
+ expect(toQueryValueList(['a,b', 'c'])).toEqual(['a', 'b', 'c'])
73
+ })
74
+
75
+ it('trims entries and drops empty ones', () => {
76
+ expect(toQueryValueList([' win ', '', ' , ', 'loose'])).toEqual(['win', 'loose'])
77
+ })
78
+
79
+ it('ignores non-string input', () => {
80
+ expect(toQueryValueList(undefined)).toEqual([])
81
+ expect(toQueryValueList(null)).toEqual([])
82
+ expect(toQueryValueList(42)).toEqual([])
83
+ expect(toQueryValueList([1, 'win'])).toEqual(['win'])
84
+ })
85
+ })
86
+
87
+ describe('readQueryParamList', () => {
88
+ it('reads the repeated and the comma form as the same list', () => {
89
+ const repeated = new URLSearchParams('status=win&status=loose')
90
+ const comma = new URLSearchParams('status=win,loose')
91
+ expect(readQueryParamList(repeated, 'status')).toEqual(['win', 'loose'])
92
+ expect(readQueryParamList(comma, 'status')).toEqual(['win', 'loose'])
93
+ })
94
+
95
+ it('returns an empty list for a key that was not supplied', () => {
96
+ expect(readQueryParamList(new URLSearchParams('status=win'), 'ownerUserId')).toEqual([])
97
+ })
98
+ })
@@ -69,12 +69,15 @@ import type { EnricherContext } from './response-enricher'
69
69
  import type { ApiInterceptorMethod, InterceptorRequest, InterceptorResponse } from './api-interceptor'
70
70
  import { runApiInterceptorsAfter, runApiInterceptorsBefore } from './interceptor-runner'
71
71
  import { mergeIdFilter, parseIdsParam, isIdsParamProvided } from './ids'
72
+ import { buildQueryParams } from './query-params'
72
73
  import { mergeAdvancedFilters } from './advanced-filter-integration'
73
74
  import { parseExtensionHeaders } from '../umes/extension-headers'
74
75
  import { createGenericOptimisticLockReader } from './optimistic-lock'
75
76
  import { registerOptimisticLockReaderIfAbsent } from './optimistic-lock-store'
76
77
  import { createLogger } from '../logger'
77
78
  import { isTransientDbError } from '../db/pg-errors'
79
+ import { getTelemetryRuntime } from '../telemetry/runtime'
80
+ import { randomUUID } from 'node:crypto'
78
81
 
79
82
  type RbacServiceLike = {
80
83
  getGrantedFeatures: (userId: string, opts: { tenantId: string | null; organizationId: string | null }) => Promise<string[]>
@@ -594,7 +597,19 @@ function attachOperationHeader(res: Response, logEntry: any) {
594
597
  return res
595
598
  }
596
599
 
597
- function handleError(err: unknown): Response {
600
+ // An inbound `x-request-id` is caller-controlled, so it is only reused when it still
601
+ // looks like an id. `Headers.get()` yields '' for an empty or whitespace-only header —
602
+ // which `??` would not replace — and an unbounded value carrying spaces or `=` would
603
+ // forge fields in the unquoted `key=value` log line this id exists to be read from.
604
+ const INBOUND_REQUEST_ID_PATTERN = /^[A-Za-z0-9._-]{1,128}$/
605
+
606
+ function resolveRequestId(request?: Request): string {
607
+ const inbound = request?.headers.get('x-request-id')?.trim()
608
+ if (inbound && INBOUND_REQUEST_ID_PATTERN.test(inbound)) return inbound
609
+ return randomUUID()
610
+ }
611
+
612
+ function handleError(err: unknown, request?: Request): Response {
598
613
  if (err instanceof Response) return err
599
614
  if (isCrudHttpError(err)) return json(err.body, { status: err.status })
600
615
  // A command interceptor that blocked with an explicit status is a deliberate business
@@ -618,14 +633,25 @@ function handleError(err: unknown): Response {
618
633
  )
619
634
  }
620
635
 
636
+ // Unexpected exceptions still collapse into a generic 500 for the client (no internal
637
+ // detail leaked), but a requestId ties that response to this log line and to whatever
638
+ // reaches APM, so a client/support ticket citing it can be correlated with server-side
639
+ // detail (issue #5608).
621
640
  const message = err instanceof Error ? err.message : undefined
622
641
  const stack = err instanceof Error ? err.stack : undefined
623
- logger.error('Unexpected CRUD error', { message, stack, err })
642
+ const errorName = err instanceof Error ? err.name : undefined
643
+ const requestId = resolveRequestId(request)
644
+ logger.error('Unexpected CRUD error', { message, stack, err, requestId })
645
+ getTelemetryRuntime()?.reportError(err, {
646
+ module: 'crud',
647
+ attributes: { requestId, errorName },
648
+ })
624
649
  const body: Record<string, unknown> = {
625
650
  error: 'Internal server error',
626
651
  message: 'Something went wrong. Please try again later.',
652
+ requestId,
627
653
  }
628
- return json(body, { status: 500 })
654
+ return json(body, { status: 500, headers: { 'x-request-id': requestId } })
629
655
  }
630
656
 
631
657
  const LIFECYCLE_ACTION_MAP: Record<string, { before: string; after: string }> = {
@@ -1491,7 +1517,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1491
1517
  return json({ error: 'Not implemented' }, { status: 501 })
1492
1518
  }
1493
1519
  const url = new URL(request.url)
1494
- const rawQueryParams = Object.fromEntries(url.searchParams.entries())
1520
+ const rawQueryParams = buildQueryParams(url.searchParams)
1495
1521
  profiler.mark('query_parsed')
1496
1522
  let validated = opts.list.schema.parse(rawQueryParams)
1497
1523
  profiler.mark('query_validated')
@@ -1941,6 +1967,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1941
1967
  page: page.page || requestedPage,
1942
1968
  pageSize: page.pageSize || requestedPageSize,
1943
1969
  totalPages: Math.ceil(res.total / (Number(page.pageSize) || 1)),
1970
+ ...(res.meta?.listCountCapWarning ? { totalIsCapped: true } : {}),
1944
1971
  ...(res.meta ? { meta: res.meta } : {}),
1945
1972
  }
1946
1973
  await opts.hooks?.afterList?.(payload, { ...ctx, query: validated as any })
@@ -2162,7 +2189,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
2162
2189
  return response
2163
2190
  } catch (e) {
2164
2191
  finishProfile({ result: 'error' })
2165
- return handleError(e)
2192
+ return handleError(e, request)
2166
2193
  }
2167
2194
  }
2168
2195
 
@@ -2476,7 +2503,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
2476
2503
  payload = await enrichSingleRecord(payload, ctx)
2477
2504
  return json(payload, { status: 201 })
2478
2505
  } catch (e) {
2479
- return handleError(e)
2506
+ return handleError(e, request)
2480
2507
  }
2481
2508
  }
2482
2509
 
@@ -2814,7 +2841,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
2814
2841
  }
2815
2842
  return json(payload)
2816
2843
  } catch (e) {
2817
- return handleError(e)
2844
+ return handleError(e, request)
2818
2845
  }
2819
2846
  }
2820
2847
 
@@ -2844,7 +2871,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
2844
2871
  if (useCommand) {
2845
2872
  const action = opts.actions!.delete!
2846
2873
  const body = await request.json().catch(() => ({}))
2847
- const raw = { body, query: Object.fromEntries(url.searchParams.entries()) }
2874
+ const raw = { body, query: buildQueryParams(url.searchParams) }
2848
2875
  const parsed = action.schema ? action.schema.parse(raw) : raw
2849
2876
  const interceptorInput =
2850
2877
  parsed && typeof parsed === 'object' && (parsed as Record<string, unknown>).body && typeof (parsed as Record<string, unknown>).body === 'object'
@@ -2863,7 +2890,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
2863
2890
  const interceptedBody = interceptorRequestPayload.body ?? {}
2864
2891
  const reparsedRaw = {
2865
2892
  body: interceptedBody,
2866
- query: Object.fromEntries(url.searchParams.entries()),
2893
+ query: buildQueryParams(url.searchParams),
2867
2894
  }
2868
2895
  const reparsed = action.schema ? action.schema.parse(reparsedRaw) : reparsedRaw
2869
2896
  const input = action.mapInput ? await action.mapInput({ parsed: reparsed, raw: reparsedRaw, ctx }) : reparsed
@@ -2980,7 +3007,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
2980
3007
  request,
2981
3008
  method: 'DELETE',
2982
3009
  body: idFrom === 'query' ? undefined : ({ id } as Record<string, unknown>),
2983
- query: idFrom === 'query' ? Object.fromEntries(url.searchParams.entries()) : undefined,
3010
+ query: idFrom === 'query' ? buildQueryParams(url.searchParams) : undefined,
2984
3011
  })
2985
3012
  if (beforeInterceptors.errorResponse) return beforeInterceptors.errorResponse
2986
3013
  interceptorRequestPayload = beforeInterceptors.requestPayload
@@ -3103,7 +3130,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
3103
3130
  }
3104
3131
  return json(payload)
3105
3132
  } catch (e) {
3106
- return handleError(e)
3133
+ return handleError(e, request)
3107
3134
  }
3108
3135
  }
3109
3136
 
@@ -1,4 +1,5 @@
1
1
  import type { Where } from '@open-mercato/shared/lib/query/types'
2
+ import { toQueryValueList } from './query-params'
2
3
 
3
4
  export const MAX_IDS_PER_REQUEST = 200
4
5
 
@@ -43,21 +44,23 @@ function readExistingIds(filter: unknown): string[] | null {
43
44
  }
44
45
 
45
46
  export function parseIdsParam(raw: unknown, maxIds: number = MAX_IDS_PER_REQUEST): string[] {
46
- if (typeof raw !== 'string' || raw.trim().length === 0) return []
47
+ const values = toQueryValueList(raw)
48
+ if (values.length === 0) return []
47
49
  const safeMax = Number.isFinite(maxIds) && maxIds > 0 ? Math.floor(maxIds) : MAX_IDS_PER_REQUEST
48
- const parsed = normalizeIdList(raw.split(','))
50
+ const parsed = normalizeIdList(values)
49
51
  return parsed.slice(0, safeMax)
50
52
  }
51
53
 
52
54
  /**
53
- * Whether an `?ids=` param was supplied at all (a non-empty string), regardless
54
- * of whether any value survived UUID validation. Lets a caller tell "no ids
55
- * filter requested" apart from "ids filter requested but every value was
56
- * malformed" — the latter must match nothing, not fall back to the full list
57
- * (#4143 Finding 3).
55
+ * Whether an `?ids=` param was supplied at all (a non-empty string, or repeated
56
+ * `?ids=` occurrences), regardless of whether any value survived UUID
57
+ * validation. Lets a caller tell "no ids filter requested" apart from "ids
58
+ * filter requested but every value was malformed" — the latter must match
59
+ * nothing, not fall back to the full list (#4143 Finding 3).
58
60
  */
59
61
  export function isIdsParamProvided(raw: unknown): boolean {
60
- return typeof raw === 'string' && raw.trim().length > 0
62
+ const occurrences = Array.isArray(raw) ? raw : [raw]
63
+ return occurrences.some((value) => typeof value === 'string' && value.trim().length > 0)
61
64
  }
62
65
 
63
66
  export function mergeIdFilter<Fields extends Record<string, unknown>>(
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Query-string parsing helpers shared by every `makeCrudRoute` handler and by
3
+ * routes that read `URLSearchParams` directly.
4
+ *
5
+ * `Object.fromEntries(url.searchParams.entries())` keeps only the last value of
6
+ * a repeated key, so `?status=win&status=loose` reached route schemas as
7
+ * `'loose'` and every earlier selection was dropped before validation ran
8
+ * (#5548). `buildQueryParams` groups repeats instead.
9
+ */
10
+
11
+ import { parseCommaSeparatedList } from '@open-mercato/shared/lib/string'
12
+
13
+ export type QueryParamValue = string | string[]
14
+
15
+ /**
16
+ * Group a query string into a plain object, preserving repeated keys.
17
+ *
18
+ * A key that occurs once keeps its raw string value — that is what today's
19
+ * `z.string()` schemas expect and nothing about them has to change. A key that
20
+ * occurs two or more times becomes the array of its values, which is what a
21
+ * `z.array(z.string())` (or `z.union([z.string(), z.array(z.string())])`)
22
+ * branch has always advertised.
23
+ *
24
+ * Values are never split on commas here: `?ids=a,b` and `?search=foo,bar` carry
25
+ * comma semantics that belong to the individual route, not to the generic
26
+ * parser. Use `readQueryParamList` / `toQueryValueList` where a field's contract
27
+ * says a comma separates values.
28
+ *
29
+ * Repeated values are treated as a set by the list response cache: its key
30
+ * serializer sorts them, so `?k=a&k=b` and `?k=b&k=a` share one entry even
31
+ * though the schema now receives `['a','b']` and `['b','a']` respectively. Do
32
+ * not declare a repeated param whose order carries meaning.
33
+ *
34
+ * The result is assembled with `Object.fromEntries`, which defines own data
35
+ * properties. Assigning into an object literal instead would run the
36
+ * `__proto__` setter, so `?__proto__=a&__proto__=b` would replace the returned
37
+ * object's prototype and drop the key rather than carrying it to the schema.
38
+ */
39
+ export function buildQueryParams(searchParams: URLSearchParams): Record<string, QueryParamValue> {
40
+ const grouped = new Map<string, string[]>()
41
+ searchParams.forEach((value, key) => {
42
+ const existing = grouped.get(key)
43
+ if (existing) existing.push(value)
44
+ else grouped.set(key, [value])
45
+ })
46
+ return Object.fromEntries(
47
+ Array.from(grouped, ([key, values]): [string, QueryParamValue] => [
48
+ key,
49
+ values.length === 1 ? values[0] : values,
50
+ ]),
51
+ )
52
+ }
53
+
54
+ /**
55
+ * Normalize a raw query value — a single string, an array of repeated values,
56
+ * or nothing — into the list it stands for. Comma-separated and repeated forms
57
+ * are equivalent here, so `?k=a,b&k=c` yields `['a', 'b', 'c']`.
58
+ */
59
+ export function toQueryValueList(raw: unknown): string[] {
60
+ const candidates = Array.isArray(raw) ? raw : [raw]
61
+ const out: string[] = []
62
+ for (const candidate of candidates) {
63
+ if (typeof candidate !== 'string') continue
64
+ out.push(...parseCommaSeparatedList(candidate))
65
+ }
66
+ return out
67
+ }
68
+
69
+ /**
70
+ * Read every value supplied for `key`, accepting both the repeated
71
+ * (`?k=a&k=b`) and the comma-separated (`?k=a,b`) form.
72
+ */
73
+ export function readQueryParamList(searchParams: URLSearchParams, key: string): string[] {
74
+ return toQueryValueList(searchParams.getAll(key))
75
+ }