@open-mercato/shared 0.6.5-develop.5337.1.534b781eac → 0.6.5

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 (62) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +1 -1
  3. package/dist/lib/ai/llm-provider-registry.js.map +1 -1
  4. package/dist/lib/crud/custom-fields.js +23 -15
  5. package/dist/lib/crud/custom-fields.js.map +2 -2
  6. package/dist/lib/crud/factory.js.map +1 -1
  7. package/dist/lib/crud/optimistic-lock-command.js.map +1 -1
  8. package/dist/lib/crud/optimistic-lock-headers.js.map +1 -1
  9. package/dist/lib/crud/optimistic-lock-store.js.map +1 -1
  10. package/dist/lib/crud/optimistic-lock.js.map +1 -1
  11. package/dist/lib/data/engine.js +25 -1
  12. package/dist/lib/data/engine.js.map +2 -2
  13. package/dist/lib/db/buildIlikeTerm.js +17 -0
  14. package/dist/lib/db/buildIlikeTerm.js.map +7 -0
  15. package/dist/lib/db/mikro.js +38 -9
  16. package/dist/lib/db/mikro.js.map +2 -2
  17. package/dist/lib/di/container.js +1 -1
  18. package/dist/lib/di/container.js.map +1 -1
  19. package/dist/lib/encryption/kms.js +41 -6
  20. package/dist/lib/encryption/kms.js.map +2 -2
  21. package/dist/lib/query/advanced-filter-tree.js +5 -5
  22. package/dist/lib/query/advanced-filter-tree.js.map +2 -2
  23. package/dist/lib/query/advanced-filter.js +5 -5
  24. package/dist/lib/query/advanced-filter.js.map +2 -2
  25. package/dist/lib/query/engine.js +3 -1
  26. package/dist/lib/query/engine.js.map +2 -2
  27. package/dist/lib/query/types.js.map +1 -1
  28. package/dist/lib/version.js +1 -1
  29. package/dist/lib/version.js.map +1 -1
  30. package/dist/modules/overrides.js +1 -1
  31. package/dist/modules/overrides.js.map +1 -1
  32. package/dist/modules/search.js.map +1 -1
  33. package/package.json +4 -5
  34. package/src/lib/ai/llm-provider-registry.ts +1 -1
  35. package/src/lib/ai/llm-provider.ts +1 -1
  36. package/src/lib/crud/__tests__/custom-fields.test.ts +91 -0
  37. package/src/lib/crud/custom-fields.ts +30 -17
  38. package/src/lib/crud/factory.ts +1 -1
  39. package/src/lib/crud/optimistic-lock-command.ts +1 -1
  40. package/src/lib/crud/optimistic-lock-headers.ts +1 -1
  41. package/src/lib/crud/optimistic-lock-store.ts +1 -1
  42. package/src/lib/crud/optimistic-lock.ts +1 -1
  43. package/src/lib/data/__tests__/engine.custom-entity-storage-guard.test.ts +78 -0
  44. package/src/lib/data/engine.ts +40 -0
  45. package/src/lib/db/__tests__/buildIlikeTerm.test.ts +40 -0
  46. package/src/lib/db/__tests__/escapeLikePattern.test.ts +123 -0
  47. package/src/lib/db/__tests__/mikro.test.ts +82 -0
  48. package/src/lib/db/buildIlikeTerm.ts +16 -0
  49. package/src/lib/db/mikro.ts +55 -16
  50. package/src/lib/di/container.ts +1 -1
  51. package/src/lib/encryption/__tests__/kms.test.ts +80 -0
  52. package/src/lib/encryption/kms.ts +55 -7
  53. package/src/lib/query/__tests__/engine.count-distinct.test.ts +229 -0
  54. package/src/lib/query/advanced-filter-tree.ts +5 -5
  55. package/src/lib/query/advanced-filter.ts +5 -5
  56. package/src/lib/query/engine.ts +13 -2
  57. package/src/lib/query/types.ts +10 -0
  58. package/src/modules/__tests__/overrides.test.ts +1 -1
  59. package/src/modules/__tests__/route-overrides.test.ts +1 -1
  60. package/src/modules/navigation/backendChrome.ts +9 -0
  61. package/src/modules/overrides.ts +3 -3
  62. package/src/modules/search.ts +1 -1
@@ -243,6 +243,97 @@ describe('loadCustomFieldValues (encryption)', () => {
243
243
  })
244
244
  expect(values['rec-1'].cf_priority).toBe(42)
245
245
  })
