@open-mercato/shared 0.6.7-develop.6600.1.49a5df927f → 0.6.7-develop.6604.1.2c49816dff

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.
@@ -38,8 +38,16 @@ function parseIdsParam(raw, maxIds = MAX_IDS_PER_REQUEST) {
38
38
  const parsed = normalizeIdList(raw.split(","));
39
39
  return parsed.slice(0, safeMax);
40
40
  }
41
- function mergeIdFilter(existingFilters, parsedIds) {
42
- if (parsedIds.length === 0) return existingFilters;
41
+ function isIdsParamProvided(raw) {
42
+ return typeof raw === "string" && raw.trim().length > 0;
43
+ }
44
+ function mergeIdFilter(existingFilters, parsedIds, options) {
45
+ if (parsedIds.length === 0) {
46
+ if (options?.idsParamProvided) {
47
+ return { ...existingFilters, id: { $in: [] } };
48
+ }
49
+ return existingFilters;
50
+ }
43
51
  const existingFilter = existingFilters.id;
44
52
  const existingIds = readExistingIds(existingFilter);
45
53
  if (!existingIds) {
@@ -57,6 +65,7 @@ function mergeIdFilter(existingFilters, parsedIds) {
57
65
  }
58
66
  export {
59
67
  MAX_IDS_PER_REQUEST,
68
+ isIdsParamProvided,
60
69
  mergeIdFilter,
61
70
  parseIdsParam
62
71
  };
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/crud/ids.ts"],
4
- "sourcesContent": ["import type { Where } from '@open-mercato/shared/lib/query/types'\n\nexport const MAX_IDS_PER_REQUEST = 200\n\nconst UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i\n\nfunction isUuid(value: unknown): value is string {\n return typeof value === 'string' && UUID_REGEX.test(value)\n}\n\nfunction normalizeIdList(values: string[]): string[] {\n if (values.length === 0) return values\n const deduped = new Set<string>()\n for (const value of values) {\n const trimmed = value.trim()\n if (!trimmed || !isUuid(trimmed)) continue\n deduped.add(trimmed)\n }\n return Array.from(deduped)\n}\n\nfunction readExistingIds(filter: unknown): string[] | null {\n if (typeof filter === 'string') {\n return isUuid(filter) ? [filter] : null\n }\n if (Array.isArray(filter)) {\n return normalizeIdList(\n filter.filter((value): value is string => typeof value === 'string'),\n )\n }\n if (!filter || typeof filter !== 'object') return null\n\n const operators = filter as Record<string, unknown>\n if (isUuid(operators.$eq)) return [operators.$eq]\n\n if (Array.isArray(operators.$in)) {\n return normalizeIdList(\n operators.$in.filter((value): value is string => typeof value === 'string'),\n )\n }\n\n return null\n}\n\nexport function parseIdsParam(raw: unknown, maxIds: number = MAX_IDS_PER_REQUEST): string[] {\n if (typeof raw !== 'string' || raw.trim().length === 0) return []\n const safeMax = Number.isFinite(maxIds) && maxIds > 0 ? Math.floor(maxIds) : MAX_IDS_PER_REQUEST\n const parsed = normalizeIdList(raw.split(','))\n return parsed.slice(0, safeMax)\n}\n\nexport function mergeIdFilter<Fields extends Record<string, unknown>>(\n existingFilters: Where<Fields>,\n parsedIds: string[],\n): Where<Fields> {\n if (parsedIds.length === 0) return existingFilters\n\n const existingFilter = (existingFilters as Record<string, unknown>).id\n const existingIds = readExistingIds(existingFilter)\n\n if (!existingIds) {\n // No existing narrowing \u2014 safe to install the user-supplied `$in`.\n if (existingFilter === undefined || existingFilter === null) {\n return { ...existingFilters, id: { $in: parsedIds } }\n }\n // Existing `id` filter is in a shape we do not recognise. Fail closed:\n // preserve the existing filter instead of widening to `parsedIds`. Adding\n // a new recognised shape to `readExistingIds` is preferable to silently\n // dropping a narrowing that another caller put in place.\n return existingFilters\n }\n\n const allowed = new Set(parsedIds)\n const intersection = existingIds.filter((id) => allowed.has(id))\n return {\n ...existingFilters,\n id: { $in: intersection },\n }\n}\n"],
5
- "mappings": "AAEO,MAAM,sBAAsB;AAEnC,MAAM,aAAa;AAEnB,SAAS,OAAO,OAAiC;AAC/C,SAAO,OAAO,UAAU,YAAY,WAAW,KAAK,KAAK;AAC3D;AAEA,SAAS,gBAAgB,QAA4B;AACnD,MAAI,OAAO,WAAW,EAAG,QAAO;AAChC,QAAM,UAAU,oBAAI,IAAY;AAChC,aAAW,SAAS,QAAQ;AAC1B,UAAM,UAAU,MAAM,KAAK;AAC3B,QAAI,CAAC,WAAW,CAAC,OAAO,OAAO,EAAG;AAClC,YAAQ,IAAI,OAAO;AAAA,EACrB;AACA,SAAO,MAAM,KAAK,OAAO;AAC3B;AAEA,SAAS,gBAAgB,QAAkC;AACzD,MAAI,OAAO,WAAW,UAAU;AAC9B,WAAO,OAAO,MAAM,IAAI,CAAC,MAAM,IAAI;AAAA,EACrC;AACA,MAAI,MAAM,QAAQ,MAAM,GAAG;AACzB,WAAO;AAAA,MACL,OAAO,OAAO,CAAC,UAA2B,OAAO,UAAU,QAAQ;AAAA,IACrE;AAAA,EACF;AACA,MAAI,CAAC,UAAU,OAAO,WAAW,SAAU,QAAO;AAElD,QAAM,YAAY;AAClB,MAAI,OAAO,UAAU,GAAG,EAAG,QAAO,CAAC,UAAU,GAAG;AAEhD,MAAI,MAAM,QAAQ,UAAU,GAAG,GAAG;AAChC,WAAO;AAAA,MACL,UAAU,IAAI,OAAO,CAAC,UAA2B,OAAO,UAAU,QAAQ;AAAA,IAC5E;AAAA,EACF;AAEA,SAAO;AACT;AAEO,SAAS,cAAc,KAAc,SAAiB,qBAA+B;AAC1F,MAAI,OAAO,QAAQ,YAAY,IAAI,KAAK,EAAE,WAAW,EAAG,QAAO,CAAC;AAChE,QAAM,UAAU,OAAO,SAAS,MAAM,KAAK,SAAS,IAAI,KAAK,MAAM,MAAM,IAAI;AAC7E,QAAM,SAAS,gBAAgB,IAAI,MAAM,GAAG,CAAC;AAC7C,SAAO,OAAO,MAAM,GAAG,OAAO;AAChC;AAEO,SAAS,cACd,iBACA,WACe;AACf,MAAI,UAAU,WAAW,EAAG,QAAO;AAEnC,QAAM,iBAAkB,gBAA4C;AACpE,QAAM,cAAc,gBAAgB,cAAc;AAElD,MAAI,CAAC,aAAa;AAEhB,QAAI,mBAAmB,UAAa,mBAAmB,MAAM;AAC3D,aAAO,EAAE,GAAG,iBAAiB,IAAI,EAAE,KAAK,UAAU,EAAE;AAAA,IACtD;AAKA,WAAO;AAAA,EACT;AAEA,QAAM,UAAU,IAAI,IAAI,SAAS;AACjC,QAAM,eAAe,YAAY,OAAO,CAAC,OAAO,QAAQ,IAAI,EAAE,CAAC;AAC/D,SAAO;AAAA,IACL,GAAG;AAAA,IACH,IAAI,EAAE,KAAK,aAAa;AAAA,EAC1B;AACF;",
4
+ "sourcesContent": ["import type { Where } from '@open-mercato/shared/lib/query/types'\n\nexport const MAX_IDS_PER_REQUEST = 200\n\nconst UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i\n\nfunction isUuid(value: unknown): value is string {\n return typeof value === 'string' && UUID_REGEX.test(value)\n}\n\nfunction normalizeIdList(values: string[]): string[] {\n if (values.length === 0) return values\n const deduped = new Set<string>()\n for (const value of values) {\n const trimmed = value.trim()\n if (!trimmed || !isUuid(trimmed)) continue\n deduped.add(trimmed)\n }\n return Array.from(deduped)\n}\n\nfunction readExistingIds(filter: unknown): string[] | null {\n if (typeof filter === 'string') {\n return isUuid(filter) ? [filter] : null\n }\n if (Array.isArray(filter)) {\n return normalizeIdList(\n filter.filter((value): value is string => typeof value === 'string'),\n )\n }\n if (!filter || typeof filter !== 'object') return null\n\n const operators = filter as Record<string, unknown>\n if (isUuid(operators.$eq)) return [operators.$eq]\n\n if (Array.isArray(operators.$in)) {\n return normalizeIdList(\n operators.$in.filter((value): value is string => typeof value === 'string'),\n )\n }\n\n return null\n}\n\nexport function parseIdsParam(raw: unknown, maxIds: number = MAX_IDS_PER_REQUEST): string[] {\n if (typeof raw !== 'string' || raw.trim().length === 0) return []\n const safeMax = Number.isFinite(maxIds) && maxIds > 0 ? Math.floor(maxIds) : MAX_IDS_PER_REQUEST\n const parsed = normalizeIdList(raw.split(','))\n return parsed.slice(0, safeMax)\n}\n\n/**\n * Whether an `?ids=` param was supplied at all (a non-empty string), regardless\n * of whether any value survived UUID validation. Lets a caller tell \"no ids\n * filter requested\" apart from \"ids filter requested but every value was\n * malformed\" \u2014 the latter must match nothing, not fall back to the full list\n * (#4143 Finding 3).\n */\nexport function isIdsParamProvided(raw: unknown): boolean {\n return typeof raw === 'string' && raw.trim().length > 0\n}\n\nexport function mergeIdFilter<Fields extends Record<string, unknown>>(\n existingFilters: Where<Fields>,\n parsedIds: string[],\n options?: { idsParamProvided?: boolean },\n): Where<Fields> {\n if (parsedIds.length === 0) {\n // The `?ids=` param was supplied but nothing survived UUID validation.\n // Match nothing, mirroring a valid-but-unknown id returning zero rows,\n // rather than silently dropping the filter and returning the full list\n // (record-count side channel \u2014 #4143 Finding 3).\n if (options?.idsParamProvided) {\n return { ...existingFilters, id: { $in: [] } }\n }\n return existingFilters\n }\n\n const existingFilter = (existingFilters as Record<string, unknown>).id\n const existingIds = readExistingIds(existingFilter)\n\n if (!existingIds) {\n // No existing narrowing \u2014 safe to install the user-supplied `$in`.\n if (existingFilter === undefined || existingFilter === null) {\n return { ...existingFilters, id: { $in: parsedIds } }\n }\n // Existing `id` filter is in a shape we do not recognise. Fail closed:\n // preserve the existing filter instead of widening to `parsedIds`. Adding\n // a new recognised shape to `readExistingIds` is preferable to silently\n // dropping a narrowing that another caller put in place.\n return existingFilters\n }\n\n const allowed = new Set(parsedIds)\n const intersection = existingIds.filter((id) => allowed.has(id))\n return {\n ...existingFilters,\n id: { $in: intersection },\n }\n}\n"],
5
+ "mappings": "AAEO,MAAM,sBAAsB;AAEnC,MAAM,aAAa;AAEnB,SAAS,OAAO,OAAiC;AAC/C,SAAO,OAAO,UAAU,YAAY,WAAW,KAAK,KAAK;AAC3D;AAEA,SAAS,gBAAgB,QAA4B;AACnD,MAAI,OAAO,WAAW,EAAG,QAAO;AAChC,QAAM,UAAU,oBAAI,IAAY;AAChC,aAAW,SAAS,QAAQ;AAC1B,UAAM,UAAU,MAAM,KAAK;AAC3B,QAAI,CAAC,WAAW,CAAC,OAAO,OAAO,EAAG;AAClC,YAAQ,IAAI,OAAO;AAAA,EACrB;AACA,SAAO,MAAM,KAAK,OAAO;AAC3B;AAEA,SAAS,gBAAgB,QAAkC;AACzD,MAAI,OAAO,WAAW,UAAU;AAC9B,WAAO,OAAO,MAAM,IAAI,CAAC,MAAM,IAAI;AAAA,EACrC;AACA,MAAI,MAAM,QAAQ,MAAM,GAAG;AACzB,WAAO;AAAA,MACL,OAAO,OAAO,CAAC,UAA2B,OAAO,UAAU,QAAQ;AAAA,IACrE;AAAA,EACF;AACA,MAAI,CAAC,UAAU,OAAO,WAAW,SAAU,QAAO;AAElD,QAAM,YAAY;AAClB,MAAI,OAAO,UAAU,GAAG,EAAG,QAAO,CAAC,UAAU,GAAG;AAEhD,MAAI,MAAM,QAAQ,UAAU,GAAG,GAAG;AAChC,WAAO;AAAA,MACL,UAAU,IAAI,OAAO,CAAC,UAA2B,OAAO,UAAU,QAAQ;AAAA,IAC5E;AAAA,EACF;AAEA,SAAO;AACT;AAEO,SAAS,cAAc,KAAc,SAAiB,qBAA+B;AAC1F,MAAI,OAAO,QAAQ,YAAY,IAAI,KAAK,EAAE,WAAW,EAAG,QAAO,CAAC;AAChE,QAAM,UAAU,OAAO,SAAS,MAAM,KAAK,SAAS,IAAI,KAAK,MAAM,MAAM,IAAI;AAC7E,QAAM,SAAS,gBAAgB,IAAI,MAAM,GAAG,CAAC;AAC7C,SAAO,OAAO,MAAM,GAAG,OAAO;AAChC;AASO,SAAS,mBAAmB,KAAuB;AACxD,SAAO,OAAO,QAAQ,YAAY,IAAI,KAAK,EAAE,SAAS;AACxD;AAEO,SAAS,cACd,iBACA,WACA,SACe;AACf,MAAI,UAAU,WAAW,GAAG;AAK1B,QAAI,SAAS,kBAAkB;AAC7B,aAAO,EAAE,GAAG,iBAAiB,IAAI,EAAE,KAAK,CAAC,EAAE,EAAE;AAAA,IAC/C;AACA,WAAO;AAAA,EACT;AAEA,QAAM,iBAAkB,gBAA4C;AACpE,QAAM,cAAc,gBAAgB,cAAc;AAElD,MAAI,CAAC,aAAa;AAEhB,QAAI,mBAAmB,UAAa,mBAAmB,MAAM;AAC3D,aAAO,EAAE,GAAG,iBAAiB,IAAI,EAAE,KAAK,UAAU,EAAE;AAAA,IACtD;AAKA,WAAO;AAAA,EACT;AAEA,QAAM,UAAU,IAAI,IAAI,SAAS;AACjC,QAAM,eAAe,YAAY,OAAO,CAAC,OAAO,QAAQ,IAAI,EAAE,CAAC;AAC/D,SAAO;AAAA,IACL,GAAG;AAAA,IACH,IAAI,EAAE,KAAK,aAAa;AAAA,EAC1B;AACF;",
6
6
  "names": []
7
7
  }
@@ -1,4 +1,4 @@
1
- const APP_VERSION = "0.6.7-develop.6600.1.49a5df927f";
1
+ const APP_VERSION = "0.6.7-develop.6604.1.2c49816dff";
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.7-develop.6600.1.49a5df927f'\nexport const appVersion = APP_VERSION\n"],
4
+ "sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.7-develop.6604.1.2c49816dff'\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.7-develop.6600.1.49a5df927f",
3
+ "version": "0.6.7-develop.6604.1.2c49816dff",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -97,7 +97,7 @@
97
97
  "@mikro-orm/core": "^7.1.5",
98
98
  "@mikro-orm/decorators": "^7.1.5",
99
99
  "@mikro-orm/postgresql": "^7.1.5",
100
- "@open-mercato/cache": "0.6.7-develop.6600.1.49a5df927f",
100
+ "@open-mercato/cache": "0.6.7-develop.6604.1.2c49816dff",
101
101
  "@types/sanitize-html": "^2.16.1",
102
102
  "dotenv": "^17.4.2",
103
103
  "pino": "^10.3.1",
@@ -2,7 +2,7 @@ jest.mock('@open-mercato/cache', () => ({
2
2
  runWithCacheTenant: async <T>(_tenant: string | null, fn: () => Promise<T> | T) => fn(),
3
3
  }))
4
4
 
5
- import { deriveResourceFromCommandId } from '@open-mercato/shared/lib/crud/cache'
5
+ import { deriveResourceFromCommandId, invalidateCrudCache } from '@open-mercato/shared/lib/crud/cache'
6
6
 
7
7
  describe('deriveResourceFromCommandId — irregular plurals (#2072)', () => {
8
8
  it('singularises "people" to "person"', () => {
@@ -62,3 +62,55 @@ describe('deriveResourceFromCommandId — irregular plurals (#2072)', () => {
62
62
  expect(deriveResourceFromCommandId('singletonly')).toBeNull()
63
63
  })
64
64
  })
65
+
66
+ describe('invalidateCrudCache — tenant-level (org:null) collection flush (#2919)', () => {
67
+ const ORIGINAL_FLAG = process.env.ENABLE_CRUD_API_CACHE
68
+
69
+ beforeAll(() => {
70
+ process.env.ENABLE_CRUD_API_CACHE = 'true'
71
+ })
72
+
73
+ afterAll(() => {
74
+ if (ORIGINAL_FLAG === undefined) delete process.env.ENABLE_CRUD_API_CACHE
75
+ else process.env.ENABLE_CRUD_API_CACHE = ORIGINAL_FLAG
76
+ })
77
+
78
+ function makeContainer(deleteByTags: jest.Mock) {
79
+ const cache = { get: jest.fn(), set: jest.fn(), deleteByTags }
80
+ return { resolve: (name: string) => (name === 'cache' ? cache : null) } as never
81
+ }
82
+
83
+ it('flushes the org:null collection tag even when the write carries an actor org', async () => {
84
+ // Roles (orgField: null) cache their list under org:null, but the command bus
85
+ // resolves a write's organizationId from the actor's auth context. Without the
86
+ // org:null tag the cache would only expire on TTL — see TC-UNDO-001 / #2919.
87
+ const deleteByTags = jest.fn(async () => 0)
88
+ await invalidateCrudCache(
89
+ makeContainer(deleteByTags),
90
+ 'auth.role',
91
+ { id: 'role-1', organizationId: 'org-9', tenantId: 'tenant-1' },
92
+ 'tenant-1',
93
+ 'test:org-mismatch',
94
+ )
95
+
96
+ expect(deleteByTags).toHaveBeenCalledTimes(1)
97
+ const tags = deleteByTags.mock.calls[0][0] as string[]
98
+ expect(tags).toContain('crud:auth.role:tenant:tenant-1:org:null:collection')
99
+ expect(tags).toContain('crud:auth.role:tenant:tenant-1:org:org-9:collection')
100
+ expect(tags).toContain('crud:auth.role:tenant:tenant-1:record:role-1')
101
+ })
102
+
103
+ it('still flushes the org:null collection tag for org-agnostic writes', async () => {
104
+ const deleteByTags = jest.fn(async () => 0)
105
+ await invalidateCrudCache(
106
+ makeContainer(deleteByTags),
107
+ 'auth.role',
108
+ { id: 'role-2', organizationId: null, tenantId: 'tenant-1' },
109
+ 'tenant-1',
110
+ 'test:org-null',
111
+ )
112
+
113
+ const tags = deleteByTags.mock.calls[0][0] as string[]
114
+ expect(tags).toContain('crud:auth.role:tenant:tenant-1:org:null:collection')
115
+ })
116
+ })
@@ -1,4 +1,4 @@
1
- import { MAX_IDS_PER_REQUEST, mergeIdFilter, parseIdsParam } from '@open-mercato/shared/lib/crud/ids'
1
+ import { MAX_IDS_PER_REQUEST, mergeIdFilter, parseIdsParam, isIdsParamProvided } from '@open-mercato/shared/lib/crud/ids'
2
2
 
3
3
  describe('crud ids helpers', () => {
4
4
  const idA = '550e8400-e29b-41d4-a716-446655440001'
@@ -109,4 +109,34 @@ describe('crud ids helpers', () => {
109
109
  id: { $in: [idA] },
110
110
  })
111
111
  })
112
+
113
+ // #4143 Finding 3: `?ids=<garbage>` parsed to [] and silently dropped the
114
+ // filter, returning the full list — a record-count side channel. A supplied
115
+ // but fully-invalid ids param must match nothing, like a valid-unknown UUID.
116
+ it('isIdsParamProvided distinguishes a supplied param from an absent one', () => {
117
+ expect(isIdsParamProvided('not-a-uuid')).toBe(true)
118
+ expect(isIdsParamProvided(`${idA}`)).toBe(true)
119
+ expect(isIdsParamProvided('')).toBe(false)
120
+ expect(isIdsParamProvided(' ')).toBe(false)
121
+ expect(isIdsParamProvided(undefined)).toBe(false)
122
+ expect(isIdsParamProvided(null)).toBe(false)
123
+ })
124
+
125
+ it('mergeIdFilter matches nothing when ids param was provided but all invalid', () => {
126
+ // Malformed input: parseIdsParam yields [], but the param WAS provided.
127
+ expect(mergeIdFilter({}, parseIdsParam('not-a-uuid'), { idsParamProvided: true })).toEqual({
128
+ id: { $in: [] },
129
+ })
130
+ // Preserves any existing filters alongside the match-nothing id clause.
131
+ expect(
132
+ mergeIdFilter({ tenantId: 't1' } as any, [], { idsParamProvided: true }),
133
+ ).toEqual({ tenantId: 't1', id: { $in: [] } })
134
+ })
135
+
136
+ it('mergeIdFilter still no-ops on empty ids when the param was absent (BC)', () => {
137
+ expect(mergeIdFilter({ tenantId: 't1' } as any, [])).toEqual({ tenantId: 't1' })
138
+ expect(mergeIdFilter({ tenantId: 't1' } as any, [], { idsParamProvided: false })).toEqual({
139
+ tenantId: 't1',
140
+ })
141
+ })
112
142
  })
@@ -191,11 +191,14 @@ export async function invalidateCrudCache(
191
191
  if (recordId) {
192
192
  tags.add(buildRecordTag(key, tenantId, recordId))
193
193
  }
194
- const organizationIds: Array<string | null> = []
195
- if (identifiers.organizationId !== undefined) {
196
- organizationIds.push(identifiers.organizationId ?? null)
197
- }
198
- if (!organizationIds.length) organizationIds.push(null)
194
+ // Always flush the tenant-level (org:null) collection tag alongside the
195
+ // write's own org. Tenant-scoped entities (orgField: null — e.g. auth roles)
196
+ // cache their collections under org:null, but the command bus resolves a
197
+ // write's organizationId from the actor's auth context, so the org-specific
198
+ // tag alone never matches those entries and they only expire on TTL (#2919).
199
+ const organizationIds: Array<string | null> = [null]
200
+ const recordOrganizationId = identifiers.organizationId ?? null
201
+ if (recordOrganizationId) organizationIds.push(recordOrganizationId)
199
202
  for (const tag of buildCollectionTags(key, tenantId, organizationIds)) {
200
203
  tags.add(tag)
201
204
  }
@@ -66,7 +66,7 @@ import { applyResponseEnrichers, applyResponseEnricherToRecord, resolveListCache
66
66
  import type { EnricherContext } from './response-enricher'
67
67
  import type { ApiInterceptorMethod, InterceptorRequest, InterceptorResponse } from './api-interceptor'
68
68
  import { runApiInterceptorsAfter, runApiInterceptorsBefore } from './interceptor-runner'
69
- import { mergeIdFilter, parseIdsParam } from './ids'
69
+ import { mergeIdFilter, parseIdsParam, isIdsParamProvided } from './ids'
70
70
  import { mergeAdvancedFilters } from './advanced-filter-integration'
71
71
  import { parseExtensionHeaders } from '../umes/extension-headers'
72
72
  import { createGenericOptimisticLockReader } from './optimistic-lock'
@@ -1445,6 +1445,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1445
1445
  ...(interceptorRequest.query ?? {}),
1446
1446
  } as Record<string, unknown>
1447
1447
  const parsedIds = parseIdsParam(queryParams.ids)
1448
+ const idsParamProvided = isIdsParamProvided(queryParams.ids)
1448
1449
 
1449
1450
  await opts.hooks?.beforeList?.(validated as any, ctx)
1450
1451
  profiler.mark('before_list_hook')
@@ -1664,7 +1665,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1664
1665
  const filters = exportFullRequested
1665
1666
  ? baseFilters
1666
1667
  : mergeAdvancedFilters(baseFilters as Record<string, unknown>, validated as Record<string, unknown>) as Where<any>
1667
- const mergedFilters = exportFullRequested ? filters : mergeIdFilter(filters, parsedIds)
1668
+ const mergedFilters = exportFullRequested ? filters : mergeIdFilter(filters, parsedIds, { idsParamProvided })
1668
1669
  const withDeleted = parseBooleanToken((queryParams as any).withDeleted) === true
1669
1670
  profiler.mark('filters_ready', { withDeleted })
1670
1671
  if (
@@ -1951,7 +1952,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1951
1952
  : mergeAdvancedFilters(fallbackBaseFilters as Record<string, unknown>, validated as Record<string, unknown>) as Where<any>
1952
1953
  const mergedFallbackFilters = exportFullRequested
1953
1954
  ? fallbackFilters
1954
- : mergeIdFilter(fallbackFilters, parsedIds)
1955
+ : mergeIdFilter(fallbackFilters, parsedIds, { idsParamProvided })
1955
1956
  const ormFilters = translateFiltersForOrm(
1956
1957
  mergedFallbackFilters as Record<string, any>,
1957
1958
  em,
@@ -49,11 +49,32 @@ export function parseIdsParam(raw: unknown, maxIds: number = MAX_IDS_PER_REQUEST
49
49
  return parsed.slice(0, safeMax)
50
50
  }
51
51
 
52
+ /**
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).
58
+ */
59
+ export function isIdsParamProvided(raw: unknown): boolean {
60
+ return typeof raw === 'string' && raw.trim().length > 0
61
+ }
62
+
52
63
  export function mergeIdFilter<Fields extends Record<string, unknown>>(
53
64
  existingFilters: Where<Fields>,
54
65
  parsedIds: string[],
66
+ options?: { idsParamProvided?: boolean },
55
67
  ): Where<Fields> {
56
- if (parsedIds.length === 0) return existingFilters
68
+ if (parsedIds.length === 0) {
69
+ // The `?ids=` param was supplied but nothing survived UUID validation.
70
+ // Match nothing, mirroring a valid-but-unknown id returning zero rows,
71
+ // rather than silently dropping the filter and returning the full list
72
+ // (record-count side channel — #4143 Finding 3).
73
+ if (options?.idsParamProvided) {
74
+ return { ...existingFilters, id: { $in: [] } }
75
+ }
76
+ return existingFilters
77
+ }
57
78
 
58
79
  const existingFilter = (existingFilters as Record<string, unknown>).id
59
80
  const existingIds = readExistingIds(existingFilter)