@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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +12 -1
- package/dist/lib/auth/jwt.js +6 -0
- package/dist/lib/auth/jwt.js.map +2 -2
- package/dist/lib/auth/mfaPendingAccess.js +42 -0
- package/dist/lib/auth/mfaPendingAccess.js.map +7 -0
- package/dist/lib/auth/organizationAccess.js +7 -4
- package/dist/lib/auth/organizationAccess.js.map +2 -2
- package/dist/lib/auth/principal-service.js +1 -0
- package/dist/lib/auth/principal-service.js.map +7 -0
- package/dist/lib/auth/server.js +38 -7
- package/dist/lib/auth/server.js.map +2 -2
- package/dist/lib/commands/command-bus.js +6 -1
- package/dist/lib/commands/command-bus.js.map +2 -2
- package/dist/lib/crud/factory.js +29 -12
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/crud/ids.js +6 -3
- package/dist/lib/crud/ids.js.map +2 -2
- package/dist/lib/crud/query-params.js +33 -0
- package/dist/lib/crud/query-params.js.map +7 -0
- package/dist/lib/data/engine.js +8 -2
- package/dist/lib/data/engine.js.map +2 -2
- package/dist/lib/html/htmlToPlainText.js +16 -0
- package/dist/lib/html/htmlToPlainText.js.map +7 -0
- package/dist/lib/location/countries.js +12 -0
- package/dist/lib/location/countries.js.map +2 -2
- package/dist/lib/openapi/crud.js +4 -1
- package/dist/lib/openapi/crud.js.map +2 -2
- package/dist/lib/query/count-cap.js +11 -0
- package/dist/lib/query/count-cap.js.map +7 -0
- package/dist/lib/query/engine.js +270 -34
- package/dist/lib/query/engine.js.map +3 -3
- package/dist/lib/query/types.js.map +1 -1
- package/dist/lib/queue/dispatchOrigin.js +20 -0
- package/dist/lib/queue/dispatchOrigin.js.map +7 -0
- package/dist/lib/search/config.js +1 -0
- package/dist/lib/search/config.js.map +2 -2
- package/dist/lib/search/entityAccess.js +44 -0
- package/dist/lib/search/entityAccess.js.map +7 -0
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/modules/events/factory.js +69 -15
- package/dist/modules/events/factory.js.map +2 -2
- package/dist/modules/registry.js +15 -0
- package/dist/modules/registry.js.map +2 -2
- package/dist/modules/widgets/component-registry.js.map +2 -2
- package/package.json +10 -3
- package/src/lib/auth/__tests__/jwt.test.ts +13 -0
- package/src/lib/auth/__tests__/mfaPendingAccess.test.ts +69 -0
- package/src/lib/auth/__tests__/organizationAccess.test.ts +36 -1
- package/src/lib/auth/__tests__/principalServiceExport.test.ts +67 -0
- package/src/lib/auth/__tests__/server.apiKeyCache.test.ts +324 -0
- package/src/lib/auth/__tests__/server.test.ts +104 -0
- package/src/lib/auth/jwt.ts +17 -0
- package/src/lib/auth/mfaPendingAccess.ts +70 -0
- package/src/lib/auth/organizationAccess.ts +11 -3
- package/src/lib/auth/principal-service.ts +110 -0
- package/src/lib/auth/server.ts +78 -8
- package/src/lib/commands/__tests__/command-bus.test.ts +31 -0
- package/src/lib/commands/command-bus.ts +8 -1
- package/src/lib/crud/__tests__/crud-factory.test.ts +236 -0
- package/src/lib/crud/__tests__/ids.test.ts +29 -0
- package/src/lib/crud/__tests__/query-params.test.ts +98 -0
- package/src/lib/crud/factory.ts +38 -11
- package/src/lib/crud/ids.ts +11 -8
- package/src/lib/crud/query-params.ts +75 -0
- package/src/lib/data/__tests__/engine.event-validation.test.ts +9 -1
- package/src/lib/data/engine.ts +7 -1
- package/src/lib/html/__tests__/htmlToPlainText.test.ts +59 -0
- package/src/lib/html/htmlToPlainText.ts +17 -0
- package/src/lib/location/__tests__/countries.test.ts +15 -0
- package/src/lib/location/countries.ts +17 -0
- package/src/lib/openapi/crud.ts +3 -0
- package/src/lib/query/__tests__/count-cap-plan.test.ts +240 -0
- package/src/lib/query/__tests__/count-cap.test.ts +41 -0
- package/src/lib/query/__tests__/engine.count-distinct.test.ts +162 -15
- package/src/lib/query/__tests__/engine.scope-and-or.test.ts +11 -1
- package/src/lib/query/__tests__/engine.test.ts +445 -7
- package/src/lib/query/count-cap.ts +19 -0
- package/src/lib/query/engine.ts +434 -54
- package/src/lib/query/types.ts +15 -0
- package/src/lib/queue/dispatchOrigin.ts +35 -0
- package/src/lib/search/config.ts +10 -0
- package/src/lib/search/entityAccess.ts +132 -0
- package/src/modules/events/__tests__/factory.test.ts +88 -0
- package/src/modules/events/factory.ts +111 -19
- package/src/modules/events/types.ts +17 -0
- package/src/modules/registry.ts +40 -0
- 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
|
+
})
|
package/src/lib/crud/factory.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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:
|
|
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' ?
|
|
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
|
|
package/src/lib/crud/ids.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
|
54
|
-
* of whether any value survived UUID
|
|
55
|
-
*
|
|
56
|
-
* malformed" — the latter must match
|
|
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
|
-
|
|
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
|
+
}
|