246
+
247
+ it('decrypts rows concurrently rather than sequentially (regression: issue #2229)', async () => {
248
+ const deks: Record<string, string> = {
249
+ 'tenant-1': Buffer.alloc(32, 1).toString('base64'),
250
+ 'tenant-2': Buffer.alloc(32, 2).toString('base64'),
251
+ 'tenant-3': Buffer.alloc(32, 3).toString('base64'),
252
+ }
253
+ const rows = [
254
+ { recordId: 'rec-1', tenantId: 'tenant-1', plaintext: 'note-1' },
255
+ { recordId: 'rec-2', tenantId: 'tenant-2', plaintext: 'note-2' },
256
+ { recordId: 'rec-3', tenantId: 'tenant-3', plaintext: 'note-3' },
257
+ ].map((row) => ({
258
+ ...row,
259
+ valueText: encryptWithAesGcm(row.plaintext, deks[row.tenantId]).value,
260
+ }))
261
+ const em = {
262
+ find: jest.fn().mockImplementation((_, where) => {
263
+ if ((where as any).recordId) {
264
+ return Promise.resolve(
265
+ rows.map((row) => ({
266
+ recordId: row.recordId,
267
+ fieldKey: 'note',
268
+ organizationId: null,
269
+ tenantId: row.tenantId,
270
+ valueText: row.valueText,
271
+ valueMultiline: null,
272
+ valueInt: null,
273
+ valueFloat: null,
274
+ valueBool: null,
275
+ deletedAt: null,
276
+ })),
277
+ )
278
+ }
279
+ return Promise.resolve([
280
+ { key: 'note', entityId: 'demo:entity', organizationId: null, tenantId: null, kind: 'text', configJson: { encrypted: true }, isActive: true },
281
+ ])
282
+ }),
283
+ }
284
+ let inFlight = 0
285
+ let maxInFlight = 0
286
+ const mockService = {
287
+ isEnabled: () => true,
288
+ getDek: jest.fn(async (tenantId: string) => {
289
+ inFlight += 1
290
+ maxInFlight = Math.max(maxInFlight, inFlight)
291
+ await new Promise((resolve) => setTimeout(resolve, 5))
292
+ inFlight -= 1
293
+ return { key: deks[tenantId] }
294
+ }),
295
+ }
296
+ const values = await loadCustomFieldValues({
297
+ em: em as any,
298
+ entityId: 'demo:entity',
299
+ recordIds: ['rec-1', 'rec-2', 'rec-3'],
300
+ tenantIdByRecord: { 'rec-1': 'tenant-1', 'rec-2': 'tenant-2', 'rec-3': 'tenant-3' },
301
+ encryptionService: mockService as any,
302
+ })
303
+ expect(values['rec-1'].cf_note).toBe('note-1')
304
+ expect(values['rec-2'].cf_note).toBe('note-2')
305
+ expect(values['rec-3'].cf_note).toBe('note-3')
306
+ // Sequential await-in-loop would cap concurrency at 1; batching lifts it.
307
+ expect(maxInFlight).toBeGreaterThan(1)
308
+ })
309
+
310
+ it('preserves multi-value grouping order for encrypted fields (regression: issue #2229)', async () => {
311
+ const dek = Buffer.alloc(32, 2).toString('base64')
312
+ const first = encryptWithAesGcm('alpha', dek).value
313
+ const second = encryptWithAesGcm('beta', dek).value
314
+ const em = {
315
+ find: jest.fn().mockImplementation((_, where) => {
316
+ if ((where as any).recordId) {
317
+ return Promise.resolve([
318
+ { recordId: 'rec-1', fieldKey: 'tags', organizationId: null, tenantId: 'tenant-1', valueText: first, valueMultiline: null, valueInt: null, valueFloat: null, valueBool: null, deletedAt: null },
319
+ { recordId: 'rec-1', fieldKey: 'tags', organizationId: null, tenantId: 'tenant-1', valueText: second, valueMultiline: null, valueInt: null, valueFloat: null, valueBool: null, deletedAt: null },
320
+ ])
321
+ }
322
+ return Promise.resolve([
323
+ { key: 'tags', entityId: 'demo:entity', organizationId: null, tenantId: 'tenant-1', kind: 'text', configJson: { encrypted: true, multi: true }, isActive: true },
324
+ ])
325
+ }),
326
+ }
327
+ const mockService = { isEnabled: () => true, getDek: async () => ({ key: dek }) }
328
+ const values = await loadCustomFieldValues({
329
+ em: em as any,
330
+ entityId: 'demo:entity',
331
+ recordIds: ['rec-1'],
332
+ tenantIdByRecord: { 'rec-1': 'tenant-1' },
333
+ encryptionService: mockService as any,
334
+ })
335
+ expect(values['rec-1'].cf_tags).toEqual(['alpha', 'beta'])
336
+ })
246
337
  })
247
338
 
