@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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +1 -1
- package/dist/lib/ai/llm-provider-registry.js.map +1 -1
- package/dist/lib/crud/custom-fields.js +23 -15
- package/dist/lib/crud/custom-fields.js.map +2 -2
- package/dist/lib/crud/factory.js.map +1 -1
- package/dist/lib/crud/optimistic-lock-command.js.map +1 -1
- package/dist/lib/crud/optimistic-lock-headers.js.map +1 -1
- package/dist/lib/crud/optimistic-lock-store.js.map +1 -1
- package/dist/lib/crud/optimistic-lock.js.map +1 -1
- package/dist/lib/data/engine.js +25 -1
- package/dist/lib/data/engine.js.map +2 -2
- package/dist/lib/db/buildIlikeTerm.js +17 -0
- package/dist/lib/db/buildIlikeTerm.js.map +7 -0
- package/dist/lib/db/mikro.js +38 -9
- package/dist/lib/db/mikro.js.map +2 -2
- package/dist/lib/di/container.js +1 -1
- package/dist/lib/di/container.js.map +1 -1
- package/dist/lib/encryption/kms.js +41 -6
- package/dist/lib/encryption/kms.js.map +2 -2
- package/dist/lib/query/advanced-filter-tree.js +5 -5
- package/dist/lib/query/advanced-filter-tree.js.map +2 -2
- package/dist/lib/query/advanced-filter.js +5 -5
- package/dist/lib/query/advanced-filter.js.map +2 -2
- package/dist/lib/query/engine.js +3 -1
- package/dist/lib/query/engine.js.map +2 -2
- package/dist/lib/query/types.js.map +1 -1
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/modules/overrides.js +1 -1
- package/dist/modules/overrides.js.map +1 -1
- package/dist/modules/search.js.map +1 -1
- package/package.json +4 -5
- package/src/lib/ai/llm-provider-registry.ts +1 -1
- package/src/lib/ai/llm-provider.ts +1 -1
- package/src/lib/crud/__tests__/custom-fields.test.ts +91 -0
- package/src/lib/crud/custom-fields.ts +30 -17
- package/src/lib/crud/factory.ts +1 -1
- package/src/lib/crud/optimistic-lock-command.ts +1 -1
- package/src/lib/crud/optimistic-lock-headers.ts +1 -1
- package/src/lib/crud/optimistic-lock-store.ts +1 -1
- package/src/lib/crud/optimistic-lock.ts +1 -1
- package/src/lib/data/__tests__/engine.custom-entity-storage-guard.test.ts +78 -0
- package/src/lib/data/engine.ts +40 -0
- package/src/lib/db/__tests__/buildIlikeTerm.test.ts +40 -0
- package/src/lib/db/__tests__/escapeLikePattern.test.ts +123 -0
- package/src/lib/db/__tests__/mikro.test.ts +82 -0
- package/src/lib/db/buildIlikeTerm.ts +16 -0
- package/src/lib/db/mikro.ts +55 -16
- package/src/lib/di/container.ts +1 -1
- package/src/lib/encryption/__tests__/kms.test.ts +80 -0
- package/src/lib/encryption/kms.ts +55 -7
- package/src/lib/query/__tests__/engine.count-distinct.test.ts +229 -0
- package/src/lib/query/advanced-filter-tree.ts +5 -5
- package/src/lib/query/advanced-filter.ts +5 -5
- package/src/lib/query/engine.ts +13 -2
- package/src/lib/query/types.ts +10 -0
- package/src/modules/__tests__/overrides.test.ts +1 -1
- package/src/modules/__tests__/route-overrides.test.ts +1 -1
- package/src/modules/navigation/backendChrome.ts +9 -0
- package/src/modules/overrides.ts +3 -3
- 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
|
-
|
|
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
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
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()) {
|
package/src/lib/crud/factory.ts
CHANGED
|
@@ -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
|
+
})
|
package/src/lib/data/engine.ts
CHANGED
|
@@ -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
|
+
}
|