248
339
  describe('decorateRecordWithCustomFields', () => {
@@ -632,7 +632,7 @@ export async function loadCustomFieldValues(opts: {
632
632
  type Bucket = { orgId: string | null; tenantId: string | null; values: unknown[]; def?: CustomFieldDef | null; encrypted?: boolean }
633
633
  const buckets = new Map<string, Bucket>()
634
634
 
635
- for (const row of cfRows) {
635
+ const rowInfos = cfRows.map((row) => {
636
636
  const recordId = String(row.recordId)
637
637
  const key = String(row.fieldKey)
638
638
  const bucketKey = `${recordId}::${key}`
@@ -643,26 +643,39 @@ export async function loadCustomFieldValues(opts: {
643
643
  const def = pickDefinition(key, resolvedOrgId, resolvedTenantId)
644
644
  const encrypted = Boolean(def?.configJson && (def as any).configJson?.encrypted)
645
645
  const value = valueFromRow(row)
646
- const decrypted = encrypted
647
- ? await decryptCustomFieldValue(
648
- value,
649
- resolvedTenantId ?? tenantId ?? null,
650
- getEncryptionService(),
651
- encryptionCache,
652
- { kind: def?.kind ?? null },
653
- )
654
- : value
655
- const existing = buckets.get(bucketKey)
646
+ return { bucketKey, resolvedOrgId, resolvedTenantId, tenantId, def, encrypted, value }
647
+ })
648
+
649
+ // Decrypt every encrypted value concurrently so a list of N rows × M encrypted
650
+ // fields costs the slowest single decryption rather than the sum (issue #2229).
651
+ // The shared encryptionCache keeps DEK lookups deduped per tenant.
652
+ const decryptedValues = await Promise.all(
653
+ rowInfos.map((info) =>
654
+ info.encrypted
655
+ ? decryptCustomFieldValue(
656
+ info.value,
657
+ info.resolvedTenantId ?? info.tenantId ?? null,
658
+ getEncryptionService(),
659
+ encryptionCache,
660
+ { kind: info.def?.kind ?? null },
661
+ )
662
+ : info.value,
663
+ ),
664
+ )
665
+
666
+ rowInfos.forEach((info, index) => {
667
+ const decrypted = decryptedValues[index]
668
+ const existing = buckets.get(info.bucketKey)
656
669
  if (existing) {
657
- if (existing.orgId == null && resolvedOrgId) existing.orgId = resolvedOrgId
658
- if (existing.tenantId == null && resolvedTenantId) existing.tenantId = resolvedTenantId
659
- if (existing.def == null && def) existing.def = def
660
- existing.encrypted = existing.encrypted || encrypted
670
+ if (existing.orgId == null && info.resolvedOrgId) existing.orgId = info.resolvedOrgId
671
+ if (existing.tenantId == null && info.resolvedTenantId) existing.tenantId = info.resolvedTenantId
672
+ if (existing.def == null && info.def) existing.def = info.def
673
+ existing.encrypted = existing.encrypted || info.encrypted
661
674
  existing.values.push(decrypted)
662
675
  } else {
663
- buckets.set(bucketKey, { orgId: resolvedOrgId, tenantId: resolvedTenantId, values: [decrypted], def: def ?? null, encrypted })
676
+ buckets.set(info.bucketKey, { orgId: info.resolvedOrgId, tenantId: info.resolvedTenantId, values: [decrypted], def: info.def ?? null, encrypted: info.encrypted })
664
677
  }
665
- }
678
+ })
666
679
 
667
680
  const result: Record<string, Record<string, unknown>> = {}
668
681
  for (const [compoundKey, bucket] of buckets.entries()) {
@@ -944,7 +944,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
944
944
 
945
945
  // OSS opt-in optimistic locking — auto-register a generic reader for every
946
946
  // CRUD entity using the factory's own ORM config (Step 13.3 of the spec at
947
- // .ai/specs/2026-05-25-oss-optimistic-locking.md). Hand-wired readers
947
+ // .ai/specs/implemented/2026-05-25-oss-optimistic-locking.md). Hand-wired readers
948
948
  // registered earlier via module DI (customers/sales) always win because we
949
949
  // use the `IfAbsent` variant. Skipped silently when the route has no
950
950
  // resolvable resourceKind or no ORM entity class (e.g. virtual routes).
@@ -24,7 +24,7 @@
24
24
  * header keep working. Respects the same `OM_OPTIMISTIC_LOCK` env contract
25
25
  * (default ON; `off` disables; allow-list scopes by `resourceKind`).
26
26
  *
27
- * Spec: .ai/specs/2026-05-25-oss-optimistic-locking.md (§ command-level checks)
27
+ * Spec: .ai/specs/implemented/2026-05-25-oss-optimistic-locking.md (§ command-level checks)
28
28
  * .ai/specs/2026-05-28-optimistic-locking-coverage-completion.md (Phase 4)
29
29
  */
30
30
  import { CrudHttpError } from './errors'
@@ -8,7 +8,7 @@
8
8
  * many HTTP intermediaries (nginx, some fetch implementations) strip
9
9
  * underscored header names — see RFC 7230 §3.2.6.
10
10
  *
11
- * Spec: .ai/specs/2026-05-25-oss-optimistic-locking.md §3.2
11
+ * Spec: .ai/specs/implemented/2026-05-25-oss-optimistic-locking.md §3.2
12
12
  */
13
13
  export const OPTIMISTIC_LOCK_MODULE_ID = 'optimistic_lock'
14
14
 
@@ -12,7 +12,7 @@
12
12
  *
13
13
  * Mirrors the `mutation-guard-store.ts` HMR-safe globalThis pattern.
14
14
  *
15
- * Spec: .ai/specs/2026-05-25-oss-optimistic-locking.md
15
+ * Spec: .ai/specs/implemented/2026-05-25-oss-optimistic-locking.md
16
16
  */
17
17
  import type { OptimisticLockCurrentReader } from './optimistic-lock'
18
18
 
@@ -28,7 +28,7 @@
28
28
  * container / em access. Stateful checks that need to read current DB state
29
29
  * MUST go through the DI service path (this file).
30
30
  *
31
- * Spec: .ai/specs/2026-05-25-oss-optimistic-locking.md
31
+ * Spec: .ai/specs/implemented/2026-05-25-oss-optimistic-locking.md
32
32
  */
33
33
  import type { EntityManager } from '@mikro-orm/postgresql'
34
34
  import type {
@@ -0,0 +1,78 @@
1
+ import { DefaultDataEngine, assertCustomEntityStorageEntityId } from '../engine'
2
+ import { isCrudHttpError } from '../../crud/errors'
3
+ import { registerEntityIds } from '../../encryption/entityIds'
4
+
5
+ function buildEm(classTables: Record<string, string>): any {
6
+ return {
7
+ getKysely: () => {
8
+ throw new Error('[internal] storage must not be touched for rejected entity ids')
9
+ },
10
+ getMetadata: () => ({
11
+ find: (className: string) => (classTables[className] ? { tableName: classTables[className] } : undefined),
12
+ getAll: () => Object.values(classTables).map((tableName) => ({ tableName })),
13
+ }),
14
+ }
15
+ }
16
+
17
+ function expectSystemEntityRejection(err: unknown) {
18
+ expect(isCrudHttpError(err)).toBe(true)
19
+ const httpError = err as { status: number; body: { code?: string } }
20
+ expect(httpError.status).toBe(400)
21
+ expect(httpError.body.code).toBe('system_entity_records_blocked')
22
+ }
23
+
24
+ describe('custom-entity storage guard (#2939 hardening)', () => {
25
+ beforeEach(() => {
26
+ registerEntityIds({
27
+ customers: { customer_deal: 'customers:customer_deal' },
28
+ example: { todo: 'example:todo', calendar_entity: 'example:calendar_entity' },
29
+ })
30
+ })
31
+
32
+ afterEach(() => {
33
+ registerEntityIds({})
34
+ })
35
+
36
+ test('assertCustomEntityStorageEntityId rejects module-declared ids backed by a registered ORM table', () => {
37
+ const em = buildEm({ CustomerDeal: 'customer_deals' })
38
+ try {
39
+ assertCustomEntityStorageEntityId(em, 'customers:customer_deal')
40
+ throw new Error('[internal] expected the guard to throw')
41
+ } catch (err) {
42
+ expectSystemEntityRejection(err)
43
+ }
44
+ })
45
+
46
+ test('assertCustomEntityStorageEntityId allows module-declared ids without a registered ORM table', () => {
47
+ const em = buildEm({})
48
+ expect(() => assertCustomEntityStorageEntityId(em, 'example:calendar_entity')).not.toThrow()
49
+ })
50
+
51
+ test('a runtime entity whose name collides with an ORM class name is NOT classified as system', () => {
52
+ const em = buildEm({ Todo: 'todos' })
53
+ expect(() => assertCustomEntityStorageEntityId(em, 'user:todo')).not.toThrow()
54
+ })
55
+
56
+ test('falls back to the ORM-table check when the entity-id registry is not populated', () => {
57
+ registerEntityIds({})
58
+ const em = buildEm({ CustomerDeal: 'customer_deals' })
59
+ try {
60
+ assertCustomEntityStorageEntityId(em, 'customers:customer_deal')
61
+ throw new Error('[internal] expected the guard to throw')
62
+ } catch (err) {
63
+ expectSystemEntityRejection(err)
64
+ }
65
+ })
66
+
67
+ test.each([
68
+ ['createCustomEntityRecord', (engine: DefaultDataEngine) => engine.createCustomEntityRecord({ entityId: 'customers:customer_deal', values: {} })],
69
+ ['updateCustomEntityRecord', (engine: DefaultDataEngine) => engine.updateCustomEntityRecord({ entityId: 'customers:customer_deal', recordId: '11111111-1111-4111-8111-111111111111', values: {} })],
70
+ ['deleteCustomEntityRecord', (engine: DefaultDataEngine) => engine.deleteCustomEntityRecord({ entityId: 'customers:customer_deal', recordId: '11111111-1111-4111-8111-111111111111' })],
71
+ ])('%s rejects a table-backed system entity id before touching storage', async (_name, run) => {
72
+ const engine = new DefaultDataEngine(buildEm({ CustomerDeal: 'customer_deals' }) as any, {} as any)
73
+ await expect(run(engine)).rejects.toMatchObject({
74
+ status: 400,
75
+ body: { code: 'system_entity_records_blocked' },
76
+ })
77
+ })
78
+ })
@@ -13,6 +13,8 @@ import type {
13
13
  CrudEntityIdentifiers,
14
14
  } from '../crud/types'
15
15
  import { CrudHttpError } from '../crud/errors'
16
+ import { resolveRegisteredEntityTableName } from '../query/engine'
17
+ import { getEntityIds } from '../encryption/entityIds'
16
18
  import { normalizeCustomFieldValues } from '../custom-fields/normalize'
17
19
  import { parseBooleanToken } from '../boolean'
18
20
  import { isEventDeclared } from '../../modules/events'
@@ -136,6 +138,41 @@ export interface DataEngine {
136
138
  flushOrmEntityChanges(): Promise<void>
137
139
  }
138
140
 
141
+ export const SYSTEM_ENTITY_RECORDS_BLOCKED_CODE = 'system_entity_records_blocked'
142
+
143
+ /**
144
+ * A system entity for doc-storage purposes is an id that modules declare in the
145
+ * generated entity-id registry AND that resolves to a registered ORM table. Both
146
+ * conditions matter: `resolveRegisteredEntityTableName` matches class-name candidates
147
+ * from the entity segment alone, so a runtime-registered custom entity whose name
148
+ * happens to collide with some ORM class (e.g. `user:todo` vs the example module's
149
+ * `Todo`) must never be classified as system. When the registry is not populated
150
+ * (exotic bootstraps, unit harnesses) the check conservatively falls back to the
151
+ * ORM-table match alone so the #2939 protection never switches off.
152
+ */
153
+ export function isOrmBackedSystemEntityId(em: EntityManager, entityId: string): boolean {
154
+ const registry = getEntityIds(false)
155
+ const moduleIds = Object.values(registry).flatMap((moduleEntities) => Object.values(moduleEntities ?? {}))
156
+ if (moduleIds.length > 0 && !moduleIds.includes(entityId)) return false
157
+ return resolveRegisteredEntityTableName(em, entityId) !== null
158
+ }
159
+
160
+ /**
161
+ * Doc storage (`custom_entities_storage`) is for custom entities only. A system
162
+ * entity's records live in its own module tables/APIs — writing doc rows for it
163
+ * poisons read-path classification (#2939) and must be rejected at the deepest
164
+ * seam so no caller (API, AI tool, workflow) can do it.
165
+ */
166
+ export function assertCustomEntityStorageEntityId(em: EntityManager, entityId: string): void {
167
+ if (isOrmBackedSystemEntityId(em, entityId)) {
168
+ throw new CrudHttpError(400, {
169
+ error: 'Records are available for custom entities only',
170
+ code: SYSTEM_ENTITY_RECORDS_BLOCKED_CODE,
171
+ entityId,
172
+ })
173
+ }
174
+ }
175
+
139
176
  export class DefaultDataEngine implements DataEngine {
140
177
  private pendingSideEffects = new Map<string, QueuedCrudSideEffect>()
141
178
  constructor(private em: EntityManager, private container: AwilixContainer) {}
@@ -254,6 +291,7 @@ export class DefaultDataEngine implements DataEngine {
254
291
  }
255
292
 
256
293
  async createCustomEntityRecord(opts: Parameters<DataEngine['createCustomEntityRecord']>[0]): Promise<{ id: string }> {
294
+ assertCustomEntityStorageEntityId(this.em, opts.entityId)
257
295
  const db = this.getKysely()
258
296
  await this.ensureStorageTableExists()
259
297
  const sanitizedValues = await sanitizeCustomFieldHtmlRichTextValuesServer(this.em, {
@@ -345,6 +383,7 @@ export class DefaultDataEngine implements DataEngine {
345
383
  }
346
384
 
347
385
  async updateCustomEntityRecord(opts: Parameters<DataEngine['updateCustomEntityRecord']>[0]): Promise<void> {
386
+ assertCustomEntityStorageEntityId(this.em, opts.entityId)
348
387
  const db = this.getKysely()
349
388
  const sanitizedValues = await sanitizeCustomFieldHtmlRichTextValuesServer(this.em, {
350
389
  entityId: opts.entityId,
@@ -410,6 +449,7 @@ export class DefaultDataEngine implements DataEngine {
410
449
  }
411
450
 
412
451
  async deleteCustomEntityRecord(opts: Parameters<DataEngine['deleteCustomEntityRecord']>[0]): Promise<void> {
452
+ assertCustomEntityStorageEntityId(this.em, opts.entityId)
413
453
  const db = this.getKysely()
414
454
  const id = String(opts.recordId)
415
455
  const orgId = opts.organizationId ?? null
@@ -0,0 +1,40 @@
1
+ import { buildIlikeTerm } from '../buildIlikeTerm'
2
+ import { escapeLikePattern } from '../escapeLikePattern'
3
+
4
+ describe('buildIlikeTerm', () => {
5
+ it('wraps the escaped term in leading and trailing wildcards by default', () => {
6
+ expect(buildIlikeTerm('acme')).toBe('%acme%')
7
+ })
8
+
9
+ it('matches the legacy inline contains pattern exactly', () => {
10
+ const term = 'Acme Corp'
11
+ expect(buildIlikeTerm(term)).toBe(`%${escapeLikePattern(term)}%`)
12
+ expect(buildIlikeTerm(term, 'contains')).toBe(`%${escapeLikePattern(term)}%`)
13
+ })
14
+
15
+ it('builds a startsWith pattern with only a trailing wildcard', () => {
16
+ const term = 'user@example.com'
17
+ expect(buildIlikeTerm(term, 'startsWith')).toBe(`${escapeLikePattern(term)}%`)
18
+ })
19
+
20
+ it('builds an endsWith pattern with only a leading wildcard', () => {
21
+ const term = 'example.com'
22
+ expect(buildIlikeTerm(term, 'endsWith')).toBe(`%${escapeLikePattern(term)}`)
23
+ })
24
+
25
+ it('escapes LIKE metacharacters so they match literally', () => {
26
+ expect(buildIlikeTerm('50%_off')).toBe('%50\\%\\_off%')
27
+ expect(buildIlikeTerm('back\\slash')).toBe('%back\\\\slash%')
28
+ })
29
+
30
+ it('keeps user wildcards literal across every mode', () => {
31
+ expect(buildIlikeTerm('a%b', 'startsWith')).toBe('a\\%b%')
32
+ expect(buildIlikeTerm('a_b', 'endsWith')).toBe('%a\\_b')
33
+ })
34
+
35
+ it('handles an empty term as a wildcard-only pattern', () => {
36
+ expect(buildIlikeTerm('')).toBe('%%')
37
+ expect(buildIlikeTerm('', 'startsWith')).toBe('%')
38
+ expect(buildIlikeTerm('', 'endsWith')).toBe('%')
39
+ })
40
+ })
@@ -0,0 +1,123 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
2
+ import { dirname, join, relative, sep } from 'node:path'
3
+ import { escapeLikePattern } from '../escapeLikePattern'
4
+
5
+ describe('escapeLikePattern', () => {
6
+ it('escapes LIKE wildcards and the escape character', () => {
7
+ expect(escapeLikePattern('%')).toBe('\\%')
8
+ expect(escapeLikePattern('_')).toBe('\\_')
9
+ expect(escapeLikePattern('\\')).toBe('\\\\')
10
+ expect(escapeLikePattern('a%b_c')).toBe('a\\%b\\_c')
11
+ })
12
+
13
+ it('escapes the backslash before the wildcards so the escape itself is literal', () => {
14
+ expect(escapeLikePattern('100%\\_')).toBe('100\\%\\\\\\_')
15
+ })
16
+
17
+ it('leaves ordinary input untouched', () => {
18
+ expect(escapeLikePattern('hello world')).toBe('hello world')
19
+ expect(escapeLikePattern('')).toBe('')
20
+ })
21
+ })
22
+
23
+ /**
24
+ * Regression guard for #2932 (and the earlier #2734 fix).
25
+ *
26
+ * Every user-supplied value interpolated into a MikroORM `$ilike` pattern under
27
+ * any module's `api/**` tree MUST flow through `escapeLikePattern`, otherwise a
28
+ * caller can inject LIKE metacharacters (`%`, `_`, `\`) to broaden predicates or
29
+ * force pathological full scans. This scans the whole monorepo so a NEW unescaped
30
+ * `$ilike` interpolation in any package or app fails the build.
31
+ */
32
+ function findRepoRoot(start: string): string {
33
+ let current = start
34
+ for (let depth = 0; depth < 12; depth += 1) {
35
+ if (existsSync(join(current, 'packages')) && existsSync(join(current, 'apps'))) {
36
+ return current
37
+ }
38
+ const parent = dirname(current)
39
+ if (parent === current) break
40
+ current = parent
41
+ }
42
+ throw new Error('[internal] could not locate monorepo root for $ilike guard')
43
+ }
44
+
45
+ const SKIP_DIRS = new Set(['node_modules', '__tests__', 'generated', 'dist', '.next', '.mercato'])
46
+
47
+ function collectApiSourceFiles(dir: string, acc: string[]): void {
48
+ let entries: string[]
49
+ try {
50
+ entries = readdirSync(dir)
51
+ } catch {
52
+ return
53
+ }
54
+ for (const name of entries) {
55
+ const full = join(dir, name)
56
+ let stat
57
+ try {
58
+ stat = statSync(full)
59
+ } catch {
60
+ continue
61
+ }
62
+ if (stat.isDirectory()) {
63
+ if (SKIP_DIRS.has(name)) continue
64
+ collectApiSourceFiles(full, acc)
65
+ } else if (
66
+ (name.endsWith('.ts') || name.endsWith('.tsx')) &&
67
+ !name.endsWith('.test.ts') &&
68
+ !name.endsWith('.test.tsx') &&
69
+ full.split(sep).includes('api')
70
+ ) {
71
+ acc.push(full)
72
+ }
73
+ }
74
+ }
75
+
76
+ const BARE_IDENTIFIER = /^[A-Za-z_$][\w$]*$/
77
+
78
+ function isPreEscapedVariable(expr: string, source: string): boolean {
79
+ if (!BARE_IDENTIFIER.test(expr)) return false
80
+ const assignedFromEscape = new RegExp(`\\b${expr}\\s*=\\s*escapeLikePattern\\(`)
81
+ return assignedFromEscape.test(source)
82
+ }
83
+
84
+ function findUnescapedIlikeInterpolations(source: string): string[] {
85
+ const ilikeTemplate = /\$ilike:\s*`([^`]*)`/g
86
+ const interpolation = /\$\{([^}]*)\}/g
87
+ const offenders: string[] = []
88
+ let templateMatch: RegExpExecArray | null
89
+ while ((templateMatch = ilikeTemplate.exec(source)) !== null) {
90
+ const literal = templateMatch[1]
91
+ let exprMatch: RegExpExecArray | null
92
+ while ((exprMatch = interpolation.exec(literal)) !== null) {
93
+ const expr = exprMatch[1].trim()
94
+ if (expr.startsWith('escapeLikePattern(')) continue
95
+ if (isPreEscapedVariable(expr, source)) continue
96
+ offenders.push(`\`${templateMatch[1]}\``)
97
+ }
98
+ }
99
+ return offenders
100
+ }
101
+
102
+ describe('$ilike user input must be escaped under api/** (#2932)', () => {
103
+ const repoRoot = findRepoRoot(__dirname)
104
+ const roots = [join(repoRoot, 'packages'), join(repoRoot, 'apps')]
105
+ const files: string[] = []
106
+ for (const root of roots) collectApiSourceFiles(root, files)
107
+
108
+ it('discovered api source files to scan', () => {
109
+ expect(files.length).toBeGreaterThan(20)
110
+ })
111
+
112
+ it('every $ilike pattern interpolating a variable uses escapeLikePattern', () => {
113
+ const violations: string[] = []
114
+ for (const full of files) {
115
+ const source = readFileSync(full, 'utf8')
116
+ const offenders = findUnescapedIlikeInterpolations(source)
117
+ if (offenders.length === 0) continue
118
+ const rel = relative(repoRoot, full).split(sep).join('/')
119
+ for (const offender of offenders) violations.push(`${rel}: ${offender}`)
120
+ }
121
+ expect(violations).toEqual([])
122
+ })
123
+ })
@@ -23,3 +23,85 @@ describe('ORM entity registry', () => {
23
23
  expect(secondLoad.getOrmEntities()).toBe(entities)
24
24
  })
25
25
  })
26
+
27
+ describe('resolvePoolConfig', () => {
28
+ const baseEnv = (extra: Record<string, string | undefined> = {}): NodeJS.ProcessEnv =>
29
+ ({ ...extra }) as NodeJS.ProcessEnv
30
+
31
+ it('applies pool size defaults when env is empty', async () => {
32
+ const { resolvePoolConfig } = await import('../mikro')
33
+ const config = resolvePoolConfig(baseEnv())
34
+ expect(config.poolMin).toBe(2)
35
+ expect(config.poolMax).toBe(20)
36
+ expect(config.poolIdleTimeout).toBe(3000)
37
+ expect(config.poolAcquireTimeout).toBe(6000)
38
+ })
39
+
40
+ it('reads pool sizes from env overrides', async () => {
41
+ const { resolvePoolConfig } = await import('../mikro')
42
+ const config = resolvePoolConfig(
43
+ baseEnv({ DB_POOL_MIN: '5', DB_POOL_MAX: '50', DB_POOL_ACQUIRE_TIMEOUT: '12000' }),
44
+ )
45
+ expect(config.poolMin).toBe(5)
46
+ expect(config.poolMax).toBe(50)
47
+ expect(config.poolAcquireTimeout).toBe(12000)
48
+ })
49
+
50
+ it('defaults idle_in_transaction to a finite 120s in production', async () => {
51
+ const { resolvePoolConfig } = await import('../mikro')
52
+ const config = resolvePoolConfig(baseEnv({ NODE_ENV: 'production' }))
53
+ expect(config.idleInTransactionTimeoutMs).toBe(120_000)
54
+ })
55
+
56
+ it('defaults idle_in_transaction to a finite 120s in development', async () => {
57
+ const { resolvePoolConfig } = await import('../mikro')
58
+ const config = resolvePoolConfig(baseEnv({ NODE_ENV: 'development' }))
59
+ expect(config.idleInTransactionTimeoutMs).toBe(120_000)
60
+ })
61
+
62
+ it('lets idle_in_transaction be overridden, including 0 to disable', async () => {
63
+ const { resolvePoolConfig } = await import('../mikro')
64
+ expect(
65
+ resolvePoolConfig(baseEnv({ DB_IDLE_IN_TRANSACTION_TIMEOUT_MS: '30000' }))
66
+ .idleInTransactionTimeoutMs,
67
+ ).toBe(30000)
68
+ expect(
69
+ resolvePoolConfig(
70
+ baseEnv({ NODE_ENV: 'production', DB_IDLE_IN_TRANSACTION_TIMEOUT_MS: '0' }),
71
+ ).idleInTransactionTimeoutMs,
72
+ ).toBe(0)
73
+ })
74
+
75
+ it('keeps idle_session production-undefined / dev-600s default', async () => {
76
+ const { resolvePoolConfig } = await import('../mikro')
77
+ expect(resolvePoolConfig(baseEnv({ NODE_ENV: 'production' })).idleSessionTimeoutMs).toBeUndefined()
78
+ expect(resolvePoolConfig(baseEnv({ NODE_ENV: 'development' })).idleSessionTimeoutMs).toBe(600_000)
79
+ })
80
+
81
+ it('leaves statement/lock timeouts unset by default (no timeout)', async () => {
82
+ const { resolvePoolConfig } = await import('../mikro')
83
+ const config = resolvePoolConfig(baseEnv({ NODE_ENV: 'production' }))
84
+ expect(config.statementTimeoutMs).toBeUndefined()
85
+ expect(config.lockTimeoutMs).toBeUndefined()
86
+ })
87
+
88
+ it('passes through positive statement/lock timeouts when set', async () => {
89
+ const { resolvePoolConfig } = await import('../mikro')
90
+ const config = resolvePoolConfig(
91
+ baseEnv({ DB_STATEMENT_TIMEOUT_MS: '30000', DB_LOCK_TIMEOUT_MS: '5000' }),
92
+ )
93
+ expect(config.statementTimeoutMs).toBe(30000)
94
+ expect(config.lockTimeoutMs).toBe(5000)
95
+ })
96
+
97
+ it('ignores non-positive or non-numeric statement/lock timeouts', async () => {
98
+ const { resolvePoolConfig } = await import('../mikro')
99
+ for (const value of ['0', '-1', 'abc', '']) {
100
+ const config = resolvePoolConfig(
101
+ baseEnv({ DB_STATEMENT_TIMEOUT_MS: value, DB_LOCK_TIMEOUT_MS: value }),
102
+ )
103
+ expect(config.statementTimeoutMs).toBeUndefined()
104
+ expect(config.lockTimeoutMs).toBeUndefined()
105
+ }
106
+ })
107
+ })
@@ -0,0 +1,16 @@
1
+ import { escapeLikePattern } from './escapeLikePattern'
2
+
3
+ export type IlikeMatchMode = 'contains' | 'startsWith' | 'endsWith'
4
+
5
+ export const buildIlikeTerm = (value: string, mode: IlikeMatchMode = 'contains'): string => {
6
+ const escaped = escapeLikePattern(value)
7
+ switch (mode) {
8
+ case 'startsWith':
9
+ return `${escaped}%`
10
+ case 'endsWith':
11
+ return `%${escaped}`
12
+ case 'contains':
13
+ default:
14
+ return `%${escaped}%`
15
+ }
16
+